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