diff --git a/apps/api/src/llm/claude-provider.service.ts b/apps/api/src/llm/claude-provider.service.ts index 774bb6a..26f0515 100644 --- a/apps/api/src/llm/claude-provider.service.ts +++ b/apps/api/src/llm/claude-provider.service.ts @@ -1,19 +1,101 @@ import { Injectable } from "@nestjs/common"; import type { LLMProvider } from "./llm-provider.js"; import type { GenerationContext, GeneratedResponse } from "./types.js"; +import { buildMessages } from "./prompt-builder.js"; +import { extractActions } from "./action-markup.js"; -const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)"; +interface OpenAiCompatibleChoice { + message?: { content?: string }; + delta?: { content?: string }; +} +interface OpenAiCompatibleResponse { + choices?: OpenAiCompatibleChoice[]; +} -// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。 +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 { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async generate(_context: GenerationContext): Promise { - throw new Error(NOT_IMPLEMENTED_MESSAGE); + 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 缺失)"); + } + return { text, actions: extractActions(text) }; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield - async *stream(_context: GenerationContext): AsyncIterable { - throw new Error(NOT_IMPLEMENTED_MESSAGE); + 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; + } + } } } diff --git a/apps/api/src/llm/context-assembler.service.ts b/apps/api/src/llm/context-assembler.service.ts index d1162db..b18e37f 100644 --- a/apps/api/src/llm/context-assembler.service.ts +++ b/apps/api/src/llm/context-assembler.service.ts @@ -44,7 +44,7 @@ export class ContextAssemblerService { ? [] : await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId }); - const history = this.workingMemory.getContext(sessionId).map((entry) => ({ + const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({ role: entry.role, content: entry.content, timestamp: entry.timestamp.toISOString(), diff --git a/apps/api/src/llm/dialogue.service.ts b/apps/api/src/llm/dialogue.service.ts index 8800a65..418160f 100644 --- a/apps/api/src/llm/dialogue.service.ts +++ b/apps/api/src/llm/dialogue.service.ts @@ -63,7 +63,7 @@ export class DialogueService { options: HandleMessageOptions = {}, ): Promise { const now = options.now ?? new Date(); - this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now }); + await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now }); // I-6:按需生成離線事件(今天只生成一次),成為話題來源。 await this.offlineEvent.maybeGenerate(characterId, now); @@ -138,7 +138,7 @@ export class DialogueService { this.outputFilter.filter(text, forbiddenWords), context.character.personalityArchetype, ); - this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now }); + await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now }); return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context }; } diff --git a/apps/api/src/llm/few-shot-examples.ts b/apps/api/src/llm/few-shot-examples.ts new file mode 100644 index 0000000..29d9061 --- /dev/null +++ b/apps/api/src/llm/few-shot-examples.ts @@ -0,0 +1,30 @@ +import type { EmotionTag } from "@kokorone/shared"; +import { pickTemplates } from "./template-library.js"; + +export interface FewShotExample { + emotionTag: EmotionTag; + tier: "low" | "high"; + userInput: string; + response: string; +} + +const SAMPLE_USER_INPUT: Record<"low" | "high", string> = { + low: "今天天氣真好呢。", + high: "謝謝你,我今天好開心!", +}; + +const SAMPLE_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"]; + +// R-3:F-7 的 Mock 模板庫在真實 Provider 這邊轉為 few-shot 範例——同一份模板資料, +// 從「隨機挑一句直接當回應」變成「示範這個性格原型在各種情緒下該有的語氣」。 +export function buildFewShotExamples(personalityArchetype: string): FewShotExample[] { + const examples: FewShotExample[] = []; + for (const tier of ["low", "high"] as const) { + for (const emotionTag of SAMPLE_EMOTIONS) { + const candidates = pickTemplates(personalityArchetype, emotionTag, tier); + if (candidates.length === 0) continue; + examples.push({ emotionTag, tier, userInput: SAMPLE_USER_INPUT[tier], response: candidates[0] }); + } + } + return examples; +} diff --git a/apps/api/src/llm/llm.module.ts b/apps/api/src/llm/llm.module.ts index 3db8caa..29be8d5 100644 --- a/apps/api/src/llm/llm.module.ts +++ b/apps/api/src/llm/llm.module.ts @@ -16,10 +16,11 @@ import { DialogueService } from "./dialogue.service.js"; import { DialogueController } from "./dialogue.controller.js"; // F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。 +// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。 function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) { const providerName = process.env.LLM_PROVIDER ?? "mock"; if (providerName === "claude") { - log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外"); + log("啟動", "INF", `LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=${process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1"})`); return claude; } return mock; diff --git a/apps/api/src/llm/prompt-builder.ts b/apps/api/src/llm/prompt-builder.ts new file mode 100644 index 0000000..24da42d --- /dev/null +++ b/apps/api/src/llm/prompt-builder.ts @@ -0,0 +1,62 @@ +import type { GenerationContext } from "./types.js"; +import { buildFewShotExamples } from "./few-shot-examples.js"; + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +const RELATIONSHIP_STAGE_LABEL: Record = { + STRANGER: "陌生", + ACQUAINTANCE: "認識", + FRIEND: "朋友", + CLOSE_AMBIGUOUS: "摯友-曖昧", + BONDED: "羈絆", +}; + +function buildSystemPrompt(context: GenerationContext): string { + const { character, emotion, relationship, retrievedMemories } = context; + const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage; + + const memoryLines = + retrievedMemories.length > 0 + ? retrievedMemories.map((m) => `- ${m.content}`).join("\n") + : "(目前沒有可用的相關記憶)"; + + const fewShot = buildFewShotExamples(character.personalityArchetype); + const fewShotLines = fewShot + .map((ex) => `情境:情緒=${ex.emotionTag}、親密度=${ex.tier}\n使用者:${ex.userInput}\n角色:${ex.response}`) + .join("\n\n"); + + return [ + `你正在扮演一個戀愛陪伴系統中的角色,必須完全以第一人稱、角色本人的口吻回應,不要說明你是 AI,不要跳出角色。`, + ``, + `【角色設定】`, + `性格原型:${character.personalityArchetype}`, + `說話風格:${character.speechStyle}`, + `喜好/厭惡:${character.likesDislikes}`, + `基本資訊:${character.basicInfo}`, + ``, + `【當前狀態】`, + `情緒:${emotion.dominant}(語氣:${emotion.style.tone},句長:${emotion.style.sentenceLength},主動性:${emotion.style.initiative})`, + `關係:親密度 ${relationship.intimacy}、信任度 ${relationship.trust}、階段:${stageLabel}`, + ``, + `【相關記憶】`, + memoryLines, + ``, + `【輸出格式】`, + `回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`, + ``, + `【語氣範例(僅供語氣參考,不要照抄內容)】`, + fewShotLines, + ].join("\n"); +} + +export function buildMessages(context: GenerationContext): ChatMessage[] { + const messages: ChatMessage[] = [{ role: "system", content: buildSystemPrompt(context) }]; + for (const turn of context.history) { + messages.push({ role: turn.role === "character" ? "assistant" : "user", content: turn.content }); + } + messages.push({ role: "user", content: context.userInput }); + return messages; +}