合并 AI 设计多会话客户端
整合 Enter 发送与多会话交互,保留服务端持久 Conversation Session,并补齐迁移、回归、Electron E2E 与 canonical 文档。
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user