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

@@ -5,6 +5,7 @@ import type {
CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftThread,
EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
@@ -36,6 +37,13 @@ import {
type CustomWorldCreatorIntentRecord,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
buildCreatorIntentFromEightAnchorContent,
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
buildEightAnchorFoundationText,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import type { UpstreamLlmClient } from './llmClient.js';
function toText(value: unknown) {
@@ -923,7 +931,15 @@ function sanitizeJsonLikeText(text: string) {
function buildFoundationGenerationSeedText(params: {
intent: CustomWorldCreatorIntentRecord;
anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
}) {
const anchorText = params.anchorContent
? buildEightAnchorFoundationText(params.anchorContent)
: '';
if (anchorText) {
return anchorText;
}
const anchorRecord = toRecord(params.anchorPack);
const anchorSummary = toText(anchorRecord?.creatorIntentSummary);
if (anchorSummary) {
@@ -1574,12 +1590,14 @@ async function buildFoundationDraftProfileWithLlm(params: {
llmClient: UpstreamLlmClient;
creatorIntent: CustomWorldCreatorIntentRecord;
anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
signal?: AbortSignal;
onProgress?: DraftProgressCallback;
}) {
const settingText = buildFoundationGenerationSeedText({
intent: params.creatorIntent,
anchorPack: params.anchorPack,
anchorContent: params.anchorContent,
});
await emitDraftProgress(params.onProgress, {
@@ -1720,22 +1738,14 @@ export class CustomWorldAgentFoundationDraftService {
private generateFallbackDraft(params: {
creatorIntent: unknown;
anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
}): CustomWorldFoundationDraftProfile {
const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? {
sourceMode: 'freeform' as const,
rawSettingText: '',
worldHook: '',
themeKeywords: [],
toneDirectives: [],
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
};
const normalizedAnchorContent = normalizeEightAnchorContent(
params.anchorContent,
);
const intent =
normalizeCreatorIntentRecord(params.creatorIntent) ??
buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent);
const anchorPack = toRecord(params.anchorPack);
const worldHook =
clampText(intent.worldHook || intent.rawSettingText, 72) ||
@@ -1757,6 +1767,8 @@ export class CustomWorldAgentFoundationDraftService {
openingSituation,
coreConflict: coreConflicts[0] || '',
});
const anchorDraftTitle =
buildDraftTitleFromEightAnchorContent(normalizedAnchorContent);
const factions = buildFactions({
intent,
coreConflicts,
@@ -1815,7 +1827,10 @@ export class CustomWorldAgentFoundationDraftService {
);
return {
name: worldName,
name:
anchorDraftTitle && anchorDraftTitle !== '未命名草稿'
? anchorDraftTitle
: worldName,
subtitle:
clampText(
[
@@ -1845,6 +1860,7 @@ export class CustomWorldAgentFoundationDraftService {
openingSituation,
iconicElements,
sourceAnchorSummary:
buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) ||
toText(anchorPack?.creatorIntentSummary) ||
buildDraftSummaryFromIntent(intent) ||
summary,
@@ -1854,10 +1870,15 @@ export class CustomWorldAgentFoundationDraftService {
async generate(params: {
creatorIntent: unknown;
anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
signal?: AbortSignal;
onProgress?: DraftProgressCallback;
}): Promise<CustomWorldFoundationDraftProfile> {
const intent = normalizeCreatorIntentRecord(params.creatorIntent);
const intent =
normalizeCreatorIntentRecord(params.creatorIntent) ??
buildCreatorIntentFromEightAnchorContent(
normalizeEightAnchorContent(params.anchorContent),
);
if (!this.llmClient || !intent) {
return this.generateFallbackDraft(params);
@@ -1867,6 +1888,7 @@ export class CustomWorldAgentFoundationDraftService {
llmClient: this.llmClient,
creatorIntent: intent,
anchorPack: params.anchorPack,
anchorContent: params.anchorContent,
signal: params.signal,
onProgress: params.onProgress,
});

View File

@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import type {
CreateCustomWorldAgentSessionRequest,
@@ -14,6 +15,7 @@ import type {
SendCustomWorldAgentMessageResponse,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest, notFound } from '../errors.js';
import { prepareEventStreamResponse } from '../http.js';
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
import {
@@ -31,7 +33,6 @@ import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntit
import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js';
import {
buildAnchorPackFromIntent,
buildCreatorIntentDisplayText,
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord,
@@ -49,11 +50,16 @@ import {
type CustomWorldAgentSessionRecord,
CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.js';
import {
buildAnchorPackFromEightAnchorContent,
buildCreatorIntentFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
estimateProgressPercentFromAnchorContent,
} from './eightAnchorCompatibilityService.js';
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
import type { UpstreamLlmClient } from './llmClient.js';
const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__';
const AUTO_COMPLETE_PATTERN = /||/u;
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) {
return value;
@@ -137,46 +143,10 @@ function buildSuggestedActions(
return actions;
}
function buildAutoCompletePatch(intent: CustomWorldCreatorIntentRecord) {
return {
worldHook:
intent.worldHook ||
intent.rawSettingText ||
'一个被未知异象改变秩序的边境世界。',
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
openingSituation:
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
themeKeywords:
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
toneDirectives:
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
coreConflicts:
intent.coreConflicts.length > 0
? intent.coreConflicts
: ['旧秩序与新威胁正在争夺世界的未来。'],
keyCharacters:
intent.keyCharacters.length > 0
? intent.keyCharacters
: [
{
id: 'auto-key-character-1',
name: '未命名关键人物',
role: '关键关系',
publicMask: '看似能帮助玩家的人。',
hiddenHook: '掌握一条会改变局势的暗线。',
relationToPlayer: '旧识',
notes: '自动补全,可继续修改。',
},
],
iconicElements:
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
};
}
function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
const phaseDetail =
type === 'draft_foundation'
? '正在把已确认锚点编成第一版世界底稿。'
? '正在把已确认设定编成第一版世界底稿。'
: type === 'update_draft_card'
? '正在把这次设定改动写回草稿。'
: type === 'generate_characters'
@@ -184,10 +154,10 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
: type === 'generate_landmarks'
? '正在围绕当前底稿补出新地点。'
: type === 'generate_role_assets'
? '正在准备角色资产工坊入口。'
: type === 'sync_role_assets'
? '正在把角色资产结果写回世界草稿。'
: '正在整理这一轮新增的世界锚点。';
? '正在准备角色资产工坊入口。'
: type === 'sync_role_assets'
? '正在把角色资产结果写回世界草稿。'
: '正在整理这一轮新增的世界设定。';
return {
operationId: `operation-${crypto.randomBytes(10).toString('hex')}`,
@@ -223,20 +193,10 @@ function buildRoleAssetSyncResultText(params: {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
}
function getRecentUserMessages(session: CustomWorldAgentSessionRecord) {
return session.messages
.filter((message) => message.role === 'user')
.map((message) => message.text.trim())
.filter(Boolean)
.slice(-12);
}
function buildQuestionLines(
pendingClarifications: CustomWorldPendingClarification[],
) {
return pendingClarifications.map(
(entry, index) => `${index + 1}. ${entry.question}`,
);
return pendingClarifications.map((entry) => entry.question.trim());
}
function composeAssistantReply(params: {
@@ -250,7 +210,7 @@ function composeAssistantReply(params: {
return [
params.openingText,
params.isReady
? '最小锚点已经齐备。'
? '当前设定已经齐备。'
: questionLines.slice(0, 1).join('\n'),
].join('\n');
}
@@ -311,227 +271,6 @@ function buildWelcomeMessage(params: {
});
}
function buildAssistantMessage(params: {
latestUserText: string;
relatedOperationId: string;
intent: CustomWorldCreatorIntentRecord;
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
}) {
return {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: params.isReady ? 'summary' : 'clarification',
text: composeAssistantReply({
openingText: `收到:${truncateText(params.latestUserText, 88)}`,
intent: params.intent,
pendingClarifications: params.pendingClarifications,
isReady: params.isReady,
}),
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId,
} satisfies CustomWorldAgentMessage;
}
function buildAgentSystemPrompt(params: {
isReady: boolean;
hasAnyAnchors: boolean;
}) {
const baseInstructions = [
'你是一个专业的RPG游戏剧情策划通过对话帮助用户补全结构化世界锚点。',
'',
'# 核心原则',
'- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端',
'- 用中文自然回复,语气专业但友好',
'- 不要重复追问用户已经明确回答过的信息',
'- 每次只聚焦一个关键问题,帮助用户高效推进',
'',
'# 输出格式',
'必须输出严格的 JSON 格式:{“reply”:”...”,”recommendedReplies”:[“...”,”...”,”...”]}',
'',
];
if (params.isReady) {
return [
...baseInstructions,
'# 当前阶段:设定已齐备',
'',
'## reply 字段要求',
'- 第一段:明确回应并收住用户刚刚给出的具体设定',
'- 第二段:明确告诉用户关键设定已经足够,可以生成第一版游戏草稿了',
'- 最后:自然询问是否现在开始生成草稿',
'- 整体要短,聚焦推进',
'',
'## recommendedReplies 字段要求',
'- 必须正好 3 条',
'- 每条都是用户下一句可以直接发送的话',
'- 第 1 条:表达开始生成草稿(例如:”现在开始生成草稿”)',
'- 第 2 条:让 Agent 总结当前设定(例如:”先总结一下当前设定”)',
'- 第 3 条:继续补充设定内容(例如:”我还想再补充一点”)',
].join('\n');
}
// When anchors are empty, use inspirational questioning strategy
if (!params.hasAnyAnchors) {
return [
...baseInstructions,
'# 当前阶段:初始启发',
'',
'## reply 字段要求',
'- 第一段:如果用户刚进入对话还没说话,用欢迎语气开场(例如:”想创造一个什么样的世界?”)',
'- 第一段:如果用户已经说了话,简短回应用户的输入',
'- 第二段:提出一个开放性、启发性的问题,帮助用户构思世界的核心概念',
'- 问题应该是高层次的,关于世界类型、主题、核心理念,而不是具体细节',
'- 例如:世界的整体风格、故事的核心主题、想传达的感觉',
'- 避免过早询问具体设定细节(如魔法系统、科技水平等)',
'',
'## recommendedReplies 字段要求',
'- 必须正好 3 条',
'- 3 条都是对当前问题的不同方向的回答',
'- 每条回答应该代表一种不同的世界类型或主题方向',
'- 回答要具体但不过于详细,给用户启发和选择空间',
].join('\n');
}
return [
...baseInstructions,
'# 当前阶段:收集设定中',
'',
'## reply 字段要求',
'- 第一段:明确回应并收住用户上一次给出的具体落地设定(不能只说”收到”)',
'- 第二段:固定只追问 1 个当前最关键、最能推进游戏设定的问题',
'- 这个问题必须帮助你更快拿到作品最核心的设定信息',
'- 必要时给一个很短的示例,帮助用户高效回答',
'',
'## recommendedReplies 字段要求',
'- 必须正好 3 条',
'- 3 条都必须是对当前这一个问题的直接回答',
'- 不允许继续提问',
'- 不允许写成”你先帮我””继续问我”这种让 Agent 行动的句子',
'- 回答要尽量具体,优先提供能推进作品设定的核心信息',
].join('\n');
}
function buildAgentUserPrompt(params: {
session: CustomWorldAgentSessionRecord;
latestUserText: string;
intent: CustomWorldCreatorIntentRecord;
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
}) {
const recentMessages = params.session.messages
.slice(-18)
.map((message) => `${message.role}: ${message.text}`)
.join('\n');
const pendingQuestions = params.pendingClarifications
.slice(0, 1)
.map((entry) => `${entry.label}: ${entry.question}`)
.join('\n');
return [
'# 当前结构化世界锚点',
buildCreatorIntentDisplayText(params.intent) || '暂无',
'',
`# 锚点是否齐备`,
params.isReady ? '是' : '否',
'',
pendingQuestions ? `# 待确认问题\n${pendingQuestions}\n` : '',
'# 最近对话',
recentMessages || '暂无',
'',
'# 用户最新输入',
params.latestUserText,
]
.filter(Boolean)
.join('\n');
}
function parseAssistantTurnJson(text: string) {
try {
const parsed = JSON.parse(text) as {
reply?: unknown;
recommendedReplies?: unknown;
};
const reply = typeof parsed.reply === 'string' ? parsed.reply.trim() : '';
const recommendedReplies = Array.isArray(parsed.recommendedReplies)
? parsed.recommendedReplies
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, 3)
: [];
return {
reply,
recommendedReplies,
};
} catch {
return {
reply: '',
recommendedReplies: [],
};
}
}
function buildFallbackRecommendedReplies(params: {
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
}) {
if (params.isReady) {
return ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'];
}
const nextQuestion = params.pendingClarifications[0];
if (!nextQuestion) {
return ['继续', '给我一个默认方案', '先总结一下'];
}
if (nextQuestion.targetKey === 'world_hook') {
return [
'一个被潮雾切开的列岛世界。',
'一个旧神遗产复苏的边境世界。',
'一个灯塔决定航路生死的海雾世界。',
];
}
if (nextQuestion.targetKey === 'player_premise') {
return [
'玩家是被迫返乡的失职守灯人。',
'玩家是背着旧案回来的流亡航海士。',
'玩家是被逐出组织的前探路员。',
];
}
if (nextQuestion.targetKey === 'theme_and_tone') {
return [
'整体偏冷峻、潮湿、悬疑。',
'气质偏压迫、克制、带一点宿命感。',
'我想要浪漫外壳下的阴冷悬疑。',
];
}
if (nextQuestion.targetKey === 'core_conflict') {
return [
'核心冲突是旧航路解释权之争。',
'主要危机是被封印的灾难正在重演。',
'核心矛盾是守旧势力和新秩序正面冲突。',
];
}
if (nextQuestion.targetKey === 'relationship_seed') {
return [
'关键人物是玩家的旧友兼宿敌。',
'她表面帮助玩家,其实另有立场。',
'关键钩子是玩家必须再次相信曾经背叛自己的人。',
];
}
return [
'标志性元素是潮雾钟声。',
'标志性规则是夜里不能出海。',
'地标意象是永不熄灭的盐火灯塔。',
];
}
function buildFoundationDraftAssistantMessage(params: {
relatedOperationId: string;
draftProfile: unknown;
@@ -555,31 +294,6 @@ function buildFoundationDraftAssistantMessage(params: {
} satisfies CustomWorldAgentMessage;
}
function buildObjectRefiningAssistantMessage(params: {
latestUserText: string;
relatedOperationId: string;
draftProfile: unknown;
}) {
const profile = normalizeFoundationDraftProfile(params.draftProfile);
const leadCharacter = profile?.playableNpcs[0];
const leadLandmark = profile?.landmarks[0];
return {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'summary',
text: [
`我先把你这轮补充挂回当前底稿语境里:${truncateText(params.latestUserText, 88)}`,
'',
profile?.summary || '当前底稿仍然保留,你可以继续围绕已有卡片精修。',
'',
`现在更适合直接看卡继续收紧内容${leadCharacter ? `,角色建议先看「${leadCharacter.name}` : ''}${leadLandmark ? `,地点建议先看「${leadLandmark.name}` : ''}`,
].join('\n'),
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId,
} satisfies CustomWorldAgentMessage;
}
function buildActionResultMessage(params: {
relatedOperationId: string;
text: string;
@@ -594,6 +308,19 @@ function buildActionResultMessage(params: {
} satisfies CustomWorldAgentMessage;
}
function writeSseEvent(
response: Response,
event: string,
data: unknown,
) {
if (response.writableEnded) {
return;
}
response.write(`event: ${event}\n`);
response.write(`data: ${JSON.stringify(data)}\n\n`);
}
export class CustomWorldAgentOrchestrator {
private readonly foundationDraftService: CustomWorldAgentFoundationDraftService;
@@ -605,9 +332,14 @@ export class CustomWorldAgentOrchestrator {
private readonly assetBridgeService: CustomWorldAgentAssetBridgeService;
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService;
constructor(
private readonly sessionStore: CustomWorldAgentSessionStore,
private readonly llmClient: UpstreamLlmClient | null = null,
llmClient: UpstreamLlmClient | null = null,
options: {
singleTurnLlmClient?: UpstreamLlmClient | null;
} = {},
) {
this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
llmClient,
@@ -618,6 +350,9 @@ export class CustomWorldAgentOrchestrator {
);
this.changeSummaryService = new CustomWorldAgentChangeSummaryService();
this.assetBridgeService = new CustomWorldAgentAssetBridgeService();
this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService(
(options.singleTurnLlmClient ?? llmClient) ?? undefined,
);
}
async createSession(
@@ -634,59 +369,35 @@ export class CustomWorldAgentOrchestrator {
: {};
const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch);
const derivedState = buildDerivedState(creatorIntent, Boolean(seedText));
const anchorContent = buildEightAnchorContentFromCreatorIntent(creatorIntent);
const progressPercent = seedText
? estimateProgressPercentFromAnchorContent(anchorContent)
: 0;
const fallbackWelcomeMessage = buildWelcomeMessage({
seedText,
intent: creatorIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const initialAssistantTurn = await this.generateAssistantTurn({
session: {
sessionId: 'preview',
userId,
seedText,
stage: derivedState.stage,
focusCardId: null,
creatorIntent,
creatorIntentReadiness: derivedState.readiness,
anchorPack: derivedState.anchorPack,
lockState: {},
draftProfile: derivedState.draftProfile,
messages: [],
draftCards: [],
pendingClarifications: derivedState.pendingClarifications,
suggestedActions: derivedState.suggestedActions,
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
operations: [],
checkpoints: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
latestUserText: seedText,
fallbackReply: fallbackWelcomeMessage,
intent: creatorIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const record = await this.sessionStore.create(userId, {
seedText,
welcomeMessage: initialAssistantTurn.reply,
welcomeMessage: fallbackWelcomeMessage,
currentTurn: 0,
anchorContent,
progressPercent,
lastAssistantReply: fallbackWelcomeMessage,
creatorIntent,
creatorIntentReadiness: derivedState.readiness,
anchorPack: derivedState.anchorPack,
anchorPack: buildAnchorPackFromEightAnchorContent(
anchorContent,
progressPercent,
),
draftProfile: derivedState.draftProfile,
pendingClarifications: derivedState.pendingClarifications,
stage: derivedState.stage,
stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent',
suggestedActions: derivedState.suggestedActions,
recommendedReplies: initialAssistantTurn.recommendedReplies,
recommendedReplies: [],
});
return (await this.sessionStore.getSnapshot(
@@ -723,6 +434,7 @@ export class CustomWorldAgentOrchestrator {
sessionId,
operationId: operation.operationId,
latestUserText: trimmedText,
quickFillRequested: Boolean(payload.quickFillRequested),
});
return {
@@ -730,6 +442,68 @@ export class CustomWorldAgentOrchestrator {
};
}
async streamMessage(params: {
request: Request;
response: Response;
userId: string;
sessionId: string;
payload: SendCustomWorldAgentMessageRequest;
}) {
const session = await this.sessionStore.get(params.userId, params.sessionId);
if (!session) {
throw notFound('custom world agent session not found');
}
prepareEventStreamResponse(params.request, params.response);
const trimmedText = params.payload.text.trim();
const userMessage = buildUserMessage(
trimmedText,
params.payload.clientMessageId,
);
await this.sessionStore.appendMessage(
params.userId,
params.sessionId,
userMessage,
);
let latestReplyText = '';
try {
const nextSession = await this.applyMessageTurn({
userId: params.userId,
sessionId: params.sessionId,
latestUserText: trimmedText,
quickFillRequested: Boolean(params.payload.quickFillRequested),
relatedOperationId: null,
onReplyUpdate: (text) => {
if (!text.trim() || text === latestReplyText) {
return;
}
latestReplyText = text;
writeSseEvent(params.response, 'reply_delta', {
text,
});
},
});
writeSseEvent(params.response, 'session', {
session: nextSession,
});
writeSseEvent(params.response, 'done', {
ok: true,
});
} catch (error) {
writeSseEvent(params.response, 'error', {
message:
error instanceof Error ? error.message : 'stream custom world message failed',
});
} finally {
params.response.end();
}
}
async executeAction(
userId: string,
sessionId: string,
@@ -741,14 +515,8 @@ export class CustomWorldAgentOrchestrator {
}
if (payload.action === 'draft_foundation') {
if (session.stage !== 'foundation_review') {
throw badRequest(
'draft_foundation is only available during foundation_review',
);
}
if (!session.creatorIntentReadiness.isReady) {
throw badRequest('draft_foundation requires a ready session');
if (session.progressPercent < 100) {
throw badRequest('draft_foundation requires progressPercent >= 100');
}
const operation = buildOperation('draft_foundation');
@@ -919,57 +687,151 @@ export class CustomWorldAgentOrchestrator {
return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId);
}
private async generateAssistantTurn(params: {
session: CustomWorldAgentSessionRecord;
private async applyMessageTurn(params: {
userId: string;
sessionId: string;
latestUserText: string;
fallbackReply: string;
intent: CustomWorldCreatorIntentRecord;
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
quickFillRequested: boolean;
relatedOperationId?: string | null;
onReplyUpdate?: (text: string) => void;
}) {
const fallbackReplies = buildFallbackRecommendedReplies({
pendingClarifications: params.pendingClarifications,
isReady: params.isReady,
const latestSession = (await this.sessionStore.get(
params.userId,
params.sessionId,
)) as CustomWorldAgentSessionRecord | null;
if (!latestSession) {
throw new Error('custom world agent session not found');
}
const shouldPreserveDraftStage =
(latestSession.stage === 'object_refining' ||
latestSession.stage === 'visual_refining') &&
latestSession.draftCards.length > 0;
const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn(
{
currentTurn: latestSession.currentTurn + 1,
progressPercent: latestSession.progressPercent,
quickFillRequested: params.quickFillRequested,
currentAnchorContent: latestSession.anchorContent,
chatHistory: latestSession.messages
.filter(
(message): message is CustomWorldAgentMessage =>
(message.role === 'user' || message.role === 'assistant') &&
Boolean(message.text.trim()),
)
.map((message) => ({
role: message.role,
content: message.text,
})),
},
{
onReplyUpdate: params.onReplyUpdate,
},
);
const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent(
assistantTurn.nextAnchorContent,
);
const progressPercent = Math.max(
0,
Math.min(100, Math.round(assistantTurn.progressPercent)),
);
const creatorIntentReadiness =
progressPercent >= 100
? {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}
: evaluateCreatorIntentReadiness(nextCreatorIntent);
const derivedState = buildDerivedState(nextCreatorIntent, true);
const preservedStage =
latestSession.stage === 'visual_refining'
? ('visual_refining' as const)
: ('object_refining' as const);
const shouldStayInDraftStage =
shouldPreserveDraftStage && progressPercent >= 100;
const nextStage = shouldStayInDraftStage
? preservedStage
: derivedState.stage;
const assistantMessage = {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'chat',
text: assistantTurn.replyText,
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId ?? null,
} satisfies CustomWorldAgentMessage;
await this.sessionStore.replaceDerivedState(params.userId, params.sessionId, {
currentTurn: latestSession.currentTurn + 1,
anchorContent: assistantTurn.nextAnchorContent,
progressPercent,
lastAssistantReply: assistantTurn.replyText,
stage: nextStage,
focusCardId: shouldStayInDraftStage ? latestSession.focusCardId : null,
creatorIntent: nextCreatorIntent,
creatorIntentReadiness,
anchorPack: buildAnchorPackFromEightAnchorContent(
assistantTurn.nextAnchorContent,
progressPercent,
),
draftProfile: shouldStayInDraftStage
? latestSession.draftProfile
: progressPercent >= 100
? {
title: buildDraftTitleFromIntent(nextCreatorIntent),
summary: buildDraftSummaryFromIntent(nextCreatorIntent),
}
: derivedState.draftProfile,
draftCards: shouldStayInDraftStage ? latestSession.draftCards : [],
assetCoverage: shouldStayInDraftStage
? latestSession.assetCoverage
: rebuildRoleAssetCoverage(
progressPercent >= 100
? {
title: buildDraftTitleFromIntent(nextCreatorIntent),
summary: buildDraftSummaryFromIntent(nextCreatorIntent),
}
: derivedState.draftProfile,
),
pendingClarifications:
progressPercent >= 100 ? [] : derivedState.pendingClarifications,
suggestedActions: shouldStayInDraftStage
? buildSuggestedActions({
stage: preservedStage,
isReady: true,
draftProfile: latestSession.draftProfile,
draftCards: latestSession.draftCards,
})
: progressPercent >= 100
? [
{
id: 'draft_foundation',
type: 'draft_foundation',
label: '生成游戏设定草稿',
},
]
: [],
recommendedReplies: [],
});
await this.sessionStore.appendMessage(
params.userId,
params.sessionId,
assistantMessage,
);
if (!this.llmClient) {
return {
reply: params.fallbackReply,
recommendedReplies: fallbackReplies,
};
}
try {
const content = await this.llmClient.requestMessageContent({
systemPrompt: buildAgentSystemPrompt({
isReady: params.isReady,
hasAnyAnchors: hasMeaningfulCreatorIntentRecord(params.intent),
}),
userPrompt: buildAgentUserPrompt({
session: params.session,
latestUserText: params.latestUserText,
intent: params.intent,
pendingClarifications: params.pendingClarifications,
isReady: params.isReady,
}),
timeoutMs: 60000,
debugLabel: 'custom-world-agent-chat-turn',
});
const parsed = parseAssistantTurnJson(content);
return {
reply: parsed.reply || params.fallbackReply,
recommendedReplies:
parsed.recommendedReplies.length === 3
? parsed.recommendedReplies
: fallbackReplies,
};
} catch {
return {
reply: params.fallbackReply,
recommendedReplies: fallbackReplies,
};
}
return (await this.sessionStore.getSnapshot(
params.userId,
params.sessionId,
)) as CustomWorldAgentSessionSnapshot;
}
private async processDraftFoundationOperation(params: {
@@ -983,7 +845,7 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
phaseDetail: '正在根据已确认设定编译第一版世界结构。',
progress: 38,
});
@@ -997,16 +859,22 @@ export class CustomWorldAgentOrchestrator {
throw new Error('custom world agent session not found');
}
if (
latestSession.stage !== 'foundation_review' ||
!latestSession.creatorIntentReadiness.isReady
) {
throw new Error('session is not ready for draft_foundation');
if (latestSession.progressPercent < 100) {
throw new Error('session progressPercent is below 100');
}
const creatorIntent = buildCreatorIntentFromEightAnchorContent(
latestSession.anchorContent,
);
const anchorPack = buildAnchorPackFromEightAnchorContent(
latestSession.anchorContent,
latestSession.progressPercent,
);
const draftProfile = await this.foundationDraftService.generate({
creatorIntent: latestSession.creatorIntent,
anchorPack: latestSession.anchorPack,
creatorIntent,
anchorPack,
anchorContent: latestSession.anchorContent,
onProgress: async (progress) => {
await this.sessionStore.updateOperation(
userId,
@@ -1040,6 +908,8 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.replaceDerivedState(userId, sessionId, {
stage: nextStage,
creatorIntent,
anchorPack,
draftProfile: draftProfile as unknown as Record<string, unknown>,
draftCards,
assetCoverage,
@@ -1070,7 +940,7 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed',
phaseLabel: '底稿生成失败',
phaseDetail: '这一轮没有成功把锚点编成世界底稿。',
phaseDetail: '这一轮没有成功把设定编成世界底稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'draft foundation failed',
@@ -1596,14 +1466,23 @@ export class CustomWorldAgentOrchestrator {
sessionId: string;
operationId: string;
latestUserText: string;
quickFillRequested: boolean;
}) {
const { userId, sessionId, operationId, latestUserText } = params;
const {
userId,
sessionId,
operationId,
latestUserText,
quickFillRequested,
} = params;
try {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running',
phaseLabel: '提取世界锚点',
phaseDetail: '正在把这轮自然语言补充整理成结构化创作意图。',
phaseLabel: quickFillRequested ? '补全剩余设定' : '整理当前设定',
phaseDetail: quickFillRequested
? '正在基于当前方向补齐剩余设定。'
: '正在把这轮输入沉淀成新的完整设定。',
progress: 45,
});
@@ -1621,107 +1500,27 @@ export class CustomWorldAgentOrchestrator {
throw new Error('custom world agent session not found');
}
const currentIntent =
normalizeCreatorIntentRecord(latestSession.creatorIntent) ??
createEmptyCreatorIntentRecord('freeform');
const recentMessages = getRecentUserMessages(latestSession).slice(0, -1);
const intentPatch = extractCreatorIntentPatch({
currentIntent,
latestUserMessage: latestUserText,
recentMessages,
});
const nextIntent = mergeCreatorIntentRecord(
currentIntent,
AUTO_COMPLETE_PATTERN.test(latestUserText)
? {
...intentPatch,
...buildAutoCompletePatch(currentIntent),
}
: intentPatch,
);
const derivedState = buildDerivedState(nextIntent, true);
const shouldPreserveDraftStage =
(latestSession.stage === 'object_refining' ||
latestSession.stage === 'visual_refining') &&
latestSession.draftCards.length > 0;
const preservedStage =
latestSession.stage === 'visual_refining'
? ('visual_refining' as const)
: ('object_refining' as const);
const nextSuggestedActions = shouldPreserveDraftStage
? buildSuggestedActions({
stage: preservedStage,
isReady: true,
draftProfile: latestSession.draftProfile,
draftCards: latestSession.draftCards,
})
: derivedState.suggestedActions;
await this.sessionStore.replaceDerivedState(userId, sessionId, {
stage: shouldPreserveDraftStage ? preservedStage : derivedState.stage,
creatorIntent: nextIntent,
creatorIntentReadiness: derivedState.readiness,
anchorPack: derivedState.anchorPack,
draftProfile: shouldPreserveDraftStage
? latestSession.draftProfile
: derivedState.draftProfile,
pendingClarifications: shouldPreserveDraftStage
? latestSession.pendingClarifications
: derivedState.pendingClarifications,
suggestedActions: nextSuggestedActions,
draftCards: shouldPreserveDraftStage
? latestSession.draftCards
: undefined,
});
const fallbackAssistantMessage = shouldPreserveDraftStage
? buildObjectRefiningAssistantMessage({
latestUserText,
relatedOperationId: operationId,
draftProfile: latestSession.draftProfile,
})
: buildAssistantMessage({
latestUserText,
relatedOperationId: operationId,
intent: nextIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const assistantTurn = shouldPreserveDraftStage
? {
reply: fallbackAssistantMessage.text,
recommendedReplies: [] as string[],
}
: await this.generateAssistantTurn({
session: latestSession,
latestUserText,
fallbackReply: fallbackAssistantMessage.text,
intent: nextIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const assistantMessage = {
...fallbackAssistantMessage,
text: assistantTurn.reply,
};
const recommendedReplies = assistantTurn.recommendedReplies;
await this.sessionStore.appendMessage(
await this.applyMessageTurn({
userId,
sessionId,
assistantMessage,
);
await this.sessionStore.replaceDerivedState(userId, sessionId, {
recommendedReplies,
latestUserText,
quickFillRequested,
relatedOperationId: operationId,
});
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'completed',
phaseLabel: '锚点已更新',
phaseLabel: '设定已更新',
phaseDetail: shouldPreserveDraftStage
? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。'
: derivedState.readiness.isReady
? '最小锚点已齐备,可以进入下一阶段。'
: '这一轮的创作锚点和澄清问题已经同步完成。',
: quickFillRequested
? '剩余设定已补全,现在可以进入游戏设定草稿生成。'
: '这一轮的设定更新已经完成。',
progress: 100,
error: null,
});
@@ -1729,7 +1528,7 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed',
phaseLabel: '处理失败',
phaseDetail: '这一轮消息没有成功沉淀为创作锚点。',
phaseDetail: '这一轮消息没有成功沉淀为当前设定。',
progress: 100,
error:
error instanceof Error ? error.message : 'process message failed',

View File

@@ -13,6 +13,7 @@ import {
} from './customWorldAgentIntentExtractionService.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
@@ -178,7 +179,9 @@ test('phase2 clarification service only keeps the top highest leverage gap', ()
test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase2-ready';
const createdSession = await orchestrator.createSession(userId, {
@@ -193,6 +196,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
),
//u,
);
assert.ok(
createdSession.messages[0]?.text.includes('1.') === false,
);
const message1 = await orchestrator.submitMessage(
userId,
@@ -246,7 +252,7 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
snapshot?.messages.some(
(message) =>
message.role === 'assistant' &&
message.text.includes('最小锚点已经齐备'),
/|稿/u.test(message.text),
),
);
});
@@ -254,7 +260,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
test('phase2 work summaries compile draft title and summary from creator intent', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase2-summary';
const createdSession = await orchestrator.createSession(userId, {

View File

@@ -5,6 +5,7 @@ import type { CustomWorldSessionRecord } from '../../../packages/shared/src/cont
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
@@ -151,7 +152,9 @@ async function createReadySession(
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase3-draft';
const readySession = await createReadySession(orchestrator, userId);
@@ -209,7 +212,9 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
test('phase3 draft_foundation rejects not-ready session', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase3-not-ready';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
@@ -220,14 +225,16 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
orchestrator.executeAction(userId, createdSession.sessionId, {
action: 'draft_foundation',
}),
/ready session|foundation_review/u,
/progressPercent >= 100|draft_foundation/u,
);
});
test('phase3 work summaries prefer compiled foundation draft fields', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase3-summary';
const readySession = await createReadySession(orchestrator, userId);

View File

@@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
@@ -161,7 +162,9 @@ async function createObjectRefiningSession(
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-edit';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
@@ -220,7 +223,9 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-characters';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
@@ -274,7 +279,9 @@ test('phase4 generate_characters appends story npcs and updates work summary cou
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-landmarks';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;

View File

@@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
@@ -160,7 +161,9 @@ async function createObjectRefiningSession(
test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase5-generate-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterIds = session.draftCards
@@ -201,7 +204,9 @@ test('phase5 generate_role_assets only allows a single role and moves session in
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');

View File

@@ -1,15 +1,16 @@
import crypto from 'node:crypto';
import type {
CustomWorldAssetCoverageSummary,
CreatorIntentReadiness,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldAgentStage,
CustomWorldAssetCoverageSummary,
CustomWorldDraftCardSummary,
CustomWorldPendingClarification,
CustomWorldSuggestedAction,
EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
@@ -19,15 +20,19 @@ import {
resolveCreatorIntentStage,
} from './customWorldAgentClarificationService.js';
import {
buildAnchorPackFromIntent,
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord,
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
import {
buildAnchorPackFromEightAnchorContent,
buildCreatorIntentFromEightAnchorContent,
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
createEmptyEightAnchorContent,
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-';
@@ -36,6 +41,10 @@ export type CustomWorldAgentSessionRecord = {
sessionId: string;
userId: string;
seedText: string;
currentTurn: number;
anchorContent: EightAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
@@ -69,6 +78,10 @@ export type CustomWorldAgentSessionRecord = {
type CreateSessionInput = {
seedText?: string;
welcomeMessage: string;
currentTurn?: number;
anchorContent?: EightAnchorContent;
progressPercent?: number;
lastAssistantReply?: string | null;
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
creatorIntentReadiness?: CreatorIntentReadiness;
@@ -169,20 +182,95 @@ function hasUserInput(record: CustomWorldAgentSessionRecord) {
}
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
const existingIntent =
normalizeCreatorIntentRecord(record.creatorIntent) ??
createEmptyCreatorIntentRecord('freeform');
const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent(
normalizeEightAnchorContent(
(record as Record<string, unknown>).anchorContent ?? null,
),
);
if (!record.seedText.trim()) {
return existingIntent;
if (
compatibleAnchorIntent &&
(compatibleAnchorIntent.worldHook ||
compatibleAnchorIntent.rawSettingText ||
compatibleAnchorIntent.playerPremise ||
compatibleAnchorIntent.openingSituation ||
compatibleAnchorIntent.coreConflicts.length > 0 ||
compatibleAnchorIntent.keyCharacters.length > 0 ||
compatibleAnchorIntent.iconicElements.length > 0)
) {
return compatibleAnchorIntent;
}
const seedPatch = extractCreatorIntentPatch({
currentIntent: existingIntent,
latestUserMessage: record.seedText,
});
return normalizeCreatorIntentRecord(record.creatorIntent);
}
return mergeCreatorIntentRecord(existingIntent, seedPatch);
function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) {
if (typeof (record as Record<string, unknown>).currentTurn === 'number') {
return Math.max(
0,
Math.round((record as Record<string, unknown>).currentTurn as number),
);
}
return record.messages.filter((message) => message.role === 'user').length;
}
function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) {
const normalized = normalizeEightAnchorContent(
(record as Record<string, unknown>).anchorContent ?? null,
);
if (
normalized.worldPromise ||
normalized.playerFantasy ||
normalized.themeBoundary ||
normalized.playerEntryPoint ||
normalized.coreConflict ||
normalized.keyRelationships.length > 0 ||
normalized.hiddenLines ||
normalized.iconicElements
) {
return normalized;
}
return buildEightAnchorContentFromCreatorIntent(
buildCompatibleCreatorIntent(record),
);
}
function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) {
const rawProgress = (record as Record<string, unknown>).progressPercent;
if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) {
return Math.max(0, Math.min(100, Math.round(rawProgress)));
}
if (
record.stage === 'foundation_review' ||
record.stage === 'object_refining' ||
record.stage === 'visual_refining' ||
record.stage === 'long_tail_review' ||
record.stage === 'ready_to_publish' ||
record.stage === 'published'
) {
return 100;
}
return estimateProgressPercentFromAnchorContent(
buildCompatibleAnchorContent(record),
);
}
function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) {
const existingReply = (record as Record<string, unknown>).lastAssistantReply;
if (typeof existingReply === 'string') {
return existingReply;
}
const lastAssistantMessage = [...record.messages]
.reverse()
.find((message) => message.role === 'assistant' && message.text.trim());
return lastAssistantMessage?.text ?? null;
}
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
@@ -239,8 +327,8 @@ function buildCompatiblePendingClarifications(
function buildCompatibleDraftProfile(
record: CustomWorldAgentSessionRecord,
creatorIntent: ReturnType<typeof buildCompatibleCreatorIntent>,
) {
const anchorContent = buildCompatibleAnchorContent(record);
const existingDraftProfile = toRecord(record.draftProfile);
const hasFoundationContent = Boolean(
existingDraftProfile &&
@@ -258,20 +346,21 @@ function buildCompatibleDraftProfile(
name:
toText(existingDraftProfile?.name) ||
toText(existingDraftProfile?.title) ||
buildDraftTitleFromIntent(creatorIntent),
buildDraftTitleFromEightAnchorContent(anchorContent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
buildDraftSummaryFromEightAnchorContent(anchorContent),
};
}
return {
...(existingDraftProfile ?? {}),
title:
toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent),
toText(existingDraftProfile?.title) ||
buildDraftTitleFromEightAnchorContent(anchorContent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
buildDraftSummaryFromEightAnchorContent(anchorContent),
};
}
@@ -381,35 +470,58 @@ function buildCompatibleAssetCoverage(
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
const creatorIntent = buildCompatibleCreatorIntent(record);
const creatorIntentReadiness = evaluateCreatorIntentReadiness(creatorIntent);
const currentTurn = buildCompatibleCurrentTurn(record);
const anchorContent = buildCompatibleAnchorContent(record);
const progressPercent = buildCompatibleProgressPercent(record);
const lastAssistantReply = buildCompatibleLastAssistantReply(record);
const creatorIntentReadiness =
progressPercent >= 100
? {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}
: evaluateCreatorIntentReadiness(creatorIntent);
const stage =
record.stage === 'collecting_intent' ||
record.stage === 'clarifying' ||
record.stage === 'foundation_review'
? resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
})
: record.stage;
record.stage === 'object_refining' ||
record.stage === 'visual_refining' ||
record.stage === 'long_tail_review' ||
record.stage === 'ready_to_publish' ||
record.stage === 'published'
? record.stage
: progressPercent >= 100
? ('foundation_review' as const)
: resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
});
const pendingClarifications = buildCompatiblePendingClarifications({
...record,
creatorIntent,
creatorIntentReadiness,
});
const draftProfile = buildCompatibleDraftProfile(record, creatorIntent);
const draftProfile = buildCompatibleDraftProfile(record);
return {
...record,
currentTurn,
anchorContent,
progressPercent,
lastAssistantReply,
stage,
creatorIntent,
creatorIntentReadiness,
anchorPack:
record.anchorPack && Object.keys(record.anchorPack).length > 0
? record.anchorPack
: buildAnchorPackFromIntent(creatorIntent, {
completedKeys: creatorIntentReadiness.completedKeys,
missingKeys: creatorIntentReadiness.missingKeys,
}),
: buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent),
draftProfile,
pendingClarifications,
suggestedActions: buildCompatibleSuggestedActions({
@@ -430,6 +542,10 @@ function toSnapshot(
): CustomWorldAgentSessionSnapshot {
return {
sessionId: record.sessionId,
currentTurn: record.currentTurn,
anchorContent: cloneRecord(record.anchorContent),
progressPercent: record.progressPercent,
lastAssistantReply: record.lastAssistantReply,
stage: record.stage,
focusCardId: record.focusCardId,
creatorIntent: cloneRecord(record.creatorIntent),
@@ -491,6 +607,15 @@ export class CustomWorldAgentSessionStore {
sessionId,
userId,
seedText: input.seedText?.trim() ?? '',
currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)),
anchorContent: normalizeEightAnchorContent(
input.anchorContent ?? createEmptyEightAnchorContent(),
),
progressPercent: Math.max(
0,
Math.min(100, Math.round(input.progressPercent ?? 0)),
),
lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage,
stage: input.stage ?? 'collecting_intent',
focusCardId: null,
creatorIntent: cloneRecord(input.creatorIntent ?? {}),
@@ -567,6 +692,10 @@ export class CustomWorldAgentSessionStore {
patch: Partial<
Pick<
CustomWorldAgentSessionRecord,
| 'currentTurn'
| 'anchorContent'
| 'progressPercent'
| 'lastAssistantReply'
| 'stage'
| 'creatorIntent'
| 'creatorIntentReadiness'
@@ -584,6 +713,21 @@ export class CustomWorldAgentSessionStore {
>,
) {
return this.mutate(userId, sessionId, (record) => {
if (typeof patch.currentTurn === 'number') {
record.currentTurn = Math.max(0, Math.round(patch.currentTurn));
}
if (patch.anchorContent !== undefined) {
record.anchorContent = normalizeEightAnchorContent(patch.anchorContent);
}
if (typeof patch.progressPercent === 'number') {
record.progressPercent = Math.max(
0,
Math.min(100, Math.round(patch.progressPercent)),
);
}
if (patch.lastAssistantReply !== undefined) {
record.lastAssistantReply = patch.lastAssistantReply;
}
if (patch.stage) {
record.stage = patch.stage;
}

View File

@@ -0,0 +1,321 @@
import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { UpstreamLlmClient } from './llmClient.js';
import {
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
buildCreatorIntentFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
createEmptyEightAnchorContent,
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
type TestChatMessage = {
role: 'user' | 'assistant';
content: string;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function shouldReplaceWorldPromise(params: {
latestUserText: string;
hasExistingWorldPromise: boolean;
}) {
if (!params.hasExistingWorldPromise) {
return true;
}
return /(|||||||||)/u.test(
params.latestUserText,
);
}
function buildAutoCompletePatch(intent: ReturnType<
typeof buildCreatorIntentFromEightAnchorContent
>) {
return {
worldHook:
intent.worldHook ||
intent.rawSettingText ||
'一个被未知异象改变秩序的边境世界。',
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
openingSituation:
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
themeKeywords:
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
toneDirectives:
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
coreConflicts:
intent.coreConflicts.length > 0
? intent.coreConflicts
: ['旧秩序与新威胁正在争夺世界的未来。'],
keyCharacters:
intent.keyCharacters.length > 0
? intent.keyCharacters
: [
{
id: 'auto-key-character-1',
name: '未命名关键人物',
role: '关键关系',
publicMask: '看似能帮助玩家的人。',
hiddenHook: '掌握一条会改变局势的暗线。',
relationToPlayer: '旧识',
notes: '自动补全,可继续修改。',
},
],
iconicElements:
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
};
}
function buildReplyText(params: {
nextAnchorContent: EightAnchorContent;
progressPercent: number;
quickFillRequested: boolean;
latestUserText: string;
}) {
if (params.quickFillRequested || params.progressPercent >= 100) {
return '这一版已经收住了,现在可以直接生成游戏设定草稿。';
}
if (/(|||)/u.test(params.latestUserText)) {
return '我已经按你刚刚修正后的方向重收了一版,现在这条主线会更稳。';
}
if (!params.nextAnchorContent.worldPromise?.hook) {
return '方向我先接住了一点。这个世界最抓人的那句核心设定,你想怎么钉住?';
}
if (!params.nextAnchorContent.playerFantasy?.playerRole) {
return '世界底色已经有了。你最想让玩家以什么身份卷进来?';
}
if (!params.nextAnchorContent.playerEntryPoint?.openingProblem) {
return '大方向先稳住了。故事开场时,玩家先撞上的麻烦是什么?';
}
if (!params.nextAnchorContent.coreConflict?.surfaceConflicts.length) {
return '现在气质和身份都更清楚了。接下来最值得钉住的,是这个世界正在爆开的主要冲突。';
}
return '这轮信息我已经收进当前版本里了,你可以继续往下补,也可以让我顺着这条线继续收束。';
}
function extractJsonBlock(text: string, marker: string) {
const markerIndex = text.indexOf(marker);
if (markerIndex < 0) {
return null;
}
let startIndex = markerIndex + marker.length;
while (startIndex < text.length && /\s/u.test(text[startIndex] ?? '')) {
startIndex += 1;
}
const firstCharacter = text[startIndex];
if (firstCharacter !== '{' && firstCharacter !== '[') {
return null;
}
const closingCharacter = firstCharacter === '{' ? '}' : ']';
let depth = 0;
let insideString = false;
let escaping = false;
for (let index = startIndex; index < text.length; index += 1) {
const character = text[index] ?? '';
if (insideString) {
if (escaping) {
escaping = false;
continue;
}
if (character === '\\') {
escaping = true;
continue;
}
if (character === '"') {
insideString = false;
}
continue;
}
if (character === '"') {
insideString = true;
continue;
}
if (character === firstCharacter) {
depth += 1;
continue;
}
if (character === closingCharacter) {
depth -= 1;
if (depth === 0) {
return text.slice(startIndex, index + 1);
}
}
}
return null;
}
function parsePromptInput(text: string) {
const anchorJson = extractJsonBlock(text, '当前完整设定结构:');
const chatJson = extractJsonBlock(text, '用户聊天记录:');
const currentAnchorContent = anchorJson
? normalizeEightAnchorContent(JSON.parse(anchorJson))
: createEmptyEightAnchorContent();
const chatHistory = chatJson
? (JSON.parse(chatJson) as TestChatMessage[])
: [];
const quickFillRequested =
text.includes('是否要求自动补全:是') ||
text.includes('conversationMode: force_complete') ||
text.includes('用户刚刚主动要求你自动补全剩余设定');
return {
currentAnchorContent,
chatHistory,
quickFillRequested,
};
}
export function buildTestEightAnchorTurn(params: {
currentAnchorContent: EightAnchorContent;
chatHistory: TestChatMessage[];
quickFillRequested: boolean;
}) {
const latestUserText =
[...params.chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
'';
const currentIntent = buildCreatorIntentFromEightAnchorContent(
params.currentAnchorContent,
);
const intentPatch = extractCreatorIntentPatch({
currentIntent,
latestUserMessage: latestUserText,
recentMessages: params.chatHistory
.filter((entry) => entry.role === 'user')
.slice(-6, -1)
.map((entry) => entry.content),
});
const mergedIntent = mergeCreatorIntentRecord(
currentIntent,
params.quickFillRequested
? {
...intentPatch,
...buildAutoCompletePatch(currentIntent),
}
: intentPatch,
);
if (
!shouldReplaceWorldPromise({
latestUserText,
hasExistingWorldPromise: Boolean(currentIntent.worldHook),
})
) {
mergedIntent.worldHook = currentIntent.worldHook;
}
const nextAnchorContent = buildEightAnchorContentFromCreatorIntent(mergedIntent);
const progressPercent = params.quickFillRequested
? 100
: estimateProgressPercentFromAnchorContent(nextAnchorContent);
return {
replyText: buildReplyText({
nextAnchorContent,
progressPercent,
quickFillRequested: params.quickFillRequested,
latestUserText,
}),
progressPercent,
nextAnchorContent,
};
}
function buildStateInferenceFromPrompt(text: string) {
const { chatHistory, quickFillRequested } = parsePromptInput(text);
const latestUserText =
[...chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
'';
const correction = /(|||||)/u.test(latestUserText);
const delegate = /(||||)/u.test(
latestUserText,
);
if (quickFillRequested) {
return {
userInputSignal: delegate ? 'delegate' : 'normal',
driftRisk: correction ? 'high' : 'medium',
conversationMode: 'force_complete',
judgementSummary: '用户希望系统直接补完,这一轮应优先补齐剩余设定并结束收集阶段。',
};
}
if (correction) {
return {
userInputSignal: 'correction',
driftRisk: 'high',
conversationMode: 'repair_direction',
judgementSummary: '用户正在修正旧方向,正式生成时要让修正后的版本直接接管当前语境。',
};
}
if (latestUserText.length < 20) {
return {
userInputSignal: delegate ? 'delegate' : 'sparse',
driftRisk: 'low',
conversationMode: 'bootstrap',
judgementSummary: '这轮新增信息较少,正式生成时应先低压力接住方向,再只推进一个最好回答的问题。',
};
}
return {
userInputSignal: latestUserText.length >= 40 ? 'rich' : 'normal',
driftRisk: 'low',
conversationMode: 'expand',
judgementSummary: '这轮是在顺着现有方向继续补充,正式生成时应吸收新增细节并往前推进一步。',
};
}
export function createTestCustomWorldAgentSingleTurnLlmClient() {
return {
requestMessageContent: async (params) => {
if (params.systemPrompt.includes('创作状态识别器')) {
return JSON.stringify(buildStateInferenceFromPrompt(params.userPrompt));
}
const promptInput = parsePromptInput(
[params.systemPrompt, params.userPrompt].join('\n\n'),
);
return JSON.stringify(buildTestEightAnchorTurn(promptInput));
},
streamMessageContent: async (params) => {
const promptInput = parsePromptInput(
[params.systemPrompt, params.userPrompt].join('\n\n'),
);
const output = buildTestEightAnchorTurn(promptInput);
const jsonText = JSON.stringify({
replyText: output.replyText,
progressPercent: output.progressPercent,
nextAnchorContent: output.nextAnchorContent,
});
params.onUpdate?.(jsonText);
return jsonText;
},
} as UpstreamLlmClient;
}

View File

@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateCustomWorldEntity } from './customWorldEntityGenerationService.js';
import type { UpstreamLlmClient } from './llmClient.js';
function createProfile() {
return {
name: '裂潮边城',
settingText: '裂潮重新逼近边城,旧封桥令也被重新翻出。',
summary: '一座在裂潮与旧案之间摇摇欲坠的边城。',
tone: '紧绷、克制、暗流涌动',
playerGoal: '查清封桥旧令背后的真正操盘者',
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
description: '熟悉裂潮边路的向导。',
visualDescription: '灰斗篷和旧路标是他最显眼的识别点。',
actionDescription: '先试探风向,再用短弓牵制。',
sceneVisualDescription: '他常在旧边路哨点出现。',
backstory: '曾在旧撤离线里失去整支同行队。',
personality: '谨慎寡言。',
motivation: '想查清旧撤离线再次失控的原因。',
combatStyle: '短弓牵制后贴近补刀。',
initialAffinity: 18,
relationshipHooks: ['旧撤离线'],
tags: ['裂潮', '向导'],
},
],
storyNpcs: [
{
id: 'story-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着旧桥与哨火的人。',
visualDescription: '披着旧制巡守外袍,枪柄磨损很重。',
actionDescription: '先立枪封路,再逼近压线。',
sceneVisualDescription: '多出现在断桥和潮湿石阶附近。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '直接、警觉。',
motivation: '不想再让封桥旧案被人利用。',
combatStyle: '长枪压线。',
initialAffinity: 6,
relationshipHooks: ['断桥'],
tags: ['巡守'],
},
],
landmarks: [
{
id: 'landmark-1',
name: '旧潮栈桥',
description: '裂潮来时最先响起铁索声的旧栈桥。',
visualDescription: '铁索、旧桩和盐雾一起压在栈桥上。',
dangerLevel: 'medium',
sceneNpcIds: ['story-1'],
connections: [],
},
],
};
}
test('generateCustomWorldEntity returns role-side visual descriptions from the same model response', async () => {
const llmClient = {
requestMessageContent: async () =>
JSON.stringify({
playableNpc: {
name: '顾潮音',
title: '潮港校灯人',
role: '边港同行者',
description: '在港区高处替玩家校正风向与路标的人。',
visualDescription:
'深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。',
actionDescription:
'先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。',
sceneVisualDescription:
'他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。',
backstory: '曾负责港区夜航校灯,后被卷进旧案。',
personality: '沉稳、寡言、观察细。',
motivation: '想在港区秩序彻底失控前找到还能守住的线。',
combatStyle: '高差观察后快速切入。',
initialAffinity: 24,
relationshipHooks: ['夜航校灯', '旧港案'],
tags: ['港区', '校灯'],
publicSummary: '港区里很少有人比他更熟悉夜里的风向。',
chapterTeasers: ['他盯风向比盯人更久。', '旧港案在他身上没过去。', '他一直在等某个信号。', '他还藏着最后一次校灯记录。'],
chapterContents: ['他总先校风向。', '旧港案改变了他的站位。', '他真正守的是港区里还没断的线。', '最后那份校灯记录能指向操盘者。'],
skills: [
{ name: '校灯试探', summary: '先用灯信号试探敌我位置。', style: '起手压制' },
{ name: '斜坡切入', summary: '借高差快速贴近改线。', style: '机动周旋' },
{ name: '潮线封口', summary: '看准潮线后一口气断掉退路。', style: '爆发终结' },
],
initialItems: [
{ name: '校灯尺', category: '武器', quantity: 1, rarity: 'rare', description: '兼具校灯与近战功能。', tags: ['港区'] },
{ name: '旧港图片', category: '专属物品', quantity: 1, rarity: 'rare', description: '记着他自己的旧线路。', tags: ['旧案'] },
{ name: '潮雾止血包', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '港区常备。', tags: ['补给'] },
],
},
}),
} as UpstreamLlmClient;
const result = await generateCustomWorldEntity(llmClient, {
profile: createProfile(),
kind: 'playable',
});
assert.equal(result.kind, 'playable');
assert.equal(
result.entity.visualDescription,
'深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。',
);
assert.equal(
result.entity.actionDescription,
'先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。',
);
assert.equal(
result.entity.sceneVisualDescription,
'他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。',
);
});
test('generateCustomWorldEntity returns landmark visual descriptions from the same model response', async () => {
const llmClient = {
requestMessageContent: async () =>
JSON.stringify({
landmark: {
name: '回潮观测台',
description: '能俯瞰旧港和裂潮边缘的新观测点。',
visualDescription:
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
dangerLevel: 'high',
sceneNpcNames: ['梁砺'],
connections: [
{
targetLandmarkName: '旧潮栈桥',
relativePosition: 'forward',
summary: '沿风雨走廊可直接回到旧潮栈桥',
},
],
},
}),
} as UpstreamLlmClient;
const result = await generateCustomWorldEntity(llmClient, {
profile: createProfile(),
kind: 'landmark',
});
assert.equal(result.kind, 'landmark');
assert.equal(
result.entity.visualDescription,
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
);
});

View File

@@ -15,6 +15,9 @@ type ParsedRole = {
title: string;
role: string;
description: string;
visualDescription: string;
actionDescription: string;
sceneVisualDescription: string;
backstory: string;
personality: string;
motivation: string;
@@ -34,6 +37,7 @@ type ParsedLandmark = {
id: string;
name: string;
description: string;
visualDescription: string;
dangerLevel: string;
sceneNpcIds: string[];
connections: ParsedLandmarkConnection[];
@@ -220,6 +224,9 @@ function normalizeRole(value: unknown): ParsedRole | null {
title: title || role || '角色',
role,
description: toText(record.description),
visualDescription: toText(record.visualDescription),
actionDescription: toText(record.actionDescription),
sceneVisualDescription: toText(record.sceneVisualDescription),
backstory: toText(record.backstory),
personality: toText(record.personality),
motivation: toText(record.motivation),
@@ -275,6 +282,7 @@ function normalizeLandmark(value: unknown): ParsedLandmark | null {
id,
name,
description: toText(record.description),
visualDescription: toText(record.visualDescription),
dangerLevel: toText(record.dangerLevel, 'medium'),
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
connections,
@@ -326,7 +334,11 @@ function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
role.backstory || '未写'
} / 性格:${role.personality || '未写'} / 动机:${
role.motivation || '未写'
} / 标签${role.tags.join('、') || '暂无'}`,
} / 形象${role.visualDescription || '未写'} / 动作表现:${
role.actionDescription || '未写'
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
role.tags.join('、') || '暂无'
}`,
)
.join('\n');
}
@@ -361,7 +373,9 @@ function buildLandmarkReferenceText(profile: ParsedProfile) {
return `${index + 1}. ${landmark.name} / 危险度:${
landmark.dangerLevel || 'medium'
} / 描述:${landmark.description || '未写'} / 场景角色${
} / 描述:${landmark.description || '未写'} / 画面${
landmark.visualDescription || '未写'
} / 场景角色:${
sceneNpcNames || '暂无'
} / 连接:${connectionNames || '暂无'}`;
})
@@ -437,6 +451,24 @@ function buildFallbackRoleDraft(
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
60,
),
visualDescription: clampText(
kind === 'playable'
? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。`
: `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`,
96,
),
actionDescription: clampText(
kind === 'playable'
? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。'
: '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。',
72,
),
sceneVisualDescription: clampText(
profile.landmarks[0]?.description
? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}`
: `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`,
96,
),
backstory: clampText(
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
80,
@@ -535,6 +567,10 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
72,
),
visualDescription: clampText(
`这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`,
88,
),
dangerLevel: 'medium',
sceneNpcNames,
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
@@ -560,6 +596,9 @@ function buildPlayablePrompt(profile: ParsedProfile) {
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须保留明确的协作价值、成长空间和入队理由。',
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
@@ -568,6 +607,9 @@ function buildPlayablePrompt(profile: ParsedProfile) {
' "title": "称号",',
' "role": "身份",',
' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",',
' "personality": "性格特点",',
' "motivation": "当前动机",',
@@ -608,6 +650,9 @@ function buildStoryPrompt(profile: ParsedProfile) {
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
'- 角色应与具体场景、关系链或局势变化发生绑定。',
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
@@ -616,6 +661,9 @@ function buildStoryPrompt(profile: ParsedProfile) {
' "title": "称号",',
' "role": "身份",',
' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",',
' "personality": "性格特点",',
' "motivation": "当前动机",',
@@ -656,12 +704,14 @@ function buildLandmarkPrompt(profile: ParsedProfile) {
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
'- 必须给出适合出现在这个新场景里的 sceneNpcNames且只能从已有场景角色里选择至少 3 个名字。',
'- 必须给出 connections且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "landmark": {',
' "name": "场景名",',
' "description": "场景描述",',
' "visualDescription": "场景画面描述",',
' "dangerLevel": "low|medium|high|extreme",',
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
' "connections": [',
@@ -737,6 +787,21 @@ function sanitizeGeneratedRole(
toText(record?.description, fallbackDraft.description),
120,
),
visualDescription: clampText(
toText(record?.visualDescription, fallbackDraft.visualDescription),
180,
),
actionDescription: clampText(
toText(record?.actionDescription, fallbackDraft.actionDescription),
140,
),
sceneVisualDescription: clampText(
toText(
record?.sceneVisualDescription,
fallbackDraft.sceneVisualDescription,
),
180,
),
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
personality: clampText(
toText(record?.personality, fallbackDraft.personality),
@@ -962,6 +1027,10 @@ function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) {
toText(record?.description, fallbackDraft.description),
140,
),
visualDescription: clampText(
toText(record?.visualDescription, fallbackDraft.visualDescription),
180,
),
dangerLevel: (() => {
const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel);
return level === 'low' ||

View File

@@ -21,6 +21,10 @@ import type {
CustomWorldAgentSessionRecord,
CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.js';
import {
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
@@ -64,6 +68,7 @@ function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
return (
draftProfile?.name ||
buildDraftTitleFromEightAnchorContent(session.anchorContent) ||
buildDraftTitleFromIntent(intent) ||
toText(session.draftProfile?.title) ||
truncateText(session.seedText, 18) ||
@@ -78,6 +83,7 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
return (
draftProfile?.summary ||
buildDraftSummaryFromEightAnchorContent(session.anchorContent) ||
compiledSummary ||
toText(session.draftProfile?.summary) ||
truncateText(session.seedText, 72) ||

View File

@@ -0,0 +1,593 @@
import type {
CoreConflictValue,
EightAnchorContent,
HiddenLineValue,
IconicElementValue,
KeyRelationshipValue,
PlayerEntryPointValue,
PlayerFantasyValue,
ThemeBoundaryValue,
WorldPromiseValue,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
buildAnchorPackFromIntent,
createEmptyCreatorIntentRecord,
type CreatorCharacterSeedRecord,
type CustomWorldCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toStringArray(value: unknown, maxCount = 8) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
maxCount,
);
}
function compactLines(items: Array<string | null | undefined>) {
return items.map((item) => toText(item)).filter(Boolean).join('');
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function createId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
function splitRelationshipPair(value: string) {
const segments = value
.split(/[/&|]/u)
.map((item) => item.trim())
.flatMap((item) => item.split(/(?:|)/u))
.map((item) => item.trim())
.filter(Boolean);
const meaningful = segments.filter(
(item) => item !== '玩家' && item !== '主角' && item !== '我',
);
return {
leadName: meaningful[0] || segments[0] || '',
relationToPlayer:
segments.length >= 2 ? segments.join(' / ') : value.trim(),
};
}
function normalizeWorldPromise(value: unknown): WorldPromiseValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
hook: toText(item.hook),
differentiator: toText(item.differentiator),
desiredExperience: toText(item.desiredExperience),
} satisfies WorldPromiseValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizePlayerFantasy(value: unknown): PlayerFantasyValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
playerRole: toText(item.playerRole),
corePursuit: toText(item.corePursuit),
fearOfLoss: toText(item.fearOfLoss),
} satisfies PlayerFantasyValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizeThemeBoundary(value: unknown): ThemeBoundaryValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
toneKeywords: toStringArray(item.toneKeywords, 8),
aestheticDirectives: toStringArray(item.aestheticDirectives, 8),
forbiddenDirectives: toStringArray(item.forbiddenDirectives, 8),
} satisfies ThemeBoundaryValue;
return Object.values(nextValue).some((entry) => entry.length > 0)
? nextValue
: null;
}
function normalizePlayerEntryPoint(value: unknown): PlayerEntryPointValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
openingIdentity: toText(item.openingIdentity),
openingProblem: toText(item.openingProblem),
entryMotivation: toText(item.entryMotivation),
} satisfies PlayerEntryPointValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizeCoreConflict(value: unknown): CoreConflictValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
surfaceConflicts: toStringArray(item.surfaceConflicts, 6),
hiddenCrisis: toText(item.hiddenCrisis),
firstTouchedConflict: toText(item.firstTouchedConflict),
} satisfies CoreConflictValue;
return (
nextValue.surfaceConflicts.length > 0 ||
nextValue.hiddenCrisis ||
nextValue.firstTouchedConflict
)
? nextValue
: null;
}
function normalizeRelationship(value: unknown): KeyRelationshipValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
pairs: toText(item.pairs),
relationshipType: toText(item.relationshipType),
secretOrCost: toText(item.secretOrCost),
} satisfies KeyRelationshipValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizeHiddenLines(value: unknown): HiddenLineValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
hiddenTruths: toStringArray(item.hiddenTruths, 6),
misdirectionHints: toStringArray(item.misdirectionHints, 6),
revealPacing: toText(item.revealPacing),
} satisfies HiddenLineValue;
return (
nextValue.hiddenTruths.length > 0 ||
nextValue.misdirectionHints.length > 0 ||
nextValue.revealPacing
)
? nextValue
: null;
}
function normalizeIconicElements(value: unknown): IconicElementValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
iconicMotifs: toStringArray(item.iconicMotifs, 8),
institutionsOrArtifacts: toStringArray(item.institutionsOrArtifacts, 8),
hardRules: toStringArray(item.hardRules, 8),
} satisfies IconicElementValue;
return (
nextValue.iconicMotifs.length > 0 ||
nextValue.institutionsOrArtifacts.length > 0 ||
nextValue.hardRules.length > 0
)
? nextValue
: null;
}
export function createEmptyEightAnchorContent(): EightAnchorContent {
return {
worldPromise: null,
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
};
}
export function normalizeEightAnchorContent(value: unknown): EightAnchorContent {
if (!value || typeof value !== 'object') {
return createEmptyEightAnchorContent();
}
const item = value as Record<string, unknown>;
return {
worldPromise: normalizeWorldPromise(item.worldPromise),
playerFantasy: normalizePlayerFantasy(item.playerFantasy),
themeBoundary: normalizeThemeBoundary(item.themeBoundary),
playerEntryPoint: normalizePlayerEntryPoint(item.playerEntryPoint),
coreConflict: normalizeCoreConflict(item.coreConflict),
keyRelationships: Array.isArray(item.keyRelationships)
? item.keyRelationships
.map((entry) => normalizeRelationship(entry))
.filter((entry): entry is KeyRelationshipValue => Boolean(entry))
.slice(0, 4)
: [],
hiddenLines: normalizeHiddenLines(item.hiddenLines),
iconicElements: normalizeIconicElements(item.iconicElements),
};
}
export function buildEightAnchorContentFromCreatorIntent(
intent: CustomWorldCreatorIntentRecord | null | undefined,
): EightAnchorContent {
if (!intent) {
return createEmptyEightAnchorContent();
}
const themeBoundary =
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
intent.forbiddenDirectives.length > 0
? {
toneKeywords: [...intent.themeKeywords],
aestheticDirectives: [...intent.toneDirectives],
forbiddenDirectives: [...intent.forbiddenDirectives],
}
: null;
const firstCharacter = intent.keyCharacters[0] ?? null;
return normalizeEightAnchorContent({
worldPromise:
intent.worldHook || intent.rawSettingText
? {
hook: intent.worldHook,
differentiator: intent.rawSettingText,
desiredExperience: compactLines([
intent.themeKeywords[0],
intent.toneDirectives[0],
]),
}
: null,
playerFantasy:
intent.playerPremise || intent.coreConflicts[0]
? {
playerRole: intent.playerPremise,
corePursuit: intent.coreConflicts[0] ?? '',
fearOfLoss: firstCharacter?.hiddenHook ?? '',
}
: null,
themeBoundary,
playerEntryPoint:
intent.playerPremise || intent.openingSituation
? {
openingIdentity: intent.playerPremise,
openingProblem: intent.openingSituation,
entryMotivation: intent.coreConflicts[0] ?? '',
}
: null,
coreConflict:
intent.coreConflicts.length > 0
? {
surfaceConflicts: intent.coreConflicts.slice(0, 3),
hiddenCrisis: intent.keyCharacters[0]?.hiddenHook ?? '',
firstTouchedConflict: intent.coreConflicts[0] ?? '',
}
: null,
keyRelationships: intent.keyCharacters.map((entry) => ({
pairs: compactLines([
entry.name,
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
]),
relationshipType: entry.role,
secretOrCost: entry.hiddenHook,
})),
hiddenLines:
intent.keyCharacters.some((entry) => entry.hiddenHook) ||
intent.forbiddenDirectives.length > 0
? {
hiddenTruths: intent.keyCharacters
.map((entry) => entry.hiddenHook)
.filter(Boolean)
.slice(0, 3),
misdirectionHints: intent.forbiddenDirectives.slice(0, 3),
revealPacing: '',
}
: null,
iconicElements:
intent.iconicElements.length > 0
? {
iconicMotifs: intent.iconicElements.slice(0, 4),
institutionsOrArtifacts: [],
hardRules: intent.forbiddenDirectives.slice(0, 3),
}
: null,
});
}
export function buildCreatorIntentFromEightAnchorContent(
anchorContent: EightAnchorContent,
): CustomWorldCreatorIntentRecord {
const nextIntent = createEmptyCreatorIntentRecord('freeform');
const normalizedContent = normalizeEightAnchorContent(anchorContent);
const keyCharacters: CreatorCharacterSeedRecord[] =
normalizedContent.keyRelationships.map((entry, index) => {
const parsedPair = splitRelationshipPair(entry.pairs);
return {
id: createId('creator-character', index),
name: parsedPair.leadName || `关键人物${index + 1}`,
role: entry.relationshipType,
publicMask: '',
hiddenHook: entry.secretOrCost,
relationToPlayer: parsedPair.relationToPlayer,
notes: '',
};
});
const worldHook = compactLines([
normalizedContent.worldPromise?.hook,
normalizedContent.worldPromise?.differentiator,
]);
const playerPremise = compactLines([
normalizedContent.playerFantasy?.playerRole,
normalizedContent.playerEntryPoint?.openingIdentity,
]);
const openingSituation = compactLines([
normalizedContent.playerEntryPoint?.openingProblem,
normalizedContent.playerEntryPoint?.entryMotivation,
]);
const coreConflicts = [
...(normalizedContent.coreConflict?.surfaceConflicts ?? []),
normalizedContent.coreConflict?.hiddenCrisis ?? '',
].filter(Boolean);
const iconicElements = [
...(normalizedContent.iconicElements?.iconicMotifs ?? []),
...(normalizedContent.iconicElements?.institutionsOrArtifacts ?? []),
].filter(Boolean);
const forbiddenDirectives = [
...(normalizedContent.themeBoundary?.forbiddenDirectives ?? []),
...(normalizedContent.iconicElements?.hardRules ?? []),
].filter(Boolean);
return {
...nextIntent,
rawSettingText: compactLines([
normalizedContent.worldPromise?.differentiator,
normalizedContent.playerFantasy?.corePursuit,
normalizedContent.hiddenLines?.hiddenTruths[0],
]),
worldHook,
themeKeywords: normalizedContent.themeBoundary?.toneKeywords ?? [],
toneDirectives: normalizedContent.themeBoundary?.aestheticDirectives ?? [],
playerPremise,
openingSituation,
coreConflicts: [...new Set(coreConflicts)].slice(0, 6),
keyCharacters,
iconicElements: [...new Set(iconicElements)].slice(0, 8),
forbiddenDirectives: [...new Set(forbiddenDirectives)].slice(0, 8),
} satisfies CustomWorldCreatorIntentRecord;
}
function scoreFilledField(filled: boolean, score: number) {
return filled ? score : 0;
}
export function estimateProgressPercentFromAnchorContent(
anchorContent: EightAnchorContent,
) {
const normalized = normalizeEightAnchorContent(anchorContent);
const progress =
scoreFilledField(Boolean(normalized.worldPromise?.hook), 14) +
scoreFilledField(Boolean(normalized.playerFantasy?.playerRole), 12) +
scoreFilledField(
Boolean(
normalized.themeBoundary?.toneKeywords.length ||
normalized.themeBoundary?.aestheticDirectives.length,
),
12,
) +
scoreFilledField(
Boolean(normalized.playerEntryPoint?.openingProblem),
12,
) +
scoreFilledField(
Boolean(normalized.coreConflict?.surfaceConflicts.length),
16,
) +
scoreFilledField(normalized.keyRelationships.length > 0, 14) +
scoreFilledField(
Boolean(
normalized.hiddenLines?.hiddenTruths.length ||
normalized.hiddenLines?.revealPacing,
),
8,
) +
scoreFilledField(
Boolean(
normalized.iconicElements?.iconicMotifs.length ||
normalized.iconicElements?.institutionsOrArtifacts.length,
),
12,
);
return Math.max(0, Math.min(100, Math.round(progress)));
}
export function buildAnchorPackFromEightAnchorContent(
anchorContent: EightAnchorContent,
progressPercent: number,
) {
const creatorIntent = buildCreatorIntentFromEightAnchorContent(anchorContent);
return buildAnchorPackFromIntent(creatorIntent, {
completedKeys: progressPercent >= 100 ? ['eight_anchor_minimum_loop'] : [],
missingKeys: progressPercent >= 100 ? [] : ['eight_anchor_minimum_loop'],
});
}
export function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
const normalized = normalizeEightAnchorContent(anchorContent);
return [
normalized.worldPromise
? `世界承诺:${compactLines([
normalized.worldPromise.hook,
normalized.worldPromise.differentiator,
normalized.worldPromise.desiredExperience,
])}`
: '',
normalized.playerFantasy
? `玩家幻想:${compactLines([
normalized.playerFantasy.playerRole,
normalized.playerFantasy.corePursuit,
normalized.playerFantasy.fearOfLoss,
])}`
: '',
normalized.themeBoundary
? `主题边界:${compactLines([
normalized.themeBoundary.toneKeywords.join('、'),
normalized.themeBoundary.aestheticDirectives.join('、'),
normalized.themeBoundary.forbiddenDirectives.join('、'),
])}`
: '',
normalized.playerEntryPoint
? `玩家切入口:${compactLines([
normalized.playerEntryPoint.openingIdentity,
normalized.playerEntryPoint.openingProblem,
normalized.playerEntryPoint.entryMotivation,
])}`
: '',
normalized.coreConflict
? `核心冲突:${compactLines([
normalized.coreConflict.surfaceConflicts.join('、'),
normalized.coreConflict.hiddenCrisis,
normalized.coreConflict.firstTouchedConflict,
])}`
: '',
normalized.keyRelationships.length > 0
? `关键关系:${normalized.keyRelationships
.map((entry) =>
compactLines([
entry.pairs,
entry.relationshipType,
entry.secretOrCost,
]),
)
.filter(Boolean)
.join('')}`
: '',
normalized.hiddenLines
? `暗线与揭示:${compactLines([
normalized.hiddenLines.hiddenTruths.join('、'),
normalized.hiddenLines.misdirectionHints.join('、'),
normalized.hiddenLines.revealPacing,
])}`
: '',
normalized.iconicElements
? `标志元素:${compactLines([
normalized.iconicElements.iconicMotifs.join('、'),
normalized.iconicElements.institutionsOrArtifacts.join('、'),
normalized.iconicElements.hardRules.join('、'),
])}`
: '',
]
.filter(Boolean)
.join('\n');
}
export function buildDraftTitleFromEightAnchorContent(
anchorContent: EightAnchorContent,
) {
const normalized = normalizeEightAnchorContent(anchorContent);
const candidate = clampText(
normalized.worldPromise?.hook ||
normalized.worldPromise?.differentiator ||
normalized.iconicElements?.iconicMotifs[0] ||
normalized.playerFantasy?.playerRole ||
'',
24,
);
return candidate || '未命名草稿';
}
export function buildDraftSummaryFromEightAnchorContent(
anchorContent: EightAnchorContent,
) {
const normalized = normalizeEightAnchorContent(anchorContent);
const summary = [
compactLines([
normalized.worldPromise?.hook,
normalized.worldPromise?.differentiator,
normalized.worldPromise?.desiredExperience,
]),
compactLines([
normalized.playerFantasy?.playerRole,
normalized.playerFantasy?.corePursuit,
normalized.playerFantasy?.fearOfLoss,
]),
compactLines([
normalized.playerEntryPoint?.openingIdentity,
normalized.playerEntryPoint?.openingProblem,
normalized.playerEntryPoint?.entryMotivation,
]),
compactLines([
normalized.coreConflict?.surfaceConflicts.join('、'),
normalized.coreConflict?.hiddenCrisis,
normalized.coreConflict?.firstTouchedConflict,
]),
normalized.keyRelationships.length > 0
? normalized.keyRelationships
.map((entry) =>
compactLines([
entry.pairs,
entry.relationshipType,
entry.secretOrCost,
]),
)
.filter(Boolean)
.join('')
: '',
compactLines([
normalized.iconicElements?.iconicMotifs.join('、'),
normalized.iconicElements?.institutionsOrArtifacts.join('、'),
normalized.iconicElements?.hardRules.join('、'),
]),
]
.filter(Boolean)
.join(' · ');
return clampText(summary, 180) || '还在收集你的世界锚点。';
}

View File

@@ -0,0 +1,784 @@
import type {
EightAnchorContent,
HiddenLineValue,
IconicElementValue,
KeyRelationshipValue,
ThemeBoundaryValue,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
createEmptyEightAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
export type PromptUserInputSignal =
| 'rich'
| 'normal'
| 'sparse'
| 'correction'
| 'delegate';
export type PromptDriftRisk = 'low' | 'medium' | 'high';
export type PromptConversationMode =
| 'bootstrap'
| 'expand'
| 'compress'
| 'repair_direction'
| 'force_complete'
| 'closing';
export type PromptDynamicState = {
currentTurn: number;
progressPercent: number;
userInputSignal: PromptUserInputSignal;
driftRisk: PromptDriftRisk;
quickFillRequested: boolean;
conversationMode: PromptConversationMode;
judgementSummary: string;
};
export type PromptDynamicStateInference = {
userInputSignal?: unknown;
driftRisk?: unknown;
conversationMode?: unknown;
judgementSummary?: unknown;
};
const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复`;
const GLOBAL_HARD_RULES = `全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。`;
const MODE_RULES: Record<PromptConversationMode, string> = {
bootstrap: `当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械`,
expand: `当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界`,
compress: `当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉`,
repair_direction: `当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`,
force_complete: `当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么`,
closing: `当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话`,
};
const USER_SIGNAL_RULES: Record<PromptUserInputSignal, string> = {
rich: `本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`,
normal: `本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`,
sparse: `本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。`,
correction: `本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。`,
delegate: `本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`,
};
const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。
这表示用户接受你基于当前方向自动补完剩余设定。
本轮要求:
1. 不要再继续提问
2. 直接输出一版尽量完整的设定结构
3. progressPercent 直接输出为 100
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`;
const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中`;
const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}`;
const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
}
}`;
function toJson(value: unknown) {
return JSON.stringify(value, null, 2);
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function getLatestUserText(
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
return (
[...chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
''
);
}
function includesAny(text: string, patterns: RegExp[]) {
return patterns.some((pattern) => pattern.test(text));
}
function isPromptUserInputSignal(
value: unknown,
): value is PromptUserInputSignal {
return (
value === 'rich' ||
value === 'normal' ||
value === 'sparse' ||
value === 'correction' ||
value === 'delegate'
);
}
function isPromptDriftRisk(value: unknown): value is PromptDriftRisk {
return value === 'low' || value === 'medium' || value === 'high';
}
function isPromptConversationMode(
value: unknown,
): value is PromptConversationMode {
return (
value === 'bootstrap' ||
value === 'expand' ||
value === 'compress' ||
value === 'repair_direction' ||
value === 'force_complete' ||
value === 'closing'
);
}
export function detectUserInputSignal(
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
): PromptUserInputSignal {
const latestUserText = getLatestUserText(chatHistory).trim();
if (!latestUserText) {
return 'sparse';
}
if (includesAny(latestUserText, [/(||||||)/u])) {
return 'correction';
}
if (includesAny(latestUserText, [/(|||)/u])) {
return 'delegate';
}
const segments = latestUserText
.split(/[\n]/u)
.map((item) => item.trim())
.filter(Boolean);
if (latestUserText.length <= 10 || segments.length <= 1) {
return 'sparse';
}
if (segments.length >= 3 || latestUserText.length >= 60) {
return 'rich';
}
return 'normal';
}
function summarizeDynamicState(
state: Pick<
PromptDynamicState,
'userInputSignal' | 'driftRisk' | 'conversationMode'
>,
) {
return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`;
}
function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) {
return Boolean(
value &&
(value.toneKeywords.length > 0 ||
value.aestheticDirectives.length > 0 ||
value.forbiddenDirectives.length > 0),
);
}
function isRelationshipsFilled(value: KeyRelationshipValue[]) {
return value.length > 0;
}
function isHiddenLinesFilled(value: HiddenLineValue | null) {
return Boolean(
value &&
(value.hiddenTruths.length > 0 ||
value.misdirectionHints.length > 0 ||
value.revealPacing),
);
}
function isIconicElementsFilled(value: IconicElementValue | null) {
return Boolean(
value &&
(value.iconicMotifs.length > 0 ||
value.institutionsOrArtifacts.length > 0 ||
value.hardRules.length > 0),
);
}
export function detectDriftRisk(params: {
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
anchorContent: EightAnchorContent;
progressPercent: number;
}) {
const latestUserText = getLatestUserText(params.chatHistory).trim();
const recentUserMessages = params.chatHistory
.filter((entry) => entry.role === 'user')
.slice(-3)
.map((entry) => entry.content.trim())
.filter(Boolean);
const correctionCount = recentUserMessages.filter((entry) =>
/(||||||)/u.test(entry),
).length;
if (
correctionCount >= 2 ||
(params.progressPercent >= 65 &&
/(|||||)/u.test(latestUserText))
) {
return 'high' as const;
}
const normalizedContent = normalizeEightAnchorContent(params.anchorContent);
const filledCount = [
Boolean(normalizedContent.worldPromise),
Boolean(normalizedContent.playerFantasy),
isThemeBoundaryFilled(normalizedContent.themeBoundary),
Boolean(normalizedContent.playerEntryPoint),
Boolean(normalizedContent.coreConflict),
isRelationshipsFilled(normalizedContent.keyRelationships),
isHiddenLinesFilled(normalizedContent.hiddenLines),
isIconicElementsFilled(normalizedContent.iconicElements),
].filter(Boolean).length;
if (filledCount >= 3 && latestUserText.length >= 40) {
return 'medium' as const;
}
return 'low' as const;
}
export function pickConversationMode(params: {
currentTurn: number;
progressPercent: number;
userInputSignal: PromptUserInputSignal;
driftRisk: PromptDriftRisk;
quickFillRequested: boolean;
}) {
if (params.quickFillRequested) {
return 'force_complete' as const;
}
if (
params.userInputSignal === 'correction' ||
params.driftRisk === 'high'
) {
return 'repair_direction' as const;
}
if (params.progressPercent >= 85 || params.currentTurn >= 15) {
return 'closing' as const;
}
if (params.currentTurn > 10 || params.progressPercent >= 65) {
return 'compress' as const;
}
if (params.currentTurn <= 10 && params.progressPercent < 65) {
return 'expand' as const;
}
return 'bootstrap' as const;
}
function buildRuleBasedPromptDynamicState(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
}): PromptDynamicState {
const userInputSignal = detectUserInputSignal(input.chatHistory);
const driftRisk = detectDriftRisk({
chatHistory: input.chatHistory,
anchorContent: input.currentAnchorContent,
progressPercent: input.progressPercent,
});
const conversationMode = pickConversationMode({
currentTurn: input.currentTurn,
progressPercent: input.progressPercent,
userInputSignal,
driftRisk,
quickFillRequested: input.quickFillRequested,
});
return {
currentTurn: input.currentTurn,
progressPercent: input.progressPercent,
userInputSignal,
driftRisk,
quickFillRequested: input.quickFillRequested,
conversationMode,
judgementSummary: summarizeDynamicState({
userInputSignal,
driftRisk,
conversationMode,
}),
};
}
export function buildPromptDynamicState(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
}, inference?: PromptDynamicStateInference | null): PromptDynamicState {
const fallbackState = buildRuleBasedPromptDynamicState(input);
if (!inference) {
return fallbackState;
}
const userInputSignal = isPromptUserInputSignal(inference.userInputSignal)
? inference.userInputSignal
: fallbackState.userInputSignal;
const driftRisk = isPromptDriftRisk(inference.driftRisk)
? inference.driftRisk
: fallbackState.driftRisk;
const conversationMode = isPromptConversationMode(inference.conversationMode)
? inference.conversationMode
: fallbackState.conversationMode;
const judgementSummary =
toText(inference.judgementSummary) ||
summarizeDynamicState({
userInputSignal,
driftRisk,
conversationMode,
});
return {
currentTurn: input.currentTurn,
progressPercent: input.progressPercent,
userInputSignal,
driftRisk,
quickFillRequested: input.quickFillRequested,
conversationMode,
judgementSummary,
};
}
export function buildPromptDynamicStateInferencePrompt(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
}) {
const currentAnchorContent =
normalizeEightAnchorContent(input.currentAnchorContent) ??
createEmptyEightAnchorContent();
return {
systemPrompt: [
STATE_INFERENCE_SYSTEM_PROMPT,
STATE_INFERENCE_OUTPUT_CONTRACT,
].join('\n\n'),
userPrompt: [
`当前轮次:${input.currentTurn}`,
`当前完成度:${input.progressPercent}`,
`是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`,
renderCurrentAnchorContext(currentAnchorContent),
renderChatHistoryContext(input.chatHistory),
].join('\n\n'),
};
}
function renderDynamicStateContext(dynamicState: PromptDynamicState) {
return `上一轮预判得到的创作状态如下。
正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。
创作状态:
- userInputSignal: ${dynamicState.userInputSignal}
- driftRisk: ${dynamicState.driftRisk}
- conversationMode: ${dynamicState.conversationMode}
- judgementSummary: ${dynamicState.judgementSummary}`;
}
function renderCurrentAnchorContext(anchorContent: EightAnchorContent) {
return `当前完整设定结构如下。
你必须把它视为上一版有效世界底子。
如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。
如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。
当前完整设定结构:
${toJson(normalizeEightAnchorContent(anchorContent))}`;
}
function renderChatHistoryContext(
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
return `以下是用户聊天记录。
请重点理解最近几轮里用户新增、修正、强调的设定信息。
不要把早期已经被用户否定的内容继续当成最终结论。
用户聊天记录:
${toJson(chatHistory)}`;
}
export function buildEightAnchorSingleTurnPrompt(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
dynamicState?: PromptDynamicStateInference | PromptDynamicState | null;
}) {
const currentAnchorContent =
normalizeEightAnchorContent(input.currentAnchorContent) ??
createEmptyEightAnchorContent();
const dynamicState = buildPromptDynamicState({
...input,
currentAnchorContent,
}, input.dynamicState);
return {
prompt: [
BASE_SYSTEM_PROMPT,
GLOBAL_HARD_RULES,
MODE_RULES[dynamicState.conversationMode],
USER_SIGNAL_RULES[dynamicState.userInputSignal],
dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null,
renderDynamicStateContext(dynamicState),
renderCurrentAnchorContext(currentAnchorContent),
renderChatHistoryContext(input.chatHistory),
OUTPUT_CONTRACT_REMINDER,
]
.filter(Boolean)
.join('\n\n'),
dynamicState,
};
}

View File

@@ -0,0 +1,420 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
import type { UpstreamLlmClient } from './llmClient.js';
test('eight anchor single turn service updates anchors from model output', async () => {
const service = new EightAnchorSingleTurnService(
createTestCustomWorldAgentSingleTurnLlmClient(),
);
const result = await service.runTurn({
currentTurn: 2,
progressPercent: 18,
quickFillRequested: false,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '',
desiredExperience: '',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'assistant',
content: '现在世界底色有了,你最想让玩家以什么身份卷进来?',
},
{
role: 'user',
content:
'玩家是被迫返乡的守灯人继承人,开场时刚回到港口就发现禁航区亮起了假航灯。',
},
],
});
assert.ok(result.nextAnchorContent.worldPromise?.hook);
assert.match(
result.nextAnchorContent.playerFantasy?.playerRole ?? '',
//u,
);
assert.match(
result.nextAnchorContent.playerEntryPoint?.openingProblem ?? '',
//u,
);
assert.ok(result.progressPercent >= 20);
assert.ok(result.replyText.length > 0);
});
test('eight anchor single turn service forces completion from model output when quick fill is requested', async () => {
const service = new EightAnchorSingleTurnService(
createTestCustomWorldAgentSingleTurnLlmClient(),
);
const result = await service.runTurn({
currentTurn: 6,
progressPercent: 62,
quickFillRequested: true,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要向旧灯塔借路。',
desiredExperience: '压抑、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'user',
content: '请直接一键补全剩余设定。',
},
],
});
assert.equal(result.progressPercent, 100);
assert.ok(result.nextAnchorContent.coreConflict);
assert.ok(result.nextAnchorContent.keyRelationships.length > 0);
assert.match(result.replyText, /稿/u);
});
test('eight anchor single turn service keeps the current anchors unchanged when llm is unavailable', async () => {
const service = new EightAnchorSingleTurnService();
const currentAnchorContent = {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要向旧灯塔借路。',
desiredExperience: '压抑、悬疑',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
};
const result = await service.runTurn({
currentTurn: 2,
progressPercent: 24,
quickFillRequested: false,
currentAnchorContent,
chatHistory: [
{
role: 'user',
content: '玩家是被迫返乡的守灯人继承人。',
},
],
});
assert.deepEqual(result.nextAnchorContent, currentAnchorContent);
assert.equal(result.progressPercent, 24);
assert.match(result.replyText, //u);
});
test('eight anchor single turn service runs state inference before formal generation and injects it into the next prompt', async () => {
const inferenceCalls: Array<{
debugLabel?: string;
systemPrompt: string;
userPrompt: string;
}> = [];
const streamCalls: Array<{
debugLabel?: string;
systemPrompt: string;
userPrompt: string;
}> = [];
const streamedReplyUpdates: string[] = [];
const llmClient = {
requestMessageContent: async (params) => {
inferenceCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
});
if (params.debugLabel === 'custom-world-eight-anchor-state-inference') {
return JSON.stringify({
userInputSignal: 'correction',
driftRisk: 'high',
conversationMode: 'repair_direction',
judgementSummary:
'用户正在修正既有方向,正式生成时要优先吸收修正并避免沿用旧设定。',
});
}
throw new Error('formal generation should use streamMessageContent');
},
streamMessageContent: async (params) => {
streamCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
});
params.onUpdate?.('{"replyText":"我先按你修正后的');
params.onUpdate?.(
'{"replyText":"我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
);
return JSON.stringify({
nextAnchorContent: {
worldPromise: {
hook: '一个以旧航线骗局为核心悬念的群岛世界。',
differentiator: '假航灯会改写整片海域的生路判断。',
desiredExperience: '压迫、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是返乡的守灯人继承人。',
corePursuit: '查清旧航线骗局的源头。',
fearOfLoss: '失去家族仅剩的航线名誉。',
},
themeBoundary: {
toneKeywords: ['压迫'],
aestheticDirectives: ['潮湿群岛'],
forbiddenDirectives: [],
},
playerEntryPoint: {
openingIdentity: '返乡继承人',
openingProblem: '港口重新亮起假航灯',
entryMotivation: '阻止更多船只误入禁航区',
},
coreConflict: {
surfaceConflicts: ['假航灯骗局重新启动'],
hiddenCrisis: '有人借旧航线秩序收割整座群岛',
firstTouchedConflict: '玩家返乡当晚就撞上假航灯',
},
keyRelationships: [
{
pairs: '玩家 vs 旧港校灯人',
relationshipType: '旧识互疑',
secretOrCost: '对方知道家族旧案',
},
],
hiddenLines: {
hiddenTruths: ['假航灯背后藏着旧案延续'],
misdirectionHints: ['表面像海盗所为'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '潮雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: ['错误航灯会把船引向死路'],
},
},
progressPercent: 58,
replyText: '我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
});
},
} as UpstreamLlmClient;
const service = new EightAnchorSingleTurnService(llmClient);
const result = await service.streamTurn(
{
currentTurn: 4,
progressPercent: 44,
quickFillRequested: false,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '',
desiredExperience: '',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'assistant',
content: '我们先把世界方向定住,你最想强调哪种悬念?',
},
{
role: 'user',
content: '不是海怪方向,改成旧航线骗局,假航灯才是这世界真正的危险。',
},
],
},
{
onReplyUpdate: (text) => {
streamedReplyUpdates.push(text);
},
},
);
assert.equal(inferenceCalls.length, 1);
assert.equal(streamCalls.length, 1);
assert.equal(
inferenceCalls[0]?.debugLabel,
'custom-world-eight-anchor-state-inference',
);
assert.equal(
streamCalls[0]?.debugLabel,
'custom-world-eight-anchor-single-turn',
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/userInputSignal: correction/u,
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/conversationMode: repair_direction/u,
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
//u,
);
assert.deepEqual(streamedReplyUpdates, [
'我先按你修正后的',
'我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
]);
assert.equal(result.progressPercent, 58);
assert.match(result.replyText, //u);
});
test('eight anchor single turn service falls back to rule-based state when inference fails and still completes formal generation', async () => {
const inferenceCalls: Array<{
debugLabel?: string;
systemPrompt: string;
}> = [];
const streamCalls: Array<{
debugLabel?: string;
systemPrompt: string;
userPrompt: string;
}> = [];
const llmClient = {
requestMessageContent: async (params) => {
inferenceCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
});
throw new Error('state inference failed');
},
streamMessageContent: async (params) => {
streamCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
});
return JSON.stringify({
nextAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要向旧灯塔借路。',
desiredExperience: '压抑、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '',
},
themeBoundary: {
toneKeywords: ['压抑'],
aestheticDirectives: ['潮雾群岛'],
forbiddenDirectives: [],
},
playerEntryPoint: {
openingIdentity: '返乡继承人',
openingProblem: '港口重新亮起假航灯',
entryMotivation: '堵住灾难扩散',
},
coreConflict: {
surfaceConflicts: ['禁航区异动'],
hiddenCrisis: '旧航线秩序正在被人篡改',
firstTouchedConflict: '返乡第一晚就撞上假航灯',
},
keyRelationships: [
{
pairs: '玩家 vs 港区旧识',
relationshipType: '彼此试探',
secretOrCost: '对方知道旧沉船夜的真相碎片',
},
],
hiddenLines: {
hiddenTruths: ['旧沉船夜不是意外'],
misdirectionHints: ['所有线索都先指向海盗'],
revealPacing: '先异常,再旧案,再真凶',
},
iconicElements: {
iconicMotifs: ['假航灯'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: ['错误航灯会直接改写生路判断'],
},
},
progressPercent: 64,
replyText: '我先顺着你这轮修正把设定收住了,接下来可以继续往冲突和关系上补。',
});
},
} as UpstreamLlmClient;
const service = new EightAnchorSingleTurnService(llmClient);
const result = await service.runTurn({
currentTurn: 3,
progressPercent: 40,
quickFillRequested: false,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '',
desiredExperience: '',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'user',
content: '不是海怪,改成旧航线骗局。',
},
],
});
assert.equal(inferenceCalls.length, 1);
assert.equal(streamCalls.length, 1);
assert.equal(
inferenceCalls[0]?.debugLabel,
'custom-world-eight-anchor-state-inference',
);
assert.equal(
streamCalls[0]?.debugLabel,
'custom-world-eight-anchor-single-turn',
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/userInputSignal: correction/u,
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/conversationMode: repair_direction/u,
);
assert.equal(result.progressPercent, 64);
assert.match(result.replyText, //u);
});

View File

@@ -0,0 +1,322 @@
import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
createEmptyEightAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import {
buildEightAnchorSingleTurnPrompt,
buildPromptDynamicState,
buildPromptDynamicStateInferencePrompt,
} from './eightAnchorPromptBuilder.js';
import type { UpstreamLlmClient } from './llmClient.js';
type SingleTurnChatMessage = {
role: 'user' | 'assistant';
content: string;
};
export type SingleTurnModelOutput = {
nextAnchorContent: EightAnchorContent;
progressPercent: number;
replyText: string;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeOutputValue(value: unknown) {
return normalizeEightAnchorContent(value ?? createEmptyEightAnchorContent());
}
function clampProgressPercent(value: unknown) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(value)));
}
function decodeEscapedCharacter(
value: string,
input: string,
index: number,
): { decoded: string; nextIndex: number } | null {
if (value === '"' || value === '\\' || value === '/') {
return {
decoded: value,
nextIndex: index + 1,
};
}
if (value === 'b') {
return {
decoded: '\b',
nextIndex: index + 1,
};
}
if (value === 'f') {
return {
decoded: '\f',
nextIndex: index + 1,
};
}
if (value === 'n') {
return {
decoded: '\n',
nextIndex: index + 1,
};
}
if (value === 'r') {
return {
decoded: '\r',
nextIndex: index + 1,
};
}
if (value === 't') {
return {
decoded: '\t',
nextIndex: index + 1,
};
}
if (value === 'u') {
const hex = input.slice(index + 1, index + 5);
if (!/^[\da-fA-F]{4}$/u.test(hex)) {
return null;
}
return {
decoded: String.fromCharCode(Number.parseInt(hex, 16)),
nextIndex: index + 5,
};
}
return {
decoded: value,
nextIndex: index + 1,
};
}
function extractReplyTextFromPartialJson(text: string) {
const keyIndex = text.indexOf('"replyText"');
if (keyIndex < 0) {
return {
text: '',
started: false,
completed: false,
};
}
const colonIndex = text.indexOf(':', keyIndex);
if (colonIndex < 0) {
return {
text: '',
started: false,
completed: false,
};
}
let stringStartIndex = colonIndex + 1;
while (
stringStartIndex < text.length &&
/\s/u.test(text[stringStartIndex] ?? '')
) {
stringStartIndex += 1;
}
if (text[stringStartIndex] !== '"') {
return {
text: '',
started: false,
completed: false,
};
}
let cursor = stringStartIndex + 1;
let decoded = '';
while (cursor < text.length) {
const character = text[cursor] ?? '';
if (character === '"') {
return {
text: decoded,
started: true,
completed: true,
};
}
if (character === '\\') {
const escaped = decodeEscapedCharacter(
text[cursor + 1] ?? '',
text,
cursor + 1,
);
if (!escaped) {
break;
}
decoded += escaped.decoded;
cursor = escaped.nextIndex;
continue;
}
decoded += character;
cursor += 1;
}
return {
text: decoded,
started: true,
completed: false,
};
}
function buildUnavailableOutput(
input: {
progressPercent: number;
currentAnchorContent: EightAnchorContent;
},
reason: 'unavailable' | 'failed',
) {
return {
nextAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent),
progressPercent: Math.max(0, Math.min(100, Math.round(input.progressPercent))),
replyText:
reason === 'unavailable'
? '当前模型不可用,这一轮设定先保留上一版。你可以稍后重试。'
: '这一轮设定还没成功更新,我先保留上一版。你可以再发一次,我继续接着收。',
} satisfies SingleTurnModelOutput;
}
export class EightAnchorSingleTurnService {
constructor(private readonly llmClient?: UpstreamLlmClient) {}
private async resolveDynamicState(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: SingleTurnChatMessage[];
}) {
const fallbackState = buildPromptDynamicState(input);
if (!this.llmClient) {
return fallbackState;
}
const { systemPrompt, userPrompt } =
buildPromptDynamicStateInferencePrompt(input);
try {
const content = await this.llmClient.requestMessageContent({
systemPrompt,
userPrompt,
timeoutMs: 45000,
debugLabel: 'custom-world-eight-anchor-state-inference',
});
const parsed = parseJsonResponseText(content) as {
userInputSignal?: unknown;
driftRisk?: unknown;
conversationMode?: unknown;
judgementSummary?: unknown;
};
return buildPromptDynamicState(input, parsed);
} catch {
return fallbackState;
}
}
async runTurn(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: SingleTurnChatMessage[];
}) {
return this.streamTurn(input);
}
async streamTurn(
input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: SingleTurnChatMessage[];
},
options: {
onReplyUpdate?: (text: string) => void;
} = {},
) {
const normalizedInput = {
...input,
currentAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent),
chatHistory: input.chatHistory.slice(-16),
};
if (!this.llmClient) {
const unavailableOutput = buildUnavailableOutput(
normalizedInput,
'unavailable',
);
options.onReplyUpdate?.(unavailableOutput.replyText);
return unavailableOutput;
}
const dynamicState = await this.resolveDynamicState(normalizedInput);
const { prompt } = buildEightAnchorSingleTurnPrompt({
...normalizedInput,
dynamicState,
});
let latestReplyText = '';
try {
const content = await this.llmClient.streamMessageContent({
systemPrompt: prompt,
userPrompt: '请按约定输出这一轮的 JSON。',
timeoutMs: 60000,
debugLabel: 'custom-world-eight-anchor-single-turn',
onUpdate: (partialText) => {
const replyProgress = extractReplyTextFromPartialJson(partialText);
if (
replyProgress.started &&
replyProgress.text !== latestReplyText
) {
latestReplyText = replyProgress.text;
options.onReplyUpdate?.(latestReplyText);
}
},
});
const parsed = parseJsonResponseText(content) as {
nextAnchorContent?: unknown;
progressPercent?: unknown;
replyText?: unknown;
};
const nextAnchorContent = normalizeOutputValue(parsed.nextAnchorContent);
const progressPercent = normalizedInput.quickFillRequested
? 100
: clampProgressPercent(parsed.progressPercent);
const replyText =
toText(parsed.replyText) ||
buildUnavailableOutput(normalizedInput, 'failed').replyText;
if (replyText !== latestReplyText) {
options.onReplyUpdate?.(replyText);
}
return {
nextAnchorContent,
progressPercent,
replyText,
} satisfies SingleTurnModelOutput;
} catch {
const unavailableOutput = buildUnavailableOutput(
normalizedInput,
'failed',
);
if (unavailableOutput.replyText !== latestReplyText) {
options.onReplyUpdate?.(unavailableOutput.replyText);
}
return unavailableOutput;
}
}
}

View File

@@ -309,6 +309,92 @@ export class UpstreamLlmClient {
return content;
}
async streamMessageContent(params: {
systemPrompt: string;
userPrompt: string;
model?: string;
signal?: AbortSignal;
timeoutMs?: number;
debugLabel?: string;
onUpdate?: (text: string) => void;
}) {
const response = await this.requestCompletion(
{
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
},
{
signal: params.signal,
timeoutMs: params.timeoutMs,
debugLabel: params.debugLabel,
},
);
if (!response.body) {
throw upstreamError('LLM 流式响应体不可用');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let accumulatedText = '';
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);
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line.startsWith('data:')) {
continue;
}
const data = line.slice(5).trim();
if (!data || data === '[DONE]') {
continue;
}
try {
const parsed = JSON.parse(data) as {
choices?: Array<{
delta?: {
content?: string;
};
}>;
};
const delta = parsed.choices?.[0]?.delta?.content;
if (typeof delta === 'string' && delta.length > 0) {
accumulatedText += delta;
params.onUpdate?.(accumulatedText);
}
} catch {
// Ignore malformed SSE frames from the upstream model.
}
}
}
}
const content = accumulatedText.trim();
if (!content) {
throw upstreamError('LLM 返回内容为空');
}
return content;
}
async forwardCompletion(
request: ExpressRequest,
body: Record<string, unknown>,