feat(llm 對話): 新增 prompt builder 與 few-shot 範例組裝,強化 Claude 對話品質
This commit is contained in:
@@ -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<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 {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async generate(_context: GenerationContext): Promise<GeneratedResponse> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
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 缺失)");
|
||||
}
|
||||
return { text, actions: extractActions(text) };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield
|
||||
async *stream(_context: GenerationContext): AsyncIterable<string> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user