This commit is contained in:
58
src/services/creation-agent/creationAgentChat.test.ts
Normal file
58
src/services/creation-agent/creationAgentChat.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
CREATION_AGENT_QUICK_FILL_MESSAGE,
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from './creationAgentChat';
|
||||
|
||||
test('creation agent chat exposes the unified summary and quick fill actions', () => {
|
||||
expect(createCreationAgentChatQuickActions()).toEqual([
|
||||
{
|
||||
key: 'summarize',
|
||||
label: '总结当前设定',
|
||||
},
|
||||
{
|
||||
key: 'quickFill',
|
||||
label: '补充剩余设定',
|
||||
minTurn: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('creation agent chat resolves quick actions through one message contract', () => {
|
||||
expect(
|
||||
resolveCreationAgentQuickActionMessage('quickFill', '请总结当前设定。'),
|
||||
).toEqual({
|
||||
text: CREATION_AGENT_QUICK_FILL_MESSAGE,
|
||||
quickFillRequested: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCreationAgentQuickActionMessage('summarize', '请总结当前设定。'),
|
||||
).toEqual({
|
||||
text: '请总结当前设定。',
|
||||
quickFillRequested: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('creation agent chat builds shared message payload with genre extras', () => {
|
||||
expect(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: 'message-1',
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
extraPayload: {
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
clientMessageId: 'message-1',
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
});
|
||||
});
|
||||
63
src/services/creation-agent/creationAgentChat.ts
Normal file
63
src/services/creation-agent/creationAgentChat.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const CREATION_AGENT_SUMMARY_ACTION_KEY = 'summarize';
|
||||
export const CREATION_AGENT_QUICK_FILL_ACTION_KEY = 'quickFill';
|
||||
|
||||
export const CREATION_AGENT_SUMMARY_ACTION_LABEL = '总结当前设定';
|
||||
export const CREATION_AGENT_QUICK_FILL_ACTION_LABEL = '补充剩余设定';
|
||||
export const CREATION_AGENT_QUICK_FILL_MESSAGE = '请补充剩余设定。';
|
||||
|
||||
type CreationAgentChatQuickAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
minTurn?: number;
|
||||
};
|
||||
|
||||
type CreationAgentChatMessageBase = {
|
||||
clientMessageId: string;
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
};
|
||||
|
||||
export function createCreationAgentChatQuickActions(): CreationAgentChatQuickAction[] {
|
||||
return [
|
||||
{
|
||||
key: CREATION_AGENT_SUMMARY_ACTION_KEY,
|
||||
label: CREATION_AGENT_SUMMARY_ACTION_LABEL,
|
||||
},
|
||||
{
|
||||
key: CREATION_AGENT_QUICK_FILL_ACTION_KEY,
|
||||
label: CREATION_AGENT_QUICK_FILL_ACTION_LABEL,
|
||||
minTurn: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveCreationAgentQuickActionMessage(
|
||||
actionKey: string,
|
||||
summaryMessage: string,
|
||||
) {
|
||||
const quickFillRequested = actionKey === CREATION_AGENT_QUICK_FILL_ACTION_KEY;
|
||||
|
||||
return {
|
||||
text: quickFillRequested ? CREATION_AGENT_QUICK_FILL_MESSAGE : summaryMessage,
|
||||
quickFillRequested,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreationAgentChatMessage<TExtraPayload extends object = Record<string, never>>({
|
||||
clientMessageId,
|
||||
text,
|
||||
quickFillRequested = false,
|
||||
extraPayload,
|
||||
}: {
|
||||
clientMessageId: string;
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
extraPayload?: TExtraPayload;
|
||||
}): CreationAgentChatMessageBase & TExtraPayload {
|
||||
return {
|
||||
...(extraPayload ?? ({} as TExtraPayload)),
|
||||
clientMessageId,
|
||||
text,
|
||||
quickFillRequested,
|
||||
};
|
||||
}
|
||||
165
src/services/creation-agent/creationAgentClientFactory.ts
Normal file
165
src/services/creation-agent/creationAgentClientFactory.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readCreationAgentSessionFromSse } from './creationAgentSse';
|
||||
|
||||
type CreationAgentClientMessages = {
|
||||
createSession: string;
|
||||
getSession: string;
|
||||
sendMessage: string;
|
||||
streamIncomplete: string;
|
||||
executeAction: string;
|
||||
};
|
||||
|
||||
type CreationAgentClientOptions = {
|
||||
apiBase: string;
|
||||
messages: CreationAgentClientMessages;
|
||||
createSessionTimeoutMs?: number;
|
||||
readRetry?: ApiRetryOptions;
|
||||
writeRetry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
function buildJsonPostInit(payload: unknown): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
async function openCreationAgentSsePost(
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
|
||||
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
|
||||
*/
|
||||
export function createCreationAgentClient<
|
||||
TCreateSessionPayload,
|
||||
TCreateSessionResponse,
|
||||
TGetSessionResponse,
|
||||
TSession,
|
||||
TSendMessagePayload,
|
||||
TSendMessageResponse,
|
||||
TExecuteActionPayload,
|
||||
TExecuteActionResponse,
|
||||
>({
|
||||
apiBase,
|
||||
messages,
|
||||
createSessionTimeoutMs = 15000,
|
||||
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
|
||||
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
|
||||
}: CreationAgentClientOptions) {
|
||||
const createSession = (
|
||||
payload: TCreateSessionPayload,
|
||||
): Promise<TCreateSessionResponse> =>
|
||||
requestJson<TCreateSessionResponse>(
|
||||
apiBase,
|
||||
buildJsonPostInit(payload),
|
||||
messages.createSession,
|
||||
{
|
||||
retry: writeRetry,
|
||||
timeoutMs: createSessionTimeoutMs,
|
||||
},
|
||||
);
|
||||
|
||||
const getSession = (sessionId: string): Promise<TGetSessionResponse> =>
|
||||
requestJson<TGetSessionResponse>(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'GET' },
|
||||
messages.getSession,
|
||||
{
|
||||
retry: readRetry,
|
||||
},
|
||||
);
|
||||
|
||||
const sendMessage = (
|
||||
sessionId: string,
|
||||
payload: TSendMessagePayload,
|
||||
): Promise<TSendMessageResponse> =>
|
||||
requestJson<TSendMessageResponse>(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages`,
|
||||
buildJsonPostInit(payload),
|
||||
messages.sendMessage,
|
||||
{
|
||||
retry: writeRetry,
|
||||
},
|
||||
);
|
||||
|
||||
const streamMessage = async (
|
||||
sessionId: string,
|
||||
payload: TSendMessagePayload,
|
||||
options: TextStreamOptions = {},
|
||||
): Promise<TSession> => {
|
||||
const response = await openCreationAgentSsePost(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
messages.sendMessage,
|
||||
options.signal,
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<TSession>(response, {
|
||||
...options,
|
||||
fallbackMessage: messages.sendMessage,
|
||||
incompleteMessage: messages.streamIncomplete,
|
||||
});
|
||||
};
|
||||
|
||||
const executeAction = (
|
||||
sessionId: string,
|
||||
payload: TExecuteActionPayload,
|
||||
): Promise<TExecuteActionResponse> =>
|
||||
requestJson<TExecuteActionResponse>(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/actions`,
|
||||
buildJsonPostInit(payload),
|
||||
messages.executeAction,
|
||||
{
|
||||
retry: writeRetry,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
createSession,
|
||||
getSession,
|
||||
sendMessage,
|
||||
streamMessage,
|
||||
executeAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
parseCreationAgentDocumentInput,
|
||||
validateCreationAgentDocumentInputFile,
|
||||
} from './creationAgentDocumentInput';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('creation agent document input validation accepts supported text documents', () => {
|
||||
expect(() => {
|
||||
validateCreationAgentDocumentInputFile(
|
||||
new File(['世界设定'], '世界设定.MD', { type: 'text/markdown' }),
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('creation agent document input validation rejects unsupported documents', () => {
|
||||
expect(() => {
|
||||
validateCreationAgentDocumentInputFile(
|
||||
new File(['binary'], '世界设定.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}),
|
||||
);
|
||||
}).toThrow('暂时只支持 txt、md、csv、json 文本文档。');
|
||||
});
|
||||
|
||||
test('creation agent document input validation rejects oversized documents', () => {
|
||||
const oversizedContent = new Uint8Array(256 * 1024 + 1);
|
||||
|
||||
expect(() => {
|
||||
validateCreationAgentDocumentInputFile(
|
||||
new File([oversizedContent], '世界设定.txt', { type: 'text/plain' }),
|
||||
);
|
||||
}).toThrow('文档过大,请上传 256KB 以内的文本文件。');
|
||||
});
|
||||
|
||||
test('creation agent document input parse skips network for unsupported files', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await expect(
|
||||
parseCreationAgentDocumentInput(new File(['binary'], '世界设定.docx')),
|
||||
).rejects.toThrow('暂时只支持 txt、md、csv、json 文本文档。');
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
82
src/services/creation-agent/creationAgentDocumentInput.ts
Normal file
82
src/services/creation-agent/creationAgentDocumentInput.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ParseCreationAgentDocumentInputRequest,
|
||||
ParseCreationAgentDocumentInputResponse,
|
||||
} from '../../../packages/shared/src/contracts/creationAgentDocumentInput';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
const DOCUMENT_INPUT_PARSE_ENDPOINT =
|
||||
'/api/runtime/creation-agent/document-inputs/parse';
|
||||
const MAX_DOCUMENT_INPUT_BYTES = 256 * 1024;
|
||||
const SUPPORTED_DOCUMENT_INPUT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'csv',
|
||||
'json',
|
||||
]);
|
||||
|
||||
export async function parseCreationAgentDocumentInput(
|
||||
file: File,
|
||||
): Promise<ParseCreationAgentDocumentInputResponse> {
|
||||
validateCreationAgentDocumentInputFile(file);
|
||||
|
||||
const contentBase64 = await readFileAsBase64(file);
|
||||
const payload: ParseCreationAgentDocumentInputRequest = {
|
||||
fileName: file.name,
|
||||
contentType: file.type || null,
|
||||
contentBase64,
|
||||
};
|
||||
|
||||
return requestJson<ParseCreationAgentDocumentInputResponse>(
|
||||
DOCUMENT_INPUT_PARSE_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'解析文档失败',
|
||||
{
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function validateCreationAgentDocumentInputFile(file: File) {
|
||||
const fileName = file.name.trim();
|
||||
const extension = fileName.includes('.')
|
||||
? fileName.split('.').pop()?.trim().toLowerCase()
|
||||
: '';
|
||||
|
||||
if (!extension || !SUPPORTED_DOCUMENT_INPUT_EXTENSIONS.has(extension)) {
|
||||
throw new Error('暂时只支持 txt、md、csv、json 文本文档。');
|
||||
}
|
||||
|
||||
if (file.size <= 0) {
|
||||
throw new Error('文档内容为空,请选择有内容的文件。');
|
||||
}
|
||||
|
||||
if (file.size > MAX_DOCUMENT_INPUT_BYTES) {
|
||||
throw new Error('文档过大,请上传 256KB 以内的文本文件。');
|
||||
}
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('读取文档失败,请重新选择文件。'));
|
||||
};
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
const commaIndex = result.indexOf(',');
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type CreationAgentOperationLike = {
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type CreationAgentProgressCopy = {
|
||||
completed?: string;
|
||||
high?: string;
|
||||
medium?: string;
|
||||
low?: string;
|
||||
initial?: string;
|
||||
};
|
||||
|
||||
export function normalizeCreationAgentProgress(progressPercent: number) {
|
||||
if (!Number.isFinite(progressPercent)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progressPercent)));
|
||||
}
|
||||
|
||||
export function isCreationAgentOperationBusy(
|
||||
operation: CreationAgentOperationLike | null | undefined,
|
||||
) {
|
||||
return operation?.status === 'queued' || operation?.status === 'running';
|
||||
}
|
||||
|
||||
export function resolveCreationAgentProgressHint(
|
||||
progressPercent: number,
|
||||
copy: CreationAgentProgressCopy = {},
|
||||
) {
|
||||
const normalizedProgress = normalizeCreationAgentProgress(progressPercent);
|
||||
|
||||
if (normalizedProgress >= 100) {
|
||||
return copy.completed || '当前设定已经收束完成,可以进入结果页生成';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 75) {
|
||||
return copy.high || '关键锚点基本成形,正在收束成可生成草稿的版本';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 45) {
|
||||
return copy.medium || '方向已经成形,继续补齐会影响体验的关键锚点';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 15) {
|
||||
return copy.low || '先把玩家一眼能感知的核心体验钉稳';
|
||||
}
|
||||
|
||||
return copy.initial || '先抓住这个创作品类最关键的方向';
|
||||
}
|
||||
|
||||
export function resolveCreationAnchorStatusLabel(status: string) {
|
||||
if (status === 'locked') {
|
||||
return '已锁定';
|
||||
}
|
||||
|
||||
if (status === 'confirmed') {
|
||||
return '已确认';
|
||||
}
|
||||
|
||||
if (status === 'inferred') {
|
||||
return '推断中';
|
||||
}
|
||||
|
||||
return '待补充';
|
||||
}
|
||||
|
||||
export function createCreationAgentClientMessageId(prefix: string) {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${prefix}-client-message-${Date.now()}`;
|
||||
}
|
||||
53
src/services/creation-agent/creationAgentSse.test.ts
Normal file
53
src/services/creation-agent/creationAgentSse.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { readCreationAgentSessionFromSse } from './creationAgentSse';
|
||||
|
||||
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('readCreationAgentSessionFromSse flushes decoder tail and handles CRLF boundaries', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
|
||||
const replyTextBytes = encoder.encode('你好,潮雾列岛');
|
||||
const suffix = encoder.encode(
|
||||
'"}\r\n\r\nevent: session\r\ndata: {"session":{"sessionId":"session-1","title":"世界共创"}}\r\n\r\n',
|
||||
);
|
||||
const splitIndex = replyTextBytes.length - 1;
|
||||
|
||||
const chunks = [
|
||||
new Uint8Array([...prefix, ...replyTextBytes.slice(0, splitIndex)]),
|
||||
new Uint8Array([...replyTextBytes.slice(splitIndex), ...suffix]),
|
||||
];
|
||||
|
||||
const updates: string[] = [];
|
||||
const session = await readCreationAgentSessionFromSse<{
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}>(createChunkedStreamResponse(chunks), {
|
||||
fallbackMessage: '发送失败',
|
||||
incompleteMessage: '结果不完整',
|
||||
onUpdate: (text) => {
|
||||
updates.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
expect(updates).toEqual(['你好,潮雾列岛']);
|
||||
expect(session).toEqual({
|
||||
sessionId: 'session-1',
|
||||
title: '世界共创',
|
||||
});
|
||||
});
|
||||
178
src/services/creation-agent/creationAgentSse.ts
Normal file
178
src/services/creation-agent/creationAgentSse.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
incompleteMessage: string;
|
||||
resolveSession?: (rawSession: unknown) => TSession | 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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const resolveSession =
|
||||
options.resolveSession ??
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
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 parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
|
||||
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 parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
5
src/services/creation-agent/index.ts
Normal file
5
src/services/creation-agent/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './creationAgentClientFactory';
|
||||
export * from './creationAgentChat';
|
||||
export * from './creationAgentDocumentInput';
|
||||
export * from './creationAgentProgress';
|
||||
export * from './creationAgentSse';
|
||||
Reference in New Issue
Block a user