1
This commit is contained in:
322
server-node/src/services/eightAnchorSingleTurnService.ts
Normal file
322
server-node/src/services/eightAnchorSingleTurnService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user