This commit is contained in:
2026-04-25 22:19:04 +08:00
parent 2ebfd1cf55
commit 8404081d7b
149 changed files with 10508 additions and 2732 deletions

View File

@@ -6,76 +6,45 @@ import type {
ExecuteBigFishActionRequest,
SendBigFishMessageRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import {
type ApiRetryOptions,
fetchWithApiAuth,
requestJson,
} from '../apiClient';
import { readCreationAgentSessionFromSse } from '../creation-agent';
import { createCreationAgentClient } from '../creation-agent';
const BIG_FISH_AGENT_API_BASE = '/api/runtime/big-fish/agent/sessions';
const BIG_FISH_SESSION_START_TIMEOUT_MS = 15000;
const BIG_FISH_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
maxDelayMs: 480,
};
const BIG_FISH_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
const bigFishAgentHttpClient = createCreationAgentClient<
CreateBigFishSessionRequest,
BigFishSessionResponse,
BigFishSessionResponse,
BigFishSessionSnapshotResponse,
SendBigFishMessageRequest,
BigFishSessionResponse,
ExecuteBigFishActionRequest,
BigFishActionResponse
>({
apiBase: BIG_FISH_AGENT_API_BASE,
messages: {
createSession: '创建大鱼吃小鱼共创会话失败',
getSession: '读取大鱼吃小鱼共创会话失败',
sendMessage: '发送大鱼吃小鱼共创消息失败',
streamIncomplete: '大鱼吃小鱼共创消息流式结果不完整',
executeAction: '执行大鱼吃小鱼共创操作失败',
},
});
export async function createBigFishCreationSession(
payload: CreateBigFishSessionRequest = {},
) {
return requestJson<BigFishSessionResponse>(
BIG_FISH_AGENT_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建大鱼吃小鱼共创会话失败',
{
retry: BIG_FISH_WRITE_RETRY,
timeoutMs: BIG_FISH_SESSION_START_TIMEOUT_MS,
},
);
return bigFishAgentHttpClient.createSession(payload);
}
export async function getBigFishCreationSession(sessionId: string) {
return requestJson<BigFishSessionResponse>(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取大鱼吃小鱼共创会话失败',
{
retry: BIG_FISH_READ_RETRY,
},
);
return bigFishAgentHttpClient.getSession(sessionId);
}
export async function sendBigFishCreationMessage(
sessionId: string,
payload: SendBigFishMessageRequest,
) {
return requestJson<BigFishSessionResponse>(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发送大鱼吃小鱼共创消息失败',
{
retry: BIG_FISH_WRITE_RETRY,
},
);
return bigFishAgentHttpClient.sendMessage(sessionId, payload);
}
export async function streamBigFishCreationMessage(
@@ -83,61 +52,14 @@ export async function streamBigFishCreationMessage(
payload: SendBigFishMessageRequest,
options: TextStreamOptions = {},
) {
const response = await openBigFishCreationSsePost(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,
'发送大鱼吃小鱼共创消息失败',
);
return readCreationAgentSessionFromSse<BigFishSessionSnapshotResponse>(
response,
{
...options,
fallbackMessage: '发送大鱼吃小鱼共创消息失败',
incompleteMessage: '大鱼吃小鱼共创消息流式结果不完整',
},
);
return bigFishAgentHttpClient.streamMessage(sessionId, payload, options);
}
export async function executeBigFishCreationAction(
sessionId: string,
payload: ExecuteBigFishActionRequest,
) {
return requestJson<BigFishActionResponse>(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'执行大鱼吃小鱼共创操作失败',
{
retry: BIG_FISH_WRITE_RETRY,
},
);
}
async function openBigFishCreationSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
) {
const response = await fetchWithApiAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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;
return bigFishAgentHttpClient.executeAction(sessionId, payload);
}
export const bigFishCreationClient = {

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

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

View File

@@ -185,11 +185,30 @@ test('keeps failed draft foundation progress on explicit failure state instead o
expect(progress?.phaseId).toBe('failed');
expect(progress?.phaseLabel).toBe('底稿生成失败');
expect(progress?.phaseDetail).toContain('角色主形象补齐失败');
expect(progress?.overallProgress).toBeLessThan(100);
expect(progress?.estimatedRemainingMs).toBeNull();
expect(progress?.steps.some((step) => step.label === '编译草稿卡')).toBe(true);
expect(progress?.steps.some((step) => step.status === 'active')).toBe(false);
expect(progress?.steps.filter((step) => step.status === 'completed').length).toBeGreaterThan(0);
});
test('estimates draft generation wait time from phase duration model instead of linear progress', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
phaseLabel: '生成幕背景图',
phaseDetail: '正在生成幕背景图 1/6潮汐码头。',
progress: 98,
updatedAt: '1970-01-01T00:00:01.000Z',
},
1_000,
6_000,
);
expect(progress?.estimatedRemainingMs).toBeGreaterThan(80_000);
expect(progress?.estimatedRemainingMs).toBeLessThan(140_000);
});
test('builds readable draft setting text from creator intent first', () => {
const settingText = buildAgentDraftFoundationSettingText(baseSession);

View File

@@ -199,6 +199,7 @@ type AgentDraftFoundationStepDefinition = {
detail: string;
matchers: string[];
minProgress: number;
expectedDurationMs: number;
};
type AgentDraftFoundationFailedStep = {
@@ -215,6 +216,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在校验当前锚点并准备底稿编译链路。',
matchers: ['已接收请求'],
minProgress: 0,
expectedDurationMs: 3_000,
},
{
id: 'framework',
@@ -222,6 +224,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在生成第一版世界框架、主题与核心冲突。',
matchers: ['整理世界骨架', '生成世界底稿'],
minProgress: 12,
expectedDurationMs: 25_000,
},
{
id: 'playable-outline',
@@ -229,6 +232,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在补出玩家视角角色的首轮名单与定位。',
matchers: ['生成可扮演角色'],
minProgress: 16,
expectedDurationMs: 18_000,
},
{
id: 'story-outline',
@@ -236,6 +240,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在整理关键 NPC、势力接口人与关系入口。',
matchers: ['生成场景角色'],
minProgress: 30,
expectedDurationMs: 45_000,
},
{
id: 'landmark-seed',
@@ -243,6 +248,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在补出第一批关键场景与地点骨架。',
matchers: ['生成关键场景'],
minProgress: 44,
expectedDurationMs: 18_000,
},
{
id: 'landmark-network',
@@ -250,6 +256,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在串联地点关系、线程挂钩与角色分布。',
matchers: ['建立场景连接'],
minProgress: 56,
expectedDurationMs: 18_000,
},
{
id: 'playable-detail',
@@ -257,6 +264,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在补全可扮演角色的叙事基础与档案细节。',
matchers: ['补全可扮演角色'],
minProgress: 66,
expectedDurationMs: 32_000,
},
{
id: 'story-detail',
@@ -264,6 +272,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在补全场景角色的叙事基础与档案细节。',
matchers: ['补全场景角色'],
minProgress: 84,
expectedDurationMs: 65_000,
},
{
id: 'finalize',
@@ -271,6 +280,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在把分批生成结果汇总成第一版可浏览的世界底稿。',
matchers: ['编译世界底稿'],
minProgress: 97,
expectedDurationMs: 6_000,
},
{
id: 'role-visuals',
@@ -278,6 +288,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在为关键角色补主形象预览资源。',
matchers: ['生成角色主形象'],
minProgress: 97,
expectedDurationMs: 85_000,
},
{
id: 'act-backgrounds',
@@ -285,6 +296,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在为场景章节的每一幕补背景图预览资源。',
matchers: ['生成幕背景图'],
minProgress: 98,
expectedDurationMs: 85_000,
},
{
id: 'cards',
@@ -292,6 +304,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在整理世界卡、角色卡、地点卡与详情结构。',
matchers: ['编译草稿卡'],
minProgress: 99,
expectedDurationMs: 15_000,
},
{
id: 'workspace',
@@ -299,6 +312,7 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
matchers: ['世界底稿已生成'],
minProgress: 100,
expectedDurationMs: 4_000,
},
] as const satisfies ReadonlyArray<AgentDraftFoundationStepDefinition>;
@@ -333,6 +347,39 @@ function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
return matchedIndex;
}
function resolveFailedProgress(
operation: CustomWorldAgentOperationRecord,
activeStepIndex: number,
) {
const progress = clampProgress(operation.progress);
if (operation.status !== 'failed') {
return progress;
}
if (progress < 100) {
return progress;
}
const activeStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
return Math.max(0, Math.min(99, activeStep.minProgress));
}
function parseOperationUpdatedAtMs(
operation: CustomWorldAgentOperationRecord,
) {
const rawUpdatedAt = operation.updatedAt?.trim();
if (!rawUpdatedAt) {
return null;
}
const parsedMs = Date.parse(rawUpdatedAt);
return Number.isFinite(parsedMs) ? parsedMs : null;
}
function resolveAgentDraftFoundationStepIndex(
operation: CustomWorldAgentOperationRecord,
) {
@@ -410,19 +457,47 @@ function resolveEstimatedRemainingMs(
startedAtMs: number | null,
nowMs: number,
status: CustomWorldAgentOperationRecord['status'],
activeStepIndex: number,
operationUpdatedAtMs: number | null,
) {
if (status === 'completed') {
return 0;
}
if (!startedAtMs || progress <= 0 || progress >= 100) {
if (status === 'failed' || progress >= 100) {
return null;
}
const elapsedMs = Math.max(0, nowMs - startedAtMs);
const progressFraction = progress / 100;
const activeStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
const nextStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex + 1] ??
activeStep;
const phaseProgressRange = Math.max(
1,
nextStep.minProgress - activeStep.minProgress,
);
const phaseProgressRatio = Math.max(
0,
Math.min(0.95, (progress - activeStep.minProgress) / phaseProgressRange),
);
const phaseStartedAtMs = operationUpdatedAtMs ?? startedAtMs;
const currentPhaseElapsedMs = phaseStartedAtMs
? Math.max(0, nowMs - phaseStartedAtMs)
: 0;
const currentPhaseRemainingMs = Math.max(
0,
Math.round(
activeStep.expectedDurationMs * (1 - phaseProgressRatio) -
currentPhaseElapsedMs,
),
);
const followingStepsRemainingMs = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.slice(
activeStepIndex + 1,
).reduce((sum, step) => sum + step.expectedDurationMs, 0);
return Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs));
return currentPhaseRemainingMs + followingStepsRemainingMs;
}
export function isDraftFoundationOperation(
@@ -449,14 +524,16 @@ export function buildAgentDraftFoundationGenerationProgress(
return null;
}
const overallProgress = clampProgress(operation.progress);
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
const overallProgress = resolveFailedProgress(operation, activeStepIndex);
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const estimatedRemainingMs = resolveEstimatedRemainingMs(
overallProgress,
startedAtMs,
nowMs,
operation.status,
activeStepIndex,
parseOperationUpdatedAtMs(operation),
);
const failedStep = resolveAgentDraftFoundationFailedStep(operation);
const activeStep =

View File

@@ -0,0 +1,350 @@
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { normalizeCustomWorldCreatorIntent } from './customWorldCreatorIntent';
import type { CustomWorldProfile } from '../types';
export type CustomWorldFoundationEntryId =
| 'world-promise'
| 'player-fantasy'
| 'theme-boundary'
| 'player-entry-point'
| 'core-conflict'
| 'key-relationships'
| 'hidden-lines'
| 'iconic-elements';
export type CustomWorldFoundationEntry = {
id: CustomWorldFoundationEntryId;
label: string;
value: string;
};
export function compactFoundationTextList(
values: Array<string | null | undefined>,
) {
return values.map((value) => value?.trim()).filter(Boolean) as string[];
}
export function parseFoundationTagText(value: string) {
return value
.split(/[;]/u)
.map((item) => item.trim())
.filter(Boolean);
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toTextArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function buildRelationshipSeedText(value: unknown) {
const record = toRecord(value);
if (!record) {
return '';
}
return compactFoundationTextList([
toText(record.name),
toText(record.role),
toText(record.relationToPlayer)
? `与玩家:${toText(record.relationToPlayer)}`
: '',
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
]).join('');
}
function buildKeyRelationshipText(value: KeyRelationshipValue) {
return compactFoundationTextList([
value.pairs,
value.relationshipType,
value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '',
]).join('');
}
function buildAnchorContentFromProfileFallback(
profile: CustomWorldProfile,
): EightAnchorContent {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
return {
worldPromise: {
hook:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
differentiator: profile.subtitle || profile.settingText,
desiredExperience:
compactFoundationTextList([
creatorIntent?.toneDirectives.join('、') || '',
profile.tone,
]).join('') || profile.tone,
},
playerFantasy: {
playerRole: creatorIntent?.playerPremise || profile.playerGoal,
corePursuit: profile.playerGoal,
fearOfLoss:
relationshipSeed?.hiddenHook ||
creatorIntent?.coreConflicts[0] ||
profile.coreConflicts[0] ||
'',
},
themeBoundary: {
toneKeywords: compactFoundationTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]),
aestheticDirectives: compactFoundationTextList([
profile.tone,
profile.subtitle,
]),
forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [],
},
playerEntryPoint: {
openingIdentity: creatorIntent?.playerPremise || '',
openingProblem:
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
entryMotivation: profile.playerGoal,
},
coreConflict: {
surfaceConflicts:
creatorIntent?.coreConflicts.length
? creatorIntent.coreConflicts
: profile.coreConflicts,
hiddenCrisis:
relationshipSeed?.hiddenHook ||
profile.summary ||
profile.settingText,
firstTouchedConflict:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
keyRelationships: relationshipSeed
? [
{
pairs: compactFoundationTextList([
relationshipSeed.name,
relationshipSeed.role,
]).join(' · '),
relationshipType: relationshipSeed.relationToPlayer || '',
secretOrCost: relationshipSeed.hiddenHook || '',
},
]
: [],
hiddenLines: {
hiddenTruths: compactFoundationTextList([
relationshipSeed?.hiddenHook || '',
profile.summary,
]),
misdirectionHints: compactFoundationTextList([
profile.subtitle,
profile.majorFactions[0] || '',
]),
revealPacing:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
iconicElements: {
iconicMotifs:
creatorIntent?.iconicElements.length
? creatorIntent.iconicElements
: compactFoundationTextList([
profile.anchorPack?.motifDirectives.join('、') || '',
profile.landmarks[0]?.name || '',
]),
institutionsOrArtifacts: compactFoundationTextList([
profile.camp?.name || '',
profile.majorFactions[0] || '',
]),
hardRules: compactFoundationTextList([
profile.playerGoal,
profile.coreConflicts[0] || '',
]),
},
} satisfies EightAnchorContent;
}
export function getCustomWorldFoundationAnchorContent(
profile: CustomWorldProfile,
) {
const anchorContentRecord = profile.anchorContent;
if (!anchorContentRecord) {
return buildAnchorContentFromProfileFallback(profile);
}
const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise);
const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy);
const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary);
const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint);
const coreConflictRecord = toRecord(anchorContentRecord.coreConflict);
const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines);
const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements);
return {
worldPromise: worldPromiseRecord
? {
hook: toText(worldPromiseRecord.hook),
differentiator: toText(worldPromiseRecord.differentiator),
desiredExperience: toText(worldPromiseRecord.desiredExperience),
}
: null,
playerFantasy: playerFantasyRecord
? {
playerRole: toText(playerFantasyRecord.playerRole),
corePursuit: toText(playerFantasyRecord.corePursuit),
fearOfLoss: toText(playerFantasyRecord.fearOfLoss),
}
: null,
themeBoundary: themeBoundaryRecord
? {
toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords),
aestheticDirectives: toTextArray(
themeBoundaryRecord.aestheticDirectives,
),
forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives),
}
: null,
playerEntryPoint: playerEntryPointRecord
? {
openingIdentity: toText(playerEntryPointRecord.openingIdentity),
openingProblem: toText(playerEntryPointRecord.openingProblem),
entryMotivation: toText(playerEntryPointRecord.entryMotivation),
}
: null,
coreConflict: coreConflictRecord
? {
surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts),
hiddenCrisis: toText(coreConflictRecord.hiddenCrisis),
firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict),
}
: null,
keyRelationships: Array.isArray(anchorContentRecord.keyRelationships)
? anchorContentRecord.keyRelationships
.map((entry) => toRecord(entry))
.filter(Boolean)
.map((entry) => ({
pairs: toText(entry?.pairs),
relationshipType: toText(entry?.relationshipType),
secretOrCost: toText(entry?.secretOrCost),
}))
: [],
hiddenLines: hiddenLinesRecord
? {
hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths),
misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints),
revealPacing: toText(hiddenLinesRecord.revealPacing),
}
: null,
iconicElements: iconicElementsRecord
? {
iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs),
institutionsOrArtifacts: toTextArray(
iconicElementsRecord.institutionsOrArtifacts,
),
hardRules: toTextArray(iconicElementsRecord.hardRules),
}
: null,
} satisfies EightAnchorContent;
}
export function buildCustomWorldFoundationEntries(
profile: CustomWorldProfile,
): CustomWorldFoundationEntry[] {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const anchorContent = getCustomWorldFoundationAnchorContent(profile);
const fallbackRelationshipText =
buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
profile.playableNpcs[0]?.relationshipHooks.join('') ||
profile.storyNpcs[0]?.relationshipHooks.join('') ||
'';
return [
{
id: 'world-promise',
label: '世界承诺',
value: compactFoundationTextList([
anchorContent.worldPromise?.hook || '',
anchorContent.worldPromise?.differentiator || '',
anchorContent.worldPromise?.desiredExperience || '',
]).join(''),
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: compactFoundationTextList([
anchorContent.playerFantasy?.playerRole || '',
anchorContent.playerFantasy?.corePursuit || '',
anchorContent.playerFantasy?.fearOfLoss || '',
]).join(''),
},
{
id: 'theme-boundary',
label: '主题边界',
value: compactFoundationTextList([
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
anchorContent.themeBoundary?.forbiddenDirectives.length
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
]).join(''),
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: compactFoundationTextList([
anchorContent.playerEntryPoint?.openingIdentity || '',
anchorContent.playerEntryPoint?.openingProblem || '',
anchorContent.playerEntryPoint?.entryMotivation || '',
]).join(''),
},
{
id: 'core-conflict',
label: '核心冲突',
value: compactFoundationTextList([
anchorContent.coreConflict?.surfaceConflicts.join('、') || '',
anchorContent.coreConflict?.hiddenCrisis || '',
anchorContent.coreConflict?.firstTouchedConflict || '',
]).join(''),
},
{
id: 'key-relationships',
label: '关键关系',
value:
anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') ||
fallbackRelationshipText,
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: compactFoundationTextList([
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
anchorContent.hiddenLines?.revealPacing || '',
]).join(''),
},
{
id: 'iconic-elements',
label: '标志元素',
value: compactFoundationTextList([
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
anchorContent.iconicElements?.hardRules.join('、') || '',
]).join(''),
},
];
}

