This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -0,0 +1,168 @@
import type {
ConfirmCreativePuzzleTemplateRequest,
CreateCreativeAgentSessionRequest,
CreativeAgentSessionResponse,
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
CreativeDraftEditStreamRequest,
StreamCreativeAgentMessageRequest,
} from '../../../packages/shared/src/contracts/creativeAgent';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import { fetchWithApiAuth, requestJson } from '../apiClient';
import {
readCreativeAgentResultFromSse,
readCreativeAgentSessionFromSse,
} from './creativeAgentSse';
const CREATIVE_AGENT_API_BASE = '/api/runtime/creative-agent/sessions';
export type CreativeAgentStreamOptions = TextStreamOptions & {
onEvent?: (event: CreativeAgentSseEvent) => void;
};
function buildJsonPostInit(payload: unknown): RequestInit {
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
};
}
async function openCreativeAgentSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
signal?: AbortSignal,
) {
const response = await fetchWithApiAuth(url, {
...buildJsonPostInit(payload),
signal,
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
return response;
}
export async function createCreativeAgentSession(
payload: CreateCreativeAgentSessionRequest = {},
) {
return requestJson<CreativeAgentSessionResponse>(
CREATIVE_AGENT_API_BASE,
buildJsonPostInit(payload),
'创建智能创作会话失败',
{
retry: {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
},
timeoutMs: 15000,
},
);
}
export async function getCreativeAgentSession(sessionId: string) {
return requestJson<CreativeAgentSessionResponse>(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
{ method: 'GET' },
'读取智能创作会话失败',
);
}
export async function streamCreativeAgentMessage(
sessionId: string,
payload: StreamCreativeAgentMessageRequest,
options: CreativeAgentStreamOptions = {},
) {
const response = await openCreativeAgentSsePost(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,
'发送智能创作消息失败',
options.signal,
);
return readCreativeAgentSessionFromSse(response, {
...options,
fallbackMessage: '发送智能创作消息失败',
incompleteMessage: '智能创作消息流式结果不完整',
});
}
export async function confirmCreativePuzzleTemplate(
sessionId: string,
payload: ConfirmCreativePuzzleTemplateRequest,
) {
return requestJson<CreativeAgentSessionResponse>(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/confirm-template`,
buildJsonPostInit(payload),
'确认拼图模板失败',
{
retry: {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
},
},
);
}
export async function streamCreativeDraftEdit(
sessionId: string,
payload: CreativeDraftEditStreamRequest,
options: CreativeAgentStreamOptions = {},
) {
const response = await openCreativeAgentSsePost(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/draft-edits/stream`,
payload,
'修改拼图草稿失败',
options.signal,
);
return requestCreativeDraftEditResultFromSse(response, options);
}
export async function cancelCreativeAgentSession(sessionId: string) {
return requestJson<CreativeAgentSessionResponse>(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/cancel`,
buildJsonPostInit({}),
'取消智能创作会话失败',
);
}
async function requestCreativeDraftEditResultFromSse(
response: Response,
options: CreativeAgentStreamOptions,
) {
const result = await readCreativeAgentResultFromSse(response, {
...options,
fallbackMessage: '修改拼图草稿失败',
incompleteMessage: '智能创作修改结果不完整',
});
if (result.draftEditResult) {
return result.draftEditResult;
}
// 中文注释:后端如果暂时只返回 session调用方仍能用 session 做保底收尾。
return result.session as CreativeAgentSessionSnapshot;
}
export const creativeAgentClient = {
createSession: createCreativeAgentSession,
getSession: getCreativeAgentSession,
streamMessage: streamCreativeAgentMessage,
confirmTemplate: confirmCreativePuzzleTemplate,
streamDraftEdit: streamCreativeDraftEdit,
cancelSession: cancelCreativeAgentSession,
};

View File

@@ -0,0 +1,141 @@
import { expect, test, vi } from 'vitest';
import {
readCreativeAgentResultFromSse,
readCreativeAgentSessionFromSse,
} from './creativeAgentSse';
function createChunkedStreamResponse(chunks: Uint8Array[]) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
});
}
test('readCreativeAgentSessionFromSse parses typed creative agent events', async () => {
const encoder = new TextEncoder();
const onEvent = vi.fn();
const session = {
sessionId: 'creative-session-1',
stage: 'waiting_template_confirmation',
inputSummary: {
text: '做一套生日拼图',
entryContext: 'creation_home',
images: [],
materialSummary: null,
unsupportedCapabilities: [],
},
messages: [],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: null,
updatedAt: '2026-05-05T10:00:00.000Z',
};
const response = createChunkedStreamResponse([
encoder.encode(
'event: stage\r\ndata: {"sessionId":"creative-session-1","stage":"perceiving"}\r\n\r\n',
),
encoder.encode(
'event: thought_summary_delta\r\ndata: {"sessionId":"creative-session-1","thoughtId":"thought-1","textDelta":"正在理解素材"}\r\n\r\n',
),
encoder.encode(
'event: puzzle_template_catalog\r\ndata: {"sessionId":"creative-session-1","templates":[]}\r\n\r\n',
),
encoder.encode(
`event: session\r\ndata: ${JSON.stringify({ session })}\r\n\r\n`,
),
]);
await expect(
readCreativeAgentSessionFromSse(response, {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
onEvent,
}),
).resolves.toEqual(session);
expect(onEvent).toHaveBeenCalledWith({
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'perceiving',
},
});
expect(onEvent).toHaveBeenCalledWith({
event: 'thought_summary_delta',
data: {
sessionId: 'creative-session-1',
thoughtId: 'thought-1',
textDelta: '正在理解素材',
},
});
expect(onEvent).toHaveBeenCalledWith({
event: 'puzzle_template_catalog',
data: {
sessionId: 'creative-session-1',
templates: [],
},
});
});
test('readCreativeAgentResultFromSse keeps draft edit payload when present', async () => {
const encoder = new TextEncoder();
const session = {
sessionId: 'creative-session-1',
stage: 'target_ready',
inputSummary: {
text: null,
entryContext: 'creation_home',
images: [],
materialSummary: null,
unsupportedCapabilities: [],
},
messages: [],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: null,
updatedAt: '2026-05-05T10:00:00.000Z',
};
const puzzleSession = {
sessionId: 'puzzle-session-1',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: {},
draft: null,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-05T10:00:00.000Z',
};
const response = createChunkedStreamResponse([
encoder.encode(
`event: draft_edit_result\ndata: ${JSON.stringify({
editInstructions: [],
session,
puzzleSession,
})}\n\n`,
),
]);
const result = await readCreativeAgentResultFromSse(response, {
fallbackMessage: '修改失败',
incompleteMessage: '结果不完整',
});
expect(result.session).toEqual(session);
expect(result.draftEditResult?.puzzleSession).toEqual(puzzleSession);
});

View File

@@ -0,0 +1,230 @@
import type {
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
CreativeDraftEditResult,
} from '../../../packages/shared/src/contracts/creativeAgent';
import type { TextStreamOptions } from '../aiTypes';
type CreativeAgentSseOptions = TextStreamOptions & {
fallbackMessage: string;
incompleteMessage: string;
onEvent?: (event: CreativeAgentSseEvent) => void;
};
type CreativeAgentSseResult = {
session: CreativeAgentSessionSnapshot | null;
draftEditResult: CreativeDraftEditResult | null;
};
function findSseEventBoundary(buffer: string) {
const lfBoundary = buffer.indexOf('\n\n');
const crlfBoundary = buffer.indexOf('\r\n\r\n');
if (lfBoundary === -1 && crlfBoundary === -1) {
return null;
}
if (lfBoundary === -1) {
return {
index: crlfBoundary,
length: 4,
};
}
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
return {
index: lfBoundary,
length: 2,
};
}
return {
index: crlfBoundary,
length: 4,
};
}
function parseSseEventBlock(eventBlock: string) {
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
return {
eventName,
data: dataLines.join('\n'),
};
}
function parseJsonObject(data: string) {
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
function normalizeCreativeAgentSseEvent(
eventName: string,
data: Record<string, unknown>,
): CreativeAgentSseEvent | null {
switch (eventName) {
case 'stage':
case 'agent_message_delta':
case 'thought_summary_delta':
case 'puzzle_template_catalog':
case 'puzzle_template_selection':
case 'puzzle_cost_range':
case 'puzzle_level_plan':
case 'tool_started':
case 'tool_completed':
case 'reflection':
case 'target_session':
case 'session':
case 'error':
case 'done':
return {
event: eventName,
data: data as never,
} as CreativeAgentSseEvent;
default:
return null;
}
}
function handleParsedCreativeAgentEvent(
eventName: string,
parsed: Record<string, unknown> | null,
options: CreativeAgentSseOptions,
): Partial<CreativeAgentSseResult> | null {
if (!parsed) {
return null;
}
const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed);
if (normalizedEvent) {
options.onEvent?.(normalizedEvent);
}
if (eventName === 'agent_message_delta') {
const textDelta = parsed.textDelta;
if (typeof textDelta === 'string') {
options.onUpdate?.(textDelta);
}
return null;
}
if (eventName === 'session' && parsed.session) {
return {
session: parsed.session as CreativeAgentSessionSnapshot,
draftEditResult:
'puzzleSession' in parsed
? (parsed as unknown as CreativeDraftEditResult)
: null,
};
}
if (eventName === 'draft_edit_result' && parsed.session) {
return {
session: parsed.session as CreativeAgentSessionSnapshot,
draftEditResult: parsed as unknown as CreativeDraftEditResult,
};
}
if (eventName === 'error') {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
}
return null;
}
export async function readCreativeAgentSessionFromSse(
response: Response,
options: CreativeAgentSseOptions,
) {
const result = await readCreativeAgentResultFromSse(response, options);
if (!result.session) {
throw new Error(options.incompleteMessage);
}
return result.session;
}
export async function readCreativeAgentResultFromSse(
response: Response,
options: CreativeAgentSseOptions,
): Promise<CreativeAgentSseResult> {
const streamBody = response.body;
if (!streamBody) {
throw new Error('streaming response body is unavailable');
}
const reader = streamBody.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
const result: CreativeAgentSseResult = {
session: null,
draftEditResult: null,
};
const consumeBuffer = () => {
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
continue;
}
const nextResult = handleParsedCreativeAgentEvent(
eventName,
parseJsonObject(data),
options,
);
if (nextResult?.session) {
result.session = nextResult.session;
}
if (nextResult?.draftEditResult) {
result.draftEditResult = nextResult.draftEditResult;
}
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
consumeBuffer();
}
buffer += decoder.decode();
consumeBuffer();
if (!result.session) {
throw new Error(options.incompleteMessage);
}
return result;
}

View File

@@ -0,0 +1,10 @@
export {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
creativeAgentClient,
type CreativeAgentStreamOptions,
getCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from './creativeAgentClient';