feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignBrief,
|
||||
DesignCapabilities,
|
||||
DesignConfirmGenerationInput,
|
||||
@@ -131,6 +132,36 @@ type ServerAgentEvent = {
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type ServerAgentCommand = {
|
||||
run_id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
error: ServerAgentCommandError | null;
|
||||
};
|
||||
|
||||
type ServerAgentCommandError = {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
type ServerAgentRun = {
|
||||
run_id: string;
|
||||
status: 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled';
|
||||
error: ServerAgentCommandError | null;
|
||||
};
|
||||
|
||||
type AgentDesignTurnSubmission = {
|
||||
workspaceId: string;
|
||||
clientTurnId: string;
|
||||
expectedTurnRevision: number;
|
||||
message: string;
|
||||
attachmentAssetIds: string[];
|
||||
action: null | {
|
||||
type: 'confirm_generation';
|
||||
quote_id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AgentWebSocket = {
|
||||
readyState: number;
|
||||
onopen: (() => void) | null;
|
||||
@@ -159,6 +190,8 @@ type TaskEventQueue = {
|
||||
|
||||
const AGENT_WEBSOCKET_OPEN = 1;
|
||||
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
|
||||
const AGENT_RUN_POLL_INTERVAL_MS = 250;
|
||||
const AGENT_RUN_TIMEOUT_MS = 120_000;
|
||||
|
||||
function mapBrief(brief: ServerBrief): DesignBrief {
|
||||
return {
|
||||
@@ -266,6 +299,41 @@ function isServerTask(value: unknown): value is ServerTask {
|
||||
&& typeof task.updated_at === 'string';
|
||||
}
|
||||
|
||||
function isServerWorkspace(value: unknown): value is ServerWorkspace {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const workspace = value as Record<string, unknown>;
|
||||
const brief = workspace.brief as Record<string, unknown> | null;
|
||||
return typeof workspace.workspace_id === 'string'
|
||||
&& typeof workspace.title === 'string'
|
||||
&& Number.isInteger(workspace.turn_revision)
|
||||
&& Number(workspace.turn_revision) >= 0
|
||||
&& Number.isInteger(workspace.view_revision)
|
||||
&& Number(workspace.view_revision) >= 0
|
||||
&& ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(workspace.phase))
|
||||
&& Boolean(brief)
|
||||
&& Number.isInteger(brief?.version)
|
||||
&& ['draft', 'ready', 'confirmed'].includes(String(brief?.status))
|
||||
&& (brief?.medium === null || brief?.medium === 'image' || brief?.medium === 'video')
|
||||
&& typeof brief?.summary === 'string'
|
||||
&& typeof brief?.ready === 'boolean'
|
||||
&& (brief?.missing_decision === null || typeof brief?.missing_decision === 'string')
|
||||
&& Array.isArray(workspace.messages)
|
||||
&& workspace.messages.every((message) => {
|
||||
if (!message || typeof message !== 'object' || Array.isArray(message)) return false;
|
||||
const item = message as Record<string, unknown>;
|
||||
return (item.role === 'user' || item.role === 'assistant')
|
||||
&& ['user', 'reply', 'choice', 'confirmation', 'safety_redirect', 'failure']
|
||||
.includes(String(item.kind))
|
||||
&& typeof item.text === 'string'
|
||||
&& Array.isArray(item.quick_replies)
|
||||
&& item.quick_replies.every((reply) => typeof reply === 'string')
|
||||
&& Number.isInteger(item.turn_revision)
|
||||
&& Number(item.turn_revision) >= 0
|
||||
&& typeof item.created_at === 'string';
|
||||
})
|
||||
&& typeof workspace.updated_at === 'string';
|
||||
}
|
||||
|
||||
function normalizeWorkspaceEvent(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
@@ -285,6 +353,31 @@ function normalizeWorkspaceEvent(
|
||||
return null;
|
||||
}
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
if (event.type === 'design.assistant.delta') {
|
||||
if (payload.workspace_id !== workspaceId
|
||||
|| typeof payload.client_turn_id !== 'string'
|
||||
|| payload.client_turn_id.length < 1
|
||||
|| payload.client_turn_id.length > 128
|
||||
|| !Number.isInteger(payload.turn_revision)
|
||||
|| Number(payload.turn_revision) < 1
|
||||
|| !Number.isInteger(payload.chunk_index)
|
||||
|| Number(payload.chunk_index) < 0
|
||||
|| typeof payload.delta !== 'string'
|
||||
|| payload.delta.length < 1
|
||||
|| payload.delta.length > 128) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `${sessionId}:${event.sequence}`,
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId,
|
||||
clientTurnId: payload.client_turn_id,
|
||||
turnRevision: Number(payload.turn_revision),
|
||||
chunkIndex: Number(payload.chunk_index),
|
||||
delta: payload.delta,
|
||||
} satisfies DesignAssistantDeltaEvent;
|
||||
}
|
||||
|
||||
if (event.type === 'design.generation_task.updated') {
|
||||
if (payload.workspace_id !== workspaceId
|
||||
|| !Number.isInteger(payload.workspace_view_revision)
|
||||
@@ -303,13 +396,11 @@ function normalizeWorkspaceEvent(
|
||||
}
|
||||
|
||||
if (event.type !== 'design.workspace.updated'
|
||||
|| !payload.workspace
|
||||
|| typeof payload.workspace !== 'object'
|
||||
|| Array.isArray(payload.workspace)
|
||||
|| !isServerWorkspace(payload.workspace)
|
||||
|| !Array.isArray(payload.generation_tasks)) {
|
||||
return null;
|
||||
}
|
||||
const workspace = payload.workspace as Record<string, unknown>;
|
||||
const workspace = payload.workspace;
|
||||
if (workspace.workspace_id !== workspaceId
|
||||
|| !Number.isInteger(workspace.view_revision)
|
||||
|| Number(workspace.view_revision) < 0
|
||||
@@ -323,6 +414,7 @@ function normalizeWorkspaceEvent(
|
||||
type: 'design.generation_tasks.snapshot',
|
||||
workspaceId,
|
||||
workspaceViewRevision: Number(workspace.view_revision),
|
||||
workspace: mapWorkspace(workspace),
|
||||
generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)),
|
||||
} satisfies DesignGenerationTasksSnapshotEvent;
|
||||
} catch {
|
||||
@@ -464,10 +556,34 @@ function userFacingErrorMessage(code: string, fallback: string): string {
|
||||
design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试',
|
||||
design_runtime_unavailable: 'AI 设计服务暂时不可用',
|
||||
design_production_unavailable: '当前生成能力暂时不可用',
|
||||
agent_command_invalid: '设计请求内容无效,请检查后重试',
|
||||
};
|
||||
return messages[code] ?? fallback;
|
||||
}
|
||||
|
||||
function agentRunErrorStatus(code: string): number {
|
||||
if (code === 'workspace_not_found') return 404;
|
||||
if (code === 'budget_denied') return 402;
|
||||
if (code === 'workspace_revision_conflict'
|
||||
|| code === 'idempotency_conflict'
|
||||
|| code === 'generation_quote_expired'
|
||||
|| code === 'generation_quote_consumed'
|
||||
|| code === 'generation_quote_invalid') {
|
||||
return 409;
|
||||
}
|
||||
if (code === 'agent_command_invalid'
|
||||
|| code === 'reference_asset_invalid'
|
||||
|| code === 'policy_blocked') {
|
||||
return 422;
|
||||
}
|
||||
if (code === 'design_reasoner_unavailable'
|
||||
|| code === 'design_runtime_unavailable'
|
||||
|| code === 'design_production_unavailable') {
|
||||
return 503;
|
||||
}
|
||||
return 502;
|
||||
}
|
||||
|
||||
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
@@ -539,40 +655,104 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
}
|
||||
|
||||
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace> {
|
||||
const workspace = await this.requestJson<ServerWorkspace>(
|
||||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_turn_id: input.clientTurnId,
|
||||
expected_turn_revision: input.expectedTurnRevision,
|
||||
return this.executeAgentTurn({
|
||||
workspaceId: input.workspaceId,
|
||||
clientTurnId: input.clientTurnId,
|
||||
expectedTurnRevision: input.expectedTurnRevision,
|
||||
message: input.message,
|
||||
attachment_asset_ids: input.attachmentAssetIds ?? [],
|
||||
attachmentAssetIds: input.attachmentAssetIds ?? [],
|
||||
action: null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return mapWorkspace(workspace);
|
||||
});
|
||||
}
|
||||
|
||||
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace> {
|
||||
const workspace = await this.requestJson<ServerWorkspace>(
|
||||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_turn_id: input.clientTurnId,
|
||||
expected_turn_revision: input.expectedTurnRevision,
|
||||
return this.executeAgentTurn({
|
||||
workspaceId: input.workspaceId,
|
||||
clientTurnId: input.clientTurnId,
|
||||
expectedTurnRevision: input.expectedTurnRevision,
|
||||
message: '确认生成',
|
||||
attachment_asset_ids: [],
|
||||
attachmentAssetIds: [],
|
||||
action: {
|
||||
type: 'confirm_generation',
|
||||
quote_id: input.quoteId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise<DesignWorkspace> {
|
||||
let session = await this.ensureEventSession(input.workspaceId);
|
||||
let command: ServerAgentCommand;
|
||||
try {
|
||||
command = await this.submitTurnCommand(session.session_id, input);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DesignWorkspaceModuleError)
|
||||
|| (error.code !== 'agent_session_not_found'
|
||||
&& error.code !== 'agent_session_closed')) {
|
||||
throw error;
|
||||
}
|
||||
await this.invalidateEventSession(input.workspaceId);
|
||||
session = await this.ensureEventSession(input.workspaceId);
|
||||
command = await this.submitTurnCommand(session.session_id, input);
|
||||
}
|
||||
const run = await this.waitForAgentRun(session.session_id, command.run_id);
|
||||
if (run.status !== 'succeeded') {
|
||||
const code = run.error?.code ?? (
|
||||
run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed'
|
||||
);
|
||||
const fallback = run.error?.message ?? (
|
||||
run.status === 'cancelled'
|
||||
? '设计 Agent 请求已取消'
|
||||
: '设计 Agent 暂时不可用,请稍后重试'
|
||||
);
|
||||
throw new DesignWorkspaceModuleError(
|
||||
agentRunErrorStatus(code),
|
||||
code,
|
||||
userFacingErrorMessage(code, fallback),
|
||||
);
|
||||
}
|
||||
return this.getWorkspace(input.workspaceId);
|
||||
}
|
||||
|
||||
private submitTurnCommand(
|
||||
sessionId: string,
|
||||
input: AgentDesignTurnSubmission,
|
||||
): Promise<ServerAgentCommand> {
|
||||
return this.requestJson<ServerAgentCommand>(
|
||||
`/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_command_id: input.clientTurnId,
|
||||
name: 'turn.submit',
|
||||
input: {
|
||||
expected_turn_revision: input.expectedTurnRevision,
|
||||
message: input.message,
|
||||
attachment_asset_ids: input.attachmentAssetIds,
|
||||
action: input.action,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
return mapWorkspace(workspace);
|
||||
}
|
||||
|
||||
private async waitForAgentRun(sessionId: string, runId: string): Promise<ServerAgentRun> {
|
||||
const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const run = await this.requestJson<ServerAgentRun>(
|
||||
`/api/agents/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}`,
|
||||
);
|
||||
if (run.status === 'succeeded' || run.status === 'failed' || run.status === 'cancelled') {
|
||||
return run;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
504,
|
||||
'design_agent_run_timeout',
|
||||
'设计 Agent 响应超时,请稍后重试',
|
||||
);
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, AGENT_RUN_POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks(workspaceId: string): Promise<DesignGenerationTask[]> {
|
||||
|
||||
@@ -98,12 +98,24 @@ export type DesignGenerationTasksSnapshotEvent = {
|
||||
type: 'design.generation_tasks.snapshot';
|
||||
workspaceId: string;
|
||||
workspaceViewRevision: number;
|
||||
workspace: DesignWorkspace;
|
||||
generationTasks: DesignGenerationTask[];
|
||||
};
|
||||
|
||||
export type DesignAssistantDeltaEvent = {
|
||||
id: string;
|
||||
type: 'design.assistant.delta';
|
||||
workspaceId: string;
|
||||
clientTurnId: string;
|
||||
turnRevision: number;
|
||||
chunkIndex: number;
|
||||
delta: string;
|
||||
};
|
||||
|
||||
export type DesignWorkspaceEvent =
|
||||
| DesignGenerationTaskUpdatedEvent
|
||||
| DesignGenerationTasksSnapshotEvent;
|
||||
| DesignGenerationTasksSnapshotEvent
|
||||
| DesignAssistantDeltaEvent;
|
||||
|
||||
export type DesignWorkspaceBootstrap = {
|
||||
capabilities: DesignCapabilities;
|
||||
|
||||
@@ -42,6 +42,10 @@ function createClientId(prefix: string): string {
|
||||
return `${prefix}-${id}`;
|
||||
}
|
||||
|
||||
export function createImageWorkspaceTurnId(): string {
|
||||
return createClientId('turn');
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number {
|
||||
if (error instanceof AppError && typeof error.details?.status === 'number') {
|
||||
return error.details.status;
|
||||
@@ -120,13 +124,14 @@ export function sendImageWorkspaceMessage(
|
||||
workspaceId: string,
|
||||
expectedTurnRevision: number,
|
||||
message: string,
|
||||
clientTurnId = createImageWorkspaceTurnId(),
|
||||
): Promise<DesignWorkspace> {
|
||||
return requestData(
|
||||
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientTurnId: createClientId('turn'),
|
||||
clientTurnId,
|
||||
expectedTurnRevision,
|
||||
message: message.trim(),
|
||||
attachmentAssetIds: [],
|
||||
@@ -139,13 +144,14 @@ export function confirmImageWorkspaceGeneration(
|
||||
workspaceId: string,
|
||||
expectedTurnRevision: number,
|
||||
quoteId: string,
|
||||
clientTurnId = createImageWorkspaceTurnId(),
|
||||
): Promise<DesignWorkspace> {
|
||||
return requestData(
|
||||
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientTurnId: createClientId('turn'),
|
||||
clientTurnId,
|
||||
expectedTurnRevision,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -58,6 +58,8 @@ function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type RenderedDesignMessage = DesignMessage & { streaming?: boolean };
|
||||
|
||||
function AssetPreview({ asset }: { asset: DesignAsset }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
@@ -190,6 +192,7 @@ export function ImageCanvas() {
|
||||
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
|
||||
const workspace = useImageWorkspaceStore((state) => state.workspace);
|
||||
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);
|
||||
@@ -208,6 +211,35 @@ export function ImageCanvas() {
|
||||
() => workspace ? activeQuote(workspace.messages) : null,
|
||||
[workspace],
|
||||
);
|
||||
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
|
||||
if (!workspace) return [];
|
||||
const messages: RenderedDesignMessage[] = [...workspace.messages];
|
||||
if (!pendingTurn || pendingTurn.workspaceId !== workspace.workspaceId) return messages;
|
||||
if (pendingTurn.userText) {
|
||||
messages.push({
|
||||
id: `${pendingTurn.clientTurnId}:user`,
|
||||
role: 'user',
|
||||
kind: 'user',
|
||||
text: pendingTurn.userText,
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: pendingTurn.turnRevision,
|
||||
createdAt: pendingTurn.createdAt,
|
||||
});
|
||||
}
|
||||
messages.push({
|
||||
id: `${pendingTurn.clientTurnId}:assistant`,
|
||||
role: 'assistant',
|
||||
kind: 'reply',
|
||||
text: pendingTurn.assistantText,
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: pendingTurn.turnRevision,
|
||||
createdAt: pendingTurn.createdAt,
|
||||
streaming: true,
|
||||
});
|
||||
return messages;
|
||||
}, [pendingTurn, workspace]);
|
||||
const taskWorkspaceId = workspace?.workspaceId ?? null;
|
||||
const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATUSES.has(task.status));
|
||||
|
||||
@@ -225,17 +257,20 @@ export function ImageCanvas() {
|
||||
if (typeof conversationEndRef.current?.scrollIntoView === 'function') {
|
||||
conversationEndRef.current.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [workspace?.messages.length]);
|
||||
}, [conversationMessages.length, pendingTurn?.assistantText]);
|
||||
|
||||
const handleSend = async (override?: string) => {
|
||||
const message = (override ?? prompt).trim();
|
||||
if (!workspace || !message || submitting) return;
|
||||
const requestedWorkspaceId = workspace.workspaceId;
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
setPrompt('');
|
||||
try {
|
||||
await sendMessage(message);
|
||||
setPrompt('');
|
||||
} catch (error) {
|
||||
if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return;
|
||||
setPrompt((current) => current.trim() ? current : message);
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -244,11 +279,13 @@ export function ImageCanvas() {
|
||||
|
||||
const handleConfirm = async (quoteId: string) => {
|
||||
if (!workspace || confirmingQuoteId) return;
|
||||
const requestedWorkspaceId = workspace.workspaceId;
|
||||
setConfirmingQuoteId(quoteId);
|
||||
setActionError(null);
|
||||
try {
|
||||
await confirmGeneration(quoteId);
|
||||
} catch (error) {
|
||||
if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return;
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setConfirmingQuoteId(null);
|
||||
@@ -368,7 +405,7 @@ export function ImageCanvas() {
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:h-full">
|
||||
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4">
|
||||
{workspace.messages.length === 0 ? (
|
||||
{conversationMessages.length === 0 ? (
|
||||
<div data-testid="image-workspace-empty-state" className="flex flex-1 items-center justify-center py-10 text-center">
|
||||
<div className="max-w-xl">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||||
@@ -382,7 +419,7 @@ export function ImageCanvas() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-7">
|
||||
{workspace.messages.map((message) => {
|
||||
{conversationMessages.map((message) => {
|
||||
const assistant = message.role === 'assistant';
|
||||
return (
|
||||
<article key={message.id} className={cn('flex gap-3', assistant ? 'justify-start' : 'justify-end')}>
|
||||
@@ -396,7 +433,25 @@ export function ImageCanvas() {
|
||||
{assistant ? '设计 Agent' : '你'}
|
||||
</div>
|
||||
<div className={assistant ? 'chat-assistant-message-surface' : 'chat-user-message-surface rounded-2xl px-4 py-3'}>
|
||||
<p className="whitespace-pre-wrap text-sm font-medium leading-6">{message.text}</p>
|
||||
<p className="whitespace-pre-wrap text-sm font-medium leading-6">
|
||||
{message.text}
|
||||
{message.streaming && message.text ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-brand align-middle"
|
||||
/>
|
||||
) : null}
|
||||
{message.streaming && !message.text ? (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="设计 Agent 正在回复"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在整理设计方向…
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{message.generationQuote?.status === 'active' ? (
|
||||
<QuoteCard
|
||||
quote={message.generationQuote}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
confirmImageWorkspaceGeneration,
|
||||
createImageWorkspaceTurnId,
|
||||
createImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
fetchImageWorkspaceProject,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
|
||||
type DesignAssistantDeltaEvent,
|
||||
type DesignGenerationTask,
|
||||
type DesignGenerationTasksSnapshotEvent,
|
||||
type DesignGenerationTaskUpdatedEvent,
|
||||
@@ -31,12 +33,23 @@ export type ImageWorkspaceLoadStatus =
|
||||
|
||||
export type ImageWorkspaceTaskStreamState = 'idle' | 'connecting' | 'connected' | 'degraded';
|
||||
|
||||
export type PendingDesignTurn = {
|
||||
workspaceId: string;
|
||||
clientTurnId: string;
|
||||
turnRevision: number;
|
||||
userText: string | null;
|
||||
assistantText: string;
|
||||
lastChunkIndex: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ImageWorkspaceState = {
|
||||
status: ImageWorkspaceLoadStatus;
|
||||
bootstrap: DesignWorkspaceBootstrap | null;
|
||||
activeWorkspaceId: string | null;
|
||||
workspace: DesignWorkspace | null;
|
||||
tasks: DesignGenerationTask[];
|
||||
pendingTurn: PendingDesignTurn | null;
|
||||
taskStreamState: ImageWorkspaceTaskStreamState;
|
||||
error: string | null;
|
||||
load: () => Promise<DesignWorkspaceBootstrap | null>;
|
||||
@@ -111,6 +124,10 @@ function parseTasksSnapshotEvent(event: Event): DesignGenerationTasksSnapshotEve
|
||||
|| typeof payload.id !== 'string'
|
||||
|| typeof payload.workspaceId !== 'string'
|
||||
|| !Number.isInteger(payload.workspaceViewRevision)
|
||||
|| !payload.workspace
|
||||
|| payload.workspace.workspaceId !== payload.workspaceId
|
||||
|| payload.workspace.viewRevision !== payload.workspaceViewRevision
|
||||
|| !Array.isArray(payload.workspace.messages)
|
||||
|| !Array.isArray(payload.generationTasks)
|
||||
|| !payload.generationTasks.every((task) => (
|
||||
typeof task?.taskId === 'string' && task.workspaceId === payload.workspaceId
|
||||
@@ -123,6 +140,30 @@ function parseTasksSnapshotEvent(event: Event): DesignGenerationTasksSnapshotEve
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssistantDeltaEvent(event: Event): DesignAssistantDeltaEvent | null {
|
||||
const data = (event as MessageEvent<unknown>).data;
|
||||
if (typeof data !== 'string') return null;
|
||||
try {
|
||||
const payload = JSON.parse(data) as Partial<DesignAssistantDeltaEvent>;
|
||||
if (payload.type !== 'design.assistant.delta'
|
||||
|| typeof payload.id !== 'string'
|
||||
|| typeof payload.workspaceId !== 'string'
|
||||
|| typeof payload.clientTurnId !== 'string'
|
||||
|| !Number.isInteger(payload.turnRevision)
|
||||
|| Number(payload.turnRevision) < 1
|
||||
|| !Number.isInteger(payload.chunkIndex)
|
||||
|| Number(payload.chunkIndex) < 0
|
||||
|| typeof payload.delta !== 'string'
|
||||
|| payload.delta.length === 0
|
||||
|| payload.delta.length > 128) {
|
||||
return null;
|
||||
}
|
||||
return payload as DesignAssistantDeltaEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] {
|
||||
return [...tasks].sort((left, right) => (
|
||||
right.createdAt.localeCompare(left.createdAt) || right.taskId.localeCompare(left.taskId)
|
||||
@@ -162,6 +203,22 @@ function upsertSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingTurn(
|
||||
workspace: DesignWorkspace,
|
||||
clientTurnId: string,
|
||||
userText: string,
|
||||
): PendingDesignTurn {
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
clientTurnId,
|
||||
turnRevision: workspace.turnRevision + 1,
|
||||
userText,
|
||||
assistantText: '',
|
||||
lastChunkIndex: -1,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) => {
|
||||
const stopTaskStream = () => {
|
||||
closeTaskEventSource();
|
||||
@@ -191,6 +248,44 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return;
|
||||
}
|
||||
activeTaskEventSource = source;
|
||||
source.addEventListener('design.assistant.delta', (event) => {
|
||||
const delta = parseAssistantDeltaEvent(event);
|
||||
if (!delta || delta.workspaceId !== activeTaskEventWorkspaceId) return;
|
||||
set((state) => {
|
||||
if (state.activeWorkspaceId !== delta.workspaceId
|
||||
|| (state.workspace?.turnRevision ?? 0) >= delta.turnRevision) {
|
||||
return state;
|
||||
}
|
||||
const pending = state.pendingTurn;
|
||||
if (!pending) {
|
||||
if (delta.chunkIndex !== 0) return state;
|
||||
return {
|
||||
pendingTurn: {
|
||||
workspaceId: delta.workspaceId,
|
||||
clientTurnId: delta.clientTurnId,
|
||||
turnRevision: delta.turnRevision,
|
||||
userText: null,
|
||||
assistantText: delta.delta,
|
||||
lastChunkIndex: delta.chunkIndex,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (pending.workspaceId !== delta.workspaceId
|
||||
|| pending.clientTurnId !== delta.clientTurnId
|
||||
|| pending.turnRevision !== delta.turnRevision
|
||||
|| delta.chunkIndex !== pending.lastChunkIndex + 1) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
pendingTurn: {
|
||||
...pending,
|
||||
assistantText: `${pending.assistantText}${delta.delta}`,
|
||||
lastChunkIndex: delta.chunkIndex,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
source.addEventListener('design.generation_tasks.snapshot', (event) => {
|
||||
const snapshot = parseTasksSnapshotEvent(event);
|
||||
if (!snapshot || snapshot.workspaceId !== activeTaskEventWorkspaceId) return;
|
||||
@@ -210,32 +305,36 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
Math.max(knownRevision, snapshot.workspaceViewRevision),
|
||||
);
|
||||
}
|
||||
const workspace = state.workspace?.workspaceId === snapshot.workspaceId
|
||||
? {
|
||||
...state.workspace,
|
||||
viewRevision: Math.max(
|
||||
state.workspace.viewRevision,
|
||||
snapshot.workspaceViewRevision,
|
||||
),
|
||||
}
|
||||
: state.workspace;
|
||||
const bootstrap = state.bootstrap
|
||||
? {
|
||||
...state.bootstrap,
|
||||
workspaces: state.bootstrap.workspaces.map((candidate) => (
|
||||
candidate.workspaceId === snapshot.workspaceId
|
||||
? {
|
||||
...candidate,
|
||||
viewRevision: Math.max(
|
||||
candidate.viewRevision,
|
||||
snapshot.workspaceViewRevision,
|
||||
),
|
||||
}
|
||||
: candidate
|
||||
)),
|
||||
}
|
||||
const currentWorkspace = state.workspace?.workspaceId === snapshot.workspaceId
|
||||
? state.workspace
|
||||
: null;
|
||||
return { tasks: sortTasks([...tasksById.values()]), workspace, bootstrap };
|
||||
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 bootstrap = workspace?.workspaceId === snapshot.workspaceId
|
||||
? upsertSummary(state.bootstrap, workspace)
|
||||
: state.bootstrap;
|
||||
const pendingTurn = state.pendingTurn?.workspaceId === snapshot.workspaceId
|
||||
&& snapshot.workspace.turnRevision >= state.pendingTurn.turnRevision
|
||||
? null
|
||||
: state.pendingTurn;
|
||||
return {
|
||||
tasks: sortTasks([...tasksById.values()]),
|
||||
workspace,
|
||||
bootstrap,
|
||||
pendingTurn,
|
||||
};
|
||||
});
|
||||
});
|
||||
source.addEventListener('design.generation_task.updated', (event) => {
|
||||
@@ -311,6 +410,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeWorkspaceId: null,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: message,
|
||||
});
|
||||
@@ -319,13 +419,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
};
|
||||
|
||||
const applyWorkspace = (workspace: DesignWorkspace): DesignWorkspace => {
|
||||
set((state) => ({
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -367,6 +474,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeWorkspaceId: null,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
|
||||
@@ -389,6 +497,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeWorkspaceId,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
@@ -404,6 +513,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeWorkspaceId: null,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: message,
|
||||
});
|
||||
@@ -419,7 +529,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
createProject: async (title) => {
|
||||
try {
|
||||
const workspace = applyWorkspace(await createImageWorkspaceProject(title));
|
||||
set({ tasks: [] });
|
||||
set({ tasks: [], pendingTurn: null });
|
||||
startTaskStream(workspace.workspaceId);
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
@@ -444,6 +554,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeWorkspaceId: workspaceId,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
@@ -512,17 +623,31 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
|
||||
sendMessage: async (message) => {
|
||||
const workspace = get().workspace;
|
||||
const userText = message.trim();
|
||||
const clientTurnId = createImageWorkspaceTurnId();
|
||||
if (!workspace) throw new Error('请先选择设计项目');
|
||||
try {
|
||||
const updated = applyWorkspace(await sendImageWorkspaceMessage(
|
||||
set({ pendingTurn: createPendingTurn(workspace, clientTurnId, userText), error: null });
|
||||
const updated = await sendImageWorkspaceMessage(
|
||||
workspace.workspaceId,
|
||||
workspace.turnRevision,
|
||||
message,
|
||||
));
|
||||
userText,
|
||||
clientTurnId,
|
||||
);
|
||||
if (get().activeWorkspaceId === workspace.workspaceId) {
|
||||
applyWorkspace(updated);
|
||||
await get().refreshTasks().catch(() => []);
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
set({ error: handleRequestError(error) });
|
||||
if (get().activeWorkspaceId !== workspace.workspaceId) throw error;
|
||||
const errorMessage = handleRequestError(error);
|
||||
set((state) => ({
|
||||
error: errorMessage,
|
||||
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
|
||||
? null
|
||||
: state.pendingTurn,
|
||||
}));
|
||||
return await recoverRevisionConflict(error);
|
||||
}
|
||||
},
|
||||
@@ -530,16 +655,32 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
confirmGeneration: async (quoteId) => {
|
||||
const workspace = get().workspace;
|
||||
if (!workspace) throw new Error('请先选择设计项目');
|
||||
const clientTurnId = createImageWorkspaceTurnId();
|
||||
try {
|
||||
const updated = applyWorkspace(await confirmImageWorkspaceGeneration(
|
||||
set({
|
||||
pendingTurn: createPendingTurn(workspace, clientTurnId, '确认生成'),
|
||||
error: null,
|
||||
});
|
||||
const updated = await confirmImageWorkspaceGeneration(
|
||||
workspace.workspaceId,
|
||||
workspace.turnRevision,
|
||||
quoteId,
|
||||
));
|
||||
clientTurnId,
|
||||
);
|
||||
if (get().activeWorkspaceId === workspace.workspaceId) {
|
||||
applyWorkspace(updated);
|
||||
await get().refreshTasks().catch(() => []);
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
set({ error: handleRequestError(error) });
|
||||
if (get().activeWorkspaceId !== workspace.workspaceId) throw error;
|
||||
const errorMessage = handleRequestError(error);
|
||||
set((state) => ({
|
||||
error: errorMessage,
|
||||
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
|
||||
? null
|
||||
: state.pendingTurn,
|
||||
}));
|
||||
return await recoverRevisionConflict(error);
|
||||
}
|
||||
},
|
||||
@@ -553,6 +694,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeWorkspaceId: null,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ImageCanvas } from '@/pages/ImageCanvas';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignWorkspace,
|
||||
@@ -40,6 +41,14 @@ class MockEventSource {
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
@@ -309,10 +318,57 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
'workspace-cloud',
|
||||
1,
|
||||
'把主角换成暖色轮廓光',
|
||||
expect.stringMatching(/^turn-/),
|
||||
));
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the pending user turn and assistant deltas while the command is running', async () => {
|
||||
const response = deferred<DesignWorkspace>();
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||
target: { value: '让画面更有海洋呼吸感' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
||||
|
||||
expect(await screen.findByText('让画面更有海洋呼吸感')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status', { name: '设计 Agent 正在回复' })).toBeInTheDocument();
|
||||
const pending = useImageWorkspaceStore.getState().pendingTurn!;
|
||||
act(() => {
|
||||
taskEventSource.emit('design.assistant.delta', {
|
||||
id: 'session-one:2',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-cloud',
|
||||
clientTurnId: pending.clientTurnId,
|
||||
turnRevision: pending.turnRevision,
|
||||
chunkIndex: 0,
|
||||
delta: '可以先增加',
|
||||
} satisfies DesignAssistantDeltaEvent);
|
||||
taskEventSource.emit('design.assistant.delta', {
|
||||
id: 'session-one:3',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-cloud',
|
||||
clientTurnId: pending.clientTurnId,
|
||||
turnRevision: pending.turnRevision,
|
||||
chunkIndex: 1,
|
||||
delta: '留白和水流节奏。',
|
||||
} satisfies DesignAssistantDeltaEvent);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('可以先增加留白和水流节奏。');
|
||||
act(() => response.resolve({
|
||||
...workspaceFixture(),
|
||||
turnRevision: 2,
|
||||
viewRevision: 2,
|
||||
}));
|
||||
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
|
||||
});
|
||||
|
||||
it('refreshes tasks after an Agent turn creates a generation task', async () => {
|
||||
const queuedTask = {
|
||||
...taskFixture,
|
||||
@@ -337,6 +393,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
'workspace-cloud',
|
||||
1,
|
||||
confirmationReply,
|
||||
expect.stringMatching(/^turn-/),
|
||||
));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
|
||||
@@ -357,7 +414,12 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||
.toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
1,
|
||||
'quote-one',
|
||||
expect.stringMatching(/^turn-/),
|
||||
));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
|
||||
@@ -80,7 +80,12 @@ describe('AI design renderer API boundary', () => {
|
||||
});
|
||||
|
||||
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
||||
await sendImageWorkspaceMessage('workspace/one', 4, ' 继续调整构图 ');
|
||||
await sendImageWorkspaceMessage(
|
||||
'workspace/one',
|
||||
4,
|
||||
' 继续调整构图 ',
|
||||
'turn-client-1',
|
||||
);
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages');
|
||||
@@ -89,7 +94,7 @@ describe('AI design renderer API boundary', () => {
|
||||
expectedTurnRevision: 4,
|
||||
message: '继续调整构图',
|
||||
attachmentAssetIds: [],
|
||||
clientTurnId: expect.stringMatching(/^turn-/),
|
||||
clientTurnId: 'turn-client-1',
|
||||
});
|
||||
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTasksSnapshotEvent,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
@@ -12,6 +13,7 @@ const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
@@ -21,6 +23,7 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -123,6 +126,7 @@ function taskSnapshotEvent(
|
||||
type: 'design.generation_tasks.snapshot',
|
||||
workspaceId: 'workspace-one',
|
||||
workspaceViewRevision,
|
||||
workspace: workspace('workspace-one', workspaceViewRevision),
|
||||
generationTasks: [{
|
||||
...task,
|
||||
status,
|
||||
@@ -139,6 +143,7 @@ describe('AI design task event store', () => {
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap());
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspace());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -167,6 +172,70 @@ describe('AI design task event store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('assembles assistant deltas once and replaces them with the canonical snapshot', async () => {
|
||||
const source = new MockEventSource();
|
||||
const response = deferred<DesignWorkspace>();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
const sending = useImageWorkspaceStore.getState().sendMessage('把主视觉改成鲸鱼');
|
||||
const pending = useImageWorkspaceStore.getState().pendingTurn;
|
||||
expect(pending).toMatchObject({
|
||||
userText: '把主视觉改成鲸鱼',
|
||||
assistantText: '',
|
||||
turnRevision: 2,
|
||||
});
|
||||
|
||||
const firstDelta = {
|
||||
id: 'session-one:2',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-one',
|
||||
clientTurnId: pending!.clientTurnId,
|
||||
turnRevision: 2,
|
||||
chunkIndex: 0,
|
||||
delta: '可以,先强化',
|
||||
} satisfies DesignAssistantDeltaEvent;
|
||||
source.emit('design.assistant.delta', firstDelta);
|
||||
source.emit('design.assistant.delta', firstDelta);
|
||||
source.emit('design.assistant.delta', {
|
||||
...firstDelta,
|
||||
id: 'session-one:3',
|
||||
chunkIndex: 1,
|
||||
delta: '鲸鱼的轮廓。',
|
||||
} satisfies DesignAssistantDeltaEvent);
|
||||
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn?.assistantText)
|
||||
.toBe('可以,先强化鲸鱼的轮廓。');
|
||||
|
||||
const canonical = {
|
||||
...workspace('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
messages: [{
|
||||
id: 'workspace-one:2:assistant:0',
|
||||
role: 'assistant' as const,
|
||||
kind: 'reply' as const,
|
||||
text: '可以,先强化鲸鱼的轮廓。',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 2,
|
||||
createdAt: '2026-08-02T10:01:00Z',
|
||||
}],
|
||||
};
|
||||
source.emit('design.generation_tasks.snapshot', {
|
||||
...taskSnapshotEvent(2, 'queued'),
|
||||
id: 'session-one:4',
|
||||
workspace: canonical,
|
||||
} satisfies DesignGenerationTasksSnapshotEvent);
|
||||
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
|
||||
expect(useImageWorkspaceStore.getState().workspace?.messages[0].text)
|
||||
.toBe('可以,先强化鲸鱼的轮廓。');
|
||||
response.resolve(canonical);
|
||||
await sending;
|
||||
});
|
||||
|
||||
it('closes the previous stream when switching workspaces and on reset', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
@@ -189,6 +258,35 @@ describe('AI design task event store', () => {
|
||||
expect(useImageWorkspaceStore.getState().taskStreamState).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not switch back when an Agent turn finishes after selecting another Workspace', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
const response = deferred<DesignWorkspace>();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(first as unknown as EventSource)
|
||||
.mockResolvedValueOnce(second as unknown as EventSource);
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
||||
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
||||
Promise.resolve(workspace(workspaceId))
|
||||
));
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报');
|
||||
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
||||
response.resolve({
|
||||
...workspace('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
});
|
||||
await sending;
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-two',
|
||||
workspace: { workspaceId: 'workspace-two' },
|
||||
pendingTurn: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a slow previous Workspace selection overwrite the latest one', async () => {
|
||||
const rootSource = new MockEventSource();
|
||||
const latestSource = new MockEventSource();
|
||||
|
||||
@@ -147,8 +147,123 @@ describe('Works Square AI design adapter', () => {
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it('submits a conversation turn through the persistent Agent Gateway Session', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
session_id: 'session-one',
|
||||
status: 'active',
|
||||
}, 201))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
run_id: 'run-one',
|
||||
status: 'queued',
|
||||
error: null,
|
||||
}, 202))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
run_id: 'run-one',
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
clientInstanceId: 'installation-one',
|
||||
});
|
||||
|
||||
await expect(adapter.submitMessage({
|
||||
workspaceId: 'workspace-one',
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 1,
|
||||
message: '做一张保护海洋的公益海报',
|
||||
attachmentAssetIds: ['asset-reference'],
|
||||
})).resolves.toMatchObject({
|
||||
workspaceId: 'workspace-one',
|
||||
turnRevision: 1,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://square.example/api/agents/sessions/session-one/commands',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_command_id: 'turn-two',
|
||||
name: 'turn.submit',
|
||||
input: {
|
||||
expected_turn_revision: 1,
|
||||
message: '做一张保护海洋的公益海报',
|
||||
attachment_asset_ids: ['asset-reference'],
|
||||
action: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://square.example/api/agents/sessions/session-one/runs/run-one',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'https://square.example/api/design/workspaces/workspace-one',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps an invalid Runtime command to a user input error', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
session_id: 'session-one',
|
||||
status: 'active',
|
||||
}, 201))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
run_id: 'run-invalid',
|
||||
status: 'queued',
|
||||
error: null,
|
||||
}, 202))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
run_id: 'run-invalid',
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'agent_command_invalid',
|
||||
message: 'private validation detail',
|
||||
retryable: false,
|
||||
},
|
||||
}));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
await expect(adapter.submitMessage({
|
||||
workspaceId: 'workspace-one',
|
||||
clientTurnId: 'turn-invalid',
|
||||
expectedTurnRevision: 1,
|
||||
message: 'invalid',
|
||||
})).rejects.toMatchObject({
|
||||
status: 422,
|
||||
code: 'agent_command_invalid',
|
||||
message: '设计请求内容无效,请检查后重试',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps task creation behind structured Quote confirmation', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(serverWorkspace));
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
session_id: 'session-one',
|
||||
status: 'active',
|
||||
}, 201))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
run_id: 'run-confirm',
|
||||
status: 'queued',
|
||||
error: null,
|
||||
}, 202))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
run_id: 'run-confirm',
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example/',
|
||||
fetchImpl: fetchMock,
|
||||
@@ -162,19 +277,33 @@ describe('Works Square AI design adapter', () => {
|
||||
});
|
||||
|
||||
expect(workspace.workspaceId).toBe('workspace-one');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.example/api/design/workspaces/workspace-one/turns',
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://square.example/api/agents/sessions/session-one/commands',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_turn_id: 'turn-two',
|
||||
client_command_id: 'turn-two',
|
||||
name: 'turn.submit',
|
||||
input: {
|
||||
expected_turn_revision: 1,
|
||||
message: '确认生成',
|
||||
attachment_asset_ids: [],
|
||||
action: { type: 'confirm_generation', quote_id: 'quote-one' },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://square.example/api/agents/sessions/session-one/runs/run-confirm',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'https://square.example/api/design/workspaces/workspace-one',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps stable generation tasks and private asset relay paths', async () => {
|
||||
@@ -278,13 +407,32 @@ describe('Works Square AI design adapter', () => {
|
||||
type: 'design.workspace.updated',
|
||||
schema_version: 1,
|
||||
payload: {
|
||||
workspace: {
|
||||
workspace_id: 'workspace-one',
|
||||
view_revision: 2,
|
||||
},
|
||||
workspace: serverWorkspace,
|
||||
generation_tasks: [snapshotTask],
|
||||
},
|
||||
};
|
||||
const assistantDeltaEvent = {
|
||||
session_id: 'session-one',
|
||||
sequence: 2,
|
||||
runtime: 'design',
|
||||
type: 'design.assistant.delta',
|
||||
schema_version: 1,
|
||||
payload: {
|
||||
workspace_id: 'workspace-one',
|
||||
client_turn_id: 'turn-two',
|
||||
turn_revision: 2,
|
||||
chunk_index: 0,
|
||||
delta: '方向已经明确',
|
||||
},
|
||||
};
|
||||
const malformedDeltaEvent = {
|
||||
...assistantDeltaEvent,
|
||||
sequence: 99,
|
||||
payload: {
|
||||
...assistantDeltaEvent.payload,
|
||||
chunk_index: -1,
|
||||
},
|
||||
};
|
||||
const taskEvent = {
|
||||
session_id: 'session-one',
|
||||
sequence: 3,
|
||||
@@ -336,6 +484,8 @@ describe('Works Square AI design adapter', () => {
|
||||
{
|
||||
frames: [
|
||||
{ type: 'event', event: snapshotEvent },
|
||||
{ type: 'event', event: malformedDeltaEvent },
|
||||
{ type: 'event', event: assistantDeltaEvent },
|
||||
{ type: 'event', event: taskEvent },
|
||||
],
|
||||
closeCode: 1000,
|
||||
@@ -367,12 +517,28 @@ describe('Works Square AI design adapter', () => {
|
||||
type: 'design.generation_tasks.snapshot',
|
||||
workspaceId: 'workspace-one',
|
||||
workspaceViewRevision: 2,
|
||||
workspace: expect.objectContaining({
|
||||
workspaceId: 'workspace-one',
|
||||
title: serverWorkspace.title,
|
||||
messages: [expect.objectContaining({
|
||||
text: serverWorkspace.messages[0].text,
|
||||
})],
|
||||
}),
|
||||
generationTasks: [expect.objectContaining({
|
||||
taskId: 'task-snapshot',
|
||||
medium: 'image',
|
||||
status: 'running',
|
||||
})],
|
||||
},
|
||||
{
|
||||
id: 'session-one:2',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-one',
|
||||
clientTurnId: 'turn-two',
|
||||
turnRevision: 2,
|
||||
chunkIndex: 0,
|
||||
delta: '方向已经明确',
|
||||
},
|
||||
{
|
||||
id: 'session-one:3',
|
||||
type: 'design.generation_task.updated',
|
||||
|
||||
Reference in New Issue
Block a user