View File

@@ -8,28 +8,29 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import {
type ApiRetryOptions,
fetchWithApiAuth,
requestJson,
} from '../apiClient';
import { readCreationAgentSessionFromSse } from '../creation-agent';
import { createCreationAgentClient } from '../creation-agent';
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
const PUZZLE_AGENT_SESSION_START_TIMEOUT_MS = 15000;
const PUZZLE_AGENT_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
maxDelayMs: 480,
};
const PUZZLE_AGENT_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
const puzzleAgentHttpClient = createCreationAgentClient<
CreatePuzzleAgentSessionRequest,
CreatePuzzleAgentSessionResponse,
CreatePuzzleAgentSessionResponse,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
{ session: PuzzleAgentSessionSnapshot },
PuzzleAgentActionRequest,
PuzzleAgentActionResponse
>({
apiBase: PUZZLE_AGENT_API_BASE,
messages: {
createSession: '创建拼图共创会话失败',
getSession: '读取拼图共创会话失败',
sendMessage: '发送拼图共创消息失败',
streamIncomplete: '拼图共创消息流式结果不完整',
executeAction: '执行拼图共创操作失败',
},
});
/**
* 创建拼图 Agent 共创会话。
@@ -38,35 +39,14 @@ const PUZZLE_AGENT_WRITE_RETRY: ApiRetryOptions = {
export async function createPuzzleAgentSession(
payload: CreatePuzzleAgentSessionRequest = {},
) {
return requestJson<CreatePuzzleAgentSessionResponse>(
PUZZLE_AGENT_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建拼图共创会话失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
timeoutMs: PUZZLE_AGENT_SESSION_START_TIMEOUT_MS,
},
);
return puzzleAgentHttpClient.createSession(payload);
}
/**
* 读取拼图 Agent 会话快照。
*/
export async function getPuzzleAgentSession(sessionId: string) {
return requestJson<CreatePuzzleAgentSessionResponse>(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取拼图共创会话失败',
{
retry: PUZZLE_AGENT_READ_RETRY,
},
);
return puzzleAgentHttpClient.getSession(sessionId);
}
/**
@@ -77,18 +57,7 @@ export async function sendPuzzleAgentMessage(
sessionId: string,
payload: SendPuzzleAgentMessageRequest,
) {
return requestJson<{ session: PuzzleAgentSessionSnapshot }>(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发送拼图共创消息失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
},
);
return puzzleAgentHttpClient.sendMessage(sessionId, payload);
}
/**
@@ -100,20 +69,7 @@ export async function streamPuzzleAgentMessage(
payload: SendPuzzleAgentMessageRequest,
options: TextStreamOptions = {},
) {
const response = await openPuzzleAgentSsePost(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,
'发送拼图共创消息失败',
);
return readCreationAgentSessionFromSse<PuzzleAgentSessionSnapshot>(
response,
{
...options,
fallbackMessage: '发送拼图共创消息失败',
incompleteMessage: '拼图共创消息流式结果不完整',
},
);
return puzzleAgentHttpClient.streamMessage(sessionId, payload, options);
}
/**
@@ -124,41 +80,7 @@ export async function executePuzzleAgentAction(
sessionId: string,
payload: PuzzleAgentActionRequest,
) {
return requestJson<PuzzleAgentActionResponse>(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'执行拼图共创操作失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
},
);
}
async function openPuzzleAgentSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
) {
const response = await fetchWithApiAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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;
return puzzleAgentHttpClient.executeAction(sessionId, payload);
}
export const puzzleAgentClient = {

View File

@@ -11,8 +11,10 @@ export {
} from './rpgEntryLibraryClient';
export {
clearRpgProfileBrowseHistory,
createRpgProfileRechargeOrder,
getRpgProfileDashboard,
getRpgProfilePlayStats,
getRpgProfileRechargeCenter,
getRpgProfileSettings,
getRpgProfileWalletLedger,
listRpgProfileBrowseHistory,

View File

@@ -1,9 +1,11 @@
import type {
CreateProfileRechargeOrderResponse,
PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfilePlayStatsResponse,
ProfileRechargeCenterResponse,
ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse,
@@ -67,6 +69,33 @@ export function getRpgProfileWalletLedger(
);
}
export function getRpgProfileRechargeCenter(
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ProfileRechargeCenterResponse>(
'/profile/recharge-center',
{ method: 'GET' },
'读取账户充值失败',
options,
);
}
export function createRpgProfileRechargeOrder(
productId: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
'/profile/recharge/orders',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, paymentChannel: 'mock' }),
},
'充值失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats',
@@ -176,6 +205,8 @@ export const rpgProfileClient = {
getDashboard: getRpgProfileDashboard,
getPlayStats: getRpgProfilePlayStats,
getWalletLedger: getRpgProfileWalletLedger,
getRechargeCenter: getRpgProfileRechargeCenter,
createRechargeOrder: createRpgProfileRechargeOrder,
getSettings: getRpgProfileSettings,
putSettings: putRpgProfileSettings,
listSaveArchives: listRpgProfileSaveArchives,