Files
Genarrative/server-node/src/services/eightAnchorSingleTurnService.ts
2026-04-18 13:05:29 +08:00

323 lines
7.9 KiB
TypeScript

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;
}
}
}