1
This commit is contained in:
168
src/services/creative-agent/creativeAgentClient.ts
Normal file
168
src/services/creative-agent/creativeAgentClient.ts
Normal 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,
|
||||
};
|
||||
141
src/services/creative-agent/creativeAgentSse.test.ts
Normal file
141
src/services/creative-agent/creativeAgentSse.test.ts
Normal 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);
|
||||
});
|
||||
230
src/services/creative-agent/creativeAgentSse.ts
Normal file
230
src/services/creative-agent/creativeAgentSse.ts
Normal 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;
|
||||
}
|
||||
10
src/services/creative-agent/index.ts
Normal file
10
src/services/creative-agent/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
cancelCreativeAgentSession,
|
||||
confirmCreativePuzzleTemplate,
|
||||
createCreativeAgentSession,
|
||||
creativeAgentClient,
|
||||
type CreativeAgentStreamOptions,
|
||||
getCreativeAgentSession,
|
||||
streamCreativeAgentMessage,
|
||||
streamCreativeDraftEdit,
|
||||
} from './creativeAgentClient';
|
||||
Reference in New Issue
Block a user