Compare commits
2
Commits
945b40de11
...
403236115b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403236115b | ||
|
|
c6b0490d7f |
No files matched your search
@@ -0,0 +1,154 @@
|
||||
# Task: Separate AI Design summary from chat messages
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260904-design-summary-separation-9c4e7a21
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260904-design-summary-separation-9c4e7a21-design-summary-separation
|
||||
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260904-design-summary-separation-9c4e7a21
|
||||
- Base commit: 945b40de11efde02fadb7ec6721de8c0b7a3aa85
|
||||
- Owner: codex-root
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Separate in-progress AI Design reasoning from committed conversation messages in
|
||||
the ImageCanvas Renderer. Raw assistant stream text must not appear as a finished
|
||||
chat bubble; the UI should show an explicit, non-content progress state until the
|
||||
canonical turn arrives.
|
||||
- Close the supported duplicate-stream path caused by overlapping event-source opens
|
||||
and repeated/out-of-order `chunkIndex` delivery.
|
||||
- Project a newly submitted chat command into the conversation immediately, before the
|
||||
server returns the canonical turn. Keep the provisional message visually honest,
|
||||
replace it with the canonical turn without duplication, and restore the draft after
|
||||
a definitive failure.
|
||||
- Add focused Renderer and Store regression tests. Do not change Works Square server,
|
||||
Electron Main DTO/API, Current Specification authority, Quote/generation, Plugin
|
||||
navigation, packaging, publication, or the three foreign root task records.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- The screenshot proves that transient stream content is visually indistinguishable
|
||||
from an assistant reply and that chunks are duplicated. A progress draft is not a
|
||||
semantic turn and must not be presented as one.
|
||||
- Keep the final persisted assistant question visible in the conversation after the
|
||||
command settles. Do not parse model prose to guess where a summary ends or a
|
||||
question begins.
|
||||
- Preserve stable operation identity and transport-unknown state. A temporarily
|
||||
uncertain accepted command may retain its internal stream state, but unfinished
|
||||
prose remains hidden behind an honest status label.
|
||||
- Preserve ADR-007: Current Specification remains the semantic authority and the
|
||||
right-side “AI 听懂的想法” remains its secondary projection.
|
||||
- Keep Chinese-only copy, reduced-motion behavior, at least 40 px interactive targets,
|
||||
and the current responsive single-mount behavior.
|
||||
- Do not create a second chat state machine: the existing pending operation is the
|
||||
provisional message identity, while only the returned Workspace turn is canonical.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Concurrent Task Gate: passed in an isolated managed worktree because root `main`
|
||||
remains owned by integration task `20260904-integrate-guided-design-6a3f9c82` and
|
||||
contains three explicitly preserved foreign untracked task records.
|
||||
- Planning Gate: passed after reading the active task, startup memory set, integrated
|
||||
state, ADR-007, system overview, business rules, success criteria, evidence index,
|
||||
and relevant peer scopes.
|
||||
- Relevant peers: the old Enter-only E2E task owns only one historical test path; the
|
||||
undefined August assessment task has no implementation scope. Neither semantically
|
||||
conflicts with this isolated current-frontier ImageCanvas change. Plugin-navigation
|
||||
tasks are in separate worktrees and outside scope.
|
||||
- `project-positioning.md` and top-level project success remain placeholders; concrete
|
||||
authority comes from ADR-007 and populated current-state, architecture, domain, and
|
||||
prior Design task records.
|
||||
- Likely files: `src/pages/ImageCanvas/DesignConversationPane.tsx`,
|
||||
`src/stores/image-workspace.ts`, `tests/unit/image-canvas-page.test.tsx`, and
|
||||
`tests/unit/image-workspace-store.test.ts`.
|
||||
- Gate result: Passed.
|
||||
|
||||
## Outcome
|
||||
|
||||
- A chat command now appears in the conversation immediately from its existing
|
||||
`PendingDesignOperation`, before the server returns the canonical Workspace turn.
|
||||
The provisional user bubble carries a small `发送中` state, changes to `正在确认`
|
||||
for an unknown accepted outcome, and is filtered to the active Workspace.
|
||||
- While that exact draft is projected as a provisional bubble, the composer looks
|
||||
cleared without deleting the stored draft. Canonical success atomically replaces
|
||||
the provisional bubble with the persisted turn; definitive failure removes the
|
||||
pending projection and naturally restores the original draft for correction/retry.
|
||||
- First-use guidance is hidden as soon as the first provisional user message exists.
|
||||
Other in-flight Design input commands retain the existing composer lock, so the UX
|
||||
change does not introduce concurrent reducer writes.
|
||||
- `DesignConversationPane` no longer renders raw `assistantStreams` prose as an
|
||||
assistant chat bubble. It renders one compact `role=status` surface that explicitly
|
||||
says the AI is organizing the idea and that the content is not a new reply. A
|
||||
transport-unknown operation instead says the existing result is being confirmed and
|
||||
must not be resent.
|
||||
- Final canonical `turn.assistantMessage` content remains in the ordinary conversation;
|
||||
the implementation does not parse or classify model prose.
|
||||
- Event connection startup now has a generation fence. An open that resolves after a
|
||||
disconnect/replacement closes itself, so React remounts cannot leave two live sources
|
||||
consuming the same stream. A failed open releases the pending connection identity so
|
||||
a later retry remains possible.
|
||||
- Assistant `chunkIndex` is now consumed as the ordering/deduplication contract;
|
||||
replayed or older chunks do not append duplicate text. Chunk progress is cleared with
|
||||
successful/definitive settlement and Workspace reset/switch/delete.
|
||||
- The root `main` worktree and its three foreign untracked task records were not
|
||||
modified.
|
||||
|
||||
## Verification
|
||||
|
||||
- New optimistic-message red baseline: Store publication passed, while the Renderer
|
||||
regression failed because no `发送中` message existed, the first-use prompt remained,
|
||||
and the composer still showed the submitted draft. After the fix, the two focused
|
||||
files pass `27/27`, including submitting → unknown → canonical replacement.
|
||||
- Adjacent ImageCanvas, Store, Fine Tune, youth summary/projection/copy tests now pass
|
||||
`6 files / 51 tests`.
|
||||
- Follow-up TypeScript `tsc --noEmit`, changed-file ESLint, and production
|
||||
Renderer/Main/Preload/utility Vite build pass. Build output contains only the
|
||||
existing browserslist, dynamic-import, and large-chunk warnings.
|
||||
- Electron/Playwright AI Design checks both pass individually: Prompt Museum media
|
||||
isolation passed in the two-test run; the conversation/adjustment/Quote/Task flow
|
||||
initially timed out at the pre-Design auth-fixture readiness gate, then passed `1/1`
|
||||
in isolation in 1.8 seconds. The timeout occurred before this feature path and is
|
||||
not recorded as product success evidence for the new provisional state.
|
||||
- Red baseline: three new regressions failed against the original implementation—raw
|
||||
unfinished prose had no progress role, an obsolete concurrent EventSource was not
|
||||
closed, and replaying chunk index `0` produced duplicated text. A fourth targeted red
|
||||
test caught and prevented a retry regression introduced by the first connection
|
||||
fence implementation.
|
||||
- Focused conversation/store tests: `25 passed`.
|
||||
- Adjacent ImageCanvas, Store, Fine Tune, youth summary/projection/copy tests:
|
||||
`6 files / 49 passed`.
|
||||
- TypeScript `tsc --noEmit`: passed.
|
||||
- Changed-file ESLint: passed.
|
||||
- Production Renderer/Main/Preload/utility Vite build: passed; only existing
|
||||
browserslist, dynamic-import, and chunk-size warnings were emitted.
|
||||
- Electron/Playwright AI Design V2 flow: `2 passed`, covering desktop/mobile layout,
|
||||
conversation, optional adjustments, Quote, and generation confirmation.
|
||||
- `git diff --check`: passed before task-document completion.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Integrate the completed source commit onto client `main` after the existing root
|
||||
integration owner is safely released or explicitly recovered. Do not force-release
|
||||
it or modify its three adopted foreign task records without separate authority.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/30-worklog/current-state.md`, canonical AI Design UX wording,
|
||||
and the AI Design stream reconciliation rule in architecture/domain memory.
|
||||
- Proposal: record that `design.assistant.delta` is unfinished progress, never a
|
||||
committed conversation turn. Renderer may show an explicit progress/uncertainty
|
||||
status but must not render its raw prose as a chat reply; only canonical persisted
|
||||
turns enter the timeline. A locally submitted chat command may appear immediately as
|
||||
a clearly provisional user bubble by projecting its existing pending operation; it
|
||||
must disappear when the canonical turn arrives and must not become a second semantic
|
||||
message store. Concurrent/replayed stream delivery must converge by connection
|
||||
generation and `chunkIndex`.
|
||||
- Evidence: the user screenshot, four red-before/green-after regressions, 49 adjacent
|
||||
tests, typecheck, scoped lint, production build, and 2/2 Electron E2E.
|
||||
- Future impact: later streaming polish must preserve the distinction between transient
|
||||
reasoning/progress, authoritative Specification projection, and committed dialogue.
|
||||
- Semantic conflicts: none; this sharpens the conversation-primary behavior already
|
||||
integrated while preserving ADR-007.
|
||||
- Human confirmation: the user explicitly identified the chat-bubble presentation as
|
||||
misleading; no additional product-direction decision is required.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Clock3, Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -47,22 +47,44 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const streams = useMemo(
|
||||
() => Object.entries(assistantStreams).filter(([, text]) => text.trim()),
|
||||
[assistantStreams],
|
||||
const pendingChatMessages = Object.values(pendingOperations).flatMap((operation) => {
|
||||
const command = operation.command;
|
||||
if (command.kind !== 'apply_input'
|
||||
|| command.workspaceId !== workspace.workspace.workspaceId
|
||||
|| command.input.kind !== 'chat') {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
operationId: operation.id,
|
||||
message: command.input.message,
|
||||
status: operation.status,
|
||||
}];
|
||||
});
|
||||
const streamingOperationIds = Object.entries(assistantStreams)
|
||||
.filter(([, text]) => text.trim())
|
||||
.map(([operationId]) => operationId);
|
||||
const hasAssistantProgress = streamingOperationIds.length > 0;
|
||||
const hasUnknownAssistantProgress = streamingOperationIds.some(
|
||||
(operationId) => pendingOperations[operationId]?.status === 'unknown',
|
||||
);
|
||||
const submittingChat = Object.values(pendingOperations).some(
|
||||
const submittingDesignInput = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
|
||||
);
|
||||
const projectedDraft = chatDraft.trim() !== ''
|
||||
&& pendingChatMessages.some((message) => message.message === chatDraft.trim());
|
||||
const composerDraft = projectedDraft ? '' : chatDraft;
|
||||
const pendingChatKey = pendingChatMessages
|
||||
.map((message) => `${message.operationId}:${message.status}`)
|
||||
.join('|');
|
||||
useEffect(() => {
|
||||
const scrollAnchor = scrollAnchorRef.current;
|
||||
if (typeof scrollAnchor?.scrollIntoView === 'function') {
|
||||
scrollAnchor.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [streams, workspace.turns.length]);
|
||||
}, [hasAssistantProgress, pendingChatKey, workspace.turns.length]);
|
||||
|
||||
const submit = () => {
|
||||
if (!chatDraft.trim() || submittingChat) return;
|
||||
if (!composerDraft.trim() || submittingDesignInput) return;
|
||||
void sendChat().catch((error) => {
|
||||
toast.error(chatFailureMessage(error));
|
||||
});
|
||||
@@ -84,7 +106,9 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
{workspace.turns.length === 0 && streams.length === 0 && (
|
||||
{workspace.turns.length === 0
|
||||
&& pendingChatMessages.length === 0
|
||||
&& !hasAssistantProgress && (
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8">
|
||||
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-2xl bg-brand/[0.09] text-brand">
|
||||
<MessageSquareText className="h-5 w-5" />
|
||||
@@ -125,16 +149,49 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streams.map(([operationId, text]) => (
|
||||
{pendingChatMessages.map((message) => (
|
||||
<div
|
||||
key={operationId}
|
||||
className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-brand/20 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm"
|
||||
key={message.operationId}
|
||||
data-testid={`pending-design-chat-${message.operationId}`}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="ml-auto max-w-[88%]"
|
||||
>
|
||||
{text}
|
||||
<span className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle" />
|
||||
<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>
|
||||
))}
|
||||
|
||||
{hasAssistantProgress && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="design-assistant-progress"
|
||||
className="mr-auto flex max-w-[92%] items-start gap-3 rounded-xl bg-brand/[0.06] px-3.5 py-3 text-muted-foreground shadow-sm"
|
||||
>
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-background text-brand shadow-sm">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{hasUnknownAssistantProgress ? '正在确认刚才的整理结果' : 'AI 正在整理你的想法'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{hasUnknownAssistantProgress
|
||||
? '结果回来后会显示在对话里,不需要重复发送。'
|
||||
: '这不是新的回复,完成后会显示下一步问题或建议。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div ref={scrollAnchorRef} />
|
||||
</div>
|
||||
@@ -142,14 +199,14 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
<footer className="border-t border-border/70 bg-background p-4">
|
||||
<div className={cn(
|
||||
'rounded-2xl border border-border/80 bg-surface-input p-2 transition-colors focus-within:border-brand/35 focus-within:ring-2 focus-within:ring-brand/10',
|
||||
submittingChat && 'opacity-80',
|
||||
submittingDesignInput && 'opacity-80',
|
||||
)}>
|
||||
<Textarea
|
||||
id="design-chat-composer"
|
||||
aria-label="告诉 AI 你想创作什么"
|
||||
value={chatDraft}
|
||||
value={composerDraft}
|
||||
rows={3}
|
||||
disabled={submittingChat}
|
||||
disabled={submittingDesignInput}
|
||||
placeholder="比如:我想做一张社团招新海报,要热闹、有活力……"
|
||||
className="min-h-[72px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => setChatDraft(event.currentTarget.value)}
|
||||
@@ -166,11 +223,11 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
type="button"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-xl"
|
||||
disabled={!chatDraft.trim() || submittingChat}
|
||||
disabled={!composerDraft.trim() || submittingDesignInput}
|
||||
aria-label="发送消息"
|
||||
onClick={submit}
|
||||
>
|
||||
{submittingChat
|
||||
{submittingDesignInput
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
@@ -96,7 +96,9 @@ type ImageWorkspaceState = {
|
||||
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
|
||||
let activeEventSource: EventSource | null = null;
|
||||
let activeEventWorkspaceId: string | null = null;
|
||||
let eventConnectionGeneration = 0;
|
||||
let selectionGeneration = 0;
|
||||
const assistantStreamChunkIndexes = new Map<string, number>();
|
||||
|
||||
function updateBackgroundLease(tasks: DesignGenerationTask[]): void {
|
||||
const active = tasks.some((task) => task.status === 'queued' || task.status === 'running');
|
||||
@@ -112,6 +114,7 @@ function updateBackgroundLease(tasks: DesignGenerationTask[]): void {
|
||||
}
|
||||
|
||||
function closeEventSource(): void {
|
||||
eventConnectionGeneration += 1;
|
||||
if (activeEventSource) {
|
||||
activeEventSource.onopen = null;
|
||||
activeEventSource.onerror = null;
|
||||
@@ -292,6 +295,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
}));
|
||||
try {
|
||||
const result = await submitImageWorkspaceCommand(command);
|
||||
assistantStreamChunkIndexes.delete(operation.id);
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
@@ -339,6 +343,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return { pendingOperations, error: null };
|
||||
});
|
||||
} else if (isDefinitiveCommandFailure(error)) {
|
||||
assistantStreamChunkIndexes.delete(operation.id);
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
@@ -426,10 +431,12 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const workspace = await createImageWorkspaceProject(title);
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
set((state) => ({
|
||||
...withWorkspace(state, workspace),
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
lastEventId: null,
|
||||
}));
|
||||
@@ -467,6 +474,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
if (deletingActive) {
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
}
|
||||
set((state) => ({
|
||||
bootstrap: state.bootstrap ? { ...state.bootstrap, workspaces: remaining } : null,
|
||||
@@ -475,6 +483,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
deletingWorkspaceId: null,
|
||||
fieldDrafts: deletingActive ? {} : state.fieldDrafts,
|
||||
chatDraft: deletingActive ? '' : state.chatDraft,
|
||||
assistantStreams: deletingActive ? {} : state.assistantStreams,
|
||||
error: null,
|
||||
}));
|
||||
if (deletingActive && remaining[0]) {
|
||||
@@ -492,6 +501,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
if (workspaceId === get().activeWorkspaceId && get().workspace) return;
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
set({
|
||||
activeWorkspaceId: workspaceId,
|
||||
workspace: null,
|
||||
@@ -499,6 +509,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
lastEventId: null,
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
error: null,
|
||||
});
|
||||
@@ -520,8 +531,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const workspace = get().workspace;
|
||||
if (!workspace) return;
|
||||
const workspaceId = workspace.workspace.workspaceId;
|
||||
if (activeEventSource && activeEventWorkspaceId === workspaceId) return;
|
||||
if (activeEventWorkspaceId === workspaceId) return;
|
||||
closeEventSource();
|
||||
const connectionGeneration = eventConnectionGeneration;
|
||||
activeEventWorkspaceId = workspaceId;
|
||||
set({ eventState: 'connecting' });
|
||||
void openImageWorkspaceEvents(
|
||||
@@ -529,7 +541,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
workspace.workspace.sessionId,
|
||||
get().lastEventId ?? undefined,
|
||||
).then((source) => {
|
||||
if (activeEventWorkspaceId !== workspaceId || get().activeWorkspaceId !== workspaceId) {
|
||||
if (connectionGeneration !== eventConnectionGeneration
|
||||
|| activeEventWorkspaceId !== workspaceId
|
||||
|| get().activeWorkspaceId !== workspaceId) {
|
||||
source.close();
|
||||
return;
|
||||
}
|
||||
@@ -543,6 +557,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const event = parseWorkspaceEvent(raw);
|
||||
if (!event || get().activeWorkspaceId !== workspaceId) return;
|
||||
if (event.type === 'design.assistant.delta') {
|
||||
const previousChunkIndex = assistantStreamChunkIndexes.get(event.clientOperationId);
|
||||
if (previousChunkIndex !== undefined && event.chunkIndex <= previousChunkIndex) return;
|
||||
assistantStreamChunkIndexes.set(event.clientOperationId, event.chunkIndex);
|
||||
set((state) => ({
|
||||
lastEventId: event.id,
|
||||
assistantStreams: {
|
||||
@@ -592,7 +609,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
'design.workspace.updated',
|
||||
]) source.addEventListener(eventType, receive);
|
||||
}).catch((error) => {
|
||||
if (activeEventWorkspaceId === workspaceId) {
|
||||
if (connectionGeneration === eventConnectionGeneration
|
||||
&& activeEventWorkspaceId === workspaceId) {
|
||||
activeEventWorkspaceId = null;
|
||||
set({ eventState: 'degraded', error: messageForError(error) });
|
||||
}
|
||||
});
|
||||
@@ -708,6 +727,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
reset: () => {
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
updateBackgroundLease([]);
|
||||
set({
|
||||
status: 'idle',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
import { ImageCanvas } from '@/pages/ImageCanvas';
|
||||
@@ -93,6 +93,115 @@ describe('youth AI Design Canvas page', () => {
|
||||
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('presents unfinished AI output as progress instead of a chat reply', () => {
|
||||
prepareWorkspace();
|
||||
const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。';
|
||||
useImageWorkspaceStore.setState({
|
||||
assistantStreams: { 'operation-chat-1': unfinishedDraft },
|
||||
pendingOperations: {
|
||||
'operation-chat-1': {
|
||||
id: 'operation-chat-1',
|
||||
label: '发送创作想法',
|
||||
command: {
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-chat-1',
|
||||
input: { kind: 'chat', message: '做一张社团活动海报' },
|
||||
},
|
||||
status: 'submitting',
|
||||
error: null,
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
expect(within(conversation).getByRole('status')).toHaveTextContent('AI 正在整理你的想法');
|
||||
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
|
||||
expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a submitted message immediately and replaces it with the canonical turn', async () => {
|
||||
const message = '画一只在月球踢球的熊猫';
|
||||
const { workspace } = prepareWorkspace({ turns: [] });
|
||||
useImageWorkspaceStore.setState({
|
||||
chatDraft: message,
|
||||
pendingOperations: {
|
||||
'operation-chat-1': {
|
||||
id: 'operation-chat-1',
|
||||
label: '发送消息',
|
||||
command: {
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-chat-1',
|
||||
input: { kind: 'chat', message },
|
||||
},
|
||||
status: 'submitting',
|
||||
error: null,
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1');
|
||||
expect(within(pendingMessage).getByText(message)).toBeInTheDocument();
|
||||
expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument();
|
||||
expect(within(conversation).queryByRole('heading', { name: '先把脑中的画面说出来' }))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' }))
|
||||
.toHaveValue('');
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState((state) => ({
|
||||
pendingOperations: {
|
||||
...state.pendingOperations,
|
||||
'operation-chat-1': {
|
||||
...state.pendingOperations['operation-chat-1']!,
|
||||
status: 'unknown',
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
expect(within(pendingMessage).getByText('正在确认')).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' }))
|
||||
.toHaveValue('');
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState({
|
||||
workspace: {
|
||||
...workspace,
|
||||
turns: [{
|
||||
turnId: 'turn-2',
|
||||
rawTurnSequence: 2,
|
||||
userMessage: message,
|
||||
assistantMessage: '听起来很有趣!你希望画面更像漫画,还是更像电影?',
|
||||
}],
|
||||
},
|
||||
chatDraft: '',
|
||||
pendingOperations: {},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(conversation).getAllByText(message)).toHaveLength(1);
|
||||
});
|
||||
expect(within(conversation).queryByTestId('pending-design-chat-operation-chat-1'))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(conversation).queryByText('发送中')).not.toBeInTheDocument();
|
||||
expect(within(conversation).getByText(/更像漫画,还是更像电影/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invites a free description and does not turn a legacy decision prompt into a form', () => {
|
||||
const workspace = designWorkspaceFixture({
|
||||
form: designFormFixture({
|
||||
|
||||
@@ -74,6 +74,14 @@ class FakeEventSource {
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function loadedStore(eventSource = new FakeEventSource()) {
|
||||
openEventsMock.mockResolvedValue(eventSource as unknown as EventSource);
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
@@ -110,6 +118,87 @@ describe('V2 Living Form store', () => {
|
||||
expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined);
|
||||
});
|
||||
|
||||
it('closes an obsolete event source when a replacement connection wins the race', async () => {
|
||||
const workspace = designWorkspaceFixture();
|
||||
useImageWorkspaceStore.setState({
|
||||
status: 'ready',
|
||||
activeWorkspaceId: workspace.workspace.workspaceId,
|
||||
workspace,
|
||||
});
|
||||
const firstOpen = deferred<EventSource>();
|
||||
const secondOpen = deferred<EventSource>();
|
||||
const firstSource = new FakeEventSource();
|
||||
const secondSource = new FakeEventSource();
|
||||
openEventsMock
|
||||
.mockReturnValueOnce(firstOpen.promise)
|
||||
.mockReturnValueOnce(secondOpen.promise);
|
||||
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
useImageWorkspaceStore.getState().disconnectEvents();
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
|
||||
firstOpen.resolve(firstSource as unknown as EventSource);
|
||||
await Promise.resolve();
|
||||
secondOpen.resolve(secondSource as unknown as EventSource);
|
||||
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(firstSource.close).toHaveBeenCalledOnce();
|
||||
expect(secondSource.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a new event connection after opening the previous one failed', async () => {
|
||||
const workspace = designWorkspaceFixture();
|
||||
const recoveredSource = new FakeEventSource();
|
||||
useImageWorkspaceStore.setState({
|
||||
status: 'ready',
|
||||
activeWorkspaceId: workspace.workspace.workspaceId,
|
||||
workspace,
|
||||
});
|
||||
openEventsMock
|
||||
.mockRejectedValueOnce(new Error('temporary event connection failure'))
|
||||
.mockResolvedValueOnce(recoveredSource as unknown as EventSource);
|
||||
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().eventState).toBe('degraded'));
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(recoveredSource.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses assistant chunk indexes to ignore replayed deltas', async () => {
|
||||
const source = await loadedStore();
|
||||
const baseEvent = {
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
clientOperationId: 'operation-chat-1',
|
||||
directionRevision: 4,
|
||||
} as const;
|
||||
source.emit('design.assistant.delta', {
|
||||
...baseEvent,
|
||||
id: 'session-1:7',
|
||||
chunkIndex: 0,
|
||||
delta: '收到',
|
||||
});
|
||||
source.emit('design.assistant.delta', {
|
||||
...baseEvent,
|
||||
id: 'session-1:7-replayed',
|
||||
chunkIndex: 0,
|
||||
delta: '收到',
|
||||
});
|
||||
source.emit('design.assistant.delta', {
|
||||
...baseEvent,
|
||||
id: 'session-1:8',
|
||||
chunkIndex: 1,
|
||||
delta: ',正在整理',
|
||||
});
|
||||
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
||||
'operation-chat-1': '收到,正在整理',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a field draft separate until the direct edit succeeds', async () => {
|
||||
await loadedStore();
|
||||
useImageWorkspaceStore.getState().setFieldDraft('output.aspect_ratio', '16:9');
|
||||
@@ -134,6 +223,50 @@ describe('V2 Living Form store', () => {
|
||||
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
||||
});
|
||||
|
||||
it('publishes a pending chat operation before the server result settles', async () => {
|
||||
await loadedStore();
|
||||
const message = '画一只在月球踢球的熊猫';
|
||||
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
||||
submitCommandMock.mockReturnValueOnce(submission.promise);
|
||||
useImageWorkspaceStore.getState().setChatDraft(message);
|
||||
|
||||
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
chatDraft: message,
|
||||
pendingOperations: {
|
||||
'operation-1': {
|
||||
id: 'operation-1',
|
||||
status: 'submitting',
|
||||
clearChatDraft: true,
|
||||
command: {
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-1',
|
||||
clientOperationId: 'operation-1',
|
||||
input: { kind: 'chat', message },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workspace = designWorkspaceFixture({
|
||||
turns: [{
|
||||
turnId: 'turn-2',
|
||||
rawTurnSequence: 2,
|
||||
userMessage: message,
|
||||
assistantMessage: '你希望画面更像漫画,还是更像电影?',
|
||||
}],
|
||||
});
|
||||
submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace });
|
||||
await sendPromise;
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
workspace,
|
||||
chatDraft: '',
|
||||
pendingOperations: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('settles a definitive reasoner failure without losing the chat draft', async () => {
|
||||
const source = await loadedStore();
|
||||
useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人');
|
||||
|
||||
Reference in new issue
Block a user