This commit is contained in:
2026-04-22 23:44:57 +08:00
parent 76ac9d22a5
commit 84dc92646a
484 changed files with 9598 additions and 9135 deletions

View File

@@ -98,8 +98,8 @@ export function normalizeTags(value: unknown, fallbackTags: string[] = []) {
].slice(0, 5);
}
export function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
export function clampText(value: unknown, maxLength: number) {
const normalized = toText(value).replace(/\s+/g, ' ');
if (!normalized) {
return '';
}

View File

@@ -18,6 +18,7 @@ import { routeMeta } from '../middleware/routeMeta.js';
const BIG_FISH_ROUTE_VERSION = '2026-04-22';
const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100';
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
const BIG_FISH_UPSTREAM_TIMEOUT_MS = 15000;
const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id';
const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret';
@@ -106,12 +107,17 @@ async function proxyBigFishRequest(params: {
: undefined;
let upstreamResponse: globalThis.Response;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, BIG_FISH_UPSTREAM_TIMEOUT_MS);
try {
upstreamResponse = await fetch(upstreamUrl, {
method,
// 这里显式转发“已通过 Node 校验的用户身份”,让 Big Fish 继续由 Rust 真相后端处理。
headers: pickForwardHeaders(request, context, userId),
body,
signal: controller.signal,
});
} catch (error) {
request.log?.error(
@@ -122,7 +128,13 @@ async function proxyBigFishRequest(params: {
},
'big fish upstream request failed',
);
throw upstreamError('大鱼吃小鱼后端暂时不可用');
throw upstreamError(
error instanceof Error && error.name === 'AbortError'
? '大鱼吃小鱼后端响应超时'
: '大鱼吃小鱼后端暂时不可用',
);
} finally {
clearTimeout(timeoutId);
}
prepareApiResponse(request, response, {

View File

@@ -18,6 +18,7 @@ import { routeMeta } from '../middleware/routeMeta.js';
const PUZZLE_ROUTE_VERSION = '2026-04-22';
const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100';
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
const PUZZLE_UPSTREAM_TIMEOUT_MS = 15000;
const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id';
const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret';
@@ -106,11 +107,16 @@ async function proxyPuzzleRequest(params: {
: undefined;
let upstreamResponse: globalThis.Response;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, PUZZLE_UPSTREAM_TIMEOUT_MS);
try {
upstreamResponse = await fetch(upstreamUrl, {
method,
headers: pickForwardHeaders(request, context, userId),
body,
signal: controller.signal,
});
} catch (error) {
request.log?.error(
@@ -121,7 +127,13 @@ async function proxyPuzzleRequest(params: {
},
'puzzle upstream request failed',
);
throw upstreamError('拼图后端暂时不可用');
throw upstreamError(
error instanceof Error && error.name === 'AbortError'
? '拼图后端响应超时'
: '拼图后端暂时不可用',
);
} finally {
clearTimeout(timeoutId);
}
prepareApiResponse(request, response, {

View File

@@ -5,7 +5,11 @@ import type { UpstreamLlmClient } from './llmClient.js';
import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
function createFoundationDraftLlmClient(): UpstreamLlmClient {
function createFoundationDraftLlmClient(
options: {
omitStoryOptionalVisualFields?: boolean;
} = {},
): UpstreamLlmClient {
let roleOutlineBatch = 0;
let landmarkSeedBatch = 0;
let landmarkNetworkBatch = 0;
@@ -68,9 +72,15 @@ function createFoundationDraftLlmClient(): UpstreamLlmClient {
title: '旧友兼宿敌',
role: '沉船商盟引路人',
description: '他像旧友,也像最早知道假航灯秘密的人。',
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
...(options.omitStoryOptionalVisualFields
? {}
: {
visualDescription:
'衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription:
'会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
}),
initialAffinity: 6,
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
tags: ['旧友', '宿敌'],
@@ -80,9 +90,14 @@ function createFoundationDraftLlmClient(): UpstreamLlmClient {
title: '守灯会巡夜官',
role: '守灯会前台接口人',
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
...(options.omitStoryOptionalVisualFields
? {}
: {
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription:
'总把巡夜灯举得很高,不给人躲闪空间。',
}),
initialAffinity: 6,
relationshipHooks: ['会逼玩家更早站队'],
tags: ['守灯会', '巡夜'],
@@ -198,9 +213,15 @@ function createFoundationDraftLlmClient(): UpstreamLlmClient {
title: '旧友兼宿敌',
role: '沉船商盟引路人',
description: '他像旧友,也像最早知道假航灯秘密的人。',
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
...(options.omitStoryOptionalVisualFields
? {}
: {
visualDescription:
'衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription:
'会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
}),
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
tags: ['旧友', '宿敌'],
},
@@ -209,9 +230,14 @@ function createFoundationDraftLlmClient(): UpstreamLlmClient {
title: '守灯会巡夜官',
role: '守灯会前台接口人',
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
...(options.omitStoryOptionalVisualFields
? {}
: {
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription:
'总把巡夜灯举得很高,不给人躲闪空间。',
}),
relationshipHooks: ['会逼玩家更早站队'],
tags: ['守灯会', '巡夜'],
},
@@ -228,9 +254,15 @@ function createFoundationDraftLlmClient(): UpstreamLlmClient {
title: '旧友兼宿敌',
role: '沉船商盟引路人',
description: '他像旧友,也像最早知道假航灯秘密的人。',
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
...(options.omitStoryOptionalVisualFields
? {}
: {
visualDescription:
'衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription:
'会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
}),
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
tags: ['旧友', '宿敌'],
},
@@ -239,9 +271,14 @@ function createFoundationDraftLlmClient(): UpstreamLlmClient {
title: '守灯会巡夜官',
role: '守灯会前台接口人',
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
...(options.omitStoryOptionalVisualFields
? {}
: {
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription:
'总把巡夜灯举得很高,不给人躲闪空间。',
}),
relationshipHooks: ['会逼玩家更早站队'],
tags: ['守灯会', '巡夜'],
},
@@ -322,3 +359,49 @@ test('foundation draft service builds draft fields directly from framework inste
assert.equal(legacyStoryNpcs[0]?.name, '沈砺');
assert.equal(legacyStoryNpcs[0]?.backstory, undefined);
});
test('foundation draft service tolerates missing optional scene role visual fields', async () => {
const service = new CustomWorldAgentFoundationDraftService(
createFoundationDraftLlmClient({
omitStoryOptionalVisualFields: true,
}),
);
const draft = await service.generate({
creatorIntent: {
sourceMode: 'freeform',
rawSettingText: '被海雾反复切开的列岛世界。',
worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。',
themeKeywords: ['海岛', '悬疑'],
toneDirectives: ['冷峻', '潮湿'],
playerPremise: '玩家是被迫返乡的失职守灯人',
openingSituation: '开局时正站在即将熄灭的旧灯塔上',
coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['潮雾钟声', '盐火灯塔'],
forbiddenDirectives: [],
},
anchorPack: {
creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。',
},
});
const normalized = normalizeFoundationDraftProfile(draft);
const legacyResultProfile = (draft as Record<string, unknown>)
.legacyResultProfile as Record<string, unknown> | undefined;
const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs)
? (legacyResultProfile?.storyNpcs as Array<Record<string, unknown>>)
: [];
assert.ok(normalized);
assert.equal(normalized?.storyNpcs.length, 2);
assert.equal(normalized?.storyNpcs[0]?.name, '沈砺');
assert.equal(
normalized?.storyNpcs[0]?.publicMask,
'他像旧友,也像最早知道假航灯秘密的人。',
);
assert.ok((normalized?.storyNpcs[0]?.currentPressure ?? '').trim());
assert.equal(legacyStoryNpcs[0]?.visualDescription, undefined);
});

View File

@@ -61,8 +61,8 @@ function toRecord(value: unknown) {
: null;
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
function clampText(value: unknown, maxLength: number) {
const normalized = toText(value).replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}