323 lines
7.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|