合并 AI 设计多会话客户端
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

整合 Enter 发送与多会话交互,保留服务端持久 Conversation Session,并补齐迁移、回归、Electron E2E 与 canonical 文档。
This commit is contained in:
2026-08-11 16:18:52 +08:00
32 changed files with 2417 additions and 804 deletions

View File

@@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import {
FolderKanban,
Loader2,
MessageSquareText,
Pencil,
Plus,
RefreshCw,
X,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -36,11 +38,16 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
const status = useImageWorkspaceStore((state) => state.status);
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId);
const workspace = useImageWorkspaceStore((state) => state.workspace);
const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation);
const workspaceError = useImageWorkspaceStore((state) => state.error);
const load = useImageWorkspaceStore((state) => state.load);
const createProject = useImageWorkspaceStore((state) => state.createProject);
const createConversation = useImageWorkspaceStore((state) => state.createConversation);
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const selectConversation = useImageWorkspaceStore((state) => state.selectConversation);
const [projectsOpen, setProjectsOpen] = useState(true);
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
const [projectName, setProjectName] = useState('');
@@ -107,6 +114,15 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
navigate('/image-canvas');
};
const handleCreateConversation = async () => {
try {
await createConversation();
navigate('/image-canvas');
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
return (
<div data-testid="sidebar-image-workspace" className="min-h-0 space-y-1">
<Button
@@ -207,7 +223,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{project.title}</span>
<span className="block truncate text-[10px] font-medium text-muted-foreground">
{project.brief.summary}
{project.conversationCount}
</span>
</span>
</button>
@@ -220,6 +236,58 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
{active ? (
<div className="mt-1 border-t border-brand/15 pt-1.5">
<div className="flex min-h-10 items-center justify-between gap-2 px-1">
<span className="text-[10px] font-semibold text-muted-foreground"></span>
<button
type="button"
aria-label="新建设计会话"
title="新建设计会话"
aria-busy={creatingConversation}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-[background-color,color,transform] hover:bg-background hover:text-brand active:scale-[0.96]"
disabled={creatingConversation}
onClick={() => void handleCreateConversation()}
>
{creatingConversation
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</button>
</div>
<div className="space-y-0.5 pb-1">
{workspace?.workspaceId === project.workspaceId
? workspace.conversations.slice(0, 6).map((conversation) => {
const selected = conversation.conversationId === activeConversationId;
return (
<button
key={conversation.conversationId}
type="button"
data-testid={`sidebar-image-conversation-${conversation.conversationId}`}
aria-current={selected ? 'page' : undefined}
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-lg px-2 text-left transition-[background-color,color,transform] active:scale-[0.96]',
selected
? 'bg-background text-foreground shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => void selectConversation(conversation.conversationId)}
>
<MessageSquareText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-semibold">
{conversation.title}
</span>
<span className="block truncate text-[10px]">
{conversation.brief.summary}
</span>
</span>
</button>
);
})
: null}
</div>
</div>
) : null}
</section>
);
}) : null}

View File

