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