diff --git a/.project-docs/30-worklog/tasks/20260806-design-multisession-ui-4e7d.md b/.project-docs/30-worklog/tasks/20260806-design-multisession-ui-4e7d.md new file mode 100644 index 0000000..d7eedc2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260806-design-multisession-ui-4e7d.md @@ -0,0 +1,56 @@ +# Task: AI 设计项目多会话客户端 + +## Identity + +- Task ID: 20260806-design-multisession-ui-4e7d +- Mode: Feature +- Branch: codex/20260806-design-multisession-ui-4e7d-design-multisession-ui +- Worktree: D:\Datas\OthersProjects\makelore-design-multisession-4e7d +- Base commit: 498089401708dc08123141891dba9b54e47cefea +- Owner: codex +- Status: Completed + +## Scope + +- Extend the shared AI Design contract with project Conversations and Conversation-tagged events/tasks. +- Add Electron adapter and host API support for listing, creating, reading and submitting to a Conversation. +- Add renderer store state/actions for the active Conversation and protect asynchronous/streaming updates with Workspace + Conversation identity. +- Add a compact Conversation selector and “new conversation” action to AI Design while leaving the generation-task panel project-scoped. +- Migrate the local fallback Workspace data model to create a default Conversation. +- Cover adapter, route, store and page behavior, including Conversation switching and project-level task persistence. + +## Intent And Constraints + +- Product model: one design project contains multiple independent conversations; starting a new conversation must not create a new project. +- The selected Conversation owns messages, Brief, Quote and `turnRevision`; changing Conversations must not leak pending or streaming text. +- Generation tasks and assets belong to the project and remain visible when the selected Conversation changes. +- Each Conversation uses its stable Gateway Session/stream. Switching Conversation reconnects to that Session without changing projects; task events delivered on any Conversation stream are still reduced by `workspaceId` only. +- Preserve compatibility with the deployed single-conversation server during rollout where practical, without retaining two sources of client state. +- Keep the existing visual language and input behavior, including Enter to send, Shift+Enter for newline and IME composition protection. + +## Outcome + +- Split the client model into Workspace, Conversation and Workspace-owned generation tasks, including local v2-to-v3 migration of the previous single chat into a default historical Conversation. +- Added Electron/host APIs for Conversation list/create/read/turn/confirm/event flows and reused the server-owned persistent Gateway Session for each Conversation. +- Added active Conversation state, guarded async/stream updates by Workspace + Conversation identity, and preserved the Workspace task list while switching chats. +- Added generation-token and monotonic-revision guards for Conversation/Workspace ABA races, including late failures. +- Task event watermarks now advance only from real stream events; REST freshness continues to use per-task `updatedAt`. +- Added Conversation switching and new-Conversation actions to both the AI Design header and expanded project sidebar, with a global in-flight guard against duplicate creation. +- Preserved composer keyboard behavior: Enter sends, Shift+Enter inserts a newline, and IME composition Enter is ignored. + +## Verification + +- TypeScript `tsc --noEmit`: passed. +- Focused AI Design suites: `7 files / 93 tests passed`, including all 19 migrated adapter behavior tests. +- ESLint for all changed TypeScript/TSX files: passed. +- Production Vite + Electron build: passed; only existing chunk-size/dynamic-import warnings remain. +- Full Vitest run is not green on the base environment: 19 failures reproduce in the original client tree (missing `zip`/`.opencode` resources and existing timing checks); one additional speech-transcription test is outside this task's diff and passes only with the user's uncommitted original-tree changes. + +## Follow-ups + +- Publish only after server migration `0033` and the multi-conversation API are live. +- Consider pagination/search when a project grows beyond the six recent Conversations shown inline in the sidebar. + +## Promotion Candidates + +- Promote Workspace/Conversation/Task client-state ownership and stream routing into the canonical client architecture/current-state documents during integration. diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 6dac961..1d76292 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -11,6 +11,7 @@ import { IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, type DesignAssetUploadInput, + type DesignCreateConversationInput, type DesignConfirmGenerationInput, type DesignCreateWorkspaceInput, type DesignRenameWorkspaceInput, @@ -283,6 +284,7 @@ async function relayWorkspaceEvents( res: ServerResponse, ctx: HostApiContext, workspaceId: string, + conversationId: string, ): Promise { if (!ctx.imageWorkspace?.openWorkspaceEvents) { throw new DesignWorkspaceModuleError( @@ -295,6 +297,7 @@ async function relayWorkspaceEvents( const afterEventId = Array.isArray(header) ? header[0] : header; const subscription = await ctx.imageWorkspace.openWorkspaceEvents({ workspaceId, + conversationId, ...(afterEventId ? { afterEventId } : {}), }); let closed = false; @@ -399,11 +402,35 @@ export async function handleImageWorkspaceRoutes( if (segments.length === 3 && segments[0] === 'workspaces' - && segments[2] === 'messages' + && segments[2] === 'conversations' + && req.method === 'POST') { + const body = await parseJsonBody>(req); + const input: DesignCreateConversationInput = { + workspaceId: segments[1], + clientConversationId: asString(body.clientConversationId), + title: asString(body.title), + }; + sendData(res, await ctx.imageWorkspace.createConversation(input)); + return true; + } + + if (segments.length === 4 + && segments[0] === 'workspaces' + && segments[2] === 'conversations' + && req.method === 'GET') { + sendData(res, await ctx.imageWorkspace.getConversation(segments[1], segments[3])); + return true; + } + + if (segments.length === 5 + && segments[0] === 'workspaces' + && segments[2] === 'conversations' + && segments[4] === 'messages' && req.method === 'POST') { const body = await parseJsonBody>(req); const input: DesignSubmitMessageInput = { workspaceId: segments[1], + conversationId: segments[3], clientTurnId: asString(body.clientTurnId), expectedTurnRevision: asInteger(body.expectedTurnRevision), message: asString(body.message), @@ -440,23 +467,26 @@ export async function handleImageWorkspaceRoutes( return true; } - if (segments.length === 3 + if (segments.length === 5 && segments[0] === 'workspaces' - && segments[2] === 'events' + && segments[2] === 'conversations' + && segments[4] === 'events' && req.method === 'GET') { - await relayWorkspaceEvents(req, res, ctx, segments[1]); + await relayWorkspaceEvents(req, res, ctx, segments[1], segments[3]); return true; } - if (segments.length === 5 + if (segments.length === 7 && segments[0] === 'workspaces' - && segments[2] === 'quotes' - && segments[4] === 'confirm' + && segments[2] === 'conversations' + && segments[4] === 'quotes' + && segments[6] === 'confirm' && req.method === 'POST') { const body = await parseJsonBody>(req); const input: DesignConfirmGenerationInput = { workspaceId: segments[1], - quoteId: segments[3], + conversationId: segments[3], + quoteId: segments[5], clientTurnId: asString(body.clientTurnId), expectedTurnRevision: asInteger(body.expectedTurnRevision), }; diff --git a/electron/image-workspace/local-workspace.ts b/electron/image-workspace/local-workspace.ts index fa8dddb..410cca3 100644 --- a/electron/image-workspace/local-workspace.ts +++ b/electron/image-workspace/local-workspace.ts @@ -3,7 +3,10 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import type { DesignCapabilities, + DesignConversation, + DesignConversationSummary, DesignConfirmGenerationInput, + DesignCreateConversationInput, DesignCreateWorkspaceInput, DesignGenerationQuote, DesignGenerationTask, @@ -12,13 +15,15 @@ import type { DesignSubmitMessageInput, DesignWorkspace, DesignWorkspaceBootstrap, + DesignWorkspaceSummary, } from '../../shared/image-workspace'; import { designAssetContentPath } from '../../shared/image-workspace'; import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module'; -const LOCAL_WORKSPACE_SCHEMA_VERSION = 2; +const LOCAL_WORKSPACE_SCHEMA_VERSION = 3; const LOCAL_WORKSPACE_DIRECTORY = 'image-workspace-development'; -const LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json'; +const LOCAL_WORKSPACE_FILE = 'design-workspace-v3.json'; +const LEGACY_LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json'; const MAX_PROJECT_NAME_LENGTH = 80; const MAX_MESSAGE_LENGTH = 4_000; @@ -29,8 +34,12 @@ const LOCAL_CAPABILITIES: DesignCapabilities = { video: true, }; -type PersistedWorkspace = DesignWorkspace & { +type PersistedWorkspace = DesignWorkspaceSummary & { clientWorkspaceId: string; +}; + +type PersistedConversation = DesignConversation & { + clientConversationId: string; requestHashes: Record; }; @@ -43,6 +52,27 @@ type PersistedAsset = { type PersistedImageWorkspace = { schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION; workspaces: PersistedWorkspace[]; + conversationsByWorkspaceId: Record; + tasksByWorkspaceId: Record; + assetsById: Record; +}; + +type LegacyPersistedWorkspace = { + workspaceId: string; + clientWorkspaceId: string; + title: string; + turnRevision: number; + viewRevision: number; + phase: DesignConversation['phase']; + brief: DesignConversation['brief']; + messages: DesignConversation['messages']; + requestHashes: Record; + updatedAt: string; +}; + +type LegacyPersistedImageWorkspace = { + schemaVersion: 2; + workspaces: LegacyPersistedWorkspace[]; tasksByWorkspaceId: Record; assetsById: Record; }; @@ -64,6 +94,7 @@ function createEmptyState(): PersistedImageWorkspace { return { schemaVersion: LOCAL_WORKSPACE_SCHEMA_VERSION, workspaces: [], + conversationsByWorkspaceId: {}, tasksByWorkspaceId: {}, assetsById: {}, }; @@ -81,6 +112,15 @@ function isPersistedWorkspace(value: unknown): value is PersistedImageWorkspace return isRecord(value) && value.schemaVersion === LOCAL_WORKSPACE_SCHEMA_VERSION && Array.isArray(value.workspaces) + && isRecord(value.conversationsByWorkspaceId) + && isRecord(value.tasksByWorkspaceId) + && isRecord(value.assetsById); +} + +function isLegacyPersistedWorkspace(value: unknown): value is LegacyPersistedImageWorkspace { + return isRecord(value) + && value.schemaVersion === 2 + && Array.isArray(value.workspaces) && isRecord(value.tasksByWorkspaceId) && isRecord(value.assetsById); } @@ -161,6 +201,7 @@ export function getLocalImageWorkspaceDirectory(userDataDir: string): string { export class LocalImageWorkspace implements DesignWorkspaceModule { private readonly rootDirectory: string; private readonly stateFile: string; + private readonly legacyStateFile: string; private readonly now: () => Date; private readonly createId: () => string; private state: PersistedImageWorkspace | null = null; @@ -173,6 +214,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { throw new Error('Invalid local AI design workspace directory'); } this.stateFile = join(this.rootDirectory, LOCAL_WORKSPACE_FILE); + this.legacyStateFile = join(this.rootDirectory, LEGACY_LOCAL_WORKSPACE_FILE); this.now = options.now ?? (() => new Date()); this.createId = options.createId ?? randomUUID; } @@ -191,39 +233,71 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { } createWorkspace(input: DesignCreateWorkspaceInput): Promise { - return this.mutate((state) => { + return this.mutateWorkspace((state) => { const existing = state.workspaces.find( (workspace) => workspace.clientWorkspaceId === input.clientWorkspaceId, ); if (existing) return existing; const timestamp = this.now().toISOString(); + const workspaceId = `local-workspace-${this.createId()}`; const workspace: PersistedWorkspace = { - workspaceId: `local-workspace-${this.createId()}`, + workspaceId, clientWorkspaceId: input.clientWorkspaceId, title: validateTitle(input.title), - turnRevision: 0, viewRevision: 0, + conversationCount: 1, phase: 'shaping', - brief: { - version: 0, - status: 'draft', - medium: null, - summary: '正在建立作品的视觉方向', - ready: false, - missingDecision: '作品形式', - }, - messages: [], - requestHashes: {}, updatedAt: timestamp, }; + const conversation = this.createEmptyConversation( + workspaceId, + `local-conversation-${this.createId()}`, + `local-client-conversation-${this.createId()}`, + '新会话', + timestamp, + ); state.workspaces.unshift(workspace); - state.tasksByWorkspaceId[workspace.workspaceId] = []; + state.conversationsByWorkspaceId[workspaceId] = [conversation]; + state.tasksByWorkspaceId[workspaceId] = []; return workspace; }); } + createConversation(input: DesignCreateConversationInput): Promise { + return this.mutateConversation((state) => { + const workspace = this.requireWorkspace(state, input.workspaceId); + const conversations = state.conversationsByWorkspaceId[input.workspaceId] ?? []; + const existing = conversations.find( + (conversation) => conversation.clientConversationId === input.clientConversationId, + ); + if (existing) return existing; + const timestamp = this.now().toISOString(); + const conversation = this.createEmptyConversation( + input.workspaceId, + `local-conversation-${this.createId()}`, + input.clientConversationId, + input.title, + timestamp, + ); + conversations.unshift(conversation); + state.conversationsByWorkspaceId[input.workspaceId] = conversations; + workspace.conversationCount = conversations.length; + workspace.viewRevision += 1; + workspace.updatedAt = timestamp; + return conversation; + }); + } + + async getConversation( + workspaceId: string, + conversationId: string, + ): Promise { + const state = await this.load(); + return clone(this.requireConversation(state, workspaceId, conversationId)); + } + renameWorkspace(input: DesignRenameWorkspaceInput): Promise { - return this.mutate((state) => { + return this.mutateWorkspace((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); workspace.title = validateTitle(input.title); workspace.viewRevision += 1; @@ -234,20 +308,25 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { async getWorkspace(workspaceId: string): Promise { const state = await this.load(); - return clone(this.requireWorkspace(state, workspaceId)); + return this.workspaceView(state, this.requireWorkspace(state, workspaceId)); } - submitMessage(input: DesignSubmitMessageInput): Promise { - return this.mutate((state) => { + submitMessage(input: DesignSubmitMessageInput): Promise { + return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); + const conversation = this.requireConversation( + state, + input.workspaceId, + input.conversationId, + ); const message = validateMessage(input.message); const hash = messageHash(input); - if (this.isRequestReplay(workspace, input.clientTurnId, hash)) return workspace; - this.assertRevision(workspace, input.expectedTurnRevision); - this.supersedeQuotes(workspace); + if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; + this.assertRevision(conversation, input.expectedTurnRevision); + this.supersedeQuotes(conversation); const timestamp = this.now().toISOString(); - const nextRevision = workspace.turnRevision + 1; + const nextRevision = conversation.turnRevision + 1; const medium = detectMedium(message); const quote: DesignGenerationQuote = { quoteId: `local-quote-${this.createId()}`, @@ -258,7 +337,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { quotedDesignPoints: medium === 'video' ? 2 : 1, expiresAt: new Date(this.now().getTime() + 15 * 60 * 1000).toISOString(), }; - workspace.messages.push( + conversation.messages.push( { id: `local-message-${this.createId()}`, role: 'user', @@ -282,10 +361,9 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { createdAt: timestamp, }, ); - workspace.turnRevision = nextRevision; - workspace.viewRevision += 1; - workspace.phase = 'awaiting_confirmation'; - workspace.brief = { + conversation.turnRevision = nextRevision; + conversation.phase = 'awaiting_confirmation'; + conversation.brief = { version: nextRevision, status: 'ready', medium, @@ -293,19 +371,27 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { ready: true, missingDecision: null, }; + conversation.updatedAt = timestamp; + conversation.requestHashes[input.clientTurnId] = hash; + workspace.phase = conversation.phase; + workspace.viewRevision += 1; workspace.updatedAt = timestamp; - workspace.requestHashes[input.clientTurnId] = hash; - return workspace; + return conversation; }); } - confirmGeneration(input: DesignConfirmGenerationInput): Promise { - return this.mutate((state) => { + confirmGeneration(input: DesignConfirmGenerationInput): Promise { + return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); + const conversation = this.requireConversation( + state, + input.workspaceId, + input.conversationId, + ); const hash = messageHash(input); - if (this.isRequestReplay(workspace, input.clientTurnId, hash)) return workspace; - this.assertRevision(workspace, input.expectedTurnRevision); - const quote = workspace.messages + if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; + this.assertRevision(conversation, input.expectedTurnRevision); + const quote = conversation.messages .map((message) => message.generationQuote) .find((candidate) => candidate?.quoteId === input.quoteId); if (!quote || quote.status !== 'active') { @@ -318,8 +404,8 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { quote.status = 'consumed'; const timestamp = this.now().toISOString(); - const nextRevision = workspace.turnRevision + 1; - workspace.messages.push( + const nextRevision = conversation.turnRevision + 1; + conversation.messages.push( { id: `local-message-${this.createId()}`, role: 'user', @@ -341,16 +427,19 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { createdAt: timestamp, }, ); - workspace.turnRevision = nextRevision; + conversation.turnRevision = nextRevision; + conversation.phase = 'shaping'; + conversation.brief = { ...conversation.brief, status: 'confirmed' }; + conversation.updatedAt = timestamp; + conversation.requestHashes[input.clientTurnId] = hash; + workspace.phase = conversation.phase; workspace.viewRevision += 1; - workspace.phase = 'shaping'; - workspace.brief = { ...workspace.brief, status: 'confirmed' }; workspace.updatedAt = timestamp; - workspace.requestHashes[input.clientTurnId] = hash; state.tasksByWorkspaceId[workspace.workspaceId] ??= []; state.tasksByWorkspaceId[workspace.workspaceId].unshift({ taskId: `local-task-${this.createId()}`, workspaceId: workspace.workspaceId, + conversationId: conversation.conversationId, medium: quote.medium, status: 'queued', briefVersion: quote.briefVersion, @@ -362,10 +451,39 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { createdAt: timestamp, updatedAt: timestamp, }); - return workspace; + return conversation; }); } + private createEmptyConversation( + workspaceId: string, + conversationId: string, + clientConversationId: string, + title: string, + timestamp: string, + ): PersistedConversation { + return { + conversationId, + clientConversationId, + workspaceId, + title: validateTitle(title), + turnRevision: 0, + phase: 'shaping', + brief: { + version: 0, + status: 'draft', + medium: null, + summary: '正在建立作品的视觉方向', + ready: false, + missingDecision: '作品形式', + }, + messages: [], + requestHashes: {}, + createdAt: timestamp, + updatedAt: timestamp, + }; + } + listTasks(workspaceId: string): Promise { return this.enqueue(async () => { const state = await this.load(); @@ -443,14 +561,25 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { }); } - private mutate( + private mutateWorkspace( operation: (state: PersistedImageWorkspace) => PersistedWorkspace, ): Promise { return this.enqueue(async () => { const state = await this.load(); const workspace = operation(state); await this.persist(state); - return clone(workspace); + return this.workspaceView(state, workspace); + }); + } + + private mutateConversation( + operation: (state: PersistedImageWorkspace) => PersistedConversation, + ): Promise { + return this.enqueue(async () => { + const state = await this.load(); + const conversation = operation(state); + await this.persist(state); + return this.conversationView(conversation); }); } @@ -474,7 +603,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { this.state = parsed; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - this.state = createEmptyState(); + this.state = await this.loadLegacyState(); } else { throw error; } @@ -482,6 +611,62 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { return this.state; } + private async loadLegacyState(): Promise { + try { + const parsed = JSON.parse(await readFile(this.legacyStateFile, 'utf8')) as unknown; + if (!isLegacyPersistedWorkspace(parsed)) { + throw new LocalImageWorkspaceError( + 500, + 'IMAGE_WORKSPACE_LOCAL_DATA_INVALID', + 'AI 设计本地数据暂时无法读取', + ); + } + const migrated = this.migrateLegacyState(parsed); + await this.persist(migrated); + return migrated; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyState(); + throw error; + } + } + + private migrateLegacyState(legacy: LegacyPersistedImageWorkspace): PersistedImageWorkspace { + const migrated = createEmptyState(); + migrated.assetsById = clone(legacy.assetsById); + migrated.tasksByWorkspaceId = clone(legacy.tasksByWorkspaceId); + for (const legacyWorkspace of legacy.workspaces) { + const conversationId = `local-conversation-${this.createId()}`; + const createdAt = legacyWorkspace.messages[0]?.createdAt ?? legacyWorkspace.updatedAt; + const conversation: PersistedConversation = { + conversationId, + clientConversationId: `migrated-${legacyWorkspace.workspaceId}`, + workspaceId: legacyWorkspace.workspaceId, + title: '历史会话', + turnRevision: legacyWorkspace.turnRevision, + phase: legacyWorkspace.phase, + brief: clone(legacyWorkspace.brief), + messages: clone(legacyWorkspace.messages), + requestHashes: clone(legacyWorkspace.requestHashes), + createdAt, + updatedAt: legacyWorkspace.updatedAt, + }; + migrated.workspaces.push({ + workspaceId: legacyWorkspace.workspaceId, + clientWorkspaceId: legacyWorkspace.clientWorkspaceId, + title: legacyWorkspace.title, + viewRevision: legacyWorkspace.viewRevision, + conversationCount: 1, + phase: legacyWorkspace.phase, + updatedAt: legacyWorkspace.updatedAt, + }); + migrated.conversationsByWorkspaceId[legacyWorkspace.workspaceId] = [conversation]; + migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] = ( + migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] ?? [] + ).map((task) => ({ ...task, conversationId })); + } + return migrated; + } + private async persist(state: PersistedImageWorkspace): Promise { await mkdir(this.rootDirectory, { recursive: true }); const temporaryFile = `${this.stateFile}.${process.pid}.tmp`; @@ -508,22 +693,39 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { return workspace; } - private assertRevision(workspace: PersistedWorkspace, expectedRevision: number): void { - if (workspace.turnRevision !== expectedRevision) { + private requireConversation( + state: PersistedImageWorkspace, + workspaceId: string, + conversationId: string, + ): PersistedConversation { + this.requireWorkspace(state, workspaceId); + const conversation = (state.conversationsByWorkspaceId[workspaceId] ?? []) + .find((item) => item.conversationId === conversationId); + if (!conversation) { + throw new LocalImageWorkspaceError(404, 'conversation_not_found', '设计会话不存在'); + } + return conversation; + } + + private assertRevision( + conversation: PersistedConversation, + expectedRevision: number, + ): void { + if (conversation.turnRevision !== expectedRevision) { throw new LocalImageWorkspaceError( 409, - 'workspace_revision_conflict', - '设计项目已在其他位置更新,请刷新后重试', + 'conversation_revision_conflict', + '设计会话已在其他位置更新,请刷新后重试', ); } } private isRequestReplay( - workspace: PersistedWorkspace, + conversation: PersistedConversation, clientTurnId: string, hash: string, ): boolean { - const existing = workspace.requestHashes[clientTurnId]; + const existing = conversation.requestHashes[clientTurnId]; if (!existing) return false; if (existing !== hash) { throw new LocalImageWorkspaceError( @@ -535,8 +737,8 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { return true; } - private supersedeQuotes(workspace: PersistedWorkspace): void { - for (const message of workspace.messages) { + private supersedeQuotes(conversation: PersistedConversation): void { + for (const message of conversation.messages) { if (message.generationQuote?.status === 'active') { message.generationQuote.status = 'superseded'; } @@ -546,7 +748,43 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { private bootstrapView(state: PersistedImageWorkspace): DesignWorkspaceBootstrap { return { capabilities: clone(LOCAL_CAPABILITIES), - workspaces: state.workspaces.map(({ messages: _messages, clientWorkspaceId: _clientId, requestHashes: _requests, ...summary }) => clone(summary)), + workspaces: state.workspaces.map(({ clientWorkspaceId: _clientId, ...summary }) => ( + clone(summary) + )), }; } + + private workspaceView( + state: PersistedImageWorkspace, + workspace: PersistedWorkspace, + ): DesignWorkspace { + const { clientWorkspaceId: _clientId, ...summary } = workspace; + return clone({ + ...summary, + conversationCount: state.conversationsByWorkspaceId[workspace.workspaceId]?.length ?? 0, + conversations: (state.conversationsByWorkspaceId[workspace.workspaceId] ?? []) + .map((conversation) => this.conversationSummaryView(conversation)), + }); + } + + private conversationSummaryView( + conversation: PersistedConversation, + ): DesignConversationSummary { + const { + clientConversationId: _clientId, + requestHashes: _requestHashes, + messages: _messages, + ...summary + } = conversation; + return clone(summary); + } + + private conversationView(conversation: PersistedConversation): DesignConversation { + const { + clientConversationId: _clientId, + requestHashes: _requestHashes, + ...view + } = conversation; + return clone(view); + } } diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 098dc60..f045f17 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -2,6 +2,8 @@ import type { DesignAsset, DesignAssetUploadInput, DesignCapabilities, + DesignConversation, + DesignCreateConversationInput, DesignConfirmGenerationInput, DesignCreateWorkspaceInput, DesignGenerationTask, @@ -19,6 +21,7 @@ export type DesignWorkspaceEventSubscription = { export type DesignWorkspaceEventSubscriptionInput = { workspaceId: string; + conversationId: string; afterEventId?: string; }; @@ -40,8 +43,10 @@ export interface DesignWorkspaceModule { createWorkspace(input: DesignCreateWorkspaceInput): Promise; renameWorkspace(input: DesignRenameWorkspaceInput): Promise; getWorkspace(workspaceId: string): Promise; - submitMessage(input: DesignSubmitMessageInput): Promise; - confirmGeneration(input: DesignConfirmGenerationInput): Promise; + createConversation(input: DesignCreateConversationInput): Promise; + getConversation(workspaceId: string, conversationId: string): Promise; + submitMessage(input: DesignSubmitMessageInput): Promise; + confirmGeneration(input: DesignConfirmGenerationInput): Promise; listTasks(workspaceId: string): Promise; uploadAsset?(input: DesignAssetUploadInput): Promise; openWorkspaceEvents?( diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index d7fabbd..3e0de72 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -4,11 +4,14 @@ import type { DesignAssistantDeltaEvent, DesignBrief, DesignCapabilities, + DesignConversation, + DesignConversationSnapshotEvent, + DesignConversationSummary, DesignConfirmGenerationInput, + DesignCreateConversationInput, DesignCreateWorkspaceInput, DesignGenerationQuote, DesignGenerationTask, - DesignGenerationTasksSnapshotEvent, DesignGenerationTaskUpdatedEvent, DesignMessage, DesignRenameWorkspaceInput, @@ -18,7 +21,6 @@ import type { DesignWorkspaceEvent, DesignWorkspaceSummary, } from '../../shared/image-workspace'; -import { createHash } from 'node:crypto'; import WebSocket from 'ws'; import { designAssetContentPath } from '../../shared/image-workspace'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; @@ -63,17 +65,36 @@ type ServerMessage = { type ServerWorkspaceSummary = { workspace_id: string; title: string; - turn_revision: number; view_revision: number; + conversation_count?: number; phase: DesignWorkspaceSummary['phase']; - brief: ServerBrief; updated_at: string; }; -type ServerWorkspace = ServerWorkspaceSummary & { +type ServerConversationSummary = { + conversation_id: string; + workspace_id: string; + agent_session_id: string | null; + title: string; + turn_revision: number; + phase: DesignConversationSummary['phase']; + brief: ServerBrief; + created_at: string; + updated_at: string; +}; + +type ServerConversation = ServerConversationSummary & { messages: ServerMessage[]; }; +type ServerWorkspace = ServerWorkspaceSummary & { + conversations?: ServerConversationSummary[]; +}; + +type ServerWorkspaceCreation = ServerWorkspace & { + initial_conversation?: ServerConversation; +}; + type ServerAsset = { asset_id: string; media_type: DesignAsset['mediaType']; @@ -87,6 +108,7 @@ type ServerAsset = { type ServerTask = { task_id: string; workspace_id: string; + conversation_id?: string | null; medium: DesignGenerationTask['medium']; status: DesignGenerationTask['status']; brief_version: number; @@ -107,17 +129,7 @@ type ServerErrorDetail = { type WorksSquareDesignWorkspaceOptions = { apiBaseUrl?: string; fetchImpl?: typeof fetch; - clientInstanceId?: string; webSocketFactory?: AgentWebSocketFactory; - eventSessionClientIdStore?: { - getOrCreate(workspaceId: string): Promise; - rotate(workspaceId: string): Promise; - }; -}; - -type ServerAgentSession = { - session_id: string; - status: 'active' | 'closed'; }; type ServerAgentStreamTicket = { @@ -154,6 +166,8 @@ type ServerAgentRun = { type AgentDesignTurnSubmission = { workspaceId: string; + conversationId: string; + agentSessionId: string; clientTurnId: string; expectedTurnRevision: number; message: string; @@ -232,19 +246,42 @@ function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspace return { workspaceId: workspace.workspace_id, title: workspace.title, - turnRevision: workspace.turn_revision, viewRevision: workspace.view_revision, + conversationCount: workspace.conversation_count ?? 0, phase: workspace.phase, - brief: mapBrief(workspace.brief), updatedAt: workspace.updated_at, }; } function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace { + const conversations = workspace.conversations ?? []; return { ...mapWorkspaceSummary(workspace), - messages: workspace.messages.map((message, index) => ({ - id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`, + conversationCount: workspace.conversation_count ?? conversations.length, + conversations: conversations.map(mapConversationSummary), + }; +} + +function mapConversationSummary( + conversation: ServerConversationSummary, +): DesignConversationSummary { + return { + conversationId: conversation.conversation_id, + workspaceId: conversation.workspace_id, + title: conversation.title, + turnRevision: conversation.turn_revision, + phase: conversation.phase, + brief: mapBrief(conversation.brief), + createdAt: conversation.created_at, + updatedAt: conversation.updated_at, + }; +} + +function mapConversation(conversation: ServerConversation): DesignConversation { + return { + ...mapConversationSummary(conversation), + messages: conversation.messages.map((message, index) => ({ + id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`, role: message.role, kind: message.kind, text: message.text, @@ -260,6 +297,7 @@ function mapTask(task: ServerTask): DesignGenerationTask { return { taskId: task.task_id, workspaceId: task.workspace_id, + conversationId: task.conversation_id ?? null, medium: task.medium, status: task.status, briefVersion: task.brief_version, @@ -287,24 +325,14 @@ function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset { }; } -function createClientId(prefix: string): string { - const id = globalThis.crypto?.randomUUID?.() - ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `${prefix}-${id}`; -} - -function stableWorkspaceSessionClientId(clientInstanceId: string, workspaceId: string): string { - const digest = createHash('sha256') - .update(`${clientInstanceId}\0${workspaceId}`) - .digest('hex'); - return `design-stream-${digest}`; -} - function isServerTask(value: unknown): value is ServerTask { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const task = value as Record; return typeof task.task_id === 'string' && typeof task.workspace_id === 'string' + && (task.conversation_id === undefined + || task.conversation_id === null + || typeof task.conversation_id === 'string') && (task.medium === 'image' || task.medium === 'video') && ['queued', 'running', 'succeeded', 'failed', 'cancelled'].includes(String(task.status)) && typeof task.brief_version === 'number' @@ -314,17 +342,18 @@ function isServerTask(value: unknown): value is ServerTask { && typeof task.updated_at === 'string'; } -function isServerWorkspace(value: unknown): value is ServerWorkspace { +function isServerConversation(value: unknown): value is ServerConversation { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const workspace = value as Record; - const brief = workspace.brief as Record | null; - return typeof workspace.workspace_id === 'string' - && typeof workspace.title === 'string' - && Number.isInteger(workspace.turn_revision) - && Number(workspace.turn_revision) >= 0 - && Number.isInteger(workspace.view_revision) - && Number(workspace.view_revision) >= 0 - && ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(workspace.phase)) + const conversation = value as Record; + const brief = conversation.brief as Record | null; + return typeof conversation.conversation_id === 'string' + && typeof conversation.workspace_id === 'string' + && (conversation.agent_session_id === null + || typeof conversation.agent_session_id === 'string') + && typeof conversation.title === 'string' + && Number.isInteger(conversation.turn_revision) + && Number(conversation.turn_revision) >= 0 + && ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(conversation.phase)) && Boolean(brief) && Number.isInteger(brief?.version) && ['draft', 'ready', 'confirmed'].includes(String(brief?.status)) @@ -332,8 +361,8 @@ function isServerWorkspace(value: unknown): value is ServerWorkspace { && typeof brief?.summary === 'string' && typeof brief?.ready === 'boolean' && (brief?.missing_decision === null || typeof brief?.missing_decision === 'string') - && Array.isArray(workspace.messages) - && workspace.messages.every((message) => { + && Array.isArray(conversation.messages) + && conversation.messages.every((message) => { if (!message || typeof message !== 'object' || Array.isArray(message)) return false; const item = message as Record; return (item.role === 'user' || item.role === 'assistant') @@ -346,13 +375,15 @@ function isServerWorkspace(value: unknown): value is ServerWorkspace { && Number(item.turn_revision) >= 0 && typeof item.created_at === 'string'; }) - && typeof workspace.updated_at === 'string'; + && typeof conversation.created_at === 'string' + && typeof conversation.updated_at === 'string'; } function normalizeWorkspaceEvent( value: unknown, sessionId: string, workspaceId: string, + conversationId: string, ): DesignWorkspaceEvent | null { try { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; @@ -370,6 +401,7 @@ function normalizeWorkspaceEvent( const payload = event.payload as Record; if (event.type === 'design.assistant.delta') { if (payload.workspace_id !== workspaceId + || payload.conversation_id !== conversationId || typeof payload.client_turn_id !== 'string' || payload.client_turn_id.length < 1 || payload.client_turn_id.length > 128 @@ -386,6 +418,7 @@ function normalizeWorkspaceEvent( id: `${sessionId}:${event.sequence}`, type: 'design.assistant.delta', workspaceId, + conversationId, clientTurnId: payload.client_turn_id, turnRevision: Number(payload.turn_revision), chunkIndex: Number(payload.chunk_index), @@ -410,15 +443,19 @@ function normalizeWorkspaceEvent( } satisfies DesignGenerationTaskUpdatedEvent; } - if (event.type !== 'design.workspace.updated' - || !isServerWorkspace(payload.workspace) + if ((event.type !== 'design.conversation.updated' + && event.type !== 'design.workspace.updated') + || payload.workspace_id !== workspaceId + || payload.conversation_id !== conversationId + || !isServerConversation(payload.conversation) || !Array.isArray(payload.generation_tasks)) { return null; } - const workspace = payload.workspace; - if (workspace.workspace_id !== workspaceId - || !Number.isInteger(workspace.view_revision) - || Number(workspace.view_revision) < 0 + const conversation = payload.conversation; + if (conversation.workspace_id !== workspaceId + || conversation.conversation_id !== conversationId + || !Number.isInteger(payload.workspace_view_revision) + || Number(payload.workspace_view_revision) < 0 || !payload.generation_tasks.every( (task) => isServerTask(task) && task.workspace_id === workspaceId, )) { @@ -426,12 +463,13 @@ function normalizeWorkspaceEvent( } return { id: `${sessionId}:${event.sequence}`, - type: 'design.generation_tasks.snapshot', + type: 'design.conversation.snapshot', workspaceId, - workspaceViewRevision: Number(workspace.view_revision), - workspace: mapWorkspace(workspace), + conversationId, + workspaceViewRevision: Number(payload.workspace_view_revision), + conversation: mapConversation(conversation), generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)), - } satisfies DesignGenerationTasksSnapshotEvent; + } satisfies DesignConversationSnapshotEvent; } catch { return null; } @@ -668,15 +706,17 @@ function agentRunErrorStatus(code: string): number { return 502; } +function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModuleError { + return error instanceof DesignWorkspaceModuleError + && (error.code === 'agent_session_not_found' + || error.code === 'agent_session_closed'); +} + export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { private readonly apiBaseUrl: string; private readonly fetchImpl: typeof fetch; private readonly webSocketFactory: AgentWebSocketFactory; - private readonly eventSessionClientIdStore: NonNullable< - WorksSquareDesignWorkspaceOptions['eventSessionClientIdStore'] - >; - private readonly eventSessions = new Map>(); - private readonly eventSessionClientIds = new Map(); + private readonly conversationSessionIds = new Map(); private readonly eventSubscriptionClosers = new Map void>>(); private readonly activeRunEventStreams = new Map(); private readonly terminalAgentRuns = new Map(); @@ -688,13 +728,6 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory; - const clientInstanceId = options.clientInstanceId?.trim() || createClientId('process'); - this.eventSessionClientIdStore = options.eventSessionClientIdStore ?? { - getOrCreate: async (workspaceId) => ( - stableWorkspaceSessionClientId(clientInstanceId, workspaceId) - ), - rotate: async () => createClientId('design-stream-rotated'), - }; } async bootstrap(): Promise { @@ -714,14 +747,15 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } async createWorkspace(input: DesignCreateWorkspaceInput): Promise { - const workspace = await this.requestJson('/api/design/workspaces', { + const workspace = await this.requestJson('/api/design/workspaces', { method: 'POST', body: JSON.stringify({ client_workspace_id: input.clientWorkspaceId, title: input.title, }), }); - return mapWorkspace(workspace); + if (workspace.initial_conversation) this.rememberConversation(workspace.initial_conversation); + return this.getWorkspace(workspace.workspace_id); } async renameWorkspace(input: DesignRenameWorkspaceInput): Promise { @@ -732,19 +766,53 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { body: JSON.stringify({ title: input.title }), }, ); - return mapWorkspace(workspace); + return this.getWorkspace(workspace.workspace_id); } async getWorkspace(workspaceId: string): Promise { - const workspace = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, - ); - return mapWorkspace(workspace); + const [workspace, conversations] = await Promise.all([ + this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, + ), + this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations?limit=100&offset=0`, + ), + ]); + return mapWorkspace({ ...workspace, conversations }); } - async submitMessage(input: DesignSubmitMessageInput): Promise { + async createConversation(input: DesignCreateConversationInput): Promise { + const conversation = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/conversations`, + { + method: 'POST', + body: JSON.stringify({ + client_conversation_id: input.clientConversationId, + title: input.title, + }), + }, + ); + this.rememberConversation(conversation); + return mapConversation(conversation); + } + + async getConversation( + workspaceId: string, + conversationId: string, + ): Promise { + const conversation = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`, + ); + this.rememberConversation(conversation); + return mapConversation(conversation); + } + + async submitMessage(input: DesignSubmitMessageInput): Promise { + const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); return this.executeAgentTurn({ workspaceId: input.workspaceId, + conversationId: input.conversationId, + agentSessionId, clientTurnId: input.clientTurnId, expectedTurnRevision: input.expectedTurnRevision, message: input.message, @@ -753,9 +821,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }); } - async confirmGeneration(input: DesignConfirmGenerationInput): Promise { + async confirmGeneration(input: DesignConfirmGenerationInput): Promise { + const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); return this.executeAgentTurn({ workspaceId: input.workspaceId, + conversationId: input.conversationId, + agentSessionId, clientTurnId: input.clientTurnId, expectedTurnRevision: input.expectedTurnRevision, message: '确认生成', @@ -767,22 +838,23 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }); } - private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { - let session = await this.ensureEventSession(input.workspaceId); - let command: ServerAgentCommand; + private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { try { - command = await this.submitTurnCommand(session.session_id, input); + return await this.executeAgentTurnOnce(input); } catch (error) { - if (!(error instanceof DesignWorkspaceModuleError) - || (error.code !== 'agent_session_not_found' - && error.code !== 'agent_session_closed')) { - throw error; - } - await this.invalidateEventSession(input.workspaceId); - session = await this.ensureEventSession(input.workspaceId); - command = await this.submitTurnCommand(session.session_id, input); + if (!isStaleAgentSessionError(error)) throw error; + this.forgetConversation(input.workspaceId, input.conversationId); + const agentSessionId = await this.getAgentSessionId( + input.workspaceId, + input.conversationId, + ); + return this.executeAgentTurnOnce({ ...input, agentSessionId }); } - const run = await this.waitForAgentRun(session.session_id, command.run_id); + } + + private async executeAgentTurnOnce(input: AgentDesignTurnSubmission): Promise { + const command = await this.submitTurnCommand(input.agentSessionId, input); + const run = await this.waitForAgentRun(input.agentSessionId, command.run_id); if (run.status !== 'succeeded') { const code = run.error?.code ?? ( run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' @@ -798,7 +870,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { userFacingErrorMessage(code, fallback), ); } - return this.getWorkspace(input.workspaceId); + return this.getConversation(input.workspaceId, input.conversationId); } private submitTurnCommand( @@ -935,18 +1007,24 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { async openWorkspaceEvents( input: DesignWorkspaceEventSubscriptionInput, ): Promise { - let session = await this.ensureEventSession(input.workspaceId); + if (!this.eventSessionsEnabled) { + throw new DesignWorkspaceModuleError( + 503, + 'DESIGN_EVENT_STREAM_PAUSED', + 'AI 设计任务状态流已暂停', + ); + } + let sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); let ticket: ServerAgentStreamTicket; try { - ticket = await this.createEventStreamTicket(session.session_id); + ticket = await this.createEventStreamTicket(sessionId); } catch (error) { - if (!(error instanceof DesignWorkspaceModuleError) - || (error.status !== 404 && error.status !== 409)) { + if (!isStaleAgentSessionError(error)) { throw error; } - await this.invalidateEventSession(input.workspaceId); - session = await this.ensureEventSession(input.workspaceId); - ticket = await this.createEventStreamTicket(session.session_id); + this.forgetConversation(input.workspaceId, input.conversationId); + sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); + ticket = await this.createEventStreamTicket(sessionId); } const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`); if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) { @@ -965,7 +1043,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } streamUrl.searchParams.set( 'after_sequence', - String(eventSequence(input.afterEventId, session.session_id)), + String(eventSequence(input.afterEventId, sessionId)), ); streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -1009,7 +1087,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { if (heartbeat !== null) clearInterval(heartbeat); heartbeat = null; unregister(); - if (didOpen) this.unregisterRunEventStream(session.session_id); + if (didOpen) this.unregisterRunEventStream(sessionId); try { await connection.dispose?.(); } catch { @@ -1052,7 +1130,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { socket.onopen = () => { if (ending) return; didOpen = true; - this.registerRunEventStream(session.session_id); + this.registerRunEventStream(sessionId); heartbeat = setInterval(() => { if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return; try { @@ -1071,16 +1149,17 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { const agentEvent = agentEventFromWebSocketFrame(data); const event = normalizeWorkspaceEvent( agentEvent, - session.session_id, + sessionId, input.workspaceId, + input.conversationId, ); if (event) { latestWorkspaceEventDelivery = boundedTaskEventDelivery(queue.push(event)); } - const run = normalizeAgentRunEvent(agentEvent, session.session_id); + const run = normalizeAgentRunEvent(agentEvent, sessionId); if (run) { const deliveryBarrier = latestWorkspaceEventDelivery; - void deliveryBarrier.then(() => this.publishAgentRun(session.session_id, run)); + void deliveryBarrier.then(() => this.publishAgentRun(sessionId, run)); } }; socket.onerror = () => { @@ -1090,23 +1169,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }; socket.onclose = ({ code }) => { const error = webSocketCloseError(code); - if (code === 4409) { - beginEnd(error, async () => { - try { - await this.closeEventSession(session.session_id); - } finally { - await this.invalidateEventSession(input.workspaceId); - } + if (code === 4409 || code === 4404) { + beginEnd(error, () => { + this.forgetConversation(input.workspaceId, input.conversationId); }); return; } - if (code === 4404) { - beginEnd(error, () => this.invalidateEventSession(input.workspaceId)); - return; - } beginEnd(error); }; - unregister = this.registerEventSubscription(input.workspaceId, close); + unregister = this.registerEventSubscription( + input.workspaceId, + input.conversationId, + close, + ); await opened; return { events: queue.events, @@ -1120,32 +1195,6 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { .flatMap((closers) => [...closers]); this.eventSubscriptionClosers.clear(); for (const close of activeSubscriptions) close(); - const pendingSessions = [...this.eventSessions.entries()]; - this.eventSessions.clear(); - const sessions = await Promise.allSettled( - pendingSessions.map(async ([workspaceId, pending]) => ({ - workspaceId, - session: await pending, - })), - ); - const closeResults = await Promise.allSettled( - sessions.flatMap((result) => ( - result.status === 'fulfilled' - ? [this.closeEventSession(result.value.session.session_id) - .then(() => this.rotateEventSession(result.value.workspaceId))] - : [] - )), - ); - const uncertainCreations = sessions.filter((result) => ( - result.status === 'rejected' - && !(result.reason instanceof DesignWorkspaceModuleError - && result.reason.code === 'DESIGN_EVENT_SESSION_CLOSED') - )).length; - const failedCloses = closeResults.filter((result) => result.status === 'rejected').length; - const failed = uncertainCreations + failedCloses; - if (failed > 0) { - throw new Error(`Failed to close ${failed} AI design Agent Session(s)`); - } } openAssetContent(workspaceId: string, assetId: string, range?: string): Promise { @@ -1214,50 +1263,40 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }); } - private ensureEventSession(workspaceId: string): Promise { - if (!this.eventSessionsEnabled) { + private conversationKey(workspaceId: string, conversationId: string): string { + return `${workspaceId}:${conversationId}`; + } + + private rememberConversation(conversation: ServerConversationSummary): void { + if (typeof conversation.agent_session_id !== 'string' + || !conversation.agent_session_id.trim()) return; + this.conversationSessionIds.set( + this.conversationKey(conversation.workspace_id, conversation.conversation_id), + conversation.agent_session_id, + ); + } + + private forgetConversation(workspaceId: string, conversationId: string): void { + this.conversationSessionIds.delete(this.conversationKey(workspaceId, conversationId)); + } + + private async getAgentSessionId( + workspaceId: string, + conversationId: string, + ): Promise { + const key = this.conversationKey(workspaceId, conversationId); + const existing = this.conversationSessionIds.get(key); + if (existing) return existing; + await this.getConversation(workspaceId, conversationId); + const refreshed = this.conversationSessionIds.get(key); + if (!refreshed) { throw new DesignWorkspaceModuleError( 503, - 'DESIGN_EVENT_STREAM_PAUSED', - 'AI 设计任务状态流已暂停', + 'DESIGN_CONVERSATION_SESSION_UNAVAILABLE', + '设计会话暂时无法连接,请稍后重试', ); } - const existing = this.eventSessions.get(workspaceId); - if (existing) return existing; - const pending = (async () => { - const clientSessionId = this.eventSessionClientIds.get(workspaceId) - ?? await this.eventSessionClientIdStore.getOrCreate(workspaceId); - this.eventSessionClientIds.set(workspaceId, clientSessionId); - const session = await this.requestJson('/api/agents/sessions', { - method: 'POST', - body: JSON.stringify({ - client_session_id: clientSessionId, - runtime: 'design', - runtime_version: 'v1', - binding: { - kind: 'design_workspace', - key: workspaceId, - }, - }), - }); - if (session.status !== 'active') { - throw new DesignWorkspaceModuleError( - 409, - 'DESIGN_EVENT_SESSION_CLOSED', - 'AI 设计任务状态会话已关闭', - ); - } - return session; - })().catch(async (error) => { - this.eventSessions.delete(workspaceId); - if (error instanceof DesignWorkspaceModuleError - && error.code === 'DESIGN_EVENT_SESSION_CLOSED') { - await this.rotateEventSession(workspaceId); - } - throw error; - }); - this.eventSessions.set(workspaceId, pending); - return pending; + return refreshed; } private createEventStreamTicket(sessionId: string): Promise { @@ -1270,13 +1309,18 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { ); } - private registerEventSubscription(workspaceId: string, close: () => void): () => void { - const closers = this.eventSubscriptionClosers.get(workspaceId) ?? new Set<() => void>(); + private registerEventSubscription( + workspaceId: string, + conversationId: string, + close: () => void, + ): () => void { + const key = this.conversationKey(workspaceId, conversationId); + const closers = this.eventSubscriptionClosers.get(key) ?? new Set<() => void>(); closers.add(close); - this.eventSubscriptionClosers.set(workspaceId, closers); + this.eventSubscriptionClosers.set(key, closers); return () => { closers.delete(close); - if (closers.size === 0) this.eventSubscriptionClosers.delete(workspaceId); + if (closers.size === 0) this.eventSubscriptionClosers.delete(key); }; } @@ -1314,27 +1358,4 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { for (const resolve of waiters) resolve(run); } - private async closeEventSession(sessionId: string): Promise { - try { - await this.requestJson( - `/api/agents/sessions/${encodeURIComponent(sessionId)}`, - { method: 'DELETE' }, - ); - } catch (error) { - if (error instanceof DesignWorkspaceModuleError - && (error.status === 404 || error.status === 409)) return; - throw error; - } - } - - private invalidateEventSession(workspaceId: string): Promise { - return this.rotateEventSession(workspaceId).then(() => undefined); - } - - private async rotateEventSession(workspaceId: string): Promise { - this.eventSessions.delete(workspaceId); - const clientSessionId = await this.eventSessionClientIdStore.rotate(workspaceId); - this.eventSessionClientIds.set(workspaceId, clientSessionId); - return clientSessionId; - } } diff --git a/electron/main/index.ts b/electron/main/index.ts index 5fce5e4..109843e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -32,10 +32,6 @@ import { logger } from '../utils/logger'; import { warmupNetworkOptimization } from '../utils/uv-env'; import { resolvePythonRuntime } from '../utils/python-runtime'; import { initTelemetry } from '../utils/telemetry'; -import { - getOrCreateAgentGatewaySessionClientId, - rotateAgentGatewaySessionClientId, -} from '../utils/store'; import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; @@ -425,12 +421,7 @@ async function initialize(): Promise { }); const imageWorkspace = localImageWorkspaceEnabled ? new LocalImageWorkspace({ userDataDir: app.getPath('userData') }) - : new WorksSquareDesignWorkspace({ - eventSessionClientIdStore: { - getOrCreate: getOrCreateAgentGatewaySessionClientId, - rotate: rotateAgentGatewaySessionClientId, - }, - }); + : new WorksSquareDesignWorkspace(); imageWorkspaceModule = imageWorkspace; if (localImageWorkspaceEnabled) { logger.info('AI painting workspace is using local development storage'); diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 27fb7e1..8772654 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -47,14 +47,28 @@ export type DesignMessage = { export type DesignWorkspaceSummary = { workspaceId: string; title: string; - turnRevision: number; viewRevision: number; + conversationCount: number; phase: DesignWorkspacePhase; - brief: DesignBrief; updatedAt: string; }; export type DesignWorkspace = DesignWorkspaceSummary & { + conversations: DesignConversationSummary[]; +}; + +export type DesignConversationSummary = { + conversationId: string; + workspaceId: string; + title: string; + turnRevision: number; + phase: DesignWorkspacePhase; + brief: DesignBrief; + createdAt: string; + updatedAt: string; +}; + +export type DesignConversation = DesignConversationSummary & { messages: DesignMessage[]; }; @@ -86,6 +100,7 @@ export type DesignAssetUploadInput = { export type DesignGenerationTask = { taskId: string; workspaceId: string; + conversationId: string | null; medium: DesignMedium; status: DesignTaskStatus; briefVersion: number; @@ -106,12 +121,13 @@ export type DesignGenerationTaskUpdatedEvent = { generationTask: DesignGenerationTask; }; -export type DesignGenerationTasksSnapshotEvent = { +export type DesignConversationSnapshotEvent = { id: string; - type: 'design.generation_tasks.snapshot'; + type: 'design.conversation.snapshot'; workspaceId: string; + conversationId: string; workspaceViewRevision: number; - workspace: DesignWorkspace; + conversation: DesignConversation; generationTasks: DesignGenerationTask[]; }; @@ -119,6 +135,7 @@ export type DesignAssistantDeltaEvent = { id: string; type: 'design.assistant.delta'; workspaceId: string; + conversationId: string; clientTurnId: string; turnRevision: number; chunkIndex: number; @@ -127,7 +144,7 @@ export type DesignAssistantDeltaEvent = { export type DesignWorkspaceEvent = | DesignGenerationTaskUpdatedEvent - | DesignGenerationTasksSnapshotEvent + | DesignConversationSnapshotEvent | DesignAssistantDeltaEvent; export type DesignWorkspaceBootstrap = { @@ -140,6 +157,12 @@ export type DesignCreateWorkspaceInput = { title: string; }; +export type DesignCreateConversationInput = { + workspaceId: string; + clientConversationId: string; + title: string; +}; + export type DesignRenameWorkspaceInput = { workspaceId: string; title: string; @@ -147,6 +170,7 @@ export type DesignRenameWorkspaceInput = { export type DesignSubmitMessageInput = { workspaceId: string; + conversationId: string; clientTurnId: string; expectedTurnRevision: number; message: string; @@ -155,6 +179,7 @@ export type DesignSubmitMessageInput = { export type DesignConfirmGenerationInput = { workspaceId: string; + conversationId: string; clientTurnId: string; expectedTurnRevision: number; quoteId: string; diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 2555cb6..3827dc4 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -2,12 +2,14 @@ import { useEffect, useState } from 'react'; import { FolderKanban, Loader2, + MessageSquareText, Pencil, Plus, RefreshCw, X, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -36,11 +38,16 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId); + const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId); + const workspace = useImageWorkspaceStore((state) => state.workspace); + const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const createProject = useImageWorkspaceStore((state) => state.createProject); + const createConversation = useImageWorkspaceStore((state) => state.createConversation); const renameProject = useImageWorkspaceStore((state) => state.renameProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); + const selectConversation = useImageWorkspaceStore((state) => state.selectConversation); const [projectsOpen, setProjectsOpen] = useState(true); const [dialog, setDialog] = useState(null); const [projectName, setProjectName] = useState(''); @@ -107,6 +114,15 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba navigate('/image-canvas'); }; + const handleCreateConversation = async () => { + try { + await createConversation(); + navigate('/image-canvas'); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } + }; + return (
@@ -220,6 +236,58 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
+ {active ? ( +
+
+ 设计会话 + +
+
+ {workspace?.workspaceId === project.workspaceId + ? workspace.conversations.slice(0, 6).map((conversation) => { + const selected = conversation.conversationId === activeConversationId; + return ( + + ); + }) + : null} +
+
+ ) : null} ); }) : null} diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index e33ab59..693bf33 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -12,6 +12,7 @@ import { designAssetDownloadPath, type DesignAsset, type DesignAssetSaveResult, + type DesignConversation, type DesignGenerationTask, type DesignWorkspace, type DesignWorkspaceBootstrap, @@ -49,6 +50,10 @@ export function createImageWorkspaceTurnId(): string { return createClientId('turn'); } +export function createImageWorkspaceConversationId(): string { + return createClientId('conversation'); +} + function getErrorStatus(error: unknown): number { if (error instanceof AppError && typeof error.details?.status === 'number') { return error.details.status; @@ -123,15 +128,42 @@ export function fetchImageWorkspaceProject(workspaceId: string): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations`, + { + method: 'POST', + body: JSON.stringify({ + clientConversationId, + title: title.trim(), + }), + }, + ); +} + +export function fetchImageWorkspaceConversation( + workspaceId: string, + conversationId: string, +): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`, + ); +} + export function sendImageWorkspaceMessage( workspaceId: string, + conversationId: string, expectedTurnRevision: number, message: string, clientTurnId = createImageWorkspaceTurnId(), attachmentAssetIds: string[] = [], -): Promise { +): Promise { return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/messages`, { method: 'POST', body: JSON.stringify({ @@ -146,12 +178,13 @@ export function sendImageWorkspaceMessage( export function confirmImageWorkspaceGeneration( workspaceId: string, + conversationId: string, expectedTurnRevision: number, quoteId: string, clientTurnId = createImageWorkspaceTurnId(), -): Promise { +): Promise { return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, { method: 'POST', body: JSON.stringify({ @@ -170,10 +203,13 @@ export function fetchImageWorkspaceTasks( ); } -export async function openImageWorkspaceTaskEvents(workspaceId: string): Promise { +export async function openImageWorkspaceTaskEvents( + workspaceId: string, + conversationId: string, +): Promise { await ensureHostApiToken(); return createHostEventSource( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/events`, ); } diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index 272b345..a733fd3 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -16,6 +16,7 @@ import { ImageIcon, Loader2, Maximize2, + MessageSquarePlus, Plus, RefreshCw, Send, @@ -26,6 +27,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Select } from '@/components/ui/select'; import { Dialog, DialogClose, @@ -402,18 +404,24 @@ export function ImageCanvas() { const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); const workspace = useImageWorkspaceStore((state) => state.workspace); + const conversation = useImageWorkspaceStore((state) => state.conversation); + const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId); + const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation); const tasks = useImageWorkspaceStore((state) => state.tasks); const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace); + const refreshConversation = useImageWorkspaceStore((state) => state.refreshConversation); const refreshTasks = useImageWorkspaceStore((state) => state.refreshTasks); const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream); const disconnectTaskStream = useImageWorkspaceStore((state) => state.disconnectTaskStream); const sendMessage = useImageWorkspaceStore((state) => state.sendMessage); const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration); + const createConversation = useImageWorkspaceStore((state) => state.createConversation); + const selectConversation = useImageWorkspaceStore((state) => state.selectConversation); const [prompt, setPrompt] = useState(''); - const [submitting, setSubmitting] = useState(false); + const [submittingConversationId, setSubmittingConversationId] = useState(null); const [confirmingQuoteId, setConfirmingQuoteId] = useState(null); const [actionError, setActionError] = useState(null); const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false); @@ -423,8 +431,8 @@ export function ImageCanvas() { const conversationEndRef = useRef(null); const quote = useMemo( - () => workspace ? activeQuote(workspace.messages) : null, - [workspace], + () => conversation ? activeQuote(conversation.messages) : null, + [conversation], ); const generationAvailable = bootstrap?.capabilities.generation ?? false; const firstFrameAssets = useMemo( @@ -442,9 +450,11 @@ export function ImageCanvas() { [tasks, uploadedFirstFrameAsset, workspace?.workspaceId], ); const conversationMessages = useMemo(() => { - if (!workspace) return []; - const messages: RenderedDesignMessage[] = [...workspace.messages]; - if (!pendingTurn || pendingTurn.workspaceId !== workspace.workspaceId) return messages; + if (!conversation) return []; + const messages: RenderedDesignMessage[] = [...conversation.messages]; + if (!pendingTurn + || pendingTurn.workspaceId !== conversation.workspaceId + || pendingTurn.conversationId !== conversation.conversationId) return messages; if (pendingTurn.userText) { messages.push({ id: `${pendingTurn.clientTurnId}:user`, @@ -469,8 +479,10 @@ export function ImageCanvas() { streaming: true, }); return messages; - }, [pendingTurn, workspace]); + }, [conversation, pendingTurn]); const taskWorkspaceId = workspace?.workspaceId ?? null; + const taskConversationId = conversation?.conversationId ?? null; + const submitting = submittingConversationId === taskConversationId; const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATUSES.has(task.status)); useEffect(() => { @@ -478,10 +490,18 @@ export function ImageCanvas() { }, [authenticated, load, status]); useEffect(() => { - if (!taskWorkspaceId) return; + if (!taskWorkspaceId || !taskConversationId) return; connectTaskStream(); return disconnectTaskStream; - }, [connectTaskStream, disconnectTaskStream, taskWorkspaceId]); + }, [connectTaskStream, disconnectTaskStream, taskConversationId, taskWorkspaceId]); + + useEffect(() => { + setPrompt(''); + setActionError(null); + setConfirmingQuoteId(null); + setFirstFramePickerOpen(false); + setFirstFrameError(null); + }, [taskConversationId]); useEffect(() => { if (typeof conversationEndRef.current?.scrollIntoView === 'function') { @@ -490,27 +510,30 @@ export function ImageCanvas() { }, [conversationMessages.length, pendingTurn?.assistantText]); const handleConfirm = async (quoteId: string) => { - if (!workspace || confirmingQuoteId) return; + if (!workspace || !conversation || confirmingQuoteId) return; if (!generationAvailable) { setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE); return; } const requestedWorkspaceId = workspace.workspaceId; + const requestedConversationId = conversation.conversationId; setConfirmingQuoteId(quoteId); setActionError(null); try { await confirmGeneration(quoteId); } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return; + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId !== requestedWorkspaceId + || current.activeConversationId !== requestedConversationId) return; setActionError(error instanceof Error ? error.message : String(error)); } finally { - setConfirmingQuoteId(null); + setConfirmingQuoteId((current) => current === quoteId ? null : current); } }; const handleSend = async (override?: string) => { const message = (override ?? prompt).trim(); - if (!workspace || !message || submitting) return; + if (!workspace || !conversation || !message || submitting) return; if (isGenerationConfirmationIntent(message)) { setPrompt(''); if (quote && generationAvailable) { @@ -521,17 +544,22 @@ export function ImageCanvas() { return; } const requestedWorkspaceId = workspace.workspaceId; - setSubmitting(true); + const requestedConversationId = conversation.conversationId; + setSubmittingConversationId(requestedConversationId); setActionError(null); setPrompt(''); try { await sendMessage(message); } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return; + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId !== requestedWorkspaceId + || current.activeConversationId !== requestedConversationId) return; setPrompt((current) => current.trim() ? current : message); setActionError(error instanceof Error ? error.message : String(error)); } finally { - setSubmitting(false); + setSubmittingConversationId((current) => ( + current === requestedConversationId ? null : current + )); } }; @@ -542,47 +570,59 @@ export function ImageCanvas() { }; const handleFirstFrameSelect = async (asset: DesignAsset) => { - if (!workspace || submitting || firstFrameBusyKey) return; + if (!workspace || !conversation || submitting || firstFrameBusyKey) return; const requestedWorkspaceId = workspace.workspaceId; + const requestedConversationId = conversation.conversationId; setFirstFrameBusyKey(asset.assetId); setFirstFrameError(null); setActionError(null); - setSubmitting(true); + setSubmittingConversationId(requestedConversationId); setFirstFramePickerOpen(false); try { await sendMessage('使用作品图片作为视频首帧', [asset.assetId]); } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId === requestedWorkspaceId + && current.activeConversationId === requestedConversationId) { setActionError(error instanceof Error ? error.message : String(error)); } } finally { setFirstFrameBusyKey(null); - setSubmitting(false); + setSubmittingConversationId((current) => ( + current === requestedConversationId ? null : current + )); } }; const handleFirstFrameUpload = async (event: ChangeEvent) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; - if (!workspace || !file || submitting || firstFrameBusyKey) return; + if (!workspace || !conversation || !file || submitting || firstFrameBusyKey) return; const requestedWorkspaceId = workspace.workspaceId; + const requestedConversationId = conversation.conversationId; setFirstFrameBusyKey('upload'); setFirstFrameError(null); - setSubmitting(true); + setSubmittingConversationId(requestedConversationId); try { const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file); setUploadedFirstFrameAsset(asset); await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]); - if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId === requestedWorkspaceId + && current.activeConversationId === requestedConversationId) { setFirstFramePickerOpen(false); } } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId === requestedWorkspaceId + && current.activeConversationId === requestedConversationId) { setFirstFrameError(error instanceof Error ? error.message : String(error)); } } finally { setFirstFrameBusyKey(null); - setSubmitting(false); + setSubmittingConversationId((current) => ( + current === requestedConversationId ? null : current + )); } }; @@ -642,7 +682,7 @@ export function ImageCanvas() { ); } - if (!workspace) { + if (!workspace || !conversation) { return (
@@ -667,25 +707,66 @@ export function ImageCanvas() {

- {workspace.brief.ready ? workspace.brief.summary : '设计 Agent 正在与你确认创作方向'} + {conversation.title} · {conversation.brief.ready + ? conversation.brief.summary + : '设计 Agent 正在与你确认创作方向'}

- +
+ + + +
diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index 2392167..07044f4 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; import { confirmImageWorkspaceGeneration, + createImageWorkspaceConversation, createImageWorkspaceTurnId, createImageWorkspaceProject, fetchImageWorkspace, + fetchImageWorkspaceConversation, fetchImageWorkspaceProject, fetchImageWorkspaceTasks, ImageWorkspaceApiError, @@ -15,8 +17,9 @@ import { useAuthStore } from '@/stores/auth'; import { IMAGE_WORKSPACE_UNAVAILABLE_CODE, type DesignAssistantDeltaEvent, + type DesignConversation, + type DesignConversationSnapshotEvent, type DesignGenerationTask, - type DesignGenerationTasksSnapshotEvent, type DesignGenerationTaskUpdatedEvent, type DesignWorkspace, type DesignWorkspaceBootstrap, @@ -35,6 +38,7 @@ export type ImageWorkspaceTaskStreamState = 'idle' | 'connecting' | 'connected' export type PendingDesignTurn = { workspaceId: string; + conversationId: string; clientTurnId: string; turnRevision: number; userText: string | null; @@ -47,21 +51,27 @@ type ImageWorkspaceState = { status: ImageWorkspaceLoadStatus; bootstrap: DesignWorkspaceBootstrap | null; activeWorkspaceId: string | null; + activeConversationId: string | null; workspace: DesignWorkspace | null; + conversation: DesignConversation | null; + creatingConversation: boolean; tasks: DesignGenerationTask[]; pendingTurn: PendingDesignTurn | null; taskStreamState: ImageWorkspaceTaskStreamState; error: string | null; load: () => Promise; createProject: (title: string) => Promise; + createConversation: (title?: string) => Promise; renameProject: (workspaceId: string, title: string) => Promise; selectProject: (workspaceId: string) => Promise; + selectConversation: (conversationId: string) => Promise; refreshWorkspace: () => Promise; + refreshConversation: () => Promise; refreshTasks: () => Promise; connectTaskStream: () => void; disconnectTaskStream: () => void; - sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise; - confirmGeneration: (quoteId: string) => Promise; + sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise; + confirmGeneration: (quoteId: string) => Promise; reset: () => void; }; @@ -71,9 +81,12 @@ const CONFIRMED_TASK_RECONCILE_ATTEMPTS = 5; const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250; let activeTaskEventSource: EventSource | null = null; let activeTaskEventWorkspaceId: string | null = null; +let activeTaskEventConversationId: string | null = null; let taskStreamGeneration = 0; let taskFallbackTimer: ReturnType | null = null; const taskEventRevisions = new Map(); +let workspaceLoadGeneration = 0; +let conversationSelectionGeneration = 0; function taskRevisionKey(workspaceId: string, taskId: string): string { return `${workspaceId}:${taskId}`; @@ -94,6 +107,7 @@ function closeTaskEventSource(): void { } activeTaskEventSource = null; activeTaskEventWorkspaceId = null; + activeTaskEventConversationId = null; taskEventRevisions.clear(); } @@ -117,26 +131,27 @@ function parseTaskUpdatedEvent(event: Event): DesignGenerationTaskUpdatedEvent | } } -function parseTasksSnapshotEvent(event: Event): DesignGenerationTasksSnapshotEvent | null { +function parseConversationSnapshotEvent(event: Event): DesignConversationSnapshotEvent | null { const data = (event as MessageEvent).data; if (typeof data !== 'string') return null; try { - const payload = JSON.parse(data) as Partial; - if (payload.type !== 'design.generation_tasks.snapshot' + const payload = JSON.parse(data) as Partial; + if (payload.type !== 'design.conversation.snapshot' || typeof payload.id !== 'string' || typeof payload.workspaceId !== 'string' + || typeof payload.conversationId !== 'string' || !Number.isInteger(payload.workspaceViewRevision) - || !payload.workspace - || payload.workspace.workspaceId !== payload.workspaceId - || payload.workspace.viewRevision !== payload.workspaceViewRevision - || !Array.isArray(payload.workspace.messages) + || !payload.conversation + || payload.conversation.workspaceId !== payload.workspaceId + || payload.conversation.conversationId !== payload.conversationId + || !Array.isArray(payload.conversation.messages) || !Array.isArray(payload.generationTasks) || !payload.generationTasks.every((task) => ( typeof task?.taskId === 'string' && task.workspaceId === payload.workspaceId ))) { return null; } - return payload as DesignGenerationTasksSnapshotEvent; + return payload as DesignConversationSnapshotEvent; } catch { return null; } @@ -150,6 +165,7 @@ function parseAssistantDeltaEvent(event: Event): DesignAssistantDeltaEvent | nul if (payload.type !== 'design.assistant.delta' || typeof payload.id !== 'string' || typeof payload.workspaceId !== 'string' + || typeof payload.conversationId !== 'string' || typeof payload.clientTurnId !== 'string' || !Number.isInteger(payload.turnRevision) || Number(payload.turnRevision) < 1 @@ -198,7 +214,7 @@ function messageOf(error: unknown): string { } function toSummary(workspace: DesignWorkspace): DesignWorkspaceSummary { - const { messages: _messages, ...summary } = workspace; + const { conversations: _conversations, ...summary } = workspace; return summary; } @@ -217,15 +233,32 @@ function upsertSummary( }; } +function upsertConversation( + workspace: DesignWorkspace | null, + conversation: DesignConversation, +): DesignWorkspace | null { + if (!workspace || workspace.workspaceId !== conversation.workspaceId) return workspace; + const conversations = workspace.conversations.filter( + (item) => item.conversationId !== conversation.conversationId, + ); + const { messages: _messages, ...summary } = conversation; + return { + ...workspace, + conversationCount: Math.max(workspace.conversationCount, conversations.length + 1), + conversations: [summary, ...conversations], + }; +} + function createPendingTurn( - workspace: DesignWorkspace, + conversation: DesignConversation, clientTurnId: string, userText: string, ): PendingDesignTurn { return { - workspaceId: workspace.workspaceId, + workspaceId: conversation.workspaceId, + conversationId: conversation.conversationId, clientTurnId, - turnRevision: workspace.turnRevision + 1, + turnRevision: conversation.turnRevision + 1, userText, assistantText: '', lastChunkIndex: -1, @@ -245,29 +278,37 @@ export const useImageWorkspaceStore = create((set, get) => if (get().taskStreamState !== 'degraded' || !get().activeWorkspaceId) return; void get().refreshWorkspace() .catch(() => null) + .then(() => get().refreshConversation().catch(() => null)) .then(() => get().refreshTasks().catch(() => [])); }, TASK_FALLBACK_POLL_INTERVAL_MS); }; - const startTaskStream = (workspaceId: string) => { + const startTaskStream = (workspaceId: string, conversationId: string) => { if (activeTaskEventWorkspaceId === workspaceId + && activeTaskEventConversationId === conversationId && get().taskStreamState !== 'idle') return; closeTaskEventSource(); const generation = taskStreamGeneration; activeTaskEventWorkspaceId = workspaceId; + activeTaskEventConversationId = conversationId; set({ taskStreamState: 'connecting' }); - void openImageWorkspaceTaskEvents(workspaceId).then((source) => { - if (generation !== taskStreamGeneration || get().activeWorkspaceId !== workspaceId) { + void openImageWorkspaceTaskEvents(workspaceId, conversationId).then((source) => { + if (generation !== taskStreamGeneration + || get().activeWorkspaceId !== workspaceId + || get().activeConversationId !== conversationId) { source.close(); return; } activeTaskEventSource = source; source.addEventListener('design.assistant.delta', (event) => { const delta = parseAssistantDeltaEvent(event); - if (!delta || delta.workspaceId !== activeTaskEventWorkspaceId) return; + if (!delta + || delta.workspaceId !== activeTaskEventWorkspaceId + || delta.conversationId !== activeTaskEventConversationId) return; set((state) => { if (state.activeWorkspaceId !== delta.workspaceId - || (state.workspace?.turnRevision ?? 0) >= delta.turnRevision) { + || state.activeConversationId !== delta.conversationId + || (state.conversation?.turnRevision ?? 0) >= delta.turnRevision) { return state; } const pending = state.pendingTurn; @@ -276,6 +317,7 @@ export const useImageWorkspaceStore = create((set, get) => return { pendingTurn: { workspaceId: delta.workspaceId, + conversationId: delta.conversationId, clientTurnId: delta.clientTurnId, turnRevision: delta.turnRevision, userText: null, @@ -286,6 +328,7 @@ export const useImageWorkspaceStore = create((set, get) => }; } if (pending.workspaceId !== delta.workspaceId + || pending.conversationId !== delta.conversationId || pending.clientTurnId !== delta.clientTurnId || pending.turnRevision !== delta.turnRevision || delta.chunkIndex !== pending.lastChunkIndex + 1) { @@ -300,11 +343,14 @@ export const useImageWorkspaceStore = create((set, get) => }; }); }); - source.addEventListener('design.generation_tasks.snapshot', (event) => { - const snapshot = parseTasksSnapshotEvent(event); - if (!snapshot || snapshot.workspaceId !== activeTaskEventWorkspaceId) return; + source.addEventListener('design.conversation.snapshot', (event) => { + const snapshot = parseConversationSnapshotEvent(event); + if (!snapshot + || snapshot.workspaceId !== activeTaskEventWorkspaceId + || snapshot.conversationId !== activeTaskEventConversationId) return; set((state) => { - if (state.activeWorkspaceId !== snapshot.workspaceId) return state; + if (state.activeWorkspaceId !== snapshot.workspaceId + || state.activeConversationId !== snapshot.conversationId) return state; const tasksById = new Map(state.tasks.map((task) => [task.taskId, task])); for (const task of snapshot.generationTasks) { const key = taskRevisionKey(snapshot.workspaceId, task.taskId); @@ -319,33 +365,34 @@ export const useImageWorkspaceStore = create((set, get) => Math.max(knownRevision, snapshot.workspaceViewRevision), ); } - const currentWorkspace = state.workspace?.workspaceId === snapshot.workspaceId - ? state.workspace - : null; - let workspace = state.workspace; - if (!currentWorkspace - || snapshot.workspaceViewRevision >= currentWorkspace.viewRevision) { - workspace = snapshot.workspace; - } else if (snapshot.workspace.turnRevision > currentWorkspace.turnRevision) { - workspace = { - ...snapshot.workspace, - title: currentWorkspace.title, - viewRevision: currentWorkspace.viewRevision, - updatedAt: currentWorkspace.updatedAt > snapshot.workspace.updatedAt - ? currentWorkspace.updatedAt - : snapshot.workspace.updatedAt, - }; - } + const conversation = !state.conversation + || snapshot.conversation.turnRevision >= state.conversation.turnRevision + ? snapshot.conversation + : state.conversation; + const workspace = upsertConversation( + state.workspace + ? { + ...state.workspace, + viewRevision: Math.max( + state.workspace.viewRevision, + snapshot.workspaceViewRevision, + ), + } + : null, + conversation, + ); const bootstrap = workspace?.workspaceId === snapshot.workspaceId ? upsertSummary(state.bootstrap, workspace) : state.bootstrap; const pendingTurn = state.pendingTurn?.workspaceId === snapshot.workspaceId - && snapshot.workspace.turnRevision >= state.pendingTurn.turnRevision + && state.pendingTurn.conversationId === snapshot.conversationId + && snapshot.conversation.turnRevision >= state.pendingTurn.turnRevision ? null : state.pendingTurn; return { tasks: sortTasks([...tasksById.values()]), workspace, + conversation, bootstrap, pendingTurn, }; @@ -407,7 +454,9 @@ export const useImageWorkspaceStore = create((set, get) => startFallbackPolling(); }; }).catch(() => { - if (generation !== taskStreamGeneration || get().activeWorkspaceId !== workspaceId) return; + if (generation !== taskStreamGeneration + || get().activeWorkspaceId !== workspaceId + || get().activeConversationId !== conversationId) return; set({ taskStreamState: 'degraded' }); startFallbackPolling(); }); @@ -422,7 +471,10 @@ export const useImageWorkspaceStore = create((set, get) => status: 'auth-required', bootstrap: null, activeWorkspaceId: null, + activeConversationId: null, workspace: null, + conversation: null, + creatingConversation: false, tasks: [], pendingTurn: null, taskStreamState: 'idle', @@ -434,49 +486,88 @@ export const useImageWorkspaceStore = create((set, get) => const applyWorkspace = (workspace: DesignWorkspace): DesignWorkspace => { set((state) => { - const pendingTurn = state.pendingTurn?.workspaceId === workspace.workspaceId - && workspace.turnRevision >= state.pendingTurn.turnRevision - ? null - : state.pendingTurn; return { status: 'ready', bootstrap: upsertSummary(state.bootstrap, workspace), activeWorkspaceId: workspace.workspaceId, workspace, - pendingTurn, error: null, }; }); return workspace; }; + const applyConversation = (conversation: DesignConversation): DesignConversation => { + let applied = conversation; + set((state) => { + if (state.conversation?.conversationId === conversation.conversationId + && (state.conversation.turnRevision > conversation.turnRevision + || (state.conversation.turnRevision === conversation.turnRevision + && state.conversation.updatedAt > conversation.updatedAt))) { + applied = state.conversation; + return state; + } + const pendingTurn = state.pendingTurn?.workspaceId === conversation.workspaceId + && state.pendingTurn.conversationId === conversation.conversationId + && conversation.turnRevision >= state.pendingTurn.turnRevision + ? null + : state.pendingTurn; + return { + workspace: upsertConversation(state.workspace, conversation), + activeConversationId: conversation.conversationId, + conversation, + pendingTurn, + error: null, + }; + }); + return applied; + }; + const loadWorkspace = async (workspaceId: string): Promise => { + const loadGeneration = ++workspaceLoadGeneration; + conversationSelectionGeneration += 1; const workspace = await fetchImageWorkspaceProject(workspaceId); - if (get().activeWorkspaceId !== workspaceId) return; + if (loadGeneration !== workspaceLoadGeneration + || get().activeWorkspaceId !== workspaceId) return; + const currentConversationId = get().activeConversationId; + const conversationId = workspace.conversations.some( + (item) => item.conversationId === currentConversationId, + ) + ? currentConversationId + : workspace.conversations[0]?.conversationId ?? null; const tasks = await fetchImageWorkspaceTasks(workspaceId); - if (get().activeWorkspaceId !== workspaceId) return; + if (loadGeneration !== workspaceLoadGeneration + || get().activeWorkspaceId !== workspaceId) return; set((state) => ({ status: 'ready', bootstrap: upsertSummary(state.bootstrap, workspace), activeWorkspaceId: workspaceId, + activeConversationId: conversationId, workspace, + conversation: null, tasks: sortTasks(tasks), error: null, })); - startTaskStream(workspaceId); - for (const task of tasks) { - taskEventRevisions.set(taskRevisionKey(workspaceId, task.taskId), workspace.viewRevision); - } + if (!conversationId) return; + const selectionGeneration = ++conversationSelectionGeneration; + const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId); + if (loadGeneration !== workspaceLoadGeneration + || selectionGeneration !== conversationSelectionGeneration + || get().activeWorkspaceId !== workspaceId + || get().activeConversationId !== conversationId) return; + applyConversation(conversation); + startTaskStream(workspaceId, conversationId); }; const recoverRevisionConflict = async (error: unknown): Promise => { if (error instanceof ImageWorkspaceApiError - && error.code === 'workspace_revision_conflict') { - await get().refreshWorkspace().catch(() => null); + && (error.code === 'conversation_revision_conflict' + || error.code === 'workspace_revision_conflict')) { + await get().refreshConversation().catch(() => null); throw new ImageWorkspaceApiError( 409, error.code, - '设计项目已更新,内容已刷新,请重新提交', + '设计会话已更新,内容已刷新,请重新提交', ); } throw error; @@ -486,7 +577,10 @@ export const useImageWorkspaceStore = create((set, get) => status: 'idle', bootstrap: null, activeWorkspaceId: null, + activeConversationId: null, workspace: null, + conversation: null, + creatingConversation: false, tasks: [], pendingTurn: null, taskStreamState: 'idle', @@ -509,7 +603,9 @@ export const useImageWorkspaceStore = create((set, get) => status: 'ready', bootstrap, activeWorkspaceId, + activeConversationId: null, workspace: null, + conversation: null, tasks: [], pendingTurn: null, taskStreamState: 'idle', @@ -525,7 +621,9 @@ export const useImageWorkspaceStore = create((set, get) => status: unavailable(error) ? 'unavailable' : 'error', bootstrap: null, activeWorkspaceId: null, + activeConversationId: null, workspace: null, + conversation: null, tasks: [], pendingTurn: null, taskStreamState: 'idle', @@ -543,8 +641,26 @@ export const useImageWorkspaceStore = create((set, get) => createProject: async (title) => { try { const workspace = applyWorkspace(await createImageWorkspaceProject(title)); - set({ tasks: [], pendingTurn: null }); - startTaskStream(workspace.workspaceId); + const conversationId = workspace.conversations[0]?.conversationId ?? null; + set({ + activeConversationId: conversationId, + conversation: null, + tasks: [], + pendingTurn: null, + }); + if (conversationId) { + const selectionGeneration = ++conversationSelectionGeneration; + const conversation = await fetchImageWorkspaceConversation( + workspace.workspaceId, + conversationId, + ); + if (selectionGeneration === conversationSelectionGeneration + && get().activeWorkspaceId === workspace.workspaceId + && get().activeConversationId === conversationId) { + applyConversation(conversation); + startTaskStream(workspace.workspaceId, conversationId); + } + } return workspace; } catch (error) { set({ error: handleRequestError(error) }); @@ -552,6 +668,37 @@ export const useImageWorkspaceStore = create((set, get) => } }, + createConversation: async (title = '新会话') => { + if (get().creatingConversation) { + throw new Error('正在创建新会话,请稍候'); + } + const workspaceId = get().activeWorkspaceId; + if (!workspaceId) throw new Error('请先选择设计项目'); + set({ creatingConversation: true }); + try { + const conversation = await createImageWorkspaceConversation(workspaceId, title); + if (get().activeWorkspaceId !== workspaceId) return conversation; + conversationSelectionGeneration += 1; + stopTaskStream(); + set({ + activeConversationId: conversation.conversationId, + conversation: null, + pendingTurn: null, + taskStreamState: 'idle', + error: null, + }); + applyConversation(conversation); + startTaskStream(workspaceId, conversation.conversationId); + void get().refreshWorkspace().catch(() => null); + return conversation; + } catch (error) { + set({ error: handleRequestError(error) }); + throw error; + } finally { + set({ creatingConversation: false }); + } + }, + renameProject: async (workspaceId, title) => { try { return applyWorkspace(await renameImageWorkspaceProject(workspaceId, title)); @@ -566,16 +713,56 @@ export const useImageWorkspaceStore = create((set, get) => stopTaskStream(); set({ activeWorkspaceId: workspaceId, + activeConversationId: null, workspace: null, + conversation: null, + creatingConversation: false, tasks: [], pendingTurn: null, taskStreamState: 'idle', error: null, }); + const loadPromise = loadWorkspace(workspaceId); + const loadGeneration = workspaceLoadGeneration; try { - await loadWorkspace(workspaceId); + await loadPromise; } catch (error) { - set({ error: handleRequestError(error) }); + if (loadGeneration === workspaceLoadGeneration + && get().activeWorkspaceId === workspaceId) { + set({ error: handleRequestError(error) }); + } + } + }, + + selectConversation: async (conversationId) => { + const state = get(); + const workspaceId = state.activeWorkspaceId; + if (!workspaceId + || !state.workspace?.conversations.some( + (item) => item.conversationId === conversationId, + )) return; + const selectionGeneration = ++conversationSelectionGeneration; + stopTaskStream(); + set({ + activeConversationId: conversationId, + conversation: null, + pendingTurn: null, + taskStreamState: 'idle', + error: null, + }); + try { + const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId); + if (selectionGeneration !== conversationSelectionGeneration + || get().activeWorkspaceId !== workspaceId + || get().activeConversationId !== conversationId) return; + applyConversation(conversation); + startTaskStream(workspaceId, conversationId); + } catch (error) { + if (selectionGeneration === conversationSelectionGeneration + && get().activeWorkspaceId === workspaceId + && get().activeConversationId === conversationId) { + set({ error: handleRequestError(error) }); + } } }, @@ -597,6 +784,26 @@ export const useImageWorkspaceStore = create((set, get) => } }, + refreshConversation: async () => { + const workspaceId = get().activeWorkspaceId; + const conversationId = get().activeConversationId; + if (!workspaceId || !conversationId) return null; + try { + const refreshed = await fetchImageWorkspaceConversation(workspaceId, conversationId); + if (get().activeWorkspaceId !== workspaceId + || get().activeConversationId !== conversationId) return null; + const current = get().conversation; + if (current?.conversationId === conversationId + && current.turnRevision > refreshed.turnRevision) { + return current; + } + return applyConversation(refreshed); + } catch (error) { + set({ error: handleRequestError(error) }); + throw error; + } + }, + refreshTasks: async () => { const workspaceId = get().activeWorkspaceId; if (!workspaceId) { @@ -613,13 +820,6 @@ export const useImageWorkspaceStore = create((set, get) => && currentWorkspace?.workspaceId === workspaceId && currentWorkspace.viewRevision === requestedViewRevision) { set((state) => ({ tasks: mergeTasks(state.tasks, tasks) })); - for (const task of tasks) { - const key = taskRevisionKey(workspaceId, task.taskId); - taskEventRevisions.set( - key, - Math.max(taskEventRevisions.get(key) ?? 0, requestedViewRevision), - ); - } } return tasks; } catch (error) { @@ -630,39 +830,48 @@ export const useImageWorkspaceStore = create((set, get) => connectTaskStream: () => { const workspaceId = get().activeWorkspaceId; - if (workspaceId) startTaskStream(workspaceId); + const conversationId = get().activeConversationId; + if (workspaceId && conversationId) startTaskStream(workspaceId, conversationId); }, disconnectTaskStream: stopTaskStream, sendMessage: async (message, attachmentAssetIds = []) => { const workspace = get().workspace; + const conversation = get().conversation; const userText = message.trim(); const clientTurnId = createImageWorkspaceTurnId(); - if (!workspace) throw new Error('请先选择设计项目'); + if (!workspace || !conversation) throw new Error('请先选择设计会话'); try { - set({ pendingTurn: createPendingTurn(workspace, clientTurnId, userText), error: null }); + set({ + pendingTurn: createPendingTurn(conversation, clientTurnId, userText), + error: null, + }); const updated = attachmentAssetIds.length > 0 ? await sendImageWorkspaceMessage( workspace.workspaceId, - workspace.turnRevision, + conversation.conversationId, + conversation.turnRevision, userText, clientTurnId, attachmentAssetIds, ) : await sendImageWorkspaceMessage( workspace.workspaceId, - workspace.turnRevision, + conversation.conversationId, + conversation.turnRevision, userText, clientTurnId, ); - if (get().activeWorkspaceId === workspace.workspaceId) { - applyWorkspace(updated); + if (get().activeWorkspaceId === workspace.workspaceId + && get().activeConversationId === conversation.conversationId) { + applyConversation(updated); await get().refreshTasks().catch(() => []); } return updated; } catch (error) { - if (get().activeWorkspaceId !== workspace.workspaceId) throw error; + if (get().activeWorkspaceId !== workspace.workspaceId + || get().activeConversationId !== conversation.conversationId) throw error; const errorMessage = handleRequestError(error); set((state) => ({ error: errorMessage, @@ -676,21 +885,25 @@ export const useImageWorkspaceStore = create((set, get) => confirmGeneration: async (quoteId) => { const workspace = get().workspace; - if (!workspace) throw new Error('请先选择设计项目'); + const conversation = get().conversation; + if (!workspace || !conversation) throw new Error('请先选择设计会话'); const clientTurnId = createImageWorkspaceTurnId(); try { set({ - pendingTurn: createPendingTurn(workspace, clientTurnId, '确认生成'), + pendingTurn: createPendingTurn(conversation, clientTurnId, '确认生成'), error: null, }); const updated = await confirmImageWorkspaceGeneration( workspace.workspaceId, - workspace.turnRevision, + conversation.conversationId, + conversation.turnRevision, quoteId, clientTurnId, ); if (get().activeWorkspaceId === workspace.workspaceId) { - applyWorkspace(updated); + if (get().activeConversationId === conversation.conversationId) { + applyConversation(updated); + } let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId); for ( let attempt = 0; @@ -718,7 +931,8 @@ export const useImageWorkspaceStore = create((set, get) => } return updated; } catch (error) { - if (get().activeWorkspaceId !== workspace.workspaceId) throw error; + if (get().activeWorkspaceId !== workspace.workspaceId + || get().activeConversationId !== conversation.conversationId) throw error; const errorMessage = handleRequestError(error); set((state) => ({ error: errorMessage, @@ -732,12 +946,17 @@ export const useImageWorkspaceStore = create((set, get) => reset: () => { inFlightLoad = null; + workspaceLoadGeneration += 1; + conversationSelectionGeneration += 1; closeTaskEventSource(); set({ status: 'idle', bootstrap: null, activeWorkspaceId: null, + activeConversationId: null, workspace: null, + conversation: null, + creatingConversation: false, tasks: [], pendingTurn: null, taskStreamState: 'idle', diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index fba90a6..dd37d7c 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignAssistantDeltaEvent, + DesignConversation, DesignGenerationTask, DesignGenerationTaskUpdatedEvent, DesignWorkspace, @@ -15,6 +16,7 @@ import type { const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn()); +const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn()); const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn()); @@ -57,6 +59,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { ...actual, fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args), fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args), + fetchImageWorkspaceConversation: (...args: unknown[]) => ( + fetchImageWorkspaceConversationMock(...args) + ), fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args), sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args), confirmImageWorkspaceGeneration: (...args: unknown[]) => ( @@ -87,8 +92,19 @@ const bootstrapFixture: DesignWorkspaceBootstrap = { workspaces: [{ workspaceId: 'workspace-cloud', title: '云端角色设定', - turnRevision: 1, viewRevision: 1, + conversationCount: 1, + phase: 'awaiting_confirmation', + updatedAt: '2026-07-31T10:00:00Z', + }], +}; + +function conversationFixture(): DesignConversation { + return { + conversationId: 'conversation-cloud', + workspaceId: 'workspace-cloud', + title: '主会话', + turnRevision: 1, phase: 'awaiting_confirmation', brief: { version: 1, @@ -98,13 +114,8 @@ const bootstrapFixture: DesignWorkspaceBootstrap = { ready: true, missingDecision: null, }, + createdAt: '2026-07-31T10:00:00Z', updatedAt: '2026-07-31T10:00:00Z', - }], -}; - -function workspaceFixture(): DesignWorkspace { - return { - ...bootstrapFixture.workspaces[0], messages: [ { id: 'message-user', @@ -138,9 +149,18 @@ function workspaceFixture(): DesignWorkspace { }; } +function workspaceFixture(): DesignWorkspace { + const { messages: _messages, ...summary } = conversationFixture(); + return { + ...bootstrapFixture.workspaces[0], + conversations: [summary], + }; +} + const taskFixture: DesignGenerationTask = { taskId: 'task-one', workspaceId: 'workspace-cloud', + conversationId: 'conversation-cloud', medium: 'image', status: 'succeeded', briefVersion: 1, @@ -171,10 +191,11 @@ describe('ImageCanvas Workspace-first design experience', () => { useImageWorkspaceStore.getState().reset(); fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture); fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture()); + fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture()); fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]); - sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture()); + sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture()); confirmImageWorkspaceGenerationMock.mockResolvedValue({ - ...workspaceFixture(), + ...conversationFixture(), turnRevision: 2, phase: 'shaping', }); @@ -365,6 +386,7 @@ describe('ImageCanvas Workspace-first design experience', () => { await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 1, '把主角换成暖色轮廓光', expect.stringMatching(/^turn-/), @@ -384,6 +406,7 @@ describe('ImageCanvas Workspace-first design experience', () => { await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 1, '让画面增加一层晨雾', expect.stringMatching(/^turn-/), @@ -409,7 +432,7 @@ describe('ImageCanvas Workspace-first design experience', () => { }); it('renders the pending user turn and assistant deltas while the command is running', async () => { - const response = deferred(); + const response = deferred(); sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); render(); await screen.findByText('云端角色设定'); @@ -428,6 +451,7 @@ describe('ImageCanvas Workspace-first design experience', () => { id: 'session-one:2', type: 'design.assistant.delta', workspaceId: 'workspace-cloud', + conversationId: 'conversation-cloud', clientTurnId: pending.clientTurnId, turnRevision: pending.turnRevision, chunkIndex: 0, @@ -445,6 +469,7 @@ describe('ImageCanvas Workspace-first design experience', () => { id: 'session-one:3', type: 'design.assistant.delta', workspaceId: 'workspace-cloud', + conversationId: 'conversation-cloud', clientTurnId: pending.clientTurnId, turnRevision: pending.turnRevision, chunkIndex: 1, @@ -455,15 +480,14 @@ describe('ImageCanvas Workspace-first design experience', () => { expect(screen.getByTestId('image-workspace-conversation')) .toHaveTextContent('可以先增加留白和水流节奏。'); act(() => response.resolve({ - ...workspaceFixture(), + ...conversationFixture(), turnRevision: 2, - viewRevision: 2, })); await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull()); }); it('never downgrades confirmation to normal chat when no active Quote exists', async () => { - const workspaceWithoutQuote = workspaceFixture(); + const workspaceWithoutQuote = conversationFixture(); workspaceWithoutQuote.messages[1] = { ...workspaceWithoutQuote.messages[1], generationQuote: null, @@ -477,7 +501,7 @@ describe('ImageCanvas Workspace-first design experience', () => { video: false, }, }); - fetchImageWorkspaceProjectMock.mockResolvedValueOnce(workspaceWithoutQuote); + fetchImageWorkspaceConversationMock.mockResolvedValueOnce(workspaceWithoutQuote); render(); const confirmReply = await screen.findByRole('button', { name: '确认生成' }); @@ -510,6 +534,7 @@ describe('ImageCanvas Workspace-first design experience', () => { await waitFor(() => expect(confirmImageWorkspaceGenerationMock) .toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 1, 'quote-one', expect.stringMatching(/^turn-/), @@ -539,6 +564,7 @@ describe('ImageCanvas Workspace-first design experience', () => { await waitFor(() => expect(confirmImageWorkspaceGenerationMock) .toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 1, 'quote-one', expect.stringMatching(/^turn-/), @@ -560,6 +586,7 @@ describe('ImageCanvas Workspace-first design experience', () => { await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 1, question, expect.stringMatching(/^turn-/), @@ -568,8 +595,8 @@ describe('ImageCanvas Workspace-first design experience', () => { }); it('opens the successful work picker when the Agent asks for a video first frame', async () => { - fetchImageWorkspaceProjectMock.mockResolvedValueOnce({ - ...workspaceFixture(), + fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ + ...conversationFixture(), phase: 'shaping', brief: { version: 2, @@ -580,7 +607,7 @@ describe('ImageCanvas Workspace-first design experience', () => { missingDecision: '请选择首帧来源', }, messages: [ - ...workspaceFixture().messages, + ...conversationFixture().messages, { id: 'message-video-source', role: 'assistant', @@ -592,7 +619,7 @@ describe('ImageCanvas Workspace-first design experience', () => { createdAt: '2026-08-06T09:00:00Z', }, ], - } satisfies DesignWorkspace); + } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); @@ -606,13 +633,13 @@ describe('ImageCanvas Workspace-first design experience', () => { }); it('submits a successful work Asset id when it is selected as the video first frame', async () => { - const pendingSelection = deferred(); + const pendingSelection = deferred(); sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise); - fetchImageWorkspaceProjectMock.mockResolvedValueOnce({ - ...workspaceFixture(), + fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ + ...conversationFixture(), turnRevision: 2, messages: [ - ...workspaceFixture().messages, + ...conversationFixture().messages, { id: 'message-video-source', role: 'assistant', @@ -624,7 +651,7 @@ describe('ImageCanvas Workspace-first design experience', () => { createdAt: '2026-08-06T09:00:00Z', }, ], - } satisfies DesignWorkspace); + } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); @@ -636,6 +663,7 @@ describe('ImageCanvas Workspace-first design experience', () => { await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 2, '使用作品图片作为视频首帧', expect.stringMatching(/^turn-/), @@ -644,16 +672,16 @@ describe('ImageCanvas Workspace-first design experience', () => { expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument(); await act(async () => { - pendingSelection.resolve(workspaceFixture()); + pendingSelection.resolve(conversationFixture()); await pendingSelection.promise; }); }); it('uploads a local first-frame image and submits its Asset id to the Agent', async () => { - fetchImageWorkspaceProjectMock.mockResolvedValueOnce({ - ...workspaceFixture(), + fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ + ...conversationFixture(), messages: [ - ...workspaceFixture().messages, + ...conversationFixture().messages, { id: 'message-video-source', role: 'assistant', @@ -666,7 +694,7 @@ describe('ImageCanvas Workspace-first design experience', () => { }, ], turnRevision: 2, - } satisfies DesignWorkspace); + } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const picker = screen.getByRole('dialog', { name: '选择视频首帧' }); @@ -680,6 +708,7 @@ describe('ImageCanvas Workspace-first design experience', () => { .toHaveBeenCalledWith('workspace-cloud', file)); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', + 'conversation-cloud', 2, '使用上传的图片作为视频首帧', expect.stringMatching(/^turn-/), @@ -714,7 +743,7 @@ describe('ImageCanvas Workspace-first design experience', () => { render(); await screen.findByTestId('design-quote-quote-one'); await waitFor(() => expect(openImageWorkspaceTaskEventsMock) - .toHaveBeenCalledWith('workspace-cloud')); + .toHaveBeenCalledWith('workspace-cloud', 'conversation-cloud')); await waitFor(() => expect(taskEventSource.onopen).not.toBeNull()); act(() => { taskEventSource.onopen?.(new Event('open')); diff --git a/tests/unit/image-workspace-api.test.ts b/tests/unit/image-workspace-api.test.ts index 709d7a6..90173c7 100644 --- a/tests/unit/image-workspace-api.test.ts +++ b/tests/unit/image-workspace-api.test.ts @@ -59,11 +59,11 @@ describe('AI design renderer API boundary', () => { }); it('opens task events only after the local Host API token is ready', async () => { - const source = await openImageWorkspaceTaskEvents('workspace/one'); + const source = await openImageWorkspaceTaskEvents('workspace/one', 'conversation/one'); expect(source).toBe(createHostEventSourceMock.mock.results[0].value); expect(createHostEventSourceMock).toHaveBeenCalledWith( - '/api/works/image-workspace/workspaces/workspace%2Fone/events', + '/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events', ); expect(ensureHostApiTokenMock.mock.invocationCallOrder[0]) .toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]); @@ -84,13 +84,14 @@ describe('AI design renderer API boundary', () => { it('sends conversation turns with the current turn revision and no provider settings', async () => { await sendImageWorkspaceMessage( 'workspace/one', + 'conversation/one', 4, ' 继续调整构图 ', 'turn-client-1', ); const [path, init] = hostApiFetchMock.mock.calls[0]; - expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages'); + expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/messages'); expect(init).toMatchObject({ method: 'POST' }); expect(JSON.parse(String(init?.body))).toMatchObject({ expectedTurnRevision: 4, @@ -104,6 +105,7 @@ describe('AI design renderer API boundary', () => { it('sends the selected first-frame Asset id with the conversation turn', async () => { await sendImageWorkspaceMessage( 'workspace-one', + 'conversation-one', 5, '使用作品图片作为视频首帧', 'turn-first-frame', @@ -148,11 +150,11 @@ describe('AI design renderer API boundary', () => { }); it('confirms a Quote through a separate structured action route', async () => { - await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one'); + await confirmImageWorkspaceGeneration('workspace-one', 'conversation-one', 5, 'quote/one'); const [path, init] = hostApiFetchMock.mock.calls[0]; expect(path).toBe( - '/api/works/image-workspace/workspaces/workspace-one/quotes/quote%2Fone/confirm', + '/api/works/image-workspace/workspaces/workspace-one/conversations/conversation-one/quotes/quote%2Fone/confirm', ); expect(JSON.parse(String(init?.body))).toMatchObject({ expectedTurnRevision: 5, diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index cbe4f72..ab21679 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -130,6 +130,8 @@ describe('AI design Main route boundary', () => { const workspace = { bootstrap: vi.fn().mockResolvedValue(bootstrap), createWorkspace: vi.fn().mockResolvedValue({ workspaceId: 'workspace-one' }), + createConversation: vi.fn(), + getConversation: vi.fn(), renameWorkspace: vi.fn(), getWorkspace: vi.fn(), submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }), @@ -177,12 +179,13 @@ describe('AI design Main route boundary', () => { }), messageResponse.res, new URL( - 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/messages', + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/messages', ), ctx, ); expect(workspace.submitMessage).toHaveBeenCalledWith({ workspaceId: 'workspace/one', + conversationId: 'conversation/one', clientTurnId: 'turn-one', expectedTurnRevision: 3, message: '继续编辑', @@ -221,12 +224,13 @@ describe('AI design Main route boundary', () => { }), confirmResponse.res, new URL( - 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/quotes/quote%2Fone/confirm', + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/quotes/quote%2Fone/confirm', ), ctx, ); expect(workspace.confirmGeneration).toHaveBeenCalledWith({ workspaceId: 'workspace/one', + conversationId: 'conversation/one', quoteId: 'quote/one', clientTurnId: 'turn-two', expectedTurnRevision: 4, @@ -391,6 +395,7 @@ describe('AI design Main route boundary', () => { generationTask: { taskId: 'task-live', workspaceId: 'workspace/one', + conversationId: 'conversation/one', medium: 'image' as const, status: 'succeeded' as const, briefVersion: 1, @@ -414,7 +419,7 @@ describe('AI design Main route boundary', () => { request, response as unknown as ServerResponse, new URL( - 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/events', + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events', ), { imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext, ); @@ -422,6 +427,7 @@ describe('AI design Main route boundary', () => { expect(handled).toBe(true); expect(openWorkspaceEvents).toHaveBeenCalledWith({ workspaceId: 'workspace/one', + conversationId: 'conversation/one', afterEventId: 'session-one:6', }); expect(response.statusCode).toBe(200); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 6671223..1a1ddfb 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignAssistantDeltaEvent, + DesignConversation, + DesignConversationSnapshotEvent, DesignGenerationTask, - DesignGenerationTasksSnapshotEvent, DesignGenerationTaskUpdatedEvent, DesignWorkspace, DesignWorkspaceBootstrap, @@ -11,6 +12,8 @@ import type { const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn()); +const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn()); +const createImageWorkspaceConversationMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn()); const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn()); const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); @@ -22,6 +25,12 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { ...actual, fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args), fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args), + fetchImageWorkspaceConversation: (...args: unknown[]) => ( + fetchImageWorkspaceConversationMock(...args) + ), + createImageWorkspaceConversation: (...args: unknown[]) => ( + createImageWorkspaceConversationMock(...args) + ), fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args), openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args), sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args), @@ -62,6 +71,7 @@ function deferred(): { promise: Promise; resolve(value: T): void } { const task: DesignGenerationTask = { taskId: 'task-one', workspaceId: 'workspace-one', + conversationId: 'conversation-one', medium: 'image', status: 'queued', briefVersion: 1, @@ -74,12 +84,16 @@ const task: DesignGenerationTask = { updatedAt: '2026-08-02T10:00:00Z', }; -function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace { +function conversation( + workspaceId = 'workspace-one', + turnRevision = 1, + conversationId = workspaceId.replace('workspace', 'conversation'), +): DesignConversation { return { + conversationId, workspaceId, - title: workspaceId, - turnRevision: 1, - viewRevision, + title: '主会话', + turnRevision, phase: 'shaping', brief: { version: 1, @@ -91,6 +105,20 @@ function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorks }, updatedAt: '2026-08-02T10:00:00Z', messages: [], + createdAt: '2026-08-02T10:00:00Z', + }; +} + +function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace { + const { messages: _messages, ...conversationSummary } = conversation(workspaceId); + return { + workspaceId, + title: workspaceId, + viewRevision, + conversationCount: 1, + phase: conversationSummary.phase, + updatedAt: '2026-08-02T10:00:00Z', + conversations: [conversationSummary], }; } @@ -98,7 +126,7 @@ function bootstrap(workspaceIds = ['workspace-one']): DesignWorkspaceBootstrap { return { capabilities: { conversation: true, generation: true, image: true, video: true }, workspaces: workspaceIds.map((workspaceId) => { - const { messages: _messages, ...summary } = workspace(workspaceId); + const { conversations: _conversations, ...summary } = workspace(workspaceId); return summary; }), }; @@ -124,13 +152,14 @@ function taskEvent( function taskSnapshotEvent( workspaceViewRevision: number, status: DesignGenerationTask['status'], -): DesignGenerationTasksSnapshotEvent { +): DesignConversationSnapshotEvent { return { id: `session-one:${workspaceViewRevision}`, - type: 'design.generation_tasks.snapshot', + type: 'design.conversation.snapshot', workspaceId: 'workspace-one', + conversationId: 'conversation-one', workspaceViewRevision, - workspace: workspace('workspace-one', workspaceViewRevision), + conversation: conversation('workspace-one', workspaceViewRevision), generationTasks: [{ ...task, status, @@ -146,10 +175,16 @@ describe('AI design task event store', () => { useImageWorkspaceStore.getState().reset(); fetchImageWorkspaceMock.mockResolvedValue(bootstrap()); fetchImageWorkspaceProjectMock.mockResolvedValue(workspace()); + fetchImageWorkspaceConversationMock.mockImplementation((workspaceId: string, conversationId: string) => ( + Promise.resolve(conversation(workspaceId, 1, conversationId)) + )); + createImageWorkspaceConversationMock.mockResolvedValue( + conversation('workspace-one', 0, 'conversation-new'), + ); fetchImageWorkspaceTasksMock.mockResolvedValue([task]); - sendImageWorkspaceMessageMock.mockResolvedValue(workspace()); + sendImageWorkspaceMessageMock.mockResolvedValue(conversation()); confirmImageWorkspaceGenerationMock.mockResolvedValue({ - ...workspace('workspace-one', 2), + ...conversation('workspace-one', 2), turnRevision: 2, }); }); @@ -159,30 +194,30 @@ describe('AI design task event store', () => { vi.useRealTimers(); }); - it('keeps a revision-5 snapshot over revision 3, then upserts a newer task by task id', async () => { + it('uses task event revisions instead of the project view revision as the task watermark', async () => { const source = new MockEventSource(); openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); - fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 5)); + fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 50)); await useImageWorkspaceStore.getState().load(); await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock) - .toHaveBeenCalledWith('workspace-one')); + .toHaveBeenCalledWith('workspace-one', 'conversation-one')); source.onopen?.(new Event('open')); source.emit('design.generation_task.updated', taskEvent(3, 'failed')); - expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('queued'); - source.emit('design.generation_tasks.snapshot', taskSnapshotEvent(6, 'running')); + expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('failed'); + source.emit('design.conversation.snapshot', taskSnapshotEvent(6, 'running')); source.emit('design.generation_task.updated', taskEvent(5, 'failed')); expect(useImageWorkspaceStore.getState()).toMatchObject({ taskStreamState: 'connected', - workspace: { viewRevision: 6 }, + workspace: { viewRevision: 50 }, tasks: [{ taskId: 'task-one', status: 'running' }], }); }); it('assembles assistant deltas once and replaces them with the canonical snapshot', async () => { const source = new MockEventSource(); - const response = deferred(); + const response = deferred(); openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); @@ -200,6 +235,7 @@ describe('AI design task event store', () => { id: 'session-one:2', type: 'design.assistant.delta', workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: pending!.clientTurnId, turnRevision: 2, chunkIndex: 0, @@ -218,7 +254,7 @@ describe('AI design task event store', () => { .toBe('可以,先强化鲸鱼的轮廓。'); const canonical = { - ...workspace('workspace-one', 2), + ...conversation('workspace-one', 2), turnRevision: 2, messages: [{ id: 'workspace-one:2:assistant:0', @@ -231,14 +267,14 @@ describe('AI design task event store', () => { createdAt: '2026-08-02T10:01:00Z', }], }; - source.emit('design.generation_tasks.snapshot', { + source.emit('design.conversation.snapshot', { ...taskSnapshotEvent(2, 'queued'), id: 'session-one:4', - workspace: canonical, - } satisfies DesignGenerationTasksSnapshotEvent); + conversation: canonical, + } satisfies DesignConversationSnapshotEvent); expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull(); - expect(useImageWorkspaceStore.getState().workspace?.messages[0].text) + expect(useImageWorkspaceStore.getState().conversation?.messages[0].text) .toBe('可以,先强化鲸鱼的轮廓。'); response.resolve(canonical); await sending; @@ -257,6 +293,7 @@ describe('AI design task event store', () => { expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledWith( 'workspace-one', + 'conversation-one', 1, 'quote-one', expect.stringMatching(/^turn-/), @@ -309,7 +346,7 @@ describe('AI design task event store', () => { it('does not switch back when an Agent turn finishes after selecting another Workspace', async () => { const first = new MockEventSource(); const second = new MockEventSource(); - const response = deferred(); + const response = deferred(); openImageWorkspaceTaskEventsMock .mockResolvedValueOnce(first as unknown as EventSource) .mockResolvedValueOnce(second as unknown as EventSource); @@ -323,7 +360,7 @@ describe('AI design task event store', () => { const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报'); await useImageWorkspaceStore.getState().selectProject('workspace-two'); response.resolve({ - ...workspace('workspace-one', 2), + ...conversation('workspace-one', 2), turnRevision: 2, }); await sending; @@ -356,7 +393,7 @@ describe('AI design task event store', () => { await useImageWorkspaceStore.getState().load(); await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock) - .toHaveBeenCalledWith('workspace-root')); + .toHaveBeenCalledWith('workspace-root', 'conversation-root')); const staleSelection = useImageWorkspaceStore.getState().selectProject('workspace-one'); await vi.waitFor(() => expect(fetchImageWorkspaceProjectMock) .toHaveBeenCalledWith('workspace-one')); @@ -366,10 +403,11 @@ describe('AI design task event store', () => { expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe('workspace-two'); expect(useImageWorkspaceStore.getState().workspace?.workspaceId).toBe('workspace-two'); - expect(openImageWorkspaceTaskEventsMock).not.toHaveBeenCalledWith('workspace-one'); + expect(openImageWorkspaceTaskEventsMock) + .not.toHaveBeenCalledWith('workspace-one', 'conversation-one'); }); - it('advances each task watermark after a degraded REST snapshot', async () => { + it('uses updatedAt to keep a newer degraded REST task over an older replay', async () => { const source = new MockEventSource(); openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); @@ -379,6 +417,7 @@ describe('AI design task event store', () => { fetchImageWorkspaceTasksMock.mockResolvedValue([{ ...task, status: 'succeeded', + updatedAt: '2026-08-02T11:00:00Z', }]); await useImageWorkspaceStore.getState().refreshWorkspace(); await useImageWorkspaceStore.getState().refreshTasks(); @@ -404,4 +443,203 @@ describe('AI design task event store', () => { await vi.advanceTimersByTimeAsync(15_000); expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2); }); + + it('switches the conversation stream without clearing project generation tasks', async () => { + const first = new MockEventSource(); + const second = new MockEventSource(); + openImageWorkspaceTaskEventsMock + .mockResolvedValueOnce(first as unknown as EventSource) + .mockResolvedValueOnce(second as unknown as EventSource); + const project = workspace(); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 0, + 'conversation-two', + ); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + + await useImageWorkspaceStore.getState().load(); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + + expect(first.close).toHaveBeenCalledOnce(); + expect(openImageWorkspaceTaskEventsMock) + .toHaveBeenLastCalledWith('workspace-one', 'conversation-two'); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-one', + activeConversationId: 'conversation-two', + conversation: { conversationId: 'conversation-two' }, + tasks: [{ taskId: 'task-one' }], + }); + }); + + it('accepts a lower-revision fresh task event after switching conversations', async () => { + const first = new MockEventSource(); + const second = new MockEventSource(); + openImageWorkspaceTaskEventsMock + .mockResolvedValueOnce(first as unknown as EventSource) + .mockResolvedValueOnce(second as unknown as EventSource); + const project = workspace('workspace-one', 50); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 0, + 'conversation-two', + ); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + + await useImageWorkspaceStore.getState().load(); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + second.emit('design.generation_task.updated', taskEvent(3, 'running')); + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeConversationId: 'conversation-two', + workspace: { viewRevision: 50 }, + tasks: [{ taskId: 'task-one', status: 'running' }], + }); + }); + + it('does not let an older ABA conversation response replace the latest turn', async () => { + const source = new MockEventSource(); + const project = workspace(); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 0, + 'conversation-two', + ); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + await useImageWorkspaceStore.getState().load(); + + const staleA = deferred(); + const freshA = deferred(); + fetchImageWorkspaceConversationMock + .mockReset() + .mockReturnValueOnce(staleA.promise) + .mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-two')) + .mockReturnValueOnce(freshA.promise); + const firstA = useImageWorkspaceStore.getState().selectConversation('conversation-one'); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + const secondA = useImageWorkspaceStore.getState().selectConversation('conversation-one'); + freshA.resolve({ + ...conversation('workspace-one', 5, 'conversation-one'), + updatedAt: '2026-08-02T10:05:00Z', + }); + await secondA; + staleA.resolve({ + ...conversation('workspace-one', 4, 'conversation-one'), + updatedAt: '2026-08-02T10:04:00Z', + }); + await firstA; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeConversationId: 'conversation-one', + conversation: { conversationId: 'conversation-one', turnRevision: 5 }, + }); + }); + + it('does not surface a late ABA conversation error on the latest selection', async () => { + const source = new MockEventSource(); + const project = workspace(); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 0, + 'conversation-two', + ); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + await useImageWorkspaceStore.getState().load(); + + let rejectStale!: (error: unknown) => void; + const staleA = new Promise((_resolve, reject) => { + rejectStale = reject; + }); + fetchImageWorkspaceConversationMock + .mockReset() + .mockReturnValueOnce(staleA) + .mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-two')) + .mockResolvedValueOnce(conversation('workspace-one', 5, 'conversation-one')); + const firstA = useImageWorkspaceStore.getState().selectConversation('conversation-one'); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + await useImageWorkspaceStore.getState().selectConversation('conversation-one'); + rejectStale(new Error('stale request failed')); + await firstA; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeConversationId: 'conversation-one', + conversation: { turnRevision: 5 }, + error: null, + }); + }); + + it('does not let stale ABA workspace tasks replace the latest project load', async () => { + const source = new MockEventSource(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two'])); + await useImageWorkspaceStore.getState().load(); + + const staleTasks = deferred(); + fetchImageWorkspaceProjectMock + .mockReset() + .mockResolvedValueOnce(workspace('workspace-one', 4)) + .mockResolvedValueOnce(workspace('workspace-two', 1)) + .mockResolvedValueOnce(workspace('workspace-one', 5)); + fetchImageWorkspaceTasksMock + .mockReset() + .mockReturnValueOnce(staleTasks.promise) + .mockResolvedValueOnce([{ ...task, taskId: 'task-two', workspaceId: 'workspace-two' }]) + .mockResolvedValueOnce([{ + ...task, + status: 'succeeded', + updatedAt: '2026-08-02T11:00:00Z', + }]); + + const firstA = useImageWorkspaceStore.getState().selectProject('workspace-one'); + await vi.waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce()); + await useImageWorkspaceStore.getState().selectProject('workspace-two'); + await useImageWorkspaceStore.getState().selectProject('workspace-one'); + staleTasks.resolve([{ + ...task, + status: 'failed', + updatedAt: '2026-08-02T10:04:00Z', + }]); + await firstA; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-one', + workspace: { workspaceId: 'workspace-one', viewRevision: 5 }, + tasks: [{ taskId: 'task-one', status: 'succeeded' }], + }); + }); + + it('prevents duplicate conversations while one create request is in flight', async () => { + const source = new MockEventSource(); + const pending = deferred(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + createImageWorkspaceConversationMock.mockReturnValueOnce(pending.promise); + await useImageWorkspaceStore.getState().load(); + + const firstCreate = useImageWorkspaceStore.getState().createConversation(); + await expect(useImageWorkspaceStore.getState().createConversation()) + .rejects.toThrow('正在创建新会话'); + expect(createImageWorkspaceConversationMock).toHaveBeenCalledOnce(); + + pending.resolve(conversation('workspace-one', 0, 'conversation-new')); + await firstCreate; + expect(useImageWorkspaceStore.getState().creatingConversation).toBe(false); + }); }); diff --git a/tests/unit/local-image-workspace.test.ts b/tests/unit/local-image-workspace.test.ts index 44fd646..7a56433 100644 --- a/tests/unit/local-image-workspace.test.ts +++ b/tests/unit/local-image-workspace.test.ts @@ -87,7 +87,7 @@ describe('local AI design workspace', () => { const reloaded = new LocalImageWorkspace({ userDataDir }); await expect(reloaded.bootstrap()).resolves.toMatchObject({ - workspaces: [{ title: '角色设计第二版', turnRevision: 0, viewRevision: 1 }], + workspaces: [{ title: '角色设计第二版', conversationCount: 1, viewRevision: 1 }], }); }); @@ -97,8 +97,10 @@ describe('local AI design workspace', () => { clientWorkspaceId: 'client-flow', title: '海洋公益海报', }); + const conversationId = created.conversations[0].conversationId; const discussed = await service.submitMessage({ workspaceId: created.workspaceId, + conversationId, clientTurnId: 'turn-one', expectedTurnRevision: 0, message: '做一张保护海洋的竖版公益海报', @@ -111,6 +113,7 @@ describe('local AI design workspace', () => { const confirmed = await service.confirmGeneration({ workspaceId: created.workspaceId, + conversationId, clientTurnId: 'turn-two', expectedTurnRevision: 1, quoteId: quote!.quoteId, @@ -141,15 +144,17 @@ describe('local AI design workspace', () => { clientWorkspaceId: 'client-reset', title: '可重置项目', }); + const conversationId = created.conversations[0].conversationId; await expect(service.submitMessage({ workspaceId: created.workspaceId, + conversationId, clientTurnId: 'stale-turn', expectedTurnRevision: 3, message: '测试', })).rejects.toMatchObject({ status: 409, - code: 'workspace_revision_conflict', + code: 'conversation_revision_conflict', }); expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true); diff --git a/tests/unit/sidebar-opencode-projects.test.tsx b/tests/unit/sidebar-opencode-projects.test.tsx index 278c75f..42dbd5a 100644 --- a/tests/unit/sidebar-opencode-projects.test.tsx +++ b/tests/unit/sidebar-opencode-projects.test.tsx @@ -371,8 +371,16 @@ describe('Sidebar project initialization flow', () => { const summary = { workspaceId: 'cloud-project', title: '云端概念设计', - turnRevision: 0, viewRevision: 0, + conversationCount: 1, + phase: 'shaping', + updatedAt: '2026-07-31T10:00:00Z', + }; + const conversation = { + conversationId: 'cloud-conversation', + workspaceId: 'cloud-project', + title: '主会话', + turnRevision: 0, phase: 'shaping', brief: { version: 0, @@ -382,8 +390,11 @@ describe('Sidebar project initialization flow', () => { ready: false, missingDecision: '作品形式', }, + createdAt: '2026-07-31T10:00:00Z', updatedAt: '2026-07-31T10:00:00Z', + messages: [], }; + const { messages: _messages, ...conversationSummary } = conversation; const bootstrap = { capabilities: { conversation: true, @@ -398,7 +409,20 @@ describe('Sidebar project initialization flow', () => { return { success: true, data: bootstrap }; } if (path === '/api/works/image-workspace/workspaces/cloud-project' && !init?.method) { - return { success: true, data: { ...summary, messages: [] } }; + return { success: true, data: { ...summary, conversations: [conversationSummary] } }; + } + if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations/cloud-conversation') { + return { success: true, data: conversation }; + } + if (path === '/api/works/image-workspace/workspaces/cloud-project-two/conversations/cloud-conversation-two') { + return { + success: true, + data: { + ...conversation, + workspaceId: 'cloud-project-two', + conversationId: 'cloud-conversation-two', + }, + }; } if (path === '/api/works/image-workspace/workspaces/cloud-project/tasks') { return { success: true, data: [] }; @@ -406,7 +430,16 @@ describe('Sidebar project initialization flow', () => { if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') { return { success: true, - data: { ...summary, workspaceId: 'cloud-project-two', title: '角色设定', messages: [] }, + data: { + ...summary, + workspaceId: 'cloud-project-two', + title: '角色设定', + conversations: [{ + ...conversationSummary, + workspaceId: 'cloud-project-two', + conversationId: 'cloud-conversation-two', + }], + }, }; } throw new Error(`Unexpected path ${path}`); @@ -451,8 +484,16 @@ describe('Sidebar project initialization flow', () => { const summary = { workspaceId: 'local-project', title: '概念设计', - turnRevision: 0, viewRevision: 0, + conversationCount: 1, + phase: 'shaping', + updatedAt: '2026-07-31T10:00:00Z', + }; + const conversation = { + conversationId: 'local-conversation', + workspaceId: 'local-project', + title: '主会话', + turnRevision: 0, phase: 'shaping', brief: { version: 0, @@ -462,8 +503,11 @@ describe('Sidebar project initialization flow', () => { ready: false, missingDecision: '作品形式', }, + createdAt: '2026-07-31T10:00:00Z', updatedAt: '2026-07-31T10:00:00Z', + messages: [], }; + const { messages: _messages, ...conversationSummary } = conversation; let bootstrap = { capabilities: { conversation: true, @@ -478,7 +522,10 @@ describe('Sidebar project initialization flow', () => { return { success: true, data: bootstrap }; } if (path === '/api/works/image-workspace/workspaces/local-project' && !init?.method) { - return { success: true, data: { ...summary, messages: [] } }; + return { success: true, data: { ...summary, conversations: [conversationSummary] } }; + } + if (path === '/api/works/image-workspace/workspaces/local-project/conversations/local-conversation') { + return { success: true, data: conversation }; } if (path === '/api/works/image-workspace/workspaces/local-project/tasks') { return { success: true, data: [] }; @@ -488,11 +535,25 @@ describe('Sidebar project initialization flow', () => { ...summary, workspaceId: 'local-project-two', title: '角色设计', - messages: [], + conversations: [{ + ...conversationSummary, + workspaceId: 'local-project-two', + conversationId: 'local-conversation-two', + }], }; bootstrap = { ...bootstrap, workspaces: [created, ...bootstrap.workspaces] }; return { success: true, data: created }; } + if (path === '/api/works/image-workspace/workspaces/local-project-two/conversations/local-conversation-two') { + return { + success: true, + data: { + ...conversation, + workspaceId: 'local-project-two', + conversationId: 'local-conversation-two', + }, + }; + } throw new Error(`Unexpected path ${path}`); }); diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index b65f706..536d208 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -9,37 +9,48 @@ vi.mock('@electron/services/works-square-session', () => ({ const getTokenMock = vi.mocked(getValidWorksSquareAccessToken); -const serverWorkspace = { +const serverConversation = { + conversation_id: 'conversation-one', workspace_id: 'workspace-one', - title: '海洋公益海报', + agent_session_id: 'session-one', + title: '????', turn_revision: 1, - view_revision: 2, phase: 'awaiting_confirmation', brief: { version: 1, status: 'ready', medium: 'image', - summary: '保护海洋的竖版公益海报', + summary: '???????????', ready: true, missing_decision: null, }, messages: [{ role: 'assistant', kind: 'confirmation', - text: '方向已经明确,是否开始生成?', - quick_replies: ['确认生成'], + text: '??????????????', + quick_replies: ['????'], generation_quote: { quote_id: 'quote-one', status: 'active', medium: 'image', brief_version: 1, - brief_summary: '保护海洋的竖版公益海报', + brief_summary: '???????????', quoted_design_points: 1, expires_at: '2026-07-31T11:00:00Z', }, turn_revision: 1, created_at: '2026-07-31T10:00:00Z', }], + created_at: '2026-07-31T10:00:00Z', + updated_at: '2026-07-31T10:00:00Z', +}; + +const serverWorkspace = { + workspace_id: 'workspace-one', + title: '??????', + view_revision: 2, + conversation_count: 1, + phase: 'awaiting_confirmation', updated_at: '2026-07-31T10:00:00Z', }; @@ -148,10 +159,9 @@ describe('Works Square AI design adapter', () => { capabilities: { conversation: true, image: true, video: false }, workspaces: [{ workspaceId: 'workspace-one', - title: '海洋公益海报', - turnRevision: 1, + title: '??????', viewRevision: 2, - brief: { missingDecision: null }, + conversationCount: 1, }], }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -162,10 +172,7 @@ describe('Works Square AI design adapter', () => { it('submits a conversation turn through the persistent Agent Gateway Session', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ - session_id: 'session-one', - status: 'active', - }, 201)) + .mockResolvedValueOnce(jsonResponse(serverConversation)) .mockResolvedValueOnce(jsonResponse({ run_id: 'run-one', status: 'queued', @@ -176,7 +183,7 @@ describe('Works Square AI design adapter', () => { status: 'succeeded', error: null, })) - .mockResolvedValueOnce(jsonResponse(serverWorkspace)); + .mockResolvedValueOnce(jsonResponse(serverConversation)); const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example', fetchImpl: fetchMock, @@ -185,9 +192,10 @@ describe('Works Square AI design adapter', () => { await expect(adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-two', expectedTurnRevision: 1, - message: '做一张保护海洋的公益海报', + message: '????????????', attachmentAssetIds: ['asset-reference'], })).resolves.toMatchObject({ workspaceId: 'workspace-one', @@ -204,7 +212,7 @@ describe('Works Square AI design adapter', () => { name: 'turn.submit', input: { expected_turn_revision: 1, - message: '做一张保护海洋的公益海报', + message: '????????????', attachment_asset_ids: ['asset-reference'], action: null, }, @@ -218,9 +226,11 @@ describe('Works Square AI design adapter', () => { ); expect(fetchMock).toHaveBeenNthCalledWith( 4, - 'https://square.example/api/design/workspaces/workspace-one', + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one', expect.objectContaining({ headers: expect.any(Object) }), ); + const commandBody = JSON.parse(String(fetchMock.mock.calls[1][1]?.body)); + expect(commandBody.input).not.toHaveProperty('conversation_id'); }); it('uploads a local reference image as multipart data and maps the returned Asset', async () => { @@ -267,8 +277,8 @@ describe('Works Square AI design adapter', () => { const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); const fetchMock = vi.fn(async (input) => { const url = String(input); - if (url.endsWith('/api/agents/sessions')) { - return jsonResponse({ session_id: 'session-live', status: 'active' }, 201); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-live' }); } if (url.endsWith('/stream-tickets')) { return jsonResponse({ @@ -301,8 +311,8 @@ describe('Works Square AI design adapter', () => { status: 'accepted', }, 202); } - if (url.endsWith('/api/design/workspaces/workspace-one')) { - return jsonResponse(serverWorkspace); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse(serverConversation); } throw new Error(`Unexpected request: ${url}`); }); @@ -311,14 +321,15 @@ describe('Works Square AI design adapter', () => { fetchImpl: fetchMock, webSocketFactory, }); - const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); try { await expect(adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-live', expectedTurnRevision: 1, - message: '做一张保护海洋的公益海报', + message: '????????????', })).resolves.toMatchObject({ workspaceId: 'workspace-one' }); expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(0); } finally { @@ -328,15 +339,15 @@ describe('Works Square AI design adapter', () => { it('does not let terminal Run completion overtake streamed design events', async () => { const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); - const streamedWorkspace = { - ...serverWorkspace, + const streamedConversation = { + ...serverConversation, + agent_session_id: 'session-ordered', turn_revision: 2, - view_revision: 3, }; const fetchMock = vi.fn(async (input) => { const url = String(input); - if (url.endsWith('/api/agents/sessions')) { - return jsonResponse({ session_id: 'session-ordered', status: 'active' }, 201); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse(streamedConversation); } if (url.endsWith('/stream-tickets')) { return jsonResponse({ @@ -356,10 +367,11 @@ describe('Works Square AI design adapter', () => { schema_version: 1, payload: { workspace_id: 'workspace-one', + conversation_id: 'conversation-one', client_turn_id: 'turn-ordered', turn_revision: 2, chunk_index: 0, - delta: '先看见这一段', + delta: '??????', }, }, }); @@ -369,11 +381,14 @@ describe('Works Square AI design adapter', () => { session_id: 'session-ordered', sequence: 5, runtime: 'design', - type: 'design.workspace.updated', + type: 'design.conversation.updated', run_id: 'run-ordered', schema_version: 1, payload: { - workspace: streamedWorkspace, + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + workspace_view_revision: 3, + conversation: streamedConversation, generation_tasks: [], }, }, @@ -393,9 +408,6 @@ describe('Works Square AI design adapter', () => { }); return jsonResponse({ run_id: 'run-ordered', status: 'queued', error: null }, 202); } - if (url.endsWith('/api/design/workspaces/workspace-one')) { - return jsonResponse(streamedWorkspace); - } throw new Error(`Unexpected request: ${url}`); }); const adapter = new WorksSquareDesignWorkspace({ @@ -403,7 +415,7 @@ describe('Works Square AI design adapter', () => { fetchImpl: fetchMock, webSocketFactory, }); - const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); const releaseFirstWrite = deferred(); const received: DesignWorkspaceEvent[] = []; const consume = (async () => { @@ -418,9 +430,10 @@ describe('Works Square AI design adapter', () => { try { const turn = adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-ordered', expectedTurnRevision: 1, - message: '让回复逐步出现', + message: '???????', }).finally(() => { resolved = true; }); @@ -429,19 +442,19 @@ describe('Works Square AI design adapter', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(received[0]).toMatchObject({ type: 'design.assistant.delta', - delta: '先看见这一段', + delta: '??????', }); expect(resolved).toBe(false); - expect(fetchMock.mock.calls.some(([url]) => ( - String(url).endsWith('/api/design/workspaces/workspace-one') - ))).toBe(false); + expect(fetchMock.mock.calls.filter(([url]) => ( + String(url).endsWith('/conversations/conversation-one') + ))).toHaveLength(1); releaseFirstWrite.resolve(); await consume; await expect(turn).resolves.toMatchObject({ turnRevision: 2 }); expect(received.map((event) => event.type)).toEqual([ 'design.assistant.delta', - 'design.generation_tasks.snapshot', + 'design.conversation.snapshot', ]); } finally { releaseFirstWrite.resolve(); @@ -452,15 +465,15 @@ describe('Works Square AI design adapter', () => { it('bounds the delivery barrier when an opened stream has no consumer', async () => { vi.useFakeTimers(); const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); - const streamedWorkspace = { - ...serverWorkspace, + const streamedConversation = { + ...serverConversation, + agent_session_id: 'session-bounded', turn_revision: 2, - view_revision: 3, }; const fetchMock = vi.fn(async (input) => { const url = String(input); - if (url.endsWith('/api/agents/sessions')) { - return jsonResponse({ session_id: 'session-bounded', status: 'active' }, 201); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse(streamedConversation); } if (url.endsWith('/stream-tickets')) { return jsonResponse({ @@ -475,10 +488,16 @@ describe('Works Square AI design adapter', () => { session_id: 'session-bounded', sequence: 5, runtime: 'design', - type: 'design.workspace.updated', + type: 'design.conversation.updated', run_id: 'run-bounded', schema_version: 1, - payload: { workspace: streamedWorkspace, generation_tasks: [] }, + payload: { + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + workspace_view_revision: 3, + conversation: streamedConversation, + generation_tasks: [], + }, }, }); sockets[0]?.emitFrame({ @@ -496,9 +515,6 @@ describe('Works Square AI design adapter', () => { }); return jsonResponse({ run_id: 'run-bounded', status: 'queued', error: null }, 202); } - if (url.endsWith('/api/design/workspaces/workspace-one')) { - return jsonResponse(streamedWorkspace); - } throw new Error(`Unexpected request: ${url}`); }); const adapter = new WorksSquareDesignWorkspace({ @@ -506,7 +522,7 @@ describe('Works Square AI design adapter', () => { fetchImpl: fetchMock, webSocketFactory, }); - const subscriptionPromise = adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const subscriptionPromise = adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); await vi.advanceTimersByTimeAsync(0); const subscription = await subscriptionPromise; let resolved = false; @@ -514,9 +530,10 @@ describe('Works Square AI design adapter', () => { try { const turn = adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-bounded', expectedTurnRevision: 1, - message: '即使页面暂停消费也不能永久等待', + message: '???????????????', }).finally(() => { resolved = true; }); @@ -537,8 +554,8 @@ describe('Works Square AI design adapter', () => { const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); const fetchMock = vi.fn(async (input) => { const url = String(input); - if (url.endsWith('/api/agents/sessions')) { - return jsonResponse({ session_id: 'session-failed', status: 'active' }, 201); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-failed' }); } if (url.endsWith('/stream-tickets')) { return jsonResponse({ @@ -573,11 +590,12 @@ describe('Works Square AI design adapter', () => { fetchImpl: fetchMock, webSocketFactory, }); - const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); try { await expect(adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-failed', expectedTurnRevision: 1, message: 'invalid', @@ -595,7 +613,10 @@ describe('Works Square AI design adapter', () => { it('falls back to one Run request after the WebSocket disconnects', async () => { const { webSocketFactory } = scriptedSockets([{ open: true, closeCode: 1006 }]); const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-fallback', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ + ...serverConversation, + agent_session_id: 'session-fallback', + })) .mockResolvedValueOnce(jsonResponse({ stream_url: '/api/agents/sessions/session-fallback/ws?ticket=ticket-fallback', })) @@ -609,20 +630,21 @@ describe('Works Square AI design adapter', () => { status: 'succeeded', error: null, })) - .mockResolvedValueOnce(jsonResponse(serverWorkspace)); + .mockResolvedValueOnce(jsonResponse(serverConversation)); const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example', fetchImpl: fetchMock, webSocketFactory, }); - const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); try { await expect(adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-fallback', expectedTurnRevision: 1, - message: '断线后继续完成', + message: '???????', })).resolves.toMatchObject({ workspaceId: 'workspace-one' }); expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(1); } finally { @@ -637,8 +659,8 @@ describe('Works Square AI design adapter', () => { let runPolls = 0; const fetchMock = vi.fn(async (input, init) => { const url = String(input); - if (url.endsWith('/api/agents/sessions')) { - return jsonResponse({ session_id: 'session-slow', status: 'active' }, 201); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-slow' }); } if (url.endsWith('/api/agents/sessions/session-slow/commands')) { return jsonResponse({ run_id: 'run-slow', status: 'queued', error: null }, 202); @@ -651,8 +673,8 @@ describe('Works Square AI design adapter', () => { error: null, }); } - if (url.endsWith('/api/design/workspaces/workspace-one')) { - return jsonResponse(serverWorkspace); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse(serverConversation); } throw new Error(`Unexpected request: ${url} ${init?.method ?? 'GET'}`); }); @@ -664,9 +686,10 @@ describe('Works Square AI design adapter', () => { try { const outcomePromise = adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-slow', expectedTurnRevision: 1, - message: '整理一个复杂的品牌设计方向', + message: '?????????????', }).then( (value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }), @@ -685,10 +708,7 @@ describe('Works Square AI design adapter', () => { it('maps an invalid Runtime command to a user input error', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ - session_id: 'session-one', - status: 'active', - }, 201)) + .mockResolvedValueOnce(jsonResponse(serverConversation)) .mockResolvedValueOnce(jsonResponse({ run_id: 'run-invalid', status: 'queued', @@ -710,6 +730,7 @@ describe('Works Square AI design adapter', () => { await expect(adapter.submitMessage({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-invalid', expectedTurnRevision: 1, message: 'invalid', @@ -722,10 +743,7 @@ describe('Works Square AI design adapter', () => { it('keeps task creation behind structured Quote confirmation', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ - session_id: 'session-one', - status: 'active', - }, 201)) + .mockResolvedValueOnce(jsonResponse(serverConversation)) .mockResolvedValueOnce(jsonResponse({ run_id: 'run-confirm', status: 'queued', @@ -736,7 +754,7 @@ describe('Works Square AI design adapter', () => { status: 'succeeded', error: null, })) - .mockResolvedValueOnce(jsonResponse(serverWorkspace)); + .mockResolvedValueOnce(jsonResponse(serverConversation)); const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example/', fetchImpl: fetchMock, @@ -744,6 +762,7 @@ describe('Works Square AI design adapter', () => { const workspace = await adapter.confirmGeneration({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-two', expectedTurnRevision: 1, quoteId: 'quote-one', @@ -774,7 +793,7 @@ describe('Works Square AI design adapter', () => { ); expect(fetchMock).toHaveBeenNthCalledWith( 4, - 'https://square.example/api/design/workspaces/workspace-one', + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one', expect.objectContaining({ headers: expect.any(Object) }), ); }); @@ -786,7 +805,7 @@ describe('Works Square AI design adapter', () => { medium: 'image', status: 'succeeded', brief_version: 1, - brief_summary: '海洋公益海报', + brief_summary: '??????', quote_id: 'quote-one', quoted_design_points: 1, failure_code: null, @@ -877,10 +896,13 @@ describe('Works Square AI design adapter', () => { session_id: 'session-one', sequence: 1, runtime: 'design', - type: 'design.workspace.updated', + type: 'design.conversation.updated', schema_version: 1, payload: { - workspace: serverWorkspace, + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + workspace_view_revision: 2, + conversation: serverConversation, generation_tasks: [snapshotTask], }, }; @@ -892,10 +914,11 @@ describe('Works Square AI design adapter', () => { schema_version: 1, payload: { workspace_id: 'workspace-one', + conversation_id: 'conversation-one', client_turn_id: 'turn-two', turn_revision: 2, chunk_index: 0, - delta: '方向已经明确', + delta: '??????', }, }; const malformedDeltaEvent = { @@ -926,7 +949,7 @@ describe('Works Square AI design adapter', () => { medium: 'video', status: 'running', brief_version: 2, - brief_summary: '海洋公益短片', + brief_summary: '??????', quote_id: 'quote-live', quoted_design_points: 8, failure_code: null, @@ -937,10 +960,7 @@ describe('Works Square AI design adapter', () => { }, }; const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ - session_id: 'session-one', - status: 'active', - }, 201)) + .mockResolvedValueOnce(jsonResponse(serverConversation)) .mockResolvedValueOnce(jsonResponse({ ticket: 'secret-ticket-one', transport: 'websocket', @@ -971,12 +991,13 @@ describe('Works Square AI design adapter', () => { webSocketFactory, }); - const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); const received = []; for await (const event of first.events) received.push(event); first.close(); const second = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', afterEventId: 'session-one:3', }); for await (const _event of second.events) { @@ -987,14 +1008,15 @@ describe('Works Square AI design adapter', () => { expect(received).toEqual([ { id: 'session-one:1', - type: 'design.generation_tasks.snapshot', + type: 'design.conversation.snapshot', workspaceId: 'workspace-one', + conversationId: 'conversation-one', workspaceViewRevision: 2, - workspace: expect.objectContaining({ - workspaceId: 'workspace-one', - title: serverWorkspace.title, + conversation: expect.objectContaining({ + conversationId: 'conversation-one', + title: serverConversation.title, messages: [expect.objectContaining({ - text: serverWorkspace.messages[0].text, + text: serverConversation.messages[0].text, })], }), generationTasks: [expect.objectContaining({ @@ -1007,10 +1029,11 @@ describe('Works Square AI design adapter', () => { id: 'session-one:2', type: 'design.assistant.delta', workspaceId: 'workspace-one', + conversationId: 'conversation-one', clientTurnId: 'turn-two', turnRevision: 2, chunkIndex: 0, - delta: '方向已经明确', + delta: '??????', }, { id: 'session-one:3', @@ -1026,13 +1049,12 @@ describe('Works Square AI design adapter', () => { ]); expect(fetchMock).toHaveBeenNthCalledWith( 1, - 'https://square.example/api/agents/sessions', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"runtime":"design"'), - }), + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one', + expect.objectContaining({ headers: expect.any(Object) }), ); - expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/api/agents/sessions'))) + expect(fetchMock.mock.calls.filter(([url]) => ( + String(url).endsWith('/conversations/conversation-one') + ))) .toHaveLength(1); expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/stream-tickets'))) .toHaveLength(2); @@ -1048,17 +1070,13 @@ describe('Works Square AI design adapter', () => { ]); }); - it('rotates the Session and client id after an upstream event cursor expires', async () => { - const rotate = vi.fn().mockResolvedValue('design-stream-next'); + it('reloads the persistent Conversation Session after an upstream event cursor expires', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-old', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' })) .mockResolvedValueOnce(jsonResponse({ stream_url: '/api/agents/sessions/session-old/ws?ticket=ticket-old', })) - .mockResolvedValueOnce(jsonResponse({ - session_id: 'session-old', status: 'closed', - })) - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-new', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' })) .mockResolvedValueOnce(jsonResponse({ stream_url: '/api/agents/sessions/session-new/ws?ticket=ticket-new', })); @@ -1070,48 +1088,39 @@ describe('Works Square AI design adapter', () => { apiBaseUrl: 'https://square.example', fetchImpl: fetchMock, webSocketFactory, - eventSessionClientIdStore: { - getOrCreate: vi.fn().mockResolvedValue('design-stream-current'), - rotate, - }, }); await expect(adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', afterEventId: 'session-old:99', })).rejects.toMatchObject({ status: 410 }); const recovered = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', + conversationId: 'conversation-one', afterEventId: 'session-old:99', }); for await (const _event of recovered.events) { // Empty recovery stream. } - const sessionCalls = fetchMock.mock.calls.filter(([url]) => ( - String(url).endsWith('/api/agents/sessions') - )); - expect(sessionCalls).toHaveLength(2); - expect(JSON.parse(String(sessionCalls[0][1]?.body)).client_session_id) - .not.toBe(JSON.parse(String(sessionCalls[1][1]?.body)).client_session_id); - expect(rotate).toHaveBeenCalledWith('workspace-one'); - expect(fetchMock.mock.calls[2]).toEqual([ - 'https://square.example/api/agents/sessions/session-old', - expect.objectContaining({ method: 'DELETE' }), - ]); + expect(fetchMock.mock.calls.filter(([url]) => ( + String(url).endsWith('/conversations/conversation-one') + ))).toHaveLength(2); + expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false); expect(sockets.map((socket) => socket.url)).toEqual([ 'wss://square.example/api/agents/sessions/session-old/ws?ticket=ticket-old&after_sequence=99', 'wss://square.example/api/agents/sessions/session-new/ws?ticket=ticket-new&after_sequence=0', ]); }); - it('replaces a closed cached Session before opening the task stream', async () => { + it('refreshes a closed persistent Session before opening the task stream', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-old', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' })) .mockResolvedValueOnce(jsonResponse({ - detail: { code: 'agent_session_closed', message: 'closed' }, + code: 'agent_session_closed', message: 'closed', }, 409)) - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-new', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' })) .mockResolvedValueOnce(jsonResponse({ stream_url: '/api/agents/sessions/session-new/ws?ticket=ticket-new', })); @@ -1122,35 +1131,36 @@ describe('Works Square AI design adapter', () => { webSocketFactory, }); - const recovered = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + const recovered = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); for await (const _event of recovered.events) { // Empty recovery stream. } - const sessionCalls = fetchMock.mock.calls.filter(([url]) => ( - String(url).endsWith('/api/agents/sessions') - )); - expect(sessionCalls).toHaveLength(2); - expect(JSON.parse(String(sessionCalls[0][1]?.body)).client_session_id) - .not.toBe(JSON.parse(String(sessionCalls[1][1]?.body)).client_session_id); + expect(fetchMock.mock.calls.filter(([url]) => ( + String(url).endsWith('/conversations/conversation-one') + ))).toHaveLength(2); + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/stream-tickets'))) + .toHaveLength(2); }); - it('closes every cached Agent Session during logout or application shutdown', async () => { + it('closes active local streams without deleting persistent Agent Sessions', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-one', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse(serverConversation)) .mockResolvedValueOnce(jsonResponse({ stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one', })) - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-two', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ + ...serverConversation, + workspace_id: 'workspace-two', + conversation_id: 'conversation-two', + agent_session_id: 'session-two', + })) .mockResolvedValueOnce(jsonResponse({ stream_url: '/api/agents/sessions/session-two/ws?ticket=ticket-two', - })) - .mockImplementation(() => Promise.resolve( - jsonResponse({ session_id: 'closed', status: 'closed' }), - )); - const { webSocketFactory } = scriptedSockets([ - { closeCode: 1000 }, - { closeCode: 1000 }, + })); + const { sockets, webSocketFactory } = scriptedSockets([ + { open: true }, + { open: true }, ]); const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example', @@ -1158,92 +1168,79 @@ describe('Works Square AI design adapter', () => { webSocketFactory, }); - const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); - for await (const _event of first.events) { - // Empty stream. - } - const second = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-two' }); - for await (const _event of second.events) { - // Empty stream. - } + const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' }); + const second = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-two', conversationId: 'conversation-two' }); await adapter.closeEventSessions(); const closeCalls = fetchMock.mock.calls.filter(([, init]) => init?.method === 'DELETE'); - expect(closeCalls.map(([url]) => String(url)).sort()).toEqual([ - 'https://square.example/api/agents/sessions/session-one', - 'https://square.example/api/agents/sessions/session-two', - ]); + expect(closeCalls).toHaveLength(0); + expect(sockets.map((socket) => socket.readyState)).toEqual([3, 3]); + first.close(); + second.close(); }); - it('keeps the persisted Session key when a close result is uncertain', async () => { - const rotate = vi.fn().mockResolvedValue('design-stream-next'); - const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-one', status: 'active' }, 201)) - .mockResolvedValueOnce(jsonResponse({ - stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one', - })) - .mockResolvedValueOnce(jsonResponse({ - detail: { code: 'service_unavailable', message: 'offline' }, - }, 503)); - const { webSocketFactory } = scriptedSockets([{ closeCode: 1000 }]); + it('reports an unavailable persistent Session instead of creating a client-owned Session', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + ...serverConversation, + agent_session_id: null, + })); const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example', fetchImpl: fetchMock, - webSocketFactory, - eventSessionClientIdStore: { - getOrCreate: vi.fn().mockResolvedValue('design-stream-current'), - rotate, - }, }); - const stream = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); - for await (const _event of stream.events) { - // Empty stream. - } - - await expect(adapter.closeEventSessions()).rejects.toThrow( - 'Failed to close 1 AI design Agent Session(s)', - ); - expect(rotate).not.toHaveBeenCalled(); + await expect(adapter.submitMessage({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + clientTurnId: 'turn-unavailable', + expectedTurnRevision: 1, + message: 'hello', + })).rejects.toMatchObject({ + status: 503, + code: 'DESIGN_CONVERSATION_SESSION_UNAVAILABLE', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('reuses a stable Session idempotency key after an unclean application restart', async () => { - const createFetch = () => vi.fn() - .mockResolvedValueOnce(jsonResponse({ session_id: 'session-stable', status: 'active' }, 201)) + it('retries a turn once with the refreshed persistent Session when the old Session is stale', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' })) .mockResolvedValueOnce(jsonResponse({ - stream_url: '/api/agents/sessions/session-stable/ws?ticket=ticket-stable', - })); - const firstFetch = createFetch(); - const secondFetch = createFetch(); - const firstSockets = scriptedSockets([{ closeCode: 1000 }]); - const restartedSockets = scriptedSockets([{ closeCode: 1000 }]); - const first = new WorksSquareDesignWorkspace({ + code: 'agent_session_not_found', + message: 'stale', + }, 404)) + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' })) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-new', + status: 'queued', + error: null, + }, 202)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-new', + status: 'succeeded', + error: null, + })) + .mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' })); + const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example', - fetchImpl: firstFetch, - clientInstanceId: 'installation-one', - webSocketFactory: firstSockets.webSocketFactory, - }); - const restarted = new WorksSquareDesignWorkspace({ - apiBaseUrl: 'https://square.example', - fetchImpl: secondFetch, - clientInstanceId: 'installation-one', - webSocketFactory: restartedSockets.webSocketFactory, + fetchImpl: fetchMock, }); - const firstStream = await first.openWorkspaceEvents({ workspaceId: 'workspace-one' }); - for await (const _event of firstStream.events) { - // Empty stream. - } - const restartedStream = await restarted.openWorkspaceEvents({ + await expect(adapter.submitMessage({ workspaceId: 'workspace-one', - }); - for await (const _event of restartedStream.events) { - // Empty stream. - } + conversationId: 'conversation-one', + clientTurnId: 'turn-retry', + expectedTurnRevision: 1, + message: 'retry', + })).resolves.toMatchObject({ conversationId: 'conversation-one' }); - const firstBody = JSON.parse(String(firstFetch.mock.calls[0][1]?.body)); - const restartedBody = JSON.parse(String(secondFetch.mock.calls[0][1]?.body)); - expect(firstBody.client_session_id).toBe(restartedBody.client_session_id); - expect(firstBody.client_session_id).toMatch(/^design-stream-[a-f0-9]{64}$/); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one', + 'https://square.example/api/agents/sessions/session-old/commands', + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one', + 'https://square.example/api/agents/sessions/session-new/commands', + 'https://square.example/api/agents/sessions/session-new/runs/run-new', + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one', + ]); }); });