feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
@@ -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) => ({
|
||||
status: 'ready',
|
||||
bootstrap: upsertSummary(state.bootstrap, workspace),
|
||||
activeWorkspaceId: workspace.workspaceId,
|
||||
workspace,
|
||||
error: null,
|
||||
}));
|
||||
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,
|
||||
));
|
||||
await get().refreshTasks().catch(() => []);
|
||||
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,
|
||||
));
|
||||
await get().refreshTasks().catch(() => []);
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user