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): Promise { 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 { 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 { 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 { 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 { 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 未回傳可解析的判斷依據)", }; } }