Files
Kokorone/apps/api/src/llm/claude-provider.service.ts
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

145 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from "@nestjs/common";
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";
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 {
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
}
function apiKey(): string {
const key = process.env.CLAUDE_API_KEY;
if (!key) {
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
}
return key;
}
function model(): string {
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
}
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
const res = await fetch(`${baseUrl()}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey()}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
}
return res;
}
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
@Injectable()
export class ClaudeProvider implements LLMProvider {
async generate(context: GenerationContext): Promise<GeneratedResponse> {
const res = await requestChatCompletion({
model: model(),
messages: buildMessages(context),
stream: false,
});
const json = (await res.json()) as OpenAiCompatibleResponse;
const text = json.choices?.[0]?.message?.content ?? "";
if (!text) {
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
}
// 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> {
const res = await requestChatCompletion({
model: model(),
messages: buildMessages(context),
stream: true,
});
if (!res.body) {
throw new Error("ClaudeProvider 串流回應沒有 body");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice("data:".length).trim();
if (payload === "[DONE]") return;
let chunk: OpenAiCompatibleResponse;
try {
chunk = JSON.parse(payload);
} catch {
continue;
}
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) yield delta;
}
}
}
// 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 未回傳可解析的判斷依據)",
};
}
}