From 6bdca7ccf71c18ae79fad2d2052c3be6a4dd4b54 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 14 Sep 2026 01:14:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E9=80=9A=E5=A4=9A=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E5=AF=B9=E6=AF=94=E6=8E=A5=E5=8F=A3=E4=B8=8E?= =?UTF-8?q?=E9=87=87=E7=BA=B3=E7=BB=93=E6=9E=9C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冻结消息上下文后并行重放多个配置,并顺带挂上定价接口路由。 Co-authored-by: Cursor --- src/server/model-compare.ts | 238 ++++++++++++++++++++++++++++++++++++ src/server/web-server.ts | 49 +++++++- tests/model-compare.test.ts | 56 +++++++++ 3 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/server/model-compare.ts create mode 100644 tests/model-compare.test.ts diff --git a/src/server/model-compare.ts b/src/server/model-compare.ts new file mode 100644 index 0000000..b4b0f36 --- /dev/null +++ b/src/server/model-compare.ts @@ -0,0 +1,238 @@ +import { randomUUID } from "node:crypto"; +import { + getApiProfile, + listApiProfiles, + profileToLlmConfig, +} from "../config/api-profiles.js"; +import { + OpenAiCompatibleProvider, + type ChatMessage as LlmChatMessage, + type ToolCallPayload, +} from "../llm/client.js"; +import type { LlmContextTrace } from "../types/context-trace.js"; +import type { GenerationParameters } from "../types/preset.js"; + +export type CompareCandidateStatus = "pending" | "running" | "done" | "error"; + +export type CompareCandidate = { + profileId: string; + profileName: string; + model: string; + status: CompareCandidateStatus; + content?: string; + thinking?: string; + error?: string; + usage?: { + totalTokens?: number; + cachedTokens?: number; + cacheMissTokens?: number; + }; + finishedAt?: string; +}; + +export type ModelCompareJob = { + id: string; + sessionId: string; + messageId: string; + createdAt: string; + status: "running" | "done" | "error"; + /** 原消息正文,便于预览对照 */ + original: { + text: string; + thinking?: string; + model?: string; + }; + candidates: CompareCandidate[]; +}; + +const jobs = new Map(); +const JOB_TTL_MS = 30 * 60 * 1000; + +function pruneJobs(): void { + const cutoff = Date.now() - JOB_TTL_MS; + for (const [id, job] of jobs) { + if (Date.parse(job.createdAt) < cutoff) jobs.delete(id); + } +} + +/** 把 contextTrace 里冻住的条目还原成可发给 LLM 的 messages */ +export function reconstructLlmMessages( + trace: LlmContextTrace, +): LlmChatMessage[] { + const out: LlmChatMessage[] = []; + for (const item of trace.messages) { + const role = String(item.role ?? "user"); + const raw = typeof item.content === "string" ? item.content : ""; + const restored = tryParseExtras(raw); + if (restored) { + out.push(restored); + continue; + } + if (role === "assistant") { + out.push({ role: "assistant", content: raw || null }); + } else if (role === "tool") { + out.push({ role: "tool", content: raw, tool_call_id: "unknown" }); + } else if (role === "system") { + out.push({ role: "system", content: raw }); + } else { + out.push({ role: "user", content: raw }); + } + } + return out; +} + +function tryParseExtras(raw: string): LlmChatMessage | null { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed) as { + role?: string; + content?: string | null; + tool_calls?: ToolCallPayload[]; + tool_call_id?: string; + }; + if (!parsed || typeof parsed !== "object" || !parsed.role) return null; + if (parsed.role === "assistant") { + return { + role: "assistant", + content: parsed.content ?? null, + ...(Array.isArray(parsed.tool_calls) + ? { tool_calls: parsed.tool_calls } + : {}), + }; + } + if (parsed.role === "tool" && parsed.tool_call_id) { + return { + role: "tool", + content: typeof parsed.content === "string" ? parsed.content : "", + tool_call_id: parsed.tool_call_id, + }; + } + if (parsed.role === "system" || parsed.role === "user") { + return { + role: parsed.role, + content: typeof parsed.content === "string" ? parsed.content : "", + }; + } + } catch { + /* plain text */ + } + return null; +} + +export function createCompareJob(input: { + sessionId: string; + messageId: string; + profileIds: string[]; + original: ModelCompareJob["original"]; +}): ModelCompareJob { + pruneJobs(); + const uniqueIds = [...new Set(input.profileIds.map((id) => id.trim()).filter(Boolean))]; + if (uniqueIds.length === 0) { + throw new Error("请至少选择一个 API 配置"); + } + const candidates: CompareCandidate[] = uniqueIds.map((profileId) => { + const profile = getApiProfile(profileId); + if (!profile?.apiKey?.trim()) { + throw new Error(`API 配置不可用:${profileId}`); + } + return { + profileId: profile.id, + profileName: profile.name, + model: profile.model, + status: "pending" as const, + }; + }); + const job: ModelCompareJob = { + id: randomUUID(), + sessionId: input.sessionId, + messageId: input.messageId, + createdAt: new Date().toISOString(), + status: "running", + original: input.original, + candidates, + }; + jobs.set(job.id, job); + return job; +} + +export function getCompareJob(jobId: string): ModelCompareJob | null { + pruneJobs(); + return jobs.get(jobId) ?? null; +} + +export function listProfilesForCompare(): Array<{ + id: string; + name: string; + model: string; + hasKey: boolean; +}> { + return listApiProfiles().map((p) => ({ + id: p.id, + name: p.name, + model: p.model, + hasKey: Boolean(p.apiKey?.trim()), + })); +} + +/** 并行用各 profile 重放同一 contextTrace(不改会话) */ +export async function runCompareJob( + job: ModelCompareJob, + trace: LlmContextTrace, +): Promise { + const messages = reconstructLlmMessages(trace); + if (messages.length === 0) { + job.status = "error"; + for (const c of job.candidates) { + c.status = "error"; + c.error = "上下文为空"; + c.finishedAt = new Date().toISOString(); + } + return job; + } + + const generation = trace.generation as GenerationParameters | undefined; + await Promise.all( + job.candidates.map(async (candidate) => { + candidate.status = "running"; + try { + const profile = getApiProfile(candidate.profileId); + if (!profile?.apiKey?.trim()) { + throw new Error("配置不存在或缺少 API Key"); + } + const llm = new OpenAiCompatibleProvider(profileToLlmConfig(profile)); + const result = await llm.complete(messages, { + generation, + caller: `model-compare:${candidate.profileId}`, + }); + candidate.content = result.content ?? ""; + candidate.thinking = result.reasoning; + candidate.usage = result.usage + ? { + totalTokens: result.usage.totalTokens, + cachedTokens: result.usage.cachedTokens, + cacheMissTokens: result.usage.cacheMissTokens, + } + : undefined; + if (result.model) candidate.model = result.model; + candidate.status = "done"; + } catch (err) { + candidate.status = "error"; + candidate.error = err instanceof Error ? err.message : String(err); + } finally { + candidate.finishedAt = new Date().toISOString(); + } + }), + ); + + const anyDone = job.candidates.some((c) => c.status === "done"); + job.status = anyDone ? "done" : "error"; + return job; +} + +export function findCandidate( + job: ModelCompareJob, + profileId: string, +): CompareCandidate | undefined { + return job.candidates.find((c) => c.profileId === profileId); +} diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 28dd48f..5cda9ea 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -70,6 +70,8 @@ const server = createServer(async (req, res) => { "GET /api/directors", "GET /api/modules", "GET /api/stats/tokens", + "GET /api/stats/pricing", + "POST /api/stats/pricing/sources", ], }); return; @@ -240,7 +242,7 @@ const server = createServer(async (req, res) => { } const messageActionMatch = sub.match( - /^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart)$/, + /^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart|compare)$/, ); if (req.method === "POST" && messageActionMatch) { const messageId = decodeURIComponent(messageActionMatch[1]); @@ -248,6 +250,7 @@ const server = createServer(async (req, res) => { const body = JSON.parse(await readBody(req).catch(() => "{}")) as { text?: string; direction?: string; + profileIds?: string[]; }; let view; try { @@ -277,6 +280,17 @@ const server = createServer(async (req, res) => { view = await sessionManager.deleteMessage(sessionId, messageId); } else if (action === "restart") { view = await sessionManager.restartFromMessage(sessionId, messageId); + } else if (action === "compare") { + const profileIds = Array.isArray(body.profileIds) + ? body.profileIds.filter((x): x is string => typeof x === "string") + : []; + const job = await sessionManager.startModelCompare( + sessionId, + messageId, + profileIds, + ); + json(res, 200, job); + return; } else { json(res, 400, { error: "未知 action" }); return; @@ -291,6 +305,39 @@ const server = createServer(async (req, res) => { return; } + const compareJobMatch = sub.match(/^\/compare\/([^/]+)(?:\/(adopt))?$/); + if (compareJobMatch) { + const jobId = decodeURIComponent(compareJobMatch[1]); + const adopt = compareJobMatch[2] === "adopt"; + try { + if (req.method === "GET" && !adopt) { + json(res, 200, sessionManager.getModelCompare(sessionId, jobId)); + return; + } + if (req.method === "POST" && adopt) { + const body = JSON.parse(await readBody(req).catch(() => "{}")) as { + profileId?: string; + }; + if (!body.profileId?.trim()) { + json(res, 400, { error: "缺少 profileId" }); + return; + } + const view = await sessionManager.adoptModelCompare( + sessionId, + jobId, + body.profileId.trim(), + ); + json(res, 200, view); + return; + } + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "对比操作失败", + }); + return; + } + } + if (req.method === "POST" && sub === "/lifecycle") { const body = JSON.parse(await readBody(req)) as { stage?: string }; if (body.stage !== "design" && body.stage !== "play") { diff --git a/tests/model-compare.test.ts b/tests/model-compare.test.ts new file mode 100644 index 0000000..ab89175 --- /dev/null +++ b/tests/model-compare.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { reconstructLlmMessages } from "../src/server/model-compare.js"; +import { buildContextTrace } from "../src/types/context-trace.js"; + +describe("model-compare reconstructLlmMessages", () => { + it("restores plain roles", () => { + const trace = buildContextTrace({ + caller: "test", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hi" }, + ], + model: "m1", + }); + expect(reconstructLlmMessages(trace)).toEqual([ + { role: "system", content: "sys" }, + { role: "user", content: "hi" }, + ]); + }); + + it("restores tool_calls extras frozen as JSON content", () => { + const trace = buildContextTrace({ + caller: "test", + messages: [ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "foo", arguments: "{}" }, + }, + ], + }, + { role: "tool", content: "ok", tool_call_id: "c1" }, + ], + }); + const msgs = reconstructLlmMessages(trace); + expect(msgs[0]).toMatchObject({ + role: "assistant", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "foo", arguments: "{}" }, + }, + ], + }); + expect(msgs[1]).toEqual({ + role: "tool", + content: "ok", + tool_call_id: "c1", + }); + }); +});