Merge remote-tracking branch 'origin/main'
# Conflicts: # .project-docs/20-architecture/module-map.md # src/pages/ImageCanvas/DesignConversationPane.tsx # tests/unit/image-canvas-page.test.tsx
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Loader2,
|
||||
Send,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import makeloreMark from '@/assets/logo.svg';
|
||||
@@ -10,7 +13,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 { DesignCompilationIssue, DesignWorkspace } from '../../../shared/image-workspace';
|
||||
import { DesignPendingOperationsPanel } from './DesignProductionPanel';
|
||||
import { DesignPlanHistory } from './DesignPlanHistory';
|
||||
@@ -22,6 +28,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') {
|
||||
@@ -62,6 +145,7 @@ export function DesignConversationPane({
|
||||
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
|
||||
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
|
||||
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
|
||||
const assistantActivities = useImageWorkspaceStore((state) => state.assistantActivities);
|
||||
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -79,12 +163,17 @@ 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 streamingReplies = pendingChatMessages.flatMap((message) => {
|
||||
const text = assistantStreams[message.operationId]?.trim();
|
||||
return text ? [{ ...message, text }] : [];
|
||||
});
|
||||
const streamingReplyByOperationId = new Map(
|
||||
streamingReplies.map((reply) => [reply.operationId, reply.text]),
|
||||
);
|
||||
const projectedDraft = chatDraft.trim() !== ''
|
||||
&& pendingChatMessages.some((message) => message.message === chatDraft.trim());
|
||||
const composerDraft = projectedDraft ? '' : chatDraft;
|
||||
@@ -94,12 +183,29 @@ 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' });
|
||||
}
|
||||
}, [
|
||||
activityKey,
|
||||
pendingChatKey,
|
||||
streamingReplyKey,
|
||||
workspace.form.specificationRevision,
|
||||
@@ -152,57 +258,80 @@ export function DesignConversationPane({
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[50rem] space-y-6">
|
||||
{workspace.turns.map((turn) => (
|
||||
<div key={turn.turnId} className="space-y-5">
|
||||
{turn.userMessage && (
|
||||
<div className="chat-user-message-surface ml-auto max-w-[82%] whitespace-pre-wrap break-words rounded-[18px] rounded-tr-md px-3.5 py-2.5 text-pretty text-[15px] leading-6 text-foreground">
|
||||
{turn.userMessage}
|
||||
</div>
|
||||
)}
|
||||
{turn.assistantMessage && (
|
||||
<div className="chat-assistant-message-surface mr-auto w-full whitespace-pre-wrap break-words py-0.5 text-pretty text-[15px] leading-6 text-foreground">
|
||||
{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-[82%]"
|
||||
>
|
||||
<div className="chat-user-message-surface whitespace-pre-wrap break-words rounded-[18px] rounded-tr-md px-3.5 py-2.5 text-pretty text-[15px] leading-6 text-foreground">
|
||||
{message.message}
|
||||
{workspace.turns.map((turn) => {
|
||||
const linkedActivity = activityByTurnId.get(turn.turnId);
|
||||
return (
|
||||
<div key={turn.turnId} className="space-y-5">
|
||||
{turn.userMessage && (
|
||||
<div className="chat-user-message-surface ml-auto max-w-[82%] whitespace-pre-wrap break-words rounded-[18px] rounded-tr-md px-3.5 py-2.5 text-pretty text-[15px] leading-6 text-foreground">
|
||||
{turn.userMessage}
|
||||
</div>
|
||||
)}
|
||||
{linkedActivity && (
|
||||
<DesignAssistantActivityPanel
|
||||
key={`${linkedActivity.operationId}:${linkedActivity.activity.status}`}
|
||||
activity={linkedActivity.activity}
|
||||
operationId={linkedActivity.operationId}
|
||||
/>
|
||||
)}
|
||||
{turn.assistantMessage && (
|
||||
<div className="chat-assistant-message-surface mr-auto w-full whitespace-pre-wrap break-words py-0.5 text-pretty text-[15px] leading-6 text-foreground">
|
||||
{turn.assistantMessage}
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
key={reply.operationId}
|
||||
data-testid={`streaming-design-assistant-${reply.operationId}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="chat-assistant-message-surface mr-auto w-full whitespace-pre-wrap break-words py-0.5 text-pretty text-[15px] leading-6 text-foreground"
|
||||
>
|
||||
{reply.text}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{pendingChatMessages.map((message) => {
|
||||
const linkedActivity = activityByOperationId.get(message.operationId);
|
||||
const streamingReply = streamingReplyByOperationId.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-[82%]"
|
||||
>
|
||||
<div className="chat-user-message-surface whitespace-pre-wrap break-words rounded-[18px] rounded-tr-md px-3.5 py-2.5 text-pretty text-[15px] leading-6 text-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}
|
||||
/>
|
||||
)}
|
||||
{streamingReply && (
|
||||
<div
|
||||
data-testid={`streaming-design-assistant-${message.operationId}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="chat-assistant-message-surface mr-auto w-full whitespace-pre-wrap break-words py-0.5 text-pretty text-[15px] leading-6 text-foreground"
|
||||
>
|
||||
{streamingReply}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<DesignPendingOperationsPanel />
|
||||
{showPlanHistory ? <DesignPlanHistory workspace={workspace} /> : null}
|
||||
|
||||
Reference in New Issue
Block a user