From 6e034185cf3b9de1262d807df31a7e50416a6499 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:24:42 +0800 Subject: [PATCH] feat: title of session (#12971) fix AI-253 --- .../migration.sql | 2 + packages/backend/server/schema.prisma | 45 ++--- .../__snapshots__/copilot.spec.ts.md | 42 ++++ .../__snapshots__/copilot.spec.ts.snap | Bin 1440 -> 1782 bytes .../server/src/__tests__/copilot.spec.ts | 179 +++++++++++++++++- .../__tests__/models/copilot-context.spec.ts | 1 + .../__tests__/models/copilot-session.spec.ts | 4 + .../server/src/models/copilot-session.ts | 41 ++-- .../server/src/plugins/copilot/resolver.ts | 8 +- .../server/src/plugins/copilot/session.ts | 100 +++++++++- .../server/src/plugins/copilot/types.ts | 2 + packages/backend/server/src/schema.gql | 1 + .../src/graphql/copilot-session-get.gql | 1 + .../src/graphql/copilot-sessions-get.gql | 1 + packages/common/graphql/src/graphql/index.ts | 2 + packages/common/graphql/src/schema.ts | 3 + 16 files changed, 390 insertions(+), 42 deletions(-) create mode 100644 packages/backend/server/migrations/20250630094158_session_title/migration.sql diff --git a/packages/backend/server/migrations/20250630094158_session_title/migration.sql b/packages/backend/server/migrations/20250630094158_session_title/migration.sql new file mode 100644 index 000000000..8267d1520 --- /dev/null +++ b/packages/backend/server/migrations/20250630094158_session_title/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ai_sessions_metadata" ADD COLUMN "title" VARCHAR; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 6b45692fc..2e16209a5 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -122,15 +122,15 @@ model Workspace { avatarKey String? @map("avatar_key") @db.VarChar indexed Boolean @default(false) - features WorkspaceFeature[] - docs WorkspaceDoc[] - permissions WorkspaceUserRole[] - docPermissions WorkspaceDocUserRole[] - blobs Blob[] - ignoredDocs AiWorkspaceIgnoredDocs[] - embedFiles AiWorkspaceFiles[] - comments Comment[] - commentAttachments CommentAttachment[] + features WorkspaceFeature[] + docs WorkspaceDoc[] + permissions WorkspaceUserRole[] + docPermissions WorkspaceDocUserRole[] + blobs Blob[] + ignoredDocs AiWorkspaceIgnoredDocs[] + embedFiles AiWorkspaceFiles[] + comments Comment[] + commentAttachments CommentAttachment[] @@map("workspaces") } @@ -443,6 +443,7 @@ model AiSession { promptName String @map("prompt_name") @db.VarChar(32) promptAction String? @default("") @map("prompt_action") @db.VarChar(32) pinned Boolean @default(false) + title String? @db.VarChar // the session id of the parent session if this session is a forked session parentSessionId String? @map("parent_session_id") @db.VarChar messageCost Int @default(0) @@ -900,8 +901,8 @@ model Reply { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) @@index([commentId, sid]) @@index([workspaceId, docId, updatedAt]) @@ -911,19 +912,19 @@ model Reply { model CommentAttachment { // NOTE: manually set this column type to identity in migration file - sid Int @unique @default(autoincrement()) - workspaceId String @map("workspace_id") @db.VarChar - docId String @map("doc_id") @db.VarChar - key String @db.VarChar - size Int @db.Integer - mime String @db.VarChar - name String @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - createdBy String? @map("created_by") @db.VarChar + sid Int @unique @default(autoincrement()) + workspaceId String @map("workspace_id") @db.VarChar + docId String @map("doc_id") @db.VarChar + key String @db.VarChar + size Int @db.Integer + mime String @db.VarChar + name String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + createdBy String? @map("created_by") @db.VarChar - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) // will delete creator record if creator's account is deleted - createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull) + createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull) @@id([workspaceId, docId, key]) @@map("comment_attachments") diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md index 39a6e2be9..26e89b173 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md @@ -330,3 +330,45 @@ Generated by [AVA](https://avajs.dev). ], {}, ] + +## should handle generateSessionTitle correctly under various conditions + +> should generate title when conditions are met + + { + chatWithPromptCalled: undefined, + exists: true, + title: 'What is Machine Learning?', + } + +> should not generate title when session already has title + + { + chatWithPromptCalled: false, + exists: true, + title: 'Existing Title', + } + +> should not generate title when no user messages exist + + { + chatWithPromptCalled: false, + exists: true, + title: null, + } + +> should not generate title when no assistant messages exist + + { + chatWithPromptCalled: false, + exists: true, + title: null, + } + +> should use correct prompt for title generation + + { + content: `[user]: Explain quantum computing briefly␊ + [assistant]: Quantum computing uses quantum mechanics principles.`, + promptName: 'Summary as title', + } diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap index 943a031e2df0991da530985188df524f3006b04d..04b188d87dfea65cc69e0100152a1f39100d848a 100644 GIT binary patch literal 1782 zcmVMNzEyU|%dg6cjVF zGqao7G}BF*US#vIna%m<{PX|k`@Z?l$$uoDvE1x_dGM%Ern1EF-LmMGtsKh;#xs^+ z%4IfpctNmikt-&Jl&0&L(%~gpbd`MJXfDSU-$}=Hw88(OcG8yuxDUXC0OnBZY-(BO zd3=N@y6W!k26ccmP$$ub%8i{w&-lN|pTl?;z%c-40GyBJY2BRJC$fsP__C*`CvZ8N zfLjPyLkUx3tm!O}4(i4n(&qYCEwiaD>pDs}D*E186qe<(A=k5Vpe~|41Vn?aCGK%s zCZ;ev*AlUb9Fa=cfiEq^S4-e)qS=hH6rLZVc7TAFli}%g;Zbt7A=k^r{(`84#WTVh zz9U@hf^dY#m0+qUSl)EF#cUxZFNo&q-ngr~GP$}rGoKfpa8$IqY{*na#vNA`g~uwP zM?!FCy%6MGZ~rA^;6Mlz?N|*^jD7$E03HFbISnj&0FhlWSPq3?=_LBZztn;$e+Td< zfLjSzn#@)mZj7x2>?Ghh0^Uf$PCFSN6Yx0!zYuUSY2G?@s4;EQP)qX_L9L7E(Z;-l z5WE~8)ks8RK4x$!O{ut}0<6EN0f)4SvEDv*v}MB4wyU9nO>j5C{l5jb(OU!V)-?bg z0zd{36YrtL&7)+YC)|tPp0Y^39lm?tlnzz@P|EmFp4&0*yYxRVA$JB`> z^s7OvR~z#(DPn!ln2%awU4|o_a~`-kJzb&bnM@||aBlK}jFx(a0dyGCrx1zJ8553n zHr1u6E=_f5s>_Y2F5zZ%L1^iTLE%-mw^`ToL{?cN)s5;)p&;q2rc;oSzi&u8f1zcU$IR4umNy_Isgtd^LJ!s7fGG6nz|G0G$5c+27KM~6@xqz!(dPNX8p5@#0`LHU#{g^vus4ltM!+C5Cfl1;vYo7w?R-eKb~5G>uz-Lk zMt8iKbcP)y;Fa;JgL;O2O~CgATu7SMGwe1E@VCY5QW!QF+GLuv8TTC3-+CWT*?u>K z^xpX8V!MNa#ZMi-W_Vr+FM;+Y({WVvQT3?kCjh?z=pkTHI=nX!u!R6G9p0}I@D>4| zroekLnEiSBj@qw2X4^@NUbfd1Yt2%oS;}0`n%pd9nx#y$lxdbS%~IyN#q6~&Wx|7; zyXw0Ryt2bg+rL2!i)>lB-Z*s1gQW} zBc)2ZjSAGY(*V9nIv6*}T|~duIMDZtk>D|B`bW~ECDeKv8=o;w@6AmAVd5zk7L_W= zfh9}CFtpk>ifCT~_$sxLNV@>wV#1?7#$&_(d~LJcoUk5`S0^>MN1&X*QnR1PFTNvTUb z*Ke}gZmQx_K`;+<%MulMwarSHwu)g>Dj9s$x)XyfLCDd!dE5QgEmN{hJXn+^XucVJlJvKs>H4JOg`?e^}ea;6j-pjVX&*hU>#SD z8*td-#<=APo*Q9BE-S-f-0xi9aqF`ZU4WO0({Y*4_Ueu&aBzbKIglP?UHvgO(!XmP zT>av~-`1T>k20+W+rQh-?w$d*ez;_D(_v4SxueQ9%er={9H27enIdnEbnK2FanAtT zQfF3{LdJL7B3tB+nU$>MnNHR$Swi+D9;Krqg->!jrb5rwvTbv3ghgNu^Vb*VuPkHn Yi-a`qdSNER#G0=2A2zLTtTrwH0IivR!s literal 1440 zcmV;R1z-9>RzVA= zmu>S|qTs5hrw7ypVxSb!p4x*cqUYS-+T);4X~*)X2hDq-@p zFlTP_2Rkpy!sb#KvLK9HvCK`QBpiq5MLfFK?&yw1M>ku{<%BKDG8kQBx+n_#b(i5_h& zijUxOV$@qAT8pucJ5JG&yevJ|UuM7&wlwRV3-2bDyxXxBE*Qfd!~MU7TOVkEJ2?*E zAplPRcm_baN@hs~dBWp)CWQR`I^<`4$WufKLZF9H_fq%Z%n(qlPQ-5#a8ix_w*>s) zb9`O2uba^yVZaUs9Av;$w0N?q_%Q~|GvG7>&P9uNHWmMe0lEgXt|7nac0 zLRhc27Nsp=eb8ErMqw=@kj6Rp!kk&HrfBJO+KX_${f1OiJzWR7^wo2S$lY~I-c7~s z61z+6F0s2@i|*ntRyX@4Z80V6`tmko*|x|?bGE)veZ@BgWwX|0o)JZF#!7j98`%-_Y_nN#rAexY^UmCyWoqhlk}Sj*i69V)fI1A zt+2xcytY_&(5$ep3HY9Xi_xOZ3cG~??y`87LSgO5CLPOW(S1~R>3vkO{BDW#fpBsu zSyRFY-#VOTcuopCLi^BaY!!W6-zxeEz;6J03Fuejy@!CO2(Z<7zd^t|1bn8zyB%hK zUcIgMwP&`ISTNaMQP$#ACQfCpW>1b&nK+e+Q<*rGiBp-Ymf0(x%J>^OcQl_muqVq# zvE)7=W<+LETJ|ERk0u)!Wdi_)RczKU91jn*3$ejzbB6oUPQlTd$$c z&H(r(8eq{*?k4)RA%Hd^X1&*3?`}y~Q>aI1Vf*w&&fctdUnZ<#OF_ztvvX)j%_vvcGU72HeLM_IgrD utXs!u6O-)mmzBH_E>!i_)~}C;$MnS+vLi diff --git a/packages/backend/server/src/__tests__/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot.spec.ts index c894992bf..27ff45b0a 100644 --- a/packages/backend/server/src/__tests__/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot.spec.ts @@ -11,7 +11,11 @@ import { EventBus, JobQueue } from '../base'; import { ConfigModule } from '../base/config'; import { AuthService } from '../core/auth'; import { QuotaModule } from '../core/quota'; -import { ContextCategories, WorkspaceModel } from '../models'; +import { + ContextCategories, + CopilotSessionModel, + WorkspaceModel, +} from '../models'; import { CopilotModule } from '../plugins/copilot'; import { CopilotContextService } from '../plugins/copilot/context'; import { @@ -57,12 +61,13 @@ import { MockCopilotProvider } from './mocks'; import { createTestingModule, TestingModule } from './utils'; import { WorkflowTestCases } from './utils/copilot'; -const test = ava as TestFn<{ +type Context = { auth: AuthService; module: TestingModule; db: PrismaClient; event: EventBus; workspace: WorkspaceModel; + copilotSession: CopilotSessionModel; context: CopilotContextService; prompt: PromptService; transcript: CopilotTranscriptionService; @@ -78,7 +83,8 @@ const test = ava as TestFn<{ html: CopilotCheckHtmlExecutor; json: CopilotCheckJsonExecutor; }; -}>; +}; +const test = ava as TestFn; let userId: string; test.before(async t => { @@ -119,6 +125,7 @@ test.before(async t => { const db = module.get(PrismaClient); const event = module.get(EventBus); const workspace = module.get(WorkspaceModel); + const copilotSession = module.get(CopilotSessionModel); const prompt = module.get(PromptService); const factory = module.get(CopilotProviderFactory); @@ -136,6 +143,7 @@ test.before(async t => { t.context.db = db; t.context.event = event; t.context.workspace = workspace; + t.context.copilotSession = copilotSession; t.context.prompt = prompt; t.context.factory = factory; t.context.session = session; @@ -1752,3 +1760,168 @@ test('should be able to manage workspace embedding', async t => { t.is(ret2.length, 0, 'should not match workspace context'); } }); + +test('should handle generateSessionTitle correctly under various conditions', async t => { + const { prompt, session, workspace, copilotSession } = t.context; + + await prompt.set('test', 'model', [{ role: 'user', content: '{{content}}' }]); + const createSession = async ( + options: { + userMessage?: string; + assistantMessage?: string; + existingTitle?: string; + } = {} + ) => { + const ws = await workspace.create(userId); + const sessionId = await session.create({ + docId: 'test-doc', + workspaceId: ws.id, + userId, + promptName: 'test', + pinned: false, + }); + + if (options.existingTitle) { + await copilotSession.update({ + userId, + sessionId, + title: options.existingTitle, + }); + } + + const chatSession = await session.get(sessionId); + if (chatSession) { + if (options.userMessage) { + chatSession.push({ + role: 'user', + content: options.userMessage, + createdAt: new Date(), + }); + } + if (options.assistantMessage) { + chatSession.push({ + role: 'assistant', + content: options.assistantMessage, + createdAt: new Date(), + }); + } + await chatSession.save(); + } + + return sessionId; + }; + + const testCases = [ + { + name: 'should generate title when conditions are met', + setup: () => + createSession({ + userMessage: 'What is machine learning?', + assistantMessage: + 'Machine learning is a subset of artificial intelligence.', + }), + mockFn: () => 'What is Machine Learning?', + expectSnapshot: true, + }, + { + name: 'should not generate title when session already has title', + setup: () => + createSession({ + userMessage: 'Test message', + assistantMessage: 'Test response', + existingTitle: 'Existing Title', + }), + mockFn: () => 'New Title', + expectSnapshot: true, + expectNotCalled: true, + }, + { + name: 'should not generate title when no user messages exist', + setup: () => + createSession({ assistantMessage: 'Hello! How can I help you?' }), + mockFn: () => 'New Title', + expectSnapshot: true, + expectNotCalled: true, + }, + { + name: 'should not generate title when no assistant messages exist', + setup: () => createSession({ userMessage: 'What is AI?' }), + mockFn: () => 'New Title', + expectSnapshot: true, + expectNotCalled: true, + }, + { + name: 'should handle errors gracefully', + setup: () => + createSession({ + userMessage: 'Test question', + assistantMessage: 'Test answer', + }), + mockFn: () => { + throw new Error('Mock error for testing'); + }, + expectError: 'Mock error for testing', + }, + ]; + + for (const testCase of testCases) { + const sessionId = await testCase.setup(); + let chatWithPromptCalled = false; + + const mockStub = Sinon.stub(session, 'chatWithPrompt').callsFake( + async () => { + chatWithPromptCalled = true; + return testCase.mockFn(); + } + ); + + if (testCase.expectError) { + await t.throwsAsync( + () => session.generateSessionTitle({ sessionId }), + { message: testCase.expectError }, + testCase.name + ); + } else { + await session.generateSessionTitle({ sessionId }); + + if (testCase.expectSnapshot) { + const sessionState = await session.getSession(sessionId); + t.snapshot( + { + chatWithPromptCalled: testCase.expectNotCalled + ? chatWithPromptCalled + : undefined, + title: sessionState?.title, + exists: !!sessionState, + }, + testCase.name + ); + } + } + + mockStub.restore(); + } + + { + const sessionId = await createSession({ + userMessage: 'Explain quantum computing briefly', + assistantMessage: 'Quantum computing uses quantum mechanics principles.', + }); + + let capturedArgs: any[] = []; + Sinon.stub(session, 'chatWithPrompt').callsFake(async (...args) => { + capturedArgs = args; + return 'Quantum Computing Explained'; + }); + + await session.generateSessionTitle({ sessionId }); + + t.snapshot( + { + promptName: capturedArgs[0], + content: capturedArgs[1]?.content, + }, + 'should use correct prompt for title generation' + ); + } +}); diff --git a/packages/backend/server/src/__tests__/models/copilot-context.spec.ts b/packages/backend/server/src/__tests__/models/copilot-context.spec.ts index f768b2a6c..1c7465f99 100644 --- a/packages/backend/server/src/__tests__/models/copilot-context.spec.ts +++ b/packages/backend/server/src/__tests__/models/copilot-context.spec.ts @@ -58,6 +58,7 @@ test.beforeEach(async t => { workspaceId: workspace.id, docId, userId: user.id, + title: null, promptName: 'prompt-name', promptAction: null, }); diff --git a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts index d521daa11..5586dfc51 100644 --- a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts +++ b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts @@ -82,6 +82,7 @@ const createTestSession = async ( workspaceId: workspace.id, docId: null, pinned: false, + title: null, promptName: TEST_PROMPTS.NORMAL, promptAction: null, ...overrides, @@ -297,6 +298,7 @@ test('should pin and unpin sessions', async t => { promptName: 'test-prompt', promptAction: null, pinned: true, + title: null, }); const firstSession = await copilotSession.get(firstSessionId); @@ -312,6 +314,7 @@ test('should pin and unpin sessions', async t => { promptName: 'test-prompt', promptAction: null, pinned: true, + title: null, }); const sessionStatesAfterSecondPin = await getSessionStates(db, [ @@ -796,6 +799,7 @@ test('should handle fork and session attachment operations', async t => { workspaceId: workspace.id, docId: forkConfig.docId, pinned: forkConfig.pinned, + title: null, parentSessionId, prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-4.1' }, messages: [ diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index 92c26613d..a10f68fb1 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -50,6 +50,7 @@ type PureChatSession = { workspaceId: string; docId?: string | null; pinned?: boolean; + title: string | null; messages?: ChatMessage[]; // connect ids userId: string; @@ -82,7 +83,7 @@ type UpdateChatSessionMessage = ChatSessionBaseState & { }; export type UpdateChatSessionOptions = ChatSessionBaseState & - Pick, 'docId' | 'pinned' | 'promptName'>; + Pick, 'docId' | 'pinned' | 'promptName' | 'title'>; export type UpdateChatSession = ChatSessionBaseState & UpdateChatSessionOptions; @@ -254,7 +255,7 @@ export class CopilotSessionModel extends BaseModel { return (await this.db.aiSession.findUnique({ where: { ...where, id: sessionId, deletedAt: null }, select, - })) as Prisma.AiSessionGetPayload<{ select: Select }>; + })) as Prisma.AiSessionGetPayload<{ select: Select }> | null; } @Transactional() @@ -266,6 +267,7 @@ export class CopilotSessionModel extends BaseModel { docId: true, pinned: true, parentSessionId: true, + title: true, messages: { select: { id: true, @@ -331,6 +333,7 @@ export class CopilotSessionModel extends BaseModel { docId: true, parentSessionId: true, pinned: true, + title: true, promptName: true, tokenCost: true, createdAt: true, @@ -373,7 +376,7 @@ export class CopilotSessionModel extends BaseModel { @Transactional() async update(options: UpdateChatSessionOptions): Promise { - const { userId, sessionId, docId, promptName, pinned } = options; + const { userId, sessionId, docId, promptName, pinned, title } = options; const session = await this.getExists( sessionId, { @@ -419,7 +422,7 @@ export class CopilotSessionModel extends BaseModel { await this.db.aiSession.update({ where: { id: sessionId }, - data: { docId, promptName, pinned }, + data: { docId, promptName, pinned, title }, }); return sessionId; @@ -522,17 +525,29 @@ export class CopilotSessionModel extends BaseModel { if (!id) { throw new CopilotSessionNotFound(); } - const ids = await this.getMessages(id, { id: true, role: true }).then( - roles => - roles - .slice( - roles.findLastIndex(({ role }) => role === AiPromptRole.user) + - (removeLatestUserMessage ? 0 : 1) - ) - .map(({ id }) => id) - ); + const messages = await this.getMessages(id, { id: true, role: true }); + const ids = messages + .slice( + messages.findLastIndex(({ role }) => role === AiPromptRole.user) + + (removeLatestUserMessage ? 0 : 1) + ) + .map(({ id }) => id); + if (ids.length) { await this.db.aiSessionMessage.deleteMany({ where: { id: { in: ids } } }); + + // clear the title if there only one round of conversation left + const remainingMessages = await this.getMessages(id, { role: true }); + const userMessageCount = remainingMessages.filter( + m => m.role === AiPromptRole.user + ).length; + + if (userMessageCount <= 1) { + await this.db.aiSession.update({ + where: { id }, + data: { title: null }, + }); + } } } diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 439a3ecc3..e0fa5ac1d 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -67,7 +67,9 @@ class CreateChatSessionInput { } @InputType() -class UpdateChatSessionInput implements Omit { +class UpdateChatSessionInput + implements Omit +{ @Field(() => String) sessionId!: string; @@ -336,6 +338,9 @@ export class CopilotSessionType { @Field(() => Boolean) pinned!: boolean; + @Field(() => String, { nullable: true }) + title!: string | null; + @Field(() => ID, { nullable: true }) parentSessionId!: string | null; @@ -653,6 +658,7 @@ export class CopilotResolver { parentSessionId: session.parentSessionId, docId: session.docId, pinned: session.pinned, + title: session.title, promptName: session.prompt.name, model: session.prompt.model, optionalModels: session.prompt.optionalModels, diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index 3a2c57847..dbd57fc95 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { Transactional } from '@nestjs-cls/transactional'; import { AiPromptRole } from '@prisma/client'; @@ -11,6 +12,9 @@ import { CopilotQuotaExceeded, CopilotSessionInvalidInput, CopilotSessionNotFound, + JobQueue, + NoCopilotProviderAvailable, + OnJob, } from '../../base'; import { QuotaService } from '../../core/quota'; import { @@ -22,7 +26,12 @@ import { } from '../../models'; import { ChatMessageCache } from './message'; import { PromptService } from './prompt'; -import { PromptMessage, PromptParams } from './providers'; +import { + CopilotProviderFactory, + ModelOutputType, + PromptMessage, + PromptParams, +} from './providers'; import { type ChatHistory, type ChatMessage, @@ -33,6 +42,14 @@ import { type SubmittedMessage, } from './types'; +declare global { + interface Jobs { + 'copilot.session.generateTitle': { + sessionId: string; + }; + } +} + export class ChatSession implements AsyncDisposable { private stashMessageCount = 0; constructor( @@ -224,10 +241,12 @@ export class ChatSessionService { private readonly logger = new Logger(ChatSessionService.name); constructor( + private readonly moduleRef: ModuleRef, + private readonly models: Models, + private readonly jobs: JobQueue, private readonly quota: QuotaService, private readonly messageCache: ChatMessageCache, - private readonly prompt: PromptService, - private readonly models: Models + private readonly prompt: PromptService ) {} async getSession(sessionId: string): Promise { @@ -244,6 +263,7 @@ export class ChatSessionService { workspaceId: session.workspaceId, docId: session.docId, pinned: session.pinned, + title: session.title, parentSessionId: session.parentSessionId, prompt, messages: messages.success ? messages.data : [], @@ -282,6 +302,7 @@ export class ChatSessionService { workspaceId: session.workspaceId, docId: session.docId, pinned: session.pinned, + title: session.title, parentSessionId: session.parentSessionId, prompt, }; @@ -303,6 +324,7 @@ export class ChatSessionService { workspaceId, docId, pinned, + title, promptName, tokenCost, messages, @@ -347,6 +369,7 @@ export class ChatSessionService { workspaceId, docId, pinned, + title, action: prompt.action || null, tokens: tokenCost, createdAt, @@ -418,6 +441,7 @@ export class ChatSessionService { ...options, sessionId, prompt, + title: null, messages: [], // when client create chat session, we always find root session parentSessionId: null, @@ -520,8 +544,78 @@ export class ChatSessionService { if (state) { return new ChatSession(this.messageCache, state, async state => { await this.models.copilotSession.updateMessages(state); + if (!state.prompt.action) { + await this.jobs.add('copilot.session.generateTitle', { sessionId }); + } }); } return null; } + + // public for test mock + async chatWithPrompt( + promptName: string, + message: Partial + ): Promise { + const prompt = await this.prompt.get(promptName); + if (!prompt) { + throw new CopilotPromptNotFound({ name: promptName }); + } + + const cond = { modelId: prompt.model }; + const msg = { role: 'user' as const, content: '', ...message }; + const config = Object.assign({}, prompt.config); + + const provider = await this.moduleRef + .get(CopilotProviderFactory) + .getProvider({ + outputType: ModelOutputType.Text, + modelId: prompt.model, + }); + + if (!provider) { + throw new NoCopilotProviderAvailable(); + } + + return provider.text(cond, [...prompt.finish({}), msg], config); + } + + @OnJob('copilot.session.generateTitle') + async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) { + const { sessionId } = job; + + try { + const session = await this.models.copilotSession.get(sessionId); + if (!session) { + this.logger.warn( + `Session ${sessionId} not found when generating title` + ); + return; + } + const { userId, title, messages } = session; + if ( + title || + !messages.length || + messages.filter(m => m.role === 'user').length === 0 || + messages.filter(m => m.role === 'assistant').length === 0 + ) { + return; + } + + { + const title = await this.chatWithPrompt('Summary as title', { + content: session.messages + .map(m => `[${m.role}]: ${m.content}`) + .join('\n'), + }); + await this.models.copilotSession.update({ userId, sessionId, title }); + } + } catch (error) { + console.error( + `Failed to generate title for session ${sessionId}:`, + error + ); + throw error; + } + } } diff --git a/packages/backend/server/src/plugins/copilot/types.ts b/packages/backend/server/src/plugins/copilot/types.ts index 4071dacc7..fc8186cd9 100644 --- a/packages/backend/server/src/plugins/copilot/types.ts +++ b/packages/backend/server/src/plugins/copilot/types.ts @@ -50,6 +50,7 @@ export const ChatHistorySchema = z workspaceId: z.string(), docId: z.string().nullable(), pinned: z.boolean(), + title: z.string().nullable(), action: z.string().nullable(), tokens: z.number(), messages: z.array(ChatMessageSchema), @@ -85,6 +86,7 @@ export interface ChatSessionForkOptions export interface ChatSessionState extends Omit { + title: string | null; // connect ids sessionId: string; parentSessionId: string | null; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index aaafc45e8..d6fa23f09 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -324,6 +324,7 @@ type CopilotSessionType { parentSessionId: ID pinned: Boolean! promptName: String! + title: String } type CopilotWorkspaceConfig { diff --git a/packages/common/graphql/src/graphql/copilot-session-get.gql b/packages/common/graphql/src/graphql/copilot-session-get.gql index 250cac827..b331aa1bc 100644 --- a/packages/common/graphql/src/graphql/copilot-session-get.gql +++ b/packages/common/graphql/src/graphql/copilot-session-get.gql @@ -9,6 +9,7 @@ query getCopilotSession( parentSessionId docId pinned + title promptName model optionalModels diff --git a/packages/common/graphql/src/graphql/copilot-sessions-get.gql b/packages/common/graphql/src/graphql/copilot-sessions-get.gql index 6e6d7eed8..3ee9b4ad5 100644 --- a/packages/common/graphql/src/graphql/copilot-sessions-get.gql +++ b/packages/common/graphql/src/graphql/copilot-sessions-get.gql @@ -10,6 +10,7 @@ query getCopilotSessions( parentSessionId docId pinned + title promptName model optionalModels diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 5e50468a4..8cb20a09e 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -799,6 +799,7 @@ export const getCopilotSessionQuery = { parentSessionId docId pinned + title promptName model optionalModels @@ -848,6 +849,7 @@ export const getCopilotSessionsQuery = { parentSessionId docId pinned + title promptName model optionalModels diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 274c55c37..0f420393c 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -419,6 +419,7 @@ export interface CopilotSessionType { parentSessionId: Maybe; pinned: Scalars['Boolean']['output']; promptName: Scalars['String']['output']; + title: Maybe; } export interface CopilotWorkspaceConfig { @@ -3619,6 +3620,7 @@ export type GetCopilotSessionQuery = { parentSessionId: string | null; docId: string | null; pinned: boolean; + title: string | null; promptName: string; model: string; optionalModels: Array; @@ -3680,6 +3682,7 @@ export type GetCopilotSessionsQuery = { parentSessionId: string | null; docId: string | null; pinned: boolean; + title: string | null; promptName: string; model: string; optionalModels: Array;