@@ -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<DesignW
);
}
export function createImageWorkspaceConversation(
workspaceId: string,
title = '新会话',
clientConversationId = createImageWorkspaceConversationId(),
): Promise<DesignConversation> {
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<DesignConversation> {
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<DesignWorkspace> {
): Promise<DesignConversation> {
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<DesignWorkspace> {
): Promise<DesignConversation> {
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<EventSource> {
export async function openImageWorkspaceTaskEvents(
workspaceId: string,
conversationId: string,
): Promise<EventSource> {
await ensureHostApiToken();
return createHostEventSource(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events`,
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/events`,
);
}

View File

@@ -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<string | null>(null);
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false);
@@ -423,8 +431,8 @@ export function ImageCanvas() {
const conversationEndRef = useRef<HTMLDivElement | null>(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<RenderedDesignMessage[]>(() => {
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,69 +544,85 @@ 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
));
}
};
const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
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) => {
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<HTMLInputElement>) => {
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
));
}
};
@@ -643,7 +682,7 @@ export function ImageCanvas() {
);
}
if (!workspace) {
if (!workspace || !conversation) {
return (
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
<Loader2 role="status" className="h-6 w-6 animate-spin text-brand" />
@@ -668,25 +707,66 @@ export function ImageCanvas() {
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Bot className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{workspace.brief.ready ? workspace.brief.summary : '设计 Agent 正在与你确认创作方向'}
{conversation.title} · {conversation.brief.ready
? conversation.brief.summary
: '设计 Agent 正在与你确认创作方向'}
</span>
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="刷新设计项目"
className="h-9 w-9 rounded-full"
onClick={() => {
void refreshWorkspace()
.then(() => refreshTasks())
.catch(() => undefined);
}}
>
<RefreshCw className="h-4 w-4" />
</Button>
<div className="flex min-w-0 items-center gap-1.5">
<Select
aria-label="切换设计会话"
data-testid="image-canvas-conversation-select"
className="h-10 w-36 max-w-[34vw] rounded-xl py-1.5 text-xs font-semibold sm:w-48"
value={activeConversationId ?? ''}
disabled={creatingConversation}
onChange={(event) => {
void selectConversation(event.currentTarget.value).catch((error) => {
setActionError(error instanceof Error ? error.message : String(error));
});
}}
>
{workspace.conversations.map((item) => (
<option key={item.conversationId} value={item.conversationId}>
{item.title}
</option>
))}
</Select>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="新建设计会话"
aria-busy={creatingConversation}
className="h-10 w-10 rounded-full transition-transform active:scale-[0.96]"
disabled={creatingConversation}
onClick={() => {
void createConversation().catch((error) => {
setActionError(error instanceof Error ? error.message : String(error));
});
}}
>
{creatingConversation
? <Loader2 className="h-4 w-4 animate-spin" />
: <MessageSquarePlus className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="刷新设计项目"
className="h-10 w-10 rounded-full transition-transform active:scale-[0.96]"
onClick={() => {
void refreshWorkspace()
.then(() => refreshConversation())
.then(() => refreshTasks())
.catch(() => undefined);
}}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</header>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row">
@@ -819,7 +899,7 @@ export function ImageCanvas() {
/>
{actionError ? <p role="alert" className="mt-2 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">{actionError}</p> : null}
<div className="mt-2 flex items-center justify-between gap-3 border-t border-border/70 pt-2">
<span className="text-[10px] font-medium text-muted-foreground">/Ctrl + Enter </span>
<span className="text-[10px] font-medium text-muted-foreground">Enter · Shift+Enter </span>
<Button type="submit" className="h-9 rounded-full bg-brand px-4 text-primary-foreground" disabled={submitting || !prompt.trim()}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Agent

View File

@@ -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<DesignWorkspaceBootstrap | null>;
createProject: (title: string) => Promise<DesignWorkspace>;
createConversation: (title?: string) => Promise<DesignConversation>;
renameProject: (workspaceId: string, title: string) => Promise<DesignWorkspace>;
selectProject: (workspaceId: string) => Promise<void>;
selectConversation: (conversationId: string) => Promise<void>;
refreshWorkspace: () => Promise<DesignWorkspace | null>;
refreshConversation: () => Promise<DesignConversation | null>;
refreshTasks: () => Promise<DesignGenerationTask[]>;
connectTaskStream: () => void;
disconnectTaskStream: () => void;
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignWorkspace>;
confirmGeneration: (quoteId: string) => Promise<DesignWorkspace>;
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignConversation>;
confirmGeneration: (quoteId: string) => Promise<DesignConversation>;
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<typeof setInterval> | null = null;
const taskEventRevisions = new Map<string, number>();
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<unknown>).data;
if (typeof data !== 'string') return null;
try {
const payload = JSON.parse(data) as Partial<DesignGenerationTasksSnapshotEvent>;
if (payload.type !== 'design.generation_tasks.snapshot'
const payload = JSON.parse(data) as Partial<DesignConversationSnapshotEvent>;
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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<void> => {
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<never> => {
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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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',