init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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: [],
});
});

View 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,
};
}

View 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,
};
}

View File

@@ -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();
});

View 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);
});
}

View 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()}`;
}

View 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: '世界共创',
});
});

View 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;
}

View File

@@ -0,0 +1,5 @@
export * from './creationAgentClientFactory';
export * from './creationAgentChat';
export * from './creationAgentDocumentInput';
export * from './creationAgentProgress';
export * from './creationAgentSse';