feat(design): render assistant activity progress

This commit is contained in:
2026-09-07 09:57:21 +08:00
parent da29294979
commit 81b524a02c
9 changed files with 2066 additions and 80 deletions

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Check,
ChevronDown,
Clock3,
Loader2,
MessageSquareText,
@@ -12,7 +14,10 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import {
type DesignAssistantActivity,
useImageWorkspaceStore,
} from '@/stores/image-workspace';
import type { DesignWorkspace } from '../../../shared/image-workspace';
const STARTER_MESSAGES = [
@@ -21,6 +26,83 @@ const STARTER_MESSAGES = [
'把我的涂鸦变成一段会动的小故事',
];
function DesignAssistantActivityPanel({
activity,
operationId,
}: {
activity: DesignAssistantActivity;
operationId: string;
}) {
const [expanded, setExpanded] = useState(activity.status !== 'completed');
const summary = activity.status === 'active'
? `正在进行第 ${activity.steps.length} 个步骤`
: activity.status === 'completed'
? `完成了 ${activity.steps.length} 个步骤`
: '连接暂时中断,已完成的步骤还在这里';
return (
<section
data-testid={`design-assistant-activity-${operationId}`}
aria-label="AI 处理过程"
aria-live="polite"
className="mr-auto w-full max-w-[92%] overflow-hidden rounded-xl border border-brand/15 bg-brand/[0.035]"
>
<button
type="button"
aria-expanded={expanded}
className="flex min-h-11 w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors duration-150 hover:bg-brand/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/25 motion-reduce:transition-none"
onClick={() => setExpanded((value) => !value)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background text-brand shadow-sm">
{activity.status === 'active'
? <Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
: activity.status === 'completed'
? <Check className="h-3.5 w-3.5" aria-hidden="true" />
: <Clock3 className="h-3.5 w-3.5" aria-hidden="true" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-semibold text-foreground">AI 处理过程</span>
<span className="mt-0.5 block truncate text-[11px] leading-4 text-muted-foreground">
{summary}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none',
expanded && 'rotate-180',
)}
/>
</button>
{expanded && (
<ol className="space-y-2 border-t border-brand/10 px-3.5 py-3">
{activity.steps.map((step, index) => {
const isCurrent = activity.status === 'active' && index === activity.steps.length - 1;
const isUncertain = activity.status === 'unknown' && index === activity.steps.length - 1;
return (
<li
key={step.stage}
className="flex min-h-6 items-start gap-2.5 text-xs leading-5 text-muted-foreground"
>
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center text-brand">
{isCurrent
? <Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
: isUncertain
? <Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
: <Check className="h-3.5 w-3.5" aria-hidden="true" />}
</span>
<span>{step.message}</span>
</li>
);
})}
</ol>
)}
</section>
);
}
function chatFailureMessage(error: unknown): string {
if (error instanceof ImageWorkspaceApiError) {
if (error.commandOutcome === 'unknown') {
@@ -60,6 +142,7 @@ export function DesignConversationPane({
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const assistantActivities = useImageWorkspaceStore((state) => state.assistantActivities);
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
@@ -77,7 +160,9 @@ export function DesignConversationPane({
}];
});
const submittingDesignInput = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
(operation) => operation.status === 'submitting'
&& operation.command.kind === 'apply_input'
&& operation.command.workspaceId === workspace.workspace.workspaceId,
);
const quoteRequestPending = Object.values(pendingOperations).some(
(operation) => operation.command.kind === 'request_quote'
@@ -96,12 +181,28 @@ export function DesignConversationPane({
const streamingReplyKey = streamingReplies
.map((reply) => `${reply.operationId}:${reply.text}`)
.join('|');
const visibleActivities = Object.entries(assistantActivities).filter(([, activity]) => (
activity.workspaceId === workspace.workspace.workspaceId
&& activity.directionId === workspace.form.directionId
&& activity.steps.length > 0
));
const activityByOperationId = new Map(visibleActivities);
const activityByTurnId = new Map(
visibleActivities.flatMap(([operationId, activity]) => (
activity.turnId ? [[activity.turnId, { operationId, activity }] as const] : []
)),
);
const activityKey = visibleActivities
.map(([operationId, activity]) => (
`${operationId}:${activity.status}:${activity.steps.map((step) => step.stage).join(',')}`
))
.join('|');
useEffect(() => {
const scrollAnchor = scrollAnchorRef.current;
if (typeof scrollAnchor?.scrollIntoView === 'function') {
scrollAnchor.scrollIntoView({ block: 'end' });
}
}, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]);
}, [activityKey, canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]);
const submit = () => {
if (!composerDraft.trim() || submittingDesignInput) return;
@@ -162,40 +263,64 @@ export function DesignConversationPane({
)}
<div className="space-y-5">
{workspace.turns.map((turn) => (
<div key={turn.turnId} className="space-y-3">
{turn.userMessage && (
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-brand px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
{turn.userMessage}
</div>
)}
{turn.assistantMessage && (
<div className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-border/65 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm">
{turn.assistantMessage}
</div>
)}
</div>
))}
{workspace.turns.map((turn) => {
const linkedActivity = activityByTurnId.get(turn.turnId);
return (
<div key={turn.turnId} className="space-y-3">
{turn.userMessage && (
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-brand px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
{turn.userMessage}
</div>
)}
{linkedActivity && (
<DesignAssistantActivityPanel
key={`${linkedActivity.operationId}:${linkedActivity.activity.status}`}
activity={linkedActivity.activity}
operationId={linkedActivity.operationId}
/>
)}
{turn.assistantMessage && (
<div className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-border/65 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm">
{turn.assistantMessage}
</div>
)}
</div>
);
})}
{pendingChatMessages.map((message) => (
<div
key={message.operationId}
data-testid={`pending-design-chat-${message.operationId}`}
aria-live="polite"
aria-atomic="true"
className="ml-auto max-w-[88%]"
>
<div className="rounded-2xl rounded-br-md bg-brand/90 px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
{message.message}
{pendingChatMessages.map((message) => {
const linkedActivity = activityByOperationId.get(message.operationId);
return (
<div
key={message.operationId}
className="space-y-2.5"
>
<div
data-testid={`pending-design-chat-${message.operationId}`}
aria-live="polite"
aria-atomic="true"
className="ml-auto max-w-[88%]"
>
<div className="rounded-2xl rounded-br-md bg-brand/90 px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
{message.message}
</div>
<div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground">
{message.status === 'submitting'
? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
: <Clock3 className="h-3 w-3" aria-hidden="true" />}
<span>{message.status === 'submitting' ? '发送中' : '正在确认'}</span>
</div>
</div>
{linkedActivity && (
<DesignAssistantActivityPanel
key={`${message.operationId}:${linkedActivity.status}`}
activity={linkedActivity}
operationId={message.operationId}
/>
)}
</div>
<div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground">
{message.status === 'submitting'
? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
: <Clock3 className="h-3 w-3" aria-hidden="true" />}
<span>{message.status === 'submitting' ? '发送中' : '正在确认'}</span>
</div>
</div>
))}
);
})}
{streamingReplies.map((reply) => (
<div