1
This commit is contained in:
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user