feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import { buildMessages } from "./prompt-builder.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
@@ -8,8 +8,13 @@ interface OpenAiCompatibleChoice {
|
||||
message?: { content?: string };
|
||||
delta?: { content?: string };
|
||||
}
|
||||
interface OpenAiCompatibleUsage {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
}
|
||||
interface OpenAiCompatibleResponse {
|
||||
choices?: OpenAiCompatibleChoice[];
|
||||
usage?: OpenAiCompatibleUsage;
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
@@ -61,7 +66,12 @@ export class ClaudeProvider implements LLMProvider {
|
||||
if (!text) {
|
||||
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
|
||||
}
|
||||
return { text, actions: extractActions(text) };
|
||||
// T9:真實 API 回應的 usage 未提供時(部分相容端點不回傳)以 0 記錄,不因此中斷生成。
|
||||
return {
|
||||
text,
|
||||
actions: extractActions(text),
|
||||
usage: { model: model(), promptTokens: json.usage?.prompt_tokens ?? 0, completionTokens: json.usage?.completion_tokens ?? 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
@@ -98,4 +108,37 @@ export class ClaudeProvider implements LLMProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T5:LLM 兼底判斷指稱哪個角色;提示詞要求只能回傳候選 id 或 NONE,避免自行編造不存在的角色。
|
||||
async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null> {
|
||||
const candidateList = candidates.map((c) => `- ${c.id}:${c.label}(${c.keywords})`).join("\n");
|
||||
const prompt = `使用者說了一句話,請判斷這句話是在呼喚下面候選名單中的哪一位。只能回傳候選名單裡的 id,不可以自行編造不存在的 id;如果判斷不出來或這句話沒有在呼喚任何人,就回傳 NONE。只回傳 id 或 NONE,不要有其他文字。\n\n候選名單:\n${candidateList}\n\n使用者的話:「${utterance}」`;
|
||||
const res = await requestChatCompletion({
|
||||
model: model(),
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
});
|
||||
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||
const answer = json.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
return candidates.some((c) => c.id === answer) ? answer : null;
|
||||
}
|
||||
|
||||
// T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;提示詞刻意不帶章節標題,
|
||||
// 只給場景摘要與原文本身,避免標題關鍵字(例如「最終回」)預先左右判斷。
|
||||
async detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise<EndingDetectionResult> {
|
||||
const prompt = `你是故事編輯,請只依下面的場景內容本身判斷:這段場景是否為作品「${workTitle}」整部作品的最終結局(整部作品在此完全收尾,不是單一篇章、單元劇或某個角色支線的結尾)。不要用章節標題或任何標題訊號預先假設,只看場景實際描寫的內容。\n\n場景摘要:${sceneSummary}\n\n場景原文:${sceneRawText}\n\n請用以下格式回答,不要有其他文字:\nANSWER: YES 或 NO\nEVIDENCE: 一到兩句話說明你的判斷依據`;
|
||||
const res = await requestChatCompletion({
|
||||
model: model(),
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
});
|
||||
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||
const text = json.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
const answerMatch = text.match(/ANSWER:\s*(YES|NO)/i);
|
||||
const evidenceMatch = text.match(/EVIDENCE:\s*([\s\S]+)/i);
|
||||
return {
|
||||
isEnding: (answerMatch?.[1] ?? "").toUpperCase() === "YES",
|
||||
evidence: evidenceMatch?.[1]?.trim() || text || "(LLM 未回傳可解析的判斷依據)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user