diff --git a/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql b/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql index 6def2babd..32306b793 100644 --- a/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql +++ b/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql @@ -5,14 +5,16 @@ ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "doc_id" DROP NOT NULL; ALTER TABLE "ai_sessions_metadata" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false; -- AlterTable -CREATE UNIQUE INDEX idx_ai_session_unique_pinned -ON ai_sessions_metadata (user_id, workspace_id) -WHERE pinned = true AND deleted_at IS NULL; +ALTER TABLE "ai_sessions_metadata" ADD COLUMN "prompt_action" VARCHAR(32) DEFAULT ''; --- AlterTable -CREATE UNIQUE INDEX idx_ai_session_unique_doc_root -ON ai_sessions_metadata (user_id, workspace_id, doc_id) -WHERE parent_session_id IS NULL AND doc_id IS NOT NULL AND deleted_at IS NULL; +-- CreateIndex +CREATE UNIQUE INDEX "ai_session_unique_pinned_idx" ON "ai_sessions_metadata" (user_id, workspace_id) WHERE pinned = true AND deleted_at IS NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "ai_session_unique_doc_session_idx" ON "ai_sessions_metadata" (user_id, workspace_id, doc_id) WHERE prompt_action IS NULL AND parent_session_id IS NULL AND doc_id IS NOT NULL AND deleted_at IS NULL; + +-- CreateIndex +CREATE INDEX "ai_sessions_metadata_prompt_name_idx" ON "ai_sessions_metadata"("prompt_name"); -- DropIndex DROP INDEX "ai_sessions_metadata_user_id_workspace_id_idx"; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 51e666f94..de3ad5f44 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -436,6 +436,7 @@ model AiSession { workspaceId String @map("workspace_id") @db.VarChar docId String? @map("doc_id") @db.VarChar promptName String @map("prompt_name") @db.VarChar(32) + promptAction String? @default("") @map("prompt_action") @db.VarChar(32) pinned Boolean @default(false) // the session id of the parent session if this session is a forked session parentSessionId String? @map("parent_session_id") @db.VarChar @@ -449,6 +450,12 @@ model AiSession { messages AiSessionMessage[] context AiContext[] + //NOTE: + // unrecorded index: + // @@index([userId, workspaceId]) where pinned = true and deleted_at is null + // @@index([userId, workspaceId, docId]) where prompt_action is null and parent_session_id is null and doc_id is not null and deleted_at is null + // since prisma does not support partial indexes, those indexes are only exists in migration files. + @@index([promptName]) @@index([userId]) @@index([userId, workspaceId, docId]) @@map("ai_sessions_metadata") diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md index 301b6c87c..3886e838d 100644 --- a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md +++ b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md @@ -25,6 +25,12 @@ Generated by [AVA](https://avajs.dev). { docId: 'doc-id-1', pinned: false, + promptName: 'action-prompt', + }, + { + docId: 'doc-id-1', + pinned: false, + promptName: 'test-prompt', }, ] diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap index 7c87d7102..89544e837 100644 Binary files a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap and b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap differ 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 d36542dab..f3648f32e 100644 --- a/packages/backend/server/src/__tests__/models/copilot-context.spec.ts +++ b/packages/backend/server/src/__tests__/models/copilot-context.spec.ts @@ -59,6 +59,7 @@ test.beforeEach(async t => { docId, userId: user.id, 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 36cb4cc04..237fc77c4 100644 --- a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts +++ b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts @@ -11,6 +11,7 @@ import { WorkspaceModel, } from '../../models'; import { createTestingModule, type TestingModule } from '../utils'; +import { cleanObject } from '../utils/copilot'; interface Context { module: TestingModule; @@ -65,6 +66,7 @@ const createTestSession = async ( docId: string | null; pinned: boolean; promptName: string; + promptAction: string | null; }> = {} ) => { const sessionData = { @@ -74,6 +76,7 @@ const createTestSession = async ( docId: null, pinned: false, promptName: 'test-prompt', + promptAction: null, ...overrides, }; @@ -98,6 +101,12 @@ test('should list and filter session type', async t => { await createTestSession(t, { sessionId: randomUUID() }); await createTestSession(t, { sessionId: randomUUID(), pinned: true }); await createTestSession(t, { sessionId: randomUUID(), docId }); + await createTestSession(t, { + sessionId: randomUUID(), + docId, + promptName: 'action-prompt', + promptAction: 'action', + }); // should list sessions { @@ -113,7 +122,13 @@ test('should list and filter session type', async t => { const docSessions = await copilotSession.list(user.id, workspace.id, docId); t.snapshot( - docSessions.map(s => ({ docId: s.docId, pinned: s.pinned })), + cleanObject(docSessions, [ + 'id', + 'userId', + 'createdAt', + 'messages', + 'tokenCost', + ]), 'doc sessions should only include sessions with matching docId' ); } @@ -206,6 +221,7 @@ test('should pin and unpin sessions', async t => { workspaceId: workspace.id, docId: null, promptName: 'test-prompt', + promptAction: null, pinned: true, }); @@ -220,6 +236,7 @@ test('should pin and unpin sessions', async t => { workspaceId: workspace.id, docId: null, promptName: 'test-prompt', + promptAction: null, pinned: true, }); @@ -336,6 +353,15 @@ test('session updates and type conversions', async t => { await convertSession('workspace_to_pinned', { pinned: true }); // Workspace → Pinned session } + // not allow convert to action prompt + { + await t.throwsAsync( + copilotSession.update(user.id, sessionId, { + promptName: 'action-prompt', + }) + ); + } + t.snapshot(conversionSteps, 'session type conversion steps'); } }); diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index c335ae364..07990886c 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -47,6 +47,7 @@ type ChatSession = { // connect ids userId: string; promptName: string; + promptAction: string | null; parentSessionId?: string | null; }; @@ -95,9 +96,9 @@ export class CopilotSessionModel extends BaseModel { } // NOTE: just for test, remove it after copilot prompt model is ready - async createPrompt(name: string, model: string) { + async createPrompt(name: string, model: string, action?: string) { await this.db.aiPrompt.create({ - data: { name, model }, + data: { name, model, action: action ?? null }, }); } @@ -116,6 +117,7 @@ export class CopilotSessionModel extends BaseModel { // connect userId: state.userId, promptName: state.promptName, + promptAction: state.promptAction, parentSessionId: state.parentSessionId, }, }); @@ -134,7 +136,9 @@ export class CopilotSessionModel extends BaseModel { } @Transactional() - async getChatSessionId(state: Omit) { + async getChatSessionId( + state: Omit + ) { const extraCondition: Record = {}; if (state.parentSessionId) { // also check session id if provided session is forked session @@ -284,10 +288,21 @@ export class CopilotSessionModel extends BaseModel { if (!session) { throw new CopilotSessionNotFound(); } - if (data.promptName && session.prompt.action) { - throw new CopilotSessionInvalidInput( - `Cannot update prompt for action: ${session.id}` - ); + if (data.promptName) { + if (session.prompt.action) { + throw new CopilotSessionInvalidInput( + `Cannot update prompt for action: ${session.id}` + ); + } + const prompt = await this.db.aiPrompt.findFirst({ + where: { name: data.promptName }, + }); + // always not allow to update to action prompt + if (!prompt || prompt.action) { + throw new CopilotSessionInvalidInput( + `Prompt ${data.promptName} not found or not available for session ${sessionId}` + ); + } } if (data.pinned && data.pinned !== session.pinned) { // if pin the session, unpin exists session in the workspace diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 39c9e5400..8695be510 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -1880,5 +1880,14 @@ export async function refreshPrompts(db: PrismaClient) { }, }, }); + + await db.aiSession.updateMany({ + where: { + promptName: prompt.name, + }, + data: { + promptAction: prompt.action ?? null, + }, + }); } } diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index 05b164b94..88f1efb2f 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -257,6 +257,7 @@ export class ChatSessionService { ...state, sessionId, promptName: state.prompt.name, + promptAction: state.prompt.action ?? null, }); } @@ -615,7 +616,7 @@ export class ChatSessionService { await tx.aiSession.updateMany({ where: { id: { in: actionIds } }, - data: { deletedAt: new Date() }, + data: { pinned: false, deletedAt: new Date() }, }); return [...sessionIds, ...actionIds];