feat: history cleanup (#7007)

fix AFF-1069
This commit is contained in:
darkskygit
2024-05-24 08:00:05 +00:00
parent 02564a8d8c
commit 937b8bf166
6 changed files with 188 additions and 77 deletions
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "deleted_at" TIMESTAMPTZ(6),
ADD COLUMN "messageCost" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "tokenCost" INTEGER NOT NULL DEFAULT 0;
@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"
+9 -6
View File
@@ -480,12 +480,15 @@ model AiSessionMessage {
} }
model AiSession { model AiSession {
id String @id @default(uuid()) @db.VarChar(36) id String @id @default(uuid()) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36) userId String @map("user_id") @db.VarChar(36)
workspaceId String @map("workspace_id") @db.VarChar(36) workspaceId String @map("workspace_id") @db.VarChar(36)
docId String @map("doc_id") @db.VarChar(36) docId String @map("doc_id") @db.VarChar(36)
promptName String @map("prompt_name") @db.VarChar(32) promptName String @map("prompt_name") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) messageCost Int @default(0)
tokenCost Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade) prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade)
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { BadRequestException, Logger } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { import {
Args, Args,
Field, Field,
@@ -55,6 +55,18 @@ class CreateChatSessionInput {
promptName!: string; promptName!: string;
} }
@InputType()
class DeleteSessionInput {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => [String])
sessionIds!: string[];
}
@InputType() @InputType()
class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> { class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
@Field(() => String) @Field(() => String)
@@ -264,6 +276,35 @@ export class CopilotResolver {
return session; return session;
} }
@Mutation(() => String, {
description: 'Cleanup sessions',
})
async cleanupCopilotSession(
@CurrentUser() user: CurrentUser,
@Args({ name: 'options', type: () => DeleteSessionInput })
options: DeleteSessionInput
) {
await this.permissions.checkCloudPagePermission(
options.workspaceId,
options.docId,
user.id
);
if (!options.sessionIds.length) {
return new NotFoundException('Session not found');
}
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
await using lock = await this.mutex.lock(lockFlag);
if (!lock) {
return new TooManyRequestsException('Server is busy');
}
const ret = await this.chatSession.cleanup({
...options,
userId: user.id,
});
return ret;
}
@Mutation(() => String, { @Mutation(() => String, {
description: 'Create a chat message', description: 'Create a chat message',
}) })
@@ -186,7 +186,7 @@ export class ChatSessionService {
// find existing session if session is chat session // find existing session if session is chat session
if (!state.prompt.action) { if (!state.prompt.action) {
const { id } = const { id, deletedAt } =
(await tx.aiSession.findFirst({ (await tx.aiSession.findFirst({
where: { where: {
userId: state.userId, userId: state.userId,
@@ -194,8 +194,9 @@ export class ChatSessionService {
docId: state.docId, docId: state.docId,
prompt: { action: { equals: null } }, prompt: { action: { equals: null } },
}, },
select: { id: true }, select: { id: true, deletedAt: true },
})) || {}; })) || {};
if (deletedAt) throw new Error(`Session is deleted: ${id}`);
if (id) sessionId = id; if (id) sessionId = id;
} }
@@ -219,6 +220,21 @@ export class ChatSessionService {
sessionId, sessionId,
})), })),
}); });
// only count message generated by user
const userMessages = state.messages.filter(m => m.role === 'user');
await tx.aiSession.update({
where: { id: sessionId },
data: {
messageCost: { increment: userMessages.length },
tokenCost: {
increment: this.calculateTokenSize(
userMessages,
state.prompt.model as AvailableModel
),
},
},
});
} }
} else { } else {
await tx.aiSession.create({ await tx.aiSession.create({
@@ -242,21 +258,15 @@ export class ChatSessionService {
): Promise<ChatSessionState | undefined> { ): Promise<ChatSessionState | undefined> {
return await this.db.aiSession return await this.db.aiSession
.findUnique({ .findUnique({
where: { id: sessionId }, where: { id: sessionId, deletedAt: null },
select: { select: {
id: true, id: true,
userId: true, userId: true,
workspaceId: true, workspaceId: true,
docId: true, docId: true,
messages: { messages: {
select: { select: { role: true, content: true, createdAt: true },
role: true, orderBy: { createdAt: 'asc' },
content: true,
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
}, },
promptName: true, promptName: true,
}, },
@@ -283,9 +293,18 @@ export class ChatSessionService {
// after revert, we can retry the action // after revert, we can retry the action
async revertLatestMessage(sessionId: string) { async revertLatestMessage(sessionId: string) {
await this.db.$transaction(async tx => { await this.db.$transaction(async tx => {
const id = await tx.aiSession
.findUnique({
where: { id: sessionId, deletedAt: null },
select: { id: true },
})
.then(session => session?.id);
if (!id) {
throw new Error(`Session not found: ${sessionId}`);
}
const ids = await tx.aiSessionMessage const ids = await tx.aiSessionMessage
.findMany({ .findMany({
where: { sessionId }, where: { sessionId: id },
select: { id: true, role: true }, select: { id: true, role: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}) })
@@ -312,22 +331,14 @@ export class ChatSessionService {
.reduce((total, length) => total + length, 0); .reduce((total, length) => total + length, 0);
} }
private async countUserActions(userId: string): Promise<number> { private async countUserMessages(userId: string): Promise<number> {
return await this.db.aiSession.count({ const sessions = await this.db.aiSession.findMany({
where: { userId, prompt: { action: { not: null } } }, where: { userId },
select: { messageCost: true, prompt: { select: { action: true } } },
}); });
} return sessions
.map(({ messageCost, prompt: { action } }) => (action ? 1 : messageCost))
private async countUserChats(userId: string): Promise<number> { .reduce((prev, cost) => prev + cost, 0);
const chats = await this.db.aiSession.findMany({
where: { userId, prompt: { action: null } },
select: {
_count: {
select: { messages: { where: { role: AiPromptRole.user } } },
},
},
});
return chats.reduce((prev, chat) => prev + chat._count.messages, 0);
} }
async listSessions( async listSessions(
@@ -344,6 +355,7 @@ export class ChatSessionService {
prompt: { prompt: {
action: options?.action ? { not: null } : null, action: options?.action ? { not: null } : null,
}, },
deletedAt: null,
}, },
select: { id: true }, select: { id: true },
}) })
@@ -367,10 +379,12 @@ export class ChatSessionService {
action: options?.action ? { not: null } : null, action: options?.action ? { not: null } : null,
}, },
id: options?.sessionId ? { equals: options.sessionId } : undefined, id: options?.sessionId ? { equals: options.sessionId } : undefined,
deletedAt: null,
}, },
select: { select: {
id: true, id: true,
promptName: true, promptName: true,
tokenCost: true,
createdAt: true, createdAt: true,
messages: { messages: {
select: { select: {
@@ -391,50 +405,48 @@ export class ChatSessionService {
}) })
.then(sessions => .then(sessions =>
Promise.all( Promise.all(
sessions.map(async ({ id, promptName, messages, createdAt }) => { sessions.map(
try { async ({ id, promptName, tokenCost, messages, createdAt }) => {
const ret = ChatMessageSchema.array().safeParse(messages); try {
if (ret.success) { const ret = ChatMessageSchema.array().safeParse(messages);
const prompt = await this.prompt.get(promptName); if (ret.success) {
if (!prompt) { const prompt = await this.prompt.get(promptName);
throw new Error(`Prompt not found: ${promptName}`); if (!prompt) {
} throw new Error(`Prompt not found: ${promptName}`);
const tokens = this.calculateTokenSize( }
ret.data,
prompt.model as AvailableModel
);
// render system prompt // render system prompt
const preload = withPrompt const preload = withPrompt
? prompt ? prompt
.finish(ret.data[0]?.params || {}, id) .finish(ret.data[0]?.params || {}, id)
.filter(({ role }) => role !== 'system') .filter(({ role }) => role !== 'system')
: []; : [];
// `createdAt` is required for history sorting in frontend, let's fake the creating time of prompt messages // `createdAt` is required for history sorting in frontend, let's fake the creating time of prompt messages
(preload as ChatMessage[]).forEach((msg, i) => { (preload as ChatMessage[]).forEach((msg, i) => {
msg.createdAt = new Date( msg.createdAt = new Date(
createdAt.getTime() - preload.length - i - 1 createdAt.getTime() - preload.length - i - 1
);
});
return {
sessionId: id,
action: prompt.action || undefined,
tokens: tokenCost,
createdAt,
messages: preload.concat(ret.data),
};
} else {
this.logger.error(
`Unexpected message schema: ${JSON.stringify(ret.error)}`
); );
}); }
} catch (e) {
return { this.logger.error('Unexpected error in listHistories', e);
sessionId: id,
action: prompt.action || undefined,
tokens,
createdAt,
messages: preload.concat(ret.data),
};
} else {
this.logger.error(
`Unexpected message schema: ${JSON.stringify(ret.error)}`
);
} }
} catch (e) { return undefined;
this.logger.error('Unexpected error in listHistories', e);
} }
return undefined; )
})
) )
) )
.then(histories => .then(histories =>
@@ -451,10 +463,9 @@ export class ChatSessionService {
limit = quota.feature.copilotActionLimit; limit = quota.feature.copilotActionLimit;
} }
const actions = await this.countUserActions(userId); const used = await this.countUserMessages(userId);
const chats = await this.countUserChats(userId);
return { limit, used: actions + chats }; return { limit, used };
} }
async checkQuota(userId: string) { async checkQuota(userId: string) {
@@ -481,6 +492,49 @@ export class ChatSessionService {
}); });
} }
async cleanup(
options: Omit<ChatSessionOptions, 'promptName'> & { sessionIds: string[] }
) {
return await this.db.$transaction(async tx => {
const sessions = await tx.aiSession.findMany({
where: {
id: { in: options.sessionIds },
userId: options.userId,
workspaceId: options.workspaceId,
docId: options.docId,
deletedAt: null,
},
select: { id: true, promptName: true },
});
const sessionIds = sessions.map(({ id }) => id);
// cleanup all messages
await tx.aiSessionMessage.deleteMany({
where: { sessionId: { in: sessionIds } },
});
// only mark action session as deleted
// chat session always can be reuse
{
const actionIds = (
await Promise.all(
sessions.map(({ id, promptName }) =>
this.prompt
.get(promptName)
.then(prompt => ({ id, action: !!prompt?.action }))
)
)
)
.filter(({ action }) => action)
.map(({ id }) => id);
await tx.aiSession.updateMany({
where: { id: { in: actionIds } },
data: { deletedAt: new Date() },
});
}
});
}
async createMessage(message: SubmittedMessage): Promise<string | undefined> { async createMessage(message: SubmittedMessage): Promise<string | undefined> {
return await this.messageCache.set(message); return await this.messageCache.set(message);
} }
+9
View File
@@ -76,6 +76,12 @@ type DeleteAccount {
success: Boolean! success: Boolean!
} }
input DeleteSessionInput {
docId: String!
sessionIds: [String!]!
workspaceId: String!
}
type DocHistoryType { type DocHistoryType {
id: String! id: String!
timestamp: DateTime! timestamp: DateTime!
@@ -184,6 +190,9 @@ type Mutation {
changeEmail(email: String!, token: String!): UserType! changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!): UserType!
"""Cleanup sessions"""
cleanupCopilotSession(options: DeleteSessionInput!): String!
"""Create a subscription checkout link of stripe""" """Create a subscription checkout link of stripe"""
createCheckoutSession(input: CreateCheckoutSessionInput!): String! createCheckoutSession(input: CreateCheckoutSessionInput!): String!