This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -629,6 +629,104 @@ export async function sendCustomWorldAgentMessage(
);
}
export async function streamCustomWorldAgentMessage(
sessionId: string,
payload: SendCustomWorldAgentMessageRequest,
options: TextStreamOptions = {},
) {
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, '发送共创消息失败'));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalSession: CustomWorldAgentSessionSnapshot | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
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());
}
}
if (dataLines.length === 0) {
continue;
}
const data = dataLines.join('\n');
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(data) as Record<string, unknown>;
} catch {
parsed = null;
}
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = parsed.session as CustomWorldAgentSessionSnapshot;
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: '发送共创消息失败';
throw new Error(message);
}
}
}
if (!finalSession) {
throw new Error('共创消息流式结果不完整');
}
return finalSession;
}
export async function executeCustomWorldAgentAction(
sessionId: string,
payload: CustomWorldAgentActionRequest,

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
@@ -93,6 +94,9 @@ export interface CustomWorldGenerationRoleOutline {
title: string;
role: string;
description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
@@ -107,6 +111,7 @@ export interface CustomWorldGenerationLandmarkConnectionOutline {
export interface CustomWorldGenerationLandmarkOutline {
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
sceneNpcNames: string[];
connections: CustomWorldGenerationLandmarkConnectionOutline[];
@@ -148,6 +153,12 @@ function toFiniteInteger(value: unknown) {
: undefined;
}
function toFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
@@ -198,6 +209,11 @@ function normalizeGeneratedAnimationConfig(
const extension = toText(item.extension);
const file = toText(item.file);
const basePath = toText(item.basePath);
const frameWidth = toFiniteInteger(item.frameWidth);
const frameHeight = toFiniteInteger(item.frameHeight);
const fps = toFiniteNumber(item.fps);
const loop = typeof item.loop === 'boolean' ? item.loop : undefined;
const previewVideoPath = toText(item.previewVideoPath);
return {
folder,
@@ -207,6 +223,11 @@ function normalizeGeneratedAnimationConfig(
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
};
}
@@ -762,6 +783,9 @@ export function buildCustomWorldRawProfileFromFramework(
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
@@ -771,6 +795,9 @@ export function buildCustomWorldRawProfileFromFramework(
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
@@ -778,6 +805,7 @@ export function buildCustomWorldRawProfileFromFramework(
landmarks: framework.landmarks.map((landmark) => ({
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
sceneNpcNames: [...landmark.sceneNpcNames],
connections: landmark.connections.map((connection) => ({
@@ -812,6 +840,9 @@ function normalizeRoleProfile(
title,
role,
description: toText(item.description),
visualDescription: toText(item.visualDescription),
actionDescription: toText(item.actionDescription),
sceneVisualDescription: toText(item.sceneVisualDescription),
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description),
@@ -923,6 +954,9 @@ function normalizeRoleOutlineList(
description:
toText(item.description) ||
truncateText(`${name || title}在世界中以${role}身份活动。`, 36),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
@@ -973,6 +1007,7 @@ function normalizeLandmarkOutlineList(value: unknown) {
description:
toText(item.description) ||
truncateText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
@@ -1033,6 +1068,7 @@ function normalizeLandmarkDraftList(value: unknown) {
id: toText(item.id) || createEntryId('landmark', name, index),
name,
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel),
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
@@ -1165,6 +1201,10 @@ export function normalizeCustomWorldProfile(
item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as WorldStoryGraph)
: null,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as EightAnchorContent)
: null,
creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent),
anchorPack:
item.anchorPack && typeof item.anchorPack === 'object'

View File

@@ -13,6 +13,31 @@ afterEach(() => {
const session: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
currentTurn: 6,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家回到群岛调查沉船真相。',
corePursuit: '找出失控航路背后的真相。',
fearOfLoss: '失去最后一个还能对上旧案的人。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: [],
},
},
progressPercent: 100,
lastAssistantReply: '八锚点已经齐备,可以进入游戏设定草稿生成。',
stage: 'object_refining',
focusCardId: null,
creatorIntent: {

View File

@@ -231,6 +231,7 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,
lockState: session.lockState,

View File

@@ -22,6 +22,31 @@ const baseOperation: CustomWorldAgentOperationRecord = {
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
currentTurn: 8,
anchorContent: {
worldPromise: {
hook: '海雾、旧灯塔和失控航路交织的边缘群岛',
differentiator: '每次借路都要向海雾付出新的代价。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家刚回到群岛,准备调查父亲沉船的真相。',
corePursuit: '查清沉船夜和禁航区异动的因果。',
fearOfLoss: '再失去唯一还敢接近真相的人。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: [],
},
},
progressPercent: 100,
lastAssistantReply: '八锚点已经收束完成,可以进入游戏设定草稿生成。',
stage: 'foundation_review',
focusCardId: null,
creatorIntent: {
@@ -121,11 +146,12 @@ test('builds readable draft setting text from creator intent first', () => {
expect(settingText).toContain('标志元素');
});
test('falls back to latest user message when creator intent is unavailable', () => {
test('falls back to anchor content when creator intent is unavailable', () => {
const settingText = buildAgentDraftFoundationSettingText({
...baseSession,
creatorIntent: null,
});
expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。');
expect(settingText).toContain('世界承诺');
expect(settingText).toContain('玩家幻想');
});

View File

@@ -1,6 +1,7 @@
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
EightAnchorContent,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGenerationProgress,
@@ -11,6 +12,187 @@ import {
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
export type CustomWorldStructuredAnchorEntry = {
id: string;
label: string;
value: string;
};
function joinText(items: Array<string | null | undefined>) {
return items.filter(Boolean).join('');
}
function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
return [
anchorContent.worldPromise
? `世界承诺:${joinText([
anchorContent.worldPromise.hook,
anchorContent.worldPromise.differentiator,
anchorContent.worldPromise.desiredExperience,
])}`
: '',
anchorContent.playerFantasy
? `玩家幻想:${joinText([
anchorContent.playerFantasy.playerRole,
anchorContent.playerFantasy.corePursuit,
anchorContent.playerFantasy.fearOfLoss,
])}`
: '',
anchorContent.themeBoundary
? `主题边界:${joinText([
anchorContent.themeBoundary.toneKeywords.join('、'),
anchorContent.themeBoundary.aestheticDirectives.join('、'),
anchorContent.themeBoundary.forbiddenDirectives.join('、'),
])}`
: '',
anchorContent.playerEntryPoint
? `玩家切入口:${joinText([
anchorContent.playerEntryPoint.openingIdentity,
anchorContent.playerEntryPoint.openingProblem,
anchorContent.playerEntryPoint.entryMotivation,
])}`
: '',
anchorContent.coreConflict
? `核心冲突:${joinText([
anchorContent.coreConflict.surfaceConflicts.join('、'),
anchorContent.coreConflict.hiddenCrisis,
anchorContent.coreConflict.firstTouchedConflict,
])}`
: '',
anchorContent.keyRelationships.length > 0
? `关键关系:${anchorContent.keyRelationships
.map((entry) =>
joinText([entry.pairs, entry.relationshipType, entry.secretOrCost]),
)
.join('')}`
: '',
anchorContent.hiddenLines
? `暗线与揭示:${joinText([
anchorContent.hiddenLines.hiddenTruths.join('、'),
anchorContent.hiddenLines.misdirectionHints.join('、'),
anchorContent.hiddenLines.revealPacing,
])}`
: '',
anchorContent.iconicElements
? `标志元素:${joinText([
anchorContent.iconicElements.iconicMotifs.join('、'),
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
anchorContent.iconicElements.hardRules.join('、'),
])}`
: '',
]
.filter(Boolean)
.join('\n');
}
export function buildAgentDraftFoundationAnchorEntries(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const anchorContent = session.anchorContent;
return [
{
id: 'world-promise',
label: '世界承诺',
value: anchorContent.worldPromise
? joinText([
anchorContent.worldPromise.hook,
anchorContent.worldPromise.differentiator,
anchorContent.worldPromise.desiredExperience,
])
: '',
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: anchorContent.playerFantasy
? joinText([
anchorContent.playerFantasy.playerRole,
anchorContent.playerFantasy.corePursuit,
anchorContent.playerFantasy.fearOfLoss,
])
: '',
},
{
id: 'theme-boundary',
label: '主题边界',
value: anchorContent.themeBoundary
? joinText([
anchorContent.themeBoundary.toneKeywords.join('、'),
anchorContent.themeBoundary.aestheticDirectives.join('、'),
anchorContent.themeBoundary.forbiddenDirectives.length > 0
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
])
: '',
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: anchorContent.playerEntryPoint
? joinText([
anchorContent.playerEntryPoint.openingIdentity,
anchorContent.playerEntryPoint.openingProblem,
anchorContent.playerEntryPoint.entryMotivation,
])
: '',
},
{
id: 'core-conflict',
label: '核心冲突',
value: anchorContent.coreConflict
? joinText([
anchorContent.coreConflict.surfaceConflicts.join('、'),
anchorContent.coreConflict.hiddenCrisis,
anchorContent.coreConflict.firstTouchedConflict,
])
: '',
},
{
id: 'key-relationships',
label: '关键关系',
value:
anchorContent.keyRelationships.length > 0
? anchorContent.keyRelationships
.map((entry) =>
joinText([
entry.pairs,
entry.relationshipType,
entry.secretOrCost ? `代价/秘密:${entry.secretOrCost}` : '',
]),
)
.join('\n')
: '',
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: anchorContent.hiddenLines
? joinText([
anchorContent.hiddenLines.hiddenTruths.join('、'),
anchorContent.hiddenLines.misdirectionHints.join('、'),
anchorContent.hiddenLines.revealPacing,
])
: '',
},
{
id: 'iconic-elements',
label: '标志元素',
value: anchorContent.iconicElements
? joinText([
anchorContent.iconicElements.iconicMotifs.join('、'),
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
anchorContent.iconicElements.hardRules.join('、'),
])
: '',
},
].filter((entry) => entry.value.trim());
}
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{
id: 'queue',
@@ -192,5 +374,11 @@ export function buildAgentDraftFoundationSettingText(
.reverse()
.find((message) => message.role === 'user' && message.text.trim());
return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。';
const anchorSettingText = buildEightAnchorFoundationText(session.anchorContent);
return (
anchorSettingText ||
latestUserMessage?.text.trim() ||
'正在整理当前共创设定。'
);
}

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
clearProfileBrowseHistory,
listProfileBrowseHistory,
syncProfileBrowseHistory,
upsertProfileBrowseHistory,
} from './storageService';
vi.mock('./apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('storageService browse history routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads browse history from the runtime profile route', async () => {
await listProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'GET' }),
'读取浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('writes browse history through the runtime profile route', async () => {
await upsertProfileBrowseHistory({
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'写入浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('syncs browse history through the runtime profile route', async () => {
await syncProfileBrowseHistory([
{
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'同步浏览历史失败',
expect.any(Object),
);
});
it('clears browse history through the runtime profile route', async () => {
await clearProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'DELETE' }),
'清空浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

@@ -61,28 +61,6 @@ function requestRuntimeJson<T>(
);
}
function requestProfileJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options: RuntimeRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry =
options.retry ??
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
return requestJson<T>(
`/api/profile${path}`,
{
...init,
signal: options.signal,
},
fallbackMessage,
{ retry },
);
}
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
@@ -315,8 +293,8 @@ export async function getCustomWorldGalleryDetail(
export async function listProfileBrowseHistory(
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{ method: 'GET' },
'读取浏览历史失败',
options,
@@ -329,8 +307,8 @@ export async function upsertProfileBrowseHistory(
entry: PlatformBrowseHistoryWriteEntry,
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -347,8 +325,8 @@ export async function syncProfileBrowseHistory(
entries: PlatformBrowseHistoryWriteEntry[],
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -366,8 +344,8 @@ export async function syncProfileBrowseHistory(
export async function clearProfileBrowseHistory(
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{ method: 'DELETE' },
'清空浏览历史失败',
options,