From 498089401708dc08123141891dba9b54e47cefea Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 6 Aug 2026 16:46:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(AI=E8=AE=BE=E8=AE=A1):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=BE=93=E5=85=A5=E6=A1=86=E5=9B=9E=E8=BD=A6=E5=8F=91?= =?UTF-8?q?=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:AI 设计输入框监听 Enter 并发送消息。 实现:复用现有发送链路,保留 Shift+Enter 换行并保护输入法组合状态;补充聚焦回归测试。 --- .../tasks/20260806-design-enter-send-b83f.md | 51 +++++++++++++++++++ src/pages/ImageCanvas/index.tsx | 9 ++-- tests/unit/image-canvas-page.test.tsx | 36 +++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260806-design-enter-send-b83f.md diff --git a/.project-docs/30-worklog/tasks/20260806-design-enter-send-b83f.md b/.project-docs/30-worklog/tasks/20260806-design-enter-send-b83f.md new file mode 100644 index 0000000..c9f2435 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260806-design-enter-send-b83f.md @@ -0,0 +1,51 @@ +# Task: 支持 AI 设计输入框回车发送 + +## Identity + +- Task ID: 20260806-design-enter-send-b83f +- Mode: Feature +- Branch: codex/design-enter-send-b83f +- Worktree: D:\Datas\OthersProjects\makelore-design-enter-send-b83f +- Base commit: 7b23cce67a1b9e61a48d89dedbd088d37b10ecd4 +- Owner: codex +- Status: Completed + +## Scope + +- 让 AI 设计输入框在非输入法组合状态下按 Enter 直接发送。 +- 保留 Shift+Enter 输入换行,并同步更新快捷键提示。 +- 增加回车发送、换行和输入法保护的回归测试。 + +## Intent And Constraints + +- 复用现有 `handleSend`,不改变消息、Quote 确认与任务创建链路。 +- 中文输入法候选确认期间的 Enter 不得误发消息。 +- 改动仅限 AI 设计页及其聚焦测试,不触碰原主工作区中的未归属改动。 + +## Plan + +1. 调整输入框键盘事件判定与可见快捷键提示。 +2. 覆盖 Enter、Shift+Enter 和 IME composition 三种行为。 +3. 执行聚焦测试、类型检查、Lint 与 Vite 构建。 + +## Outcome + +- AI 设计输入框现在按 Enter 即可发送,继续复用既有消息与结构化 Quote 确认逻辑。 +- Shift+Enter 保留多行输入;输入法组合状态下按 Enter 不会误发。 +- 输入框下方提示已更新为 `Enter 发送 · Shift+Enter 换行`。 + +## Verification + +- `vitest run tests/unit/image-canvas-page.test.tsx`:21 passed。 +- `tsc --noEmit`:通过。 +- `eslint src/pages/ImageCanvas/index.tsx tests/unit/image-canvas-page.test.tsx`:通过。 +- `vite build`:通过,仅有既有动态导入与 chunk 体积警告。 +- 全仓 `eslint .`:未通过,命中本任务之外的既有问题:`src/components/layout/MainLayout.tsx` effect 内同步 setState、`tests/unit/chat-command-dialogs.test.tsx` 未使用变量,以及 6 个既有 warning。 + +## Follow-ups + +- 后续可单独清理全仓既有 Lint 基线问题,不阻塞本次交互修复。 + +## Promotion Candidates + +- None recorded. diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index 52c4fd6..272b345 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -536,10 +536,9 @@ export function ImageCanvas() { }; const handleComposerKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { - event.preventDefault(); - void handleSend(); - } + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + void handleSend(); }; const handleFirstFrameSelect = async (asset: DesignAsset) => { @@ -819,7 +818,7 @@ export function ImageCanvas() { /> {actionError ?

{actionError}

: null}
- ⌘/Ctrl + Enter 发送 + Enter 发送 · Shift+Enter 换行 @@ -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', + ]); }); });