feat: 流式展示设计 Agent 对话

需求:设计 Agent 对话与确认生成的回复需要实时展示。

实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
2026-08-02 21:21:50 +08:00
parent 518d7ab4cd
commit 90a249db31
9 changed files with 819 additions and 93 deletions

View File

@@ -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,
message: input.message,
attachment_asset_ids: input.attachmentAssetIds ?? [],
action: null,
}),
},
);
return mapWorkspace(workspace);
return this.executeAgentTurn({
workspaceId: input.workspaceId,
clientTurnId: input.clientTurnId,
expectedTurnRevision: input.expectedTurnRevision,
message: input.message,
attachmentAssetIds: input.attachmentAssetIds ?? [],
action: null,
});
}
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
return this.executeAgentTurn({
workspaceId: input.workspaceId,
clientTurnId: input.clientTurnId,
expectedTurnRevision: input.expectedTurnRevision,
message: '确认生成',
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_turn_id: input.clientTurnId,
expected_turn_revision: input.expectedTurnRevision,
message: '确认生成',
attachment_asset_ids: [],
action: {
type: 'confirm_generation',
quote_id: input.quoteId,
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[]> {