fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)

This commit is contained in:
mac
2026-07-20 08:48:56 +08:00
parent 42e23663ee
commit aadd2b04ae
28 changed files with 5881 additions and 329 deletions
+162 -43
View File
@@ -48,6 +48,17 @@ function grokDebugLog(entry) {
} catch {}
}
function seedanceDebugLog(entry) {
try {
const dir = path.join(os.homedir(), "Library", "Application Support", "codex-market");
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(
path.join(dir, "seedance-video-debug.jsonl"),
JSON.stringify({ time: new Date().toISOString(), ...entry }) + "\n"
);
} catch {}
}
function systemProxyUrl() {
const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || "";
if (envProxy) return envProxy;
@@ -81,7 +92,7 @@ function proxyAgentFor(targetUrl, proxyUrl) {
}
}
function requestBuffer(url, agent) {
function requestBuffer(url, agent, extraHeaders = {}) {
return new Promise((resolve, reject) => {
let u;
try {
@@ -95,13 +106,13 @@ function requestBuffer(url, agent) {
url,
{
agent,
headers: { "User-Agent": "codex-market" },
headers: { "User-Agent": "codex-market", ...extraHeaders },
},
(res) => {
const loc = res.headers.location;
if (res.statusCode >= 300 && res.statusCode < 400 && loc) {
res.resume();
requestBuffer(new URL(loc, url).toString(), agent).then(resolve, reject);
requestBuffer(new URL(loc, url).toString(), agent, extraHeaders).then(resolve, reject);
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
@@ -120,14 +131,14 @@ function requestBuffer(url, agent) {
}
// —— 下载任意远程资源 → base64 字符串 ——
async function downloadAsBase64(url) {
async function downloadAsBase64(url, extraHeaders = {}) {
const proxyUrl = systemProxyUrl();
let proxyErr = "";
if (proxyUrl) {
const agent = proxyAgentFor(url, proxyUrl);
if (agent) {
try {
return (await requestBuffer(url, agent)).toString("base64");
return (await requestBuffer(url, agent, extraHeaders)).toString("base64");
} catch (e) {
proxyErr = String((e && e.message) || e);
// 代理下载失败时再直连兜底,保留原行为。
@@ -137,7 +148,7 @@ async function downloadAsBase64(url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 120000);
try {
const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal: ctrl.signal });
const res = await fetch(url, { headers: { "User-Agent": "codex-market", ...extraHeaders }, signal: ctrl.signal });
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString("base64");
@@ -149,6 +160,18 @@ async function downloadAsBase64(url) {
}
}
function trustedDownloadHeaders(url, llm) {
let host = "";
try {
host = new URL(url).host.toLowerCase();
} catch {}
if (!host || !llm || !llm.key) return {};
if (host === "api.tokendun.com" || host.endsWith(".tokendun.com") || host === "pulseaify.com" || host.endsWith(".pulseaify.com")) {
return { Authorization: "Bearer " + llm.key };
}
return {};
}
// —— 组装 data URL ——
function toDataUrl(mime, b64) {
return `data:${mime};base64,${b64}`;
@@ -399,9 +422,39 @@ async function runTts(base, llm, params) {
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
// port 自 提示词提取/server.js
function findVideoUrl(o) {
function isLikelyVideoUrl(u) {
try {
const p = new URL(u).pathname.toLowerCase();
return /\.(mp4|mov|webm|m4v)(?:$|\.)/.test(p) || p.endsWith(".m3u8");
} catch {
return false;
}
}
function findLikelyVideoUrl(o) {
if (!o) return "";
if (typeof o === "string") return /^https?:\/\//.test(o) ? o : "";
if (typeof o === "string") return /^https?:\/\//.test(o) && isLikelyVideoUrl(o) ? o : "";
if (Array.isArray(o)) {
for (const v of o) {
const u = findLikelyVideoUrl(v);
if (u) return u;
}
return "";
}
if (typeof o === "object") {
for (const v of Object.values(o)) {
const u = findLikelyVideoUrl(v);
if (u) return u;
}
}
return "";
}
function findVideoUrl(o) {
const likely = findLikelyVideoUrl(o);
if (likely) return likely;
if (!o) return "";
if (typeof o === "string") return "";
if (Array.isArray(o)) {
for (const v of o) {
const u = findVideoUrl(v);
@@ -410,7 +463,7 @@ function findVideoUrl(o) {
return "";
}
if (typeof o === "object") {
for (const k of ["video_url", "result_url", "url"]) {
for (const k of ["video_url", "result_url", "content_url", "download_url", "file_url", "output_url"]) {
const v = o[k];
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
}
@@ -432,13 +485,16 @@ function normalizeVideoStatus(raw) {
}
function joinUrl(base, p) {
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p);
const root = trimSlash(base);
const suffix = String(p || "").startsWith("/") ? String(p || "") : "/" + String(p || "");
if (/\/v1$/i.test(root) && /^\/v1(?:\/|$)/i.test(suffix)) return root + suffix.slice(3);
return root + suffix;
}
function pickTaskId(o) {
if (!o || typeof o !== "object") return "";
for (const k of ["request_id", "requestId", "task_id", "taskId", "id"]) {
if (typeof o[k] === "string" && o[k]) return o[k];
if ((typeof o[k] === "string" || typeof o[k] === "number") && String(o[k])) return String(o[k]);
}
for (const k of ["data", "result", "task"]) {
const v = o[k];
@@ -456,27 +512,52 @@ function pushRecentPoll(arr, value) {
if (arr.length > 3) arr.shift();
}
async function resolveGrokImage(v) {
const s = String(v || "").trim();
if (!s) return "";
if (/^data:image\/[^;,]+;base64,/i.test(s)) return await uploadMedia(s);
if (/^data:/i.test(s)) throw new Error("Grok 首帧图需为 image/* 的 base64 data URL 或公网图片 URL");
if (/^https?:\/\//i.test(s)) return s;
throw new Error("Grok 首帧图需为 base64 data URL 或公网 http(s) 地址");
}
function grokSizeFromParams(params) {
const rawSize = String((params && params.size) || "").trim();
if (/^\d+x\d+$/i.test(rawSize)) return rawSize.toLowerCase();
const resolution = String((params && params.resolution) || "720p").trim().toLowerCase();
const shortSide = resolution === "1080p" ? 1080 : resolution === "480p" ? 480 : 720;
const roundEven = (n) => Math.max(2, Math.round(n / 2) * 2);
const ratio = String((params && (params.aspectRatio || params.ratio)) || "16:9").trim();
const match = ratio.match(/^(\d+)\s*:\s*(\d+)$/);
if (!match) return `${roundEven((shortSide * 16) / 9)}x${shortSide}`;
const rw = Math.max(1, parseInt(match[1], 10));
const rh = Math.max(1, parseInt(match[2], 10));
if (rw === rh) return `${shortSide}x${shortSide}`;
if (rw > rh) return `${roundEven((shortSide * rw) / rh)}x${shortSide}`;
return `${shortSide}x${roundEven((shortSide * rh) / rw)}`;
}
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
// base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。
async function runVideo(base, llm, params) {
const model = params.model;
if (!model) return { ok: false, error: "请先填写视频模型" };
const createUrl = base + (params.endpointPath || "/v1/video/generations");
const model = String(params.model || "").trim();
const createUrl = joinUrl(base, params.endpointPath || "/v1/video/generations");
// 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first
let mode = String(params.mode || "text");
if (mode === "image") mode = "first";
// —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 ——
// prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询
const body = { model, prompt: (params.prompt || "").trim(), async: true };
const body = { prompt: (params.prompt || "").trim(), async: true };
if (model) body.model = model; // 文档允许省略,交给上游使用默认 video-v1
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; // 顶层 ratio
// 注:文档的 quality 字段示例值是 "hd",与 UI 的 720p/1080p 不同类,未确认合法值前不下发,避免 400
if ([5, 10, 15].includes(dur)) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio;
if (params.quality && params.quality !== "auto") body.quality = params.quality;
try {
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传图床换公网 URL
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
@@ -492,7 +573,7 @@ async function runVideo(base, llm, params) {
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" };
body.images = resolved;
body[params.referenceField === "ref_assets" ? "ref_assets" : "images"] = resolved;
} else if (mode === "video") {
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
}
@@ -500,6 +581,22 @@ async function runVideo(base, llm, params) {
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
// —— 建任务 ——
const debugBase = {
base: hostOf(base),
create_url: createUrl,
mode,
request: {
model: body.model || "<upstream-default>",
duration: body.duration || "",
ratio: body.ratio || "",
quality: body.quality || "",
async: body.async,
has_image: typeof body.image === "string" && /^https?:\/\//i.test(body.image),
image_count: Array.isArray(body.images) ? body.images.length : 0,
ref_asset_count: Array.isArray(body.ref_assets) ? body.ref_assets.length : 0,
},
};
seedanceDebugLog({ type: "seedance_video_create", ...debugBase });
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
@@ -507,21 +604,23 @@ async function runVideo(base, llm, params) {
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
seedanceDebugLog({ type: "seedance_video_create_failed", ...debugBase, status: cres.status, detail: detail.slice(0, 500) });
return { ok: false, error: `视频建任务失败(${cres.status}):POST ${createUrl} model=${body.model || "<默认>"} → ${detail.slice(0, 300)}` };
}
let data;
const rawCreate = await cres.text().catch(() => "");
try {
data = await cres.json();
data = JSON.parse(rawCreate);
} catch {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` };
seedanceDebugLog({ type: "seedance_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) });
return { ok: false, error: `视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` };
}
const taskId =
(data.data && (data.data.task_id || data.data.id)) ||
data.task_id ||
data.id ||
(data.result && (data.result.task_id || data.result.id));
if (!taskId) return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
const taskId = pickTaskId(data);
if (!taskId) {
seedanceDebugLog({ type: "seedance_video_missing_task_id", ...debugBase, response: JSON.stringify(data).slice(0, 500) });
return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
}
seedanceDebugLog({ type: "seedance_video_accepted", ...debugBase, task_id: taskId });
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
@@ -548,16 +647,25 @@ async function runVideo(base, llm, params) {
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
const b64 = await downloadAsBase64(url);
let b64;
try {
b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm));
} catch (e) {
seedanceDebugLog({ type: "seedance_video_download_failed", ...debugBase, task_id: taskId, status: st, url, error: String((e && e.message) || e) });
throw e;
}
seedanceDebugLog({ type: "seedance_video_done", ...debugBase, task_id: taskId, status: st, url });
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}
if (st === "failed") {
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
const raw = JSON.stringify(pd).slice(0, 500);
seedanceDebugLog({ type: "seedance_video_failed", ...debugBase, task_id: taskId, status: st, response: raw });
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
}
// queued/running → 继续等
}
seedanceDebugLog({ type: "seedance_video_timeout", ...debugBase, task_id: taskId, last_poll: lastPoll || "" });
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
@@ -566,37 +674,39 @@ async function runVideo(base, llm, params) {
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
async function runGrokVideo(base, llm, params) {
let model = params.model || "grok-imagine-video";
let model = params.model || "grok-video-1.5";
const prompt = (params.prompt || "").trim();
if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" };
const body = { model, prompt };
if (params.resolution && params.resolution !== "auto") body.resolution = params.resolution;
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur;
const seconds = String(params.seconds || "6").trim();
const body = { model, prompt, seconds, size: grokSizeFromParams(params) };
let mode = String(params.mode || "text");
if (mode === "image") mode = "first";
try {
if (
mode === "text" &&
/grok-imagine-video-1\.5/i.test(model) &&
body.resolution === "1080p" &&
Number(body.duration) === 15
/grok-(?:imagine-)?video-1\.5/i.test(model) &&
body.size === "1920x1080" &&
Number(body.seconds) === 15
) {
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
}
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
const img = await resolveGrokImage(params.imageUrl || params.image);
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
body.image = { image_url: img };
body.image = { url: img };
} else if (mode === "reference") {
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
if (!resolved.length) return { ok: false, error: "Grok 多参考图模式请至少提供 1 张参考图" };
model = "grok-imagine-video";
body.model = model;
delete body.size;
body.resolution = "720p";
delete body.seconds;
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur;
if (!body.aspect_ratio) body.aspect_ratio = params.aspectRatio || "16:9";
body.reference_images = resolved.map((url) => ({ url }));
} else if (mode !== "text") {
@@ -611,10 +721,13 @@ async function runGrokVideo(base, llm, params) {
request: {
model: body.model,
prompt: body.prompt,
seconds: body.seconds || "",
size: body.size || "",
resolution: body.resolution || "",
duration: body.duration || "",
aspect_ratio: body.aspect_ratio || "",
has_image: !!body.image,
image_is_url: !!(body.image && typeof body.image === "object" && typeof body.image.url === "string" && /^https?:\/\//i.test(body.image.url)),
reference_image_count: Array.isArray(body.reference_images) ? body.reference_images.length : 0,
},
};
@@ -669,7 +782,13 @@ async function runGrokVideo(base, llm, params) {
lastPoll = "status=succeeded,但暂未返回视频地址";
continue;
}
const b64 = await downloadAsBase64(url);
let b64;
try {
b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm));
} catch (e) {
grokDebugLog({ type: "grok_video_download_failed", ...debugBase, request_id: requestId, status: st, url, error: String((e && e.message) || e) });
throw e;
}
grokDebugLog({ type: "grok_video_done", ...debugBase, request_id: requestId, status: st, url });
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}
+259
View File
@@ -0,0 +1,259 @@
// claudedesk.js —— 把中转分组接入 Claude 桌面 App(图形客户端),免登录直用中转。
// 关键:走 App 自己的「已保存配置库」(configLibrary),不是托管偏好(defaults/.mobileconfig)。
// 托管偏好那条路是企业 MDM 通道,会强制 SSO 登录,个人账号卡登录页 —— 早期踩过坑,已弃用。
// 做法对照 cc-switch 源码 src-tauri/src/claude_desktop_config.rs(farion1231/cc-switch)核实。
// 详见 memory/claude-desktop-3p-config.md。
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const router = require("./router");
const PLATFORM = process.platform; // 'darwin' | 'win32' | ...
const CONFIG_FILE = "claude_desktop_config.json";
const CONFIG_LIBRARY_DIR = "configLibrary";
const PROFILE_ID = "00000000-0000-4000-8000-000000157210";
const PROFILE_NAME = "TokenDun";
// 备份根:复用现有 ~/.codex/backups_state 约定
const BACKUP_DIR = path.join(os.homedir(), ".codex", "backups_state", "claudedesk", "official");
// —— 平台路径 ——
// 返回 { normalConfig, threepConfig, libDir, profilePath, metaPath } 或 null(不支持)
function platformPaths() {
if (PLATFORM === "darwin") {
const appSupport = path.join(os.homedir(), "Library", "Application Support");
return dirsToPaths(path.join(appSupport, "Claude"), path.join(appSupport, "Claude-3p"));
}
if (PLATFORM === "win32") {
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
return dirsToPaths(pickWinDir(localAppData, "Claude"), pickWinDir(localAppData, "Claude-3p"));
}
return null;
}
// Windows 下 App 目录名可能带后缀,先精确后模糊匹配
function pickWinDir(base, exact) {
const p = path.join(base, exact);
if (fs.existsSync(p)) return p;
try {
const hit = fs.readdirSync(base).find((n) => {
const full = path.join(base, n);
const isDir = (() => { try { return fs.statSync(full).isDirectory(); } catch { return false; } })();
if (!isDir) return false;
return exact === "Claude-3p" ? /claude.*3p/i.test(n) : (/claude/i.test(n) && !/3p/i.test(n));
});
if (hit) return path.join(base, hit);
} catch {}
return p;
}
function dirsToPaths(normalDir, threepDir) {
const libDir = path.join(threepDir, CONFIG_LIBRARY_DIR);
return {
normalConfig: path.join(normalDir, CONFIG_FILE),
threepConfig: path.join(threepDir, CONFIG_FILE),
libDir,
profilePath: path.join(libDir, `${PROFILE_ID}.json`),
metaPath: path.join(libDir, "_meta.json"),
};
}
// —— JSON 读写小工具 ——
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return null; } };
const readJsonOrEmpty = (p) => { const v = readJson(p); return v && typeof v === "object" && !Array.isArray(v) ? v : {}; };
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
}
const isSupported = () => PLATFORM === "darwin" || PLATFORM === "win32";
// —— gateway profile 本体(写进 configLibrary/<id>.json)——
function buildGatewayProfile(group) {
const oneM = new Set(Array.isArray(group.oneMillionModels) ? group.oneMillionModels : []);
const profile = {
coworkEgressAllowedHosts: ["*"],
disableDeploymentModeChooser: true,
inferenceGatewayApiKey: group.key || "",
inferenceGatewayAuthScheme: "bearer",
inferenceGatewayBaseUrl: String(group.baseUrl || "").replace(/\/+$/, ""),
inferenceProvider: "gateway",
};
if (group.model) {
const item = { id: group.model };
if (oneM.has(group.model)) item.supports1m = true; // 1M 长上下文标记(不是 beta 头)
profile.inferenceModels = [item];
}
return profile;
}
// —— deploymentMode 写入(就地改,不动其它字段,尤其保留官方账号绑定 preferences)——
function writeDeploymentMode(p, mode) {
const v = readJsonOrEmpty(p);
v.deploymentMode = mode;
writeJson(p, v);
}
// —— _meta.json:登记/清除本 profile ——
function writeMeta(metaPath, applied) {
const v = readJsonOrEmpty(metaPath);
let entries = Array.isArray(v.entries) ? v.entries : [];
entries = entries.filter((e) => !(e && e.id === PROFILE_ID)); // 去重
if (applied) {
entries.push({ id: PROFILE_ID, name: PROFILE_NAME });
v.appliedId = PROFILE_ID;
} else if (v.appliedId === PROFILE_ID) {
// 取消:若当前 applied 指向本 profile,切到剩余任一或清空
const next = entries.find((e) => e && e.id);
if (next) v.appliedId = next.id; else delete v.appliedId;
}
v.entries = entries;
writeJson(metaPath, v);
}
// ============================================================================
// 备份 / 还原(防「回不去官方登录」)—— 幂等 write-once 快照那 4 个文件
// ============================================================================
const SNAP_FILES = (p) => [
["normalConfig", p.normalConfig],
["threepConfig", p.threepConfig],
["profile", p.profilePath],
["meta", p.metaPath],
];
function snapshotOfficial() {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const marker = path.join(BACKUP_DIR, "done.json");
if (fs.existsSync(marker)) return { firstTime: false, dir: BACKUP_DIR };
const p = platformPaths();
const meta = { ts: Date.now(), platform: PLATFORM, files: {} };
if (p) {
for (const [key, src] of SNAP_FILES(p)) {
const dst = path.join(BACKUP_DIR, key + ".json");
if (fs.existsSync(src)) {
try { fs.copyFileSync(src, dst); meta.files[key] = { existed: true }; try { fs.chmodSync(dst, 0o600); } catch {} }
catch { meta.files[key] = { existed: true, copyFailed: true }; }
} else {
meta.files[key] = { existed: false }; // 记录原本不存在 → 还原时删除
}
}
}
fs.writeFileSync(marker, JSON.stringify(meta, null, 2));
return { firstTime: true, dir: BACKUP_DIR };
}
// 还原官方:把中转配置抹掉,deploymentMode 回 1p,App 恢复官方账号登录。
// 优先用首份快照精确复原;无快照则按规则清理(删 profile、meta 清 entry、config 回 1p)。
function restoreOfficial() {
if (!isSupported()) return { ok: false, error: "当前系统不支持(仅 macOS / Windows)" };
const p = platformPaths();
if (!p) return { ok: false, error: "无法定位 Claude 桌面 App 配置目录" };
const marker = path.join(BACKUP_DIR, "done.json");
const meta = readJson(marker);
try {
if (meta && meta.files) {
// 精确复原:有备份写回,标记「原本不存在」的删掉
for (const [key, src] of SNAP_FILES(p)) {
const info = meta.files[key] || {};
const bak = path.join(BACKUP_DIR, key + ".json");
if (info.existed && fs.existsSync(bak)) {
fs.mkdirSync(path.dirname(src), { recursive: true });
fs.copyFileSync(bak, src);
} else if (!info.existed && fs.existsSync(src)) {
try { fs.unlinkSync(src); } catch {}
}
}
} else {
// 无备份:按规则清理回官方
writeDeploymentMode(p.normalConfig, "1p");
writeDeploymentMode(p.threepConfig, "1p");
if (fs.existsSync(p.profilePath)) { try { fs.unlinkSync(p.profilePath); } catch {} }
writeMeta(p.metaPath, null);
}
} catch (e) {
return { ok: false, error: "还原失败:" + (e.message || e) };
}
return { ok: true, message: "已还原 Claude 官方配置,请重启 Claude 桌面 App(会回到账号登录)" };
}
// ============================================================================
// 一键接入(写 configLibrary,免登录)
// ============================================================================
function applyGroup(groupId) {
if (!isSupported()) return { ok: false, error: "当前系统暂不支持(仅 macOS / Windows)" };
const g = router.groupById(groupId);
if (!g) return { ok: false, error: "未找到该分组" };
if (g.isDefault) return { ok: false, error: "默认直连分组不能接入" };
if (!g.baseUrl || !g.key) return { ok: false, error: "该分组缺少 API 地址或密钥" };
if ((g.mode || "responses") !== "claude") {
return { ok: false, error: "Claude 桌面 App 只接受 Anthropic 协议(/v1/messages)分组,请把该分组的接口类型改成 Claude Code" };
}
const p = platformPaths();
if (!p) return { ok: false, error: "无法定位 Claude 桌面 App 配置目录(未安装?)" };
// 写前强制备份官方现状(幂等 write-once)
let backup;
try { backup = snapshotOfficial(); } catch (e) { return { ok: false, error: "备份官方配置失败:" + (e.message || e) }; }
// 失败回滚:先记住本次要写的 4 个文件的当前内容
const rollback = SNAP_FILES(p).map(([k, f]) => ({ f, content: fs.existsSync(f) ? fs.readFileSync(f) : null }));
try {
const profile = buildGatewayProfile(g);
writeDeploymentMode(p.normalConfig, "3p");
writeDeploymentMode(p.threepConfig, "3p");
writeJson(p.profilePath, profile);
writeMeta(p.metaPath, true);
} catch (e) {
// 回滚
for (const r of rollback) {
try { if (r.content === null) { if (fs.existsSync(r.f)) fs.unlinkSync(r.f); } else fs.writeFileSync(r.f, r.content); } catch {}
}
return { ok: false, error: "写入 Claude 桌面配置失败:" + (e.message || e) };
}
const has1m = Array.isArray(g.oneMillionModels) && g.model && g.oneMillionModels.includes(g.model);
return {
ok: true,
firstTimeBackup: backup.firstTime,
has1m: !!has1m,
message: "已接入 Claude 桌面 App,请重启 App 生效(无需登录,直接用中转)。官方配置已备份,可随时一键还原。",
};
}
// 导出 profile JSON(供高级用户手动放置 / 备份)
function exportConfig(groupId, format) {
const g = router.groupById(groupId);
if (!g) return { ok: false, error: "未找到该分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "该分组缺少 API 地址或密钥" };
const profile = buildGatewayProfile(g);
const content = JSON.stringify(profile, null, 2) + "\n";
return { ok: true, content, filename: `${PROFILE_ID}.json`, ext: "json" };
}
// 复制片段(configLibrary profile 内容)
function snippet(groupId) {
const r = exportConfig(groupId, "json");
return r.ok ? { ok: true, text: r.content } : r;
}
function status() {
const marker = path.join(BACKUP_DIR, "done.json");
const meta = readJson(marker);
const p = platformPaths();
let applied = false;
if (p) {
const cfg = readJson(p.threepConfig) || readJson(p.normalConfig);
const m = readJson(p.metaPath);
applied = !!(cfg && cfg.deploymentMode === "3p" && m && m.appliedId === PROFILE_ID);
}
return {
supported: isSupported(),
platform: PLATFORM,
hasBackup: !!meta,
backupTs: meta ? meta.ts : 0,
currentApplied: applied,
};
}
module.exports = {
status, applyGroup, restoreOfficial, exportConfig, snippet,
// 供测试
buildGatewayProfile, platformPaths, snapshotOfficial, PROFILE_ID,
};
+162
View File
@@ -0,0 +1,162 @@
// prompt-analysis.js —— 图片反推生图提示词(移植自「提示词提取」项目)
// LLM 通道走本应用的 router 分组(直接读中转分组列表),与 team.js 同模式。
"use strict";
const router = require("./router");
const { SYSTEM_PROMPT, USER_INSTRUCTION } = require("./team-prompt");
// ============================================================================
// 以下 <CORE> 自 提示词提取/server.js 原样提取(extractJson / callChat),未改动逻辑。
// ============================================================================
function extractJson(text) {
if (!text) throw new Error('模型返回为空');
let t = text.trim();
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) t = fence[1].trim();
const start = t.indexOf('{');
const end = t.lastIndexOf('}');
if (start !== -1 && end !== -1 && end > start) t = t.slice(start, end + 1);
const tryParse = (s) => { try { return JSON.parse(s); } catch (_) { return undefined; } };
let r = tryParse(t);
if (r !== undefined) return r;
r = tryParse(t.replace(/,\s*([}\]])/g, '$1'));
if (r !== undefined) return r;
throw new Error('模型输出的 JSON 格式有误,请重试');
}
// 流式调用:空闲超时——120 秒收不到任何数据就判定挂起并中止
const CHAT_IDLE_MS = 120000;
function chatTimeoutError() {
return Object.assign(new Error('模型长时间无响应(可能渠道不稳或模型太慢),请重试或换个更快的模型'), { status: 504 });
}
async function callChat(gwBase, apiKey, model, messages, maxTokens) {
const ctrl = new AbortController();
let idle;
const resetIdle = () => { clearTimeout(idle); idle = setTimeout(() => ctrl.abort(), CHAT_IDLE_MS); };
resetIdle();
let upstream;
try {
upstream = await fetch(`${gwBase}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, temperature: 0.4, max_tokens: maxTokens, messages, stream: true }),
signal: ctrl.signal,
});
} catch (e) {
clearTimeout(idle);
if (e.name === 'AbortError') throw chatTimeoutError();
throw e;
}
if (!upstream.ok) {
clearTimeout(idle);
const detail = await upstream.text().catch(() => '');
const err = new Error(`上游模型调用失败 (${upstream.status})`);
err.status = upstream.status === 401 ? 401 : 502;
err.detail = detail.slice(0, 300);
throw err;
}
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let content = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
resetIdle();
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
try {
const j = JSON.parse(data);
const delta = j.choices?.[0]?.delta;
if (delta?.content) content += delta.content;
else if (j.choices?.[0]?.message?.content) content += j.choices[0].message.content;
} catch (_) { /* 跳过 keep-alive / 非 JSON 行 */ }
}
}
} catch (e) {
if (e.name === 'AbortError') throw chatTimeoutError();
throw e;
} finally {
clearTimeout(idle);
}
if (!content) throw new Error('模型未返回内容(可能超时或渠道异常)');
return content;
}
// ============================================================================
// 适配层:LLM 走 router 分组
// ============================================================================
// 把分组 baseUrl 归一成「+ /chat/completions 即正确」的基址(对齐 team.js)
function chatBase(baseUrl) {
const base = String(baseUrl || "").replace(/\/+$/, "");
if (/\/chat\/completions$/.test(base)) return base.replace(/\/chat\/completions$/, "");
if (/\/v\d+$/.test(base)) return base;
return base + "/v1";
}
// 取分组:优先按 id,否则用默认 LLM 分组
function resolveGroup(groupId) {
let g = null;
if (groupId != null && groupId !== "" && typeof router.groupById === "function") {
g = router.groupById(groupId);
}
if (!g && typeof router.llmGroup === "function") g = router.llmGroup();
if (!g) throw new Error("没有可用的中转分组,请先在「路由」里配置好带 baseUrl 和密钥的分组");
if (!g.baseUrl || !g.key) throw new Error(`分组「${g.name || g.id}」缺少 baseUrl 或密钥`);
return g;
}
// 获取所有可用分组列表(给前端下拉用)
function listGroups() {
try {
return router.usableGroups().map((g) => ({
id: g.id,
name: g.name || g.id,
host: (() => { try { return new (require("url").URL)(g.baseUrl).host; } catch (_) { return ""; } })(),
mode: g.mode || "chat",
}));
} catch (_) {
return [];
}
}
// 拉取指定分组的模型列表
async function listModels(groupId) {
const g = resolveGroup(groupId);
const models = await router.fetchModels(g.baseUrl, g.key);
return models;
}
// 核心:图片 → 提示词分析
async function analyze({ image, model, groupId } = {}) {
if (!image || typeof image !== "string") throw new Error("缺少图片数据");
if (!model) throw new Error("未选择模型");
const g = resolveGroup(groupId);
const gwBase = chatBase(g.baseUrl);
const content = await callChat(gwBase, g.key, model, [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: [
{ type: "text", text: USER_INSTRUCTION },
{ type: "image_url", image_url: { url: image } },
],
},
], 2000);
return { result: extractJson(content), model };
}
module.exports = { analyze, listGroups, listModels };
+321 -51
View File
@@ -36,12 +36,18 @@ function dec(s) {
return "";
}
// —— 配置持久化 —— config: { port, routerKey, activeId, groups:[{id,name,baseUrl,key,model}] }
// —— 配置持久化 —— config: { port, routerKey, activeId, activeClaudeId, groups:[{id,name,baseUrl,key,model}] }
// 两个激活槽,按协议族各留一个,让 Codex/GPT 与 Claude Code 能同时激活、互不顶替:
// activeId —— OpenAI 协议族(Codex/GPT:mode = responses / chat,含默认分组)
// activeClaudeId —— Anthropic 协议族(Claude Code:mode = claude)
let config = null;
function defaultConfig() {
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, groups: [] };
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, activeClaudeId: null, groups: [] };
}
// 分组所属协议族:claude 模式 = Anthropic 族,其余(responses/chat/默认)= OpenAI 族
function isClaudeGroup(g) { return !!g && g.mode === "claude"; }
function load() {
if (config) return config;
try {
@@ -60,8 +66,28 @@ function load() {
config = defaultConfig();
}
ensureDefault();
migrateActiveSlots();
return config;
}
// 老配置只有单一 activeId:若它指向的是 claude 分组,迁移到 activeClaudeId,
// 并为空出来的 OpenAI 槽自动选一个非 claude 分组,保证两族各有默认激活。
function migrateActiveSlots() {
if (!("activeClaudeId" in config)) config.activeClaudeId = null;
const act = config.groups.find((g) => g.id === config.activeId);
if (act && isClaudeGroup(act)) {
if (!config.activeClaudeId) config.activeClaudeId = act.id;
// 优先选真正的中转分组(非默认、非 claude);没有再退到默认分组
const proxy = config.groups.find((g) => !isClaudeGroup(g) && !g.isDefault);
const fallback = config.groups.find((g) => !isClaudeGroup(g));
config.activeId = (proxy || fallback || {}).id || null;
}
// 补齐 claude 槽:若从未设过,且存在 claude 分组,默认选第一个
if (!config.activeClaudeId) {
const cg = config.groups.find((g) => isClaudeGroup(g));
if (cg) config.activeClaudeId = cg.id;
}
}
function save() {
const out = {
...config,
@@ -82,9 +108,17 @@ function save() {
} catch {}
}
function activeGroup() {
// 取激活分组。wantClaude=true 取 Anthropic 族(activeClaudeId),否则取 OpenAI 族(activeId)。
function activeGroup(wantClaude) {
load();
return config.groups.find((g) => g.id === config.activeId) || null;
const id = wantClaude ? config.activeClaudeId : config.activeId;
return config.groups.find((g) => g.id === id) || null;
}
// 判断一个进来的请求是否是 Anthropic 协议(Claude Code):走 /v1/messages,或带 anthropic 头。
function reqIsAnthropic(req) {
const h = req.headers || {};
return /\/v1\/messages/.test(req.url || "") || "anthropic-version" in h || "x-api-key" in h;
}
// —— 默认分组:客户原本的 Codex 配置(自己账号,直连)——
@@ -243,8 +277,12 @@ function catalogPath() {
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
}
// 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩
// 铁律:只要分组指定了 model,就必须保证它在目录里存在(visibility=list)。
// 否则 config.toml 的根级 model 指向一个「目录里没有的模型」,Codex 会以未知模型启动、
// 无法解析出该模型 → 侧栏历史线程还原失败 = 用户看到「切模型后会话记录消失」。
// contextWindow 只是「可选的窗口覆盖」,绝不能作为「是否登记进目录」的前置条件。
function applyModelCatalog(group) {
if (!group || group.isDefault || !group.model || !group.contextWindow) return;
if (!group || group.isDefault || !group.model) return; // 不再要求 contextWindow
const p = catalogPath();
let cat;
try {
@@ -253,20 +291,23 @@ function applyModelCatalog(group) {
return; // 无目录文件就不动
}
if (!cat.models || !cat.models.length) return;
const win = parseInt(group.contextWindow, 10);
if (!win) return;
const win = parseInt(group.contextWindow, 10); // 未填 → NaN(假值),此时保留/沿用既有窗口
const existing = cat.models.find((m) => m.slug === group.model);
if (existing) {
existing.context_window = win;
existing.max_context_window = win;
existing.visibility = "list";
if (win) {
existing.context_window = win;
existing.max_context_window = win;
}
existing.visibility = "list"; // 已在目录:无论有无窗口,都确保可见
} else {
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
// 未指定窗口时,用模板自带窗口兜底(再退到 128000),保证新模型有合法窗口
const w = win || parseInt(tpl.context_window, 10) || 128000;
tpl.slug = group.model;
tpl.display_name = group.model;
tpl.description = group.model;
tpl.context_window = win;
tpl.max_context_window = win;
tpl.context_window = w;
tpl.max_context_window = w;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
@@ -276,9 +317,100 @@ function applyModelCatalog(group) {
} catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 把 config.toml 根级 model 设成指定值
// 【根治「切模型丢会话」】把分组中转站 /models 返回的全部文本模型,先登记进 Codex 模型目录。
// 关键:用户是在 Codex 本体的模型选择器里切模型的,那条路径不经过本 App,App 的 applyModelCatalog
// 不会触发;若目标 slug 不在目录里,Codex 会以未知模型启动、侧栏历史还原失败 = 会话"消失"。
// 因此必须【先回填】:把该中转能用的模型全部登记为 visibility=list,用户在 Codex 里选哪个都不丢。
// 非文本模型(画图/审阅/语音等)不是对话模型,排除;任何网络/解析失败都不动既有目录(离线安全)。
const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|tts|stt|embed|rerank|review|moderation|vision-ocr)/i;
async function syncCatalogFromProvider(group) {
try {
if (!group || group.isDefault || !group.baseUrl || !group.key) return { ok: false, skipped: true };
if (group.mode === "claude") return { ok: false, skipped: true }; // Claude 族不写 Codex 目录
let ids = [];
try {
ids = await fetchModels(group.baseUrl, group.key); // 复用既有 /v1/models · /models 探测
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) }; // 网络/接口失败 → 不动既有目录
}
const textIds = ids.filter((s) => !NON_TEXT_MODEL_RE.test(s));
if (!textIds.length) return { ok: false, error: "无可登记的文本模型" };
const p = catalogPath();
let cat;
try {
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return { ok: false, error: "无模型目录文件" };
}
if (!cat.models || !cat.models.length) return { ok: false, error: "模型目录为空" };
const tpl0 = cat.models[0];
const w0 = parseInt(tpl0.context_window, 10) || 128000;
let added = 0;
let revealed = 0;
for (const slug of textIds) {
const ex = cat.models.find((m) => m.slug === slug);
if (ex) {
if (ex.visibility !== "list") { ex.visibility = "list"; revealed++; }
continue;
}
const t = JSON.parse(JSON.stringify(tpl0)); // 克隆模板(含 base_instructions 等必需字段)
t.slug = slug;
t.display_name = slug;
t.description = slug;
t.context_window = w0;
t.max_context_window = w0;
t.priority = 900;
t.visibility = "list";
cat.models.push(t);
added++;
}
if (added || revealed) {
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
return { ok: true, added, revealed, total: textIds.length };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
}
// 兜底:确保某个裸 model slug 一定在目录里(visibility=list)。用于 setConfigModel 前的自愈,
// 覆盖「config.toml 指向的模型没有对应分组(手改/历史残骸)」这种也会导致侧栏历史还原失败的情况。
function ensureModelInCatalog(model) {
if (!model) return;
const p = catalogPath();
let cat;
try {
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return; // 无目录文件就不动
}
if (!cat.models || !cat.models.length) return;
const existing = cat.models.find((m) => m.slug === model);
if (existing) {
if (existing.visibility !== "list") {
existing.visibility = "list";
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
return;
}
const tpl = JSON.parse(JSON.stringify(cat.models[0]));
const w = parseInt(tpl.context_window, 10) || 128000;
tpl.slug = model;
tpl.display_name = model;
tpl.description = model;
tpl.context_window = w;
tpl.max_context_window = w;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 把 config.toml 根级 model 设成指定值(写入前先确保该模型已在目录里,避免 Codex 以未知模型启动)
function setConfigModel(model) {
if (!model) return;
ensureModelInCatalog(model);
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
@@ -468,10 +600,13 @@ function rewriteResponsesBody(body, group) {
}
function handle(req, res) {
const g = activeGroup();
// 按请求协议派发:Claude Code(Anthropic) → activeClaudeId;Codex/GPT(OpenAI) → activeId。
// 这样两族各转发到各自激活的分组,可同时使用、互不顶替。
const wantClaude = reqIsAnthropic(req);
const g = activeGroup(wantClaude);
if (!g) {
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
res.end("本地路由:未选择中转分组");
res.end(wantClaude ? "本地路由:未选择 Claude Code 分组" : "本地路由:未选择中转分组");
return;
}
// 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来)
@@ -612,6 +747,17 @@ function handle(req, res) {
} else {
headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥
}
// 1M 长上下文:若本次请求的模型被该分组标记为「1M」,加上 Anthropic 的 1M beta 头解锁百万窗口。
// 对 GPT/GLM 等 OpenAI 兼容网关该头无害(被忽略);对 Claude 生效。
if (Array.isArray(g.oneMillionModels) && g.oneMillionModels.length && body.length) {
let reqModel = "";
try { reqModel = (JSON.parse(body.toString("utf-8")) || {}).model || ""; } catch {}
if (reqModel && g.oneMillionModels.includes(reqModel)) {
const beta = "context-1m-2025-08-07";
const existing = headers["anthropic-beta"];
headers["anthropic-beta"] = existing ? (existing.includes(beta) ? existing : existing + "," + beta) : beta;
}
}
delete headers["content-length"];
if (body.length) headers["content-length"] = Buffer.byteLength(body);
if (doCapture) {
@@ -681,6 +827,7 @@ function status() {
routerKey: config.routerKey,
address: `http://127.0.0.1:${config.port}`,
activeId: config.activeId,
activeClaudeId: config.activeClaudeId,
claudeApplied: claudeApplied(),
groups: config.groups.map((g) => ({
id: g.id,
@@ -694,6 +841,7 @@ function status() {
codexImage: !!g.codexImage,
stt: !!g.stt,
sttModel: g.sttModel || "",
oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [],
})),
};
}
@@ -765,9 +913,10 @@ function syncGenImage() {
function upsertGroup(g) {
load();
let id;
let prevMode = null; // 编辑前的接口类型(用于判断是否从 claude 改成别的)
if (g.id) {
const i = config.groups.findIndex((x) => x.id === g.id);
if (i >= 0) config.groups[i] = { ...config.groups[i], ...g };
if (i >= 0) { prevMode = config.groups[i].mode || "responses"; config.groups[i] = { ...config.groups[i], ...g }; }
else config.groups.push({ ...g });
id = g.id;
} else {
@@ -775,7 +924,10 @@ function upsertGroup(g) {
config.groups.push(g);
id = g.id;
}
if (!config.activeId) config.activeId = g.id;
// 首个分组自动激活到所属协议族的槽:claude→activeClaudeId,其余→activeId
const curG = config.groups.find((x) => x.id === id);
if (isClaudeGroup(curG)) { if (!config.activeClaudeId) config.activeClaudeId = id; }
else { if (!config.activeId) config.activeId = id; }
// 生图来源唯一:勾选的分组独占,清掉其它分组的标记
const cur = config.groups.find((x) => x.id === id);
if (cur && cur.codexImage) {
@@ -783,28 +935,49 @@ function upsertGroup(g) {
}
save();
try { syncGenImage(); } catch {}
// 保存中转分组后:把它的直连中转配置写进 Codex config.toml(不经路由、不起进程),
// 并设为激活分组。用户重启 Codex 即用当前分组。只有显式「启用本地路由」才走路由进程。
// 保存分组后按接口类型分流:
// - claude 模式:把分组直连配置写进 ~/.claude/settings.json(接入 Claude Code CLI),不碰 Codex
// - responses/chat 模式:把直连中转配置写进 Codex config.toml(不经路由、不起进程)
const st = status();
if (cur && !cur.isDefault) {
// 路由进程正在跑(用户处于「启用路由」高级模式)→ 保持走本地路由,不改成直连
if (isRunning()) {
st._directSkipped = true;
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
} else if (!cur.baseUrl || !cur.key) {
st._directSkipped = true;
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
} else if (cur.mode === "chat") {
st._directSkipped = true;
st._directNeedRouter = true;
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
if (cur.mode === "claude") {
// 接入 Claude Code
if (!cur.baseUrl || !cur.key) {
st._claudeSkipped = true;
st._claudeReason = "该分组缺少 API 地址或密钥,未写入 Claude Code 配置";
} else {
try {
const rc = applyDirectToClaude(cur.id);
if (rc.ok) st._claudeApplied = true;
else { st._claudeSkipped = true; st._claudeReason = rc.error || "写入失败"; }
} catch (e) {
st._claudeError = String((e && e.message) || e);
}
}
} else {
try {
const r = applyDirectToCodex(cur.id);
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
} catch (e) {
st._directError = String((e && e.message) || e);
// 接入 Codex(responses 直连 / chat 协议转换)
// 仅当【本分组】之前是 claude 模式、现改回 Codex 模式 → 还原它写过的 Claude Code 配置
if (prevMode === "claude" && config.claudePrev && claudeDirectApplied()) {
try { restoreClaudeCode(); st._claudeRestored = true; } catch {}
}
if (isRunning()) {
st._directSkipped = true;
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
} else if (!cur.baseUrl || !cur.key) {
st._directSkipped = true;
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
} else if (cur.mode === "chat") {
st._directSkipped = true;
st._directNeedRouter = true;
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
} else {
try {
const r = applyDirectToCodex(cur.id);
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
} catch (e) {
st._directError = String((e && e.message) || e);
}
}
}
}
@@ -814,8 +987,20 @@ function deleteGroup(id) {
load();
const g = config.groups.find((x) => x.id === id);
if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" };
const wasClaude = isClaudeGroup(g);
config.groups = config.groups.filter((x) => x.id !== id);
if (config.activeId === id) config.activeId = config.groups[0] ? config.groups[0].id : null;
// 删的是哪族分组,就补选同族的另一个(找不到则清空该族激活槽),不影响另一族。
if (wasClaude) {
if (config.activeClaudeId === id) {
const next = config.groups.find((x) => isClaudeGroup(x));
config.activeClaudeId = next ? next.id : null;
}
} else {
if (config.activeId === id) {
const next = config.groups.find((x) => !isClaudeGroup(x));
config.activeId = next ? next.id : null;
}
}
save();
try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env
return status();
@@ -824,6 +1009,25 @@ function setActive(id) {
load();
const target = config.groups.find((g) => g.id === id);
if (!target) return status();
// Claude Code(Anthropic 族):落 activeClaudeId,写 ~/.claude/settings.json,绝不碰 Codex 配置。
// 与 OpenAI 族互不顶替 —— 两族可同时激活。
if (isClaudeGroup(target)) {
config.activeClaudeId = id;
let claudeMode = "proxy";
let claudeSkipped, claudeReason;
if (isRunning()) {
// 路由在跑:settings.json 指向本地路由即可,路由按 activeClaudeId 动态转发,无需改文件、无需重启
claudeMode = "proxy";
if (!claudeApplied()) applyToClaudeCode();
} else {
// 默认模式:直连中转,把分组地址写进 settings.json(新开 claude 会话生效)
claudeMode = "direct";
const r = applyDirectToClaude(id);
if (!r.ok) { claudeSkipped = r.skipped; claudeReason = r.error; }
}
save();
return { ...status(), _mode: claudeMode, _claudeSkipped: claudeSkipped, _claudeReason: claudeReason };
}
const before = currentModelProvider();
const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID;
let needsRestart = false;
@@ -873,10 +1077,10 @@ function codexSnippet() {
function applyToCodex() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组(排除 claude 模式)
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (!cur || cur.isDefault || cur.mode === "claude") {
const proxy = config.groups.find((g) => !g.isDefault && g.mode !== "claude");
if (proxy) config.activeId = proxy.id;
}
const cfgFile = path.join(CODEX_HOME, "config.toml");
@@ -924,6 +1128,9 @@ function applyToCodex() {
applyModelCatalog(cur2);
setConfigModel(cur2.model);
}
// 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录),
// 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。
if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {});
save();
return { ok: true };
}
@@ -947,6 +1154,9 @@ function applyDirectToCodex(groupId) {
if (g.mode === "chat") {
return { ok: false, skipped: true, needRouter: true, error: "该分组为 Chat 协议转换模式,直连 Codex 不支持,需启用本地路由" };
}
if (g.mode === "claude") {
return { ok: false, skipped: true, error: "该分组为 Claude Code 类型,不写入 Codex 配置" };
}
// 接管前先确保官方登录已完整备份(幂等)
const def = config.groups.find((x) => x.isDefault);
@@ -991,6 +1201,10 @@ function applyDirectToCodex(groupId) {
applyModelCatalog(g);
setConfigModel(g.model);
}
// 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。
// 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App),
// 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。
syncCatalogFromProvider(g).catch(() => {});
config.activeId = g.id;
save();
return { ok: true, direct: true, group: g.name };
@@ -1001,15 +1215,8 @@ function claudeSettingsPath() {
function applyToClaudeCode() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (proxy) {
config.activeId = proxy.id;
save();
}
}
// Anthropic 请求由路由按 activeClaudeId 派发(见 handle);这里只把 settings.json 指向本地路由。
// 不再动 activeId(OpenAI/Codex 槽)—— 两族独立,接入 Claude Code 不该顶掉 Codex 的选择。
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const p = claudeSettingsPath();
let settings = {};
@@ -1055,8 +1262,8 @@ function restoreClaudeCode() {
return { ok: true }; // 没有文件就无需还原
}
settings.env = settings.env || {};
const prev = config.claudePrev || { base: null, token: null };
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token]]) {
const prev = config.claudePrev || { base: null, token: null, model: null };
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token], ["ANTHROPIC_MODEL", prev.model]]) {
if (v == null) delete settings.env[k];
else settings.env[k] = v;
}
@@ -1077,6 +1284,66 @@ function claudeApplied() {
}
}
// —— Claude Code 直连中转(不经本地路由)——
// 把指定分组的中转地址/key 直接写进 ~/.claude/settings.json 的 env,Claude Code 直连中转站。
// 与 applyToClaudeCode(写本地路由地址)对称,但 base_url = 分组主地址(非 127.0.0.1)。
function applyDirectToClaude(groupId) {
load();
const g = config.groups.find((x) => x.id === (groupId || config.activeId));
if (!g) return { ok: false, error: "分组不存在" };
if (g.isDefault) return { ok: false, error: "默认分组不接入 Claude Code" };
// Claude Code 直连用分组主字段:API 地址 baseUrl、API 密钥 key、默认模型 model
if (!g.baseUrl || !g.key) {
return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Claude Code 配置" };
}
const base = g.baseUrl.replace(/\/+$/, "");
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const p = claudeSettingsPath();
let settings = {};
let existed = false;
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
existed = true;
} catch {}
// 备份原文件
if (existed) {
try {
const dir = path.join(CLAUDE_HOME, "backups_router");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "settings.json." + Date.now() + ".bak"), fs.readFileSync(p));
} catch {}
}
settings.env = settings.env || {};
// 首次接入时记住用户原有值,便于「取消接入」还原(与 applyToClaudeCode 共用 claudePrev)
if (!config.claudePrev) {
config.claudePrev = {
base: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_BASE_URL") ? settings.env.ANTHROPIC_BASE_URL : null,
token: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_AUTH_TOKEN") ? settings.env.ANTHROPIC_AUTH_TOKEN : null,
model: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_MODEL") ? settings.env.ANTHROPIC_MODEL : null,
};
}
settings.env.ANTHROPIC_BASE_URL = base;
settings.env.ANTHROPIC_AUTH_TOKEN = g.key;
if (g.model && g.model.trim()) settings.env.ANTHROPIC_MODEL = g.model.trim();
else delete settings.env.ANTHROPIC_MODEL;
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
try { fs.chmodSync(p, 0o600); } catch {}
save();
return { ok: true, direct: true, path: p, group: g.name };
}
// Claude Code 是否已指向某个中转直连地址(非本地路由)
function claudeDirectApplied() {
try {
const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8"));
const url = s.env && s.env.ANTHROPIC_BASE_URL;
return !!(url && !/127\.0\.0\.1/.test(url));
} catch {
return false;
}
}
module.exports = {
load,
start,
@@ -1091,8 +1358,10 @@ module.exports = {
applyDirectToCodex,
codexSnippet,
applyToClaudeCode,
applyDirectToClaude,
restoreClaudeCode,
claudeApplied,
claudeDirectApplied,
getGroupKey,
syncGenImage,
groupById,
@@ -1100,4 +1369,5 @@ module.exports = {
llmGroup,
sttGroup,
fetchModels,
syncCatalogFromProvider,
};
+284
View File
@@ -0,0 +1,284 @@
// ============================================================================
// 核心提示词工程 —— 整个产品的命门
// 这段 System Prompt 决定了视觉模型"如何看图 + 输出什么结构的结果"。
// 想提升效果,绝大多数时候是改这里,而不是改界面。
// ============================================================================
const SYSTEM_PROMPT = `You are a world-class visual analyst and prompt engineer. Your single job: look at an image and reverse-engineer the most effective text-to-image generation prompt that would let a modern model (Flux / 即梦 / 可灵 / SDXL 等自然语言生图模型) reproduce an image as close as possible to the original.
# How to look (analyze systematically, do NOT skip a dimension)
1. Subject 主体:who/what is the focus, count, pose, action, expression, clothing, key objects.
2. Scene / environment 场景:location, background, foreground, time of day, weather, season.
3. Style / medium 风格媒介:photography? 3D render? oil painting? anime? watercolor? pixel art? Name the concrete style and, if it clearly matches a well-known aesthetic, name it (e.g. cyberpunk, Ghibli-like, film photography, cinematic).
4. Composition & camera 构图镜头:shot type (close-up / wide / bird's-eye / low-angle), framing, rule of thirds, symmetry, subject placement, aspect ratio if obvious.
5. Lighting 光影:source & direction (backlight, rim light, soft light, hard light), golden hour, neon, studio, dramatic shadows.
6. Color 色彩:palette, temperature (warm/cool), saturation, contrast, dominant colors, any grading (teal-orange, Morandi, pastel).
7. Texture & detail 材质细节:skin texture, fabric, metal reflection, depth of field / bokeh, grain, sharpness.
8. Technical cues 技术线索:if photographic — plausible lens/focal length, aperture, camera feel; if rendered — engine/render look (octane, unreal, cel-shading).
9. Mood / atmosphere 氛围:the overall feeling the image conveys.
# Rules that decide quality
- The ENGLISH prompt is the primary deliverable — make it the most effective one. Modern T2I models are trained mostly on English; English gives the best results.
- Write the English prompt as ONE flowing, natural, descriptive paragraph (natural-language style, NOT a bare comma-list). Front-load the most important elements (subject → style → composition → lighting → color → detail). Be concrete and specific; never rely on vague adjectives alone.
- NEVER invent content that is not supported by the image. If something is uncertain, describe only what is actually visible. Do not hallucinate text, logos, or details you cannot see.
- Provide a faithful Chinese translation that mirrors the English prompt one-to-one (same information, natural Chinese).
- Provide a negative prompt: things a generator should AVOID to keep quality high and stay faithful (e.g. deformed hands, extra fingers, blurry, watermark, low-res, plus anything clearly absent from THIS image's style).
- Provide a per-dimension breakdown so the user understands and can tweak individual aspects.
- Provide a compact English keyword tag list (for users who prefer Midjourney-style comma prompts).
- Give an honest confidence level about how faithfully the prompt can reproduce the original.
# Output format — CRITICAL
Return ONLY a single valid JSON object. No markdown, no code fences, no commentary before or after. Use exactly this schema:
{
"prompt_en": "the primary English generation prompt, one rich natural-language paragraph",
"prompt_zh": "对应的中文提示词(与英文一一对应)",
"negative_prompt_en": "comma-separated things to avoid",
"negative_prompt_zh": "需要规避的内容(中文)",
"tags_en": ["keyword1", "keyword2", "..."],
"breakdown": [
{ "label_zh": "主体", "label_en": "Subject", "value_zh": "中文描述", "value_en": "english description" },
{ "label_zh": "风格", "label_en": "Style", "value_zh": "…", "value_en": "…" },
{ "label_zh": "构图/镜头", "label_en": "Composition & Camera", "value_zh": "…", "value_en": "…" },
{ "label_zh": "光影", "label_en": "Lighting", "value_zh": "…", "value_en": "…" },
{ "label_zh": "色彩", "label_en": "Color", "value_zh": "…", "value_en": "…" },
{ "label_zh": "材质/细节", "label_en": "Texture & Detail", "value_zh": "…", "value_en": "…" },
{ "label_zh": "氛围", "label_en": "Mood", "value_zh": "…", "value_en": "…" }
],
"confidence": "high | medium | low",
"notes_zh": "一句话给出复现建议或需要注意的地方"
}`;
// 用户消息里附带的文字指令(图片作为 image_url 一起发送)
const USER_INSTRUCTION =
'请精准分析这张图片,反推出能复现它的生图提示词,严格按 System 里的 JSON schema 输出,只返回 JSON。';
// ============================================================================
// 员工团队生成 —— 分析客户需求,拆解出一支带约束的 AI agent 团队
// ============================================================================
// —— 通用工程协同规范(写死,保证每次生成都一致;会注入进每个 agent 的 prompt)——
const TEAM_CONVENTIONS = {
// 每个 Agent 完成任务后必须固定输出的结构化报告
output_format: [
'变更摘要:一句话说明这次做了什么',
'修改文件:列出新增 / 修改 / 删除的文件路径',
'测试结果:跑了哪些测试、是否通过(贴关键输出)',
'风险与影响:可能波及的其他模块或接口',
'需要配合:需要哪个角色接手 / 确认,以及交接内容与格式',
'跨职责说明:为什么修改非本职责文件 / 影响哪个角色 / 需要谁复核 / 是否改动契约、数据库结构、安全策略、部署配置(无则写"无")',
],
// 任务的完成定义(基线,模型可追加项目特有项)
definition_of_done: [
'Lint / 类型检查通过',
'相关单元与集成测试通过',
'接口契约与文档同步更新',
'数据库迁移可回滚(有 up/down)',
'README / 变更说明已更新',
'跨职责修改已在报告中说明并获得对应 owner 复核',
],
// Git / 职责归属规范(开放写权限模式:owner_paths 表达审核归属,不限制编辑)
git_rules: [
'开放写权限:所有执行角色技术上可改动项目内任意文件',
'跨职责(他人 owner_paths)修改必须在完成报告中说明并请对应 owner 复核',
'涉及接口契约 / 数据库结构 / 安全策略 / 部署配置的改动,必须提请对应 owner 与总控确认后合入',
'文件冲突或边界不清时,交总控 Agent 裁决',
'提交信息格式:type(scope): 摘要(如 feat(order): 新增下单接口)',
'一个任务对应一组内聚提交,便于审核与回滚',
],
// 任务交接协议
handoff_protocol: [
'交接用结构化「交接单」传递:来源角色 / 目标角色 / 交接物 / 约定格式 / 验收点',
'接口相关交接必须附契约:路径、方法、入参、出参、错误码、分页、权限码',
'数据库变更交接必须附:变更说明、迁移脚本、回滚脚本、受影响表',
'接收方动工前先确认契约;有异议退回来源角色或总控,不擅自改契约',
],
};
// 总控调度状态机
const ORCHESTRATOR_WORKFLOW = [
'需求分析:澄清目标、边界与验收标准',
'任务拆分:拆成可独立交付的小任务,标注负责角色与依赖关系',
'契约收敛:先让契约 owner 定稿接口契约(OpenAPI / DTO / 错误码 / 分页 / 权限码),各方确认',
'任务立契:分派前为每个任务产出《任务契约》(套 templates/task-charter.md),必须写明目标、可判定的验收标准、以及至少 2 条「明确不做(out of scope)」;写不出 out of scope 说明任务没想清楚,不得分派',
'分派执行:把任务连同《任务契约》、契约、DoD 一起分派给对应角色,并在 docs/coordination/task-ledger.md 登记为 assigned',
'进度跟踪:收集各角色的结构化产出报告,更新 task-ledger 状态',
'交叉审核:对照该任务《任务契约》审核——产出是否只落在目标内、是否命中 out of scope、契约一致性、安全与 DoD 达成情况',
'返工闭环:不达标的退回对应角色,打回理由必须引用《任务契约》或 DoD 的具体条款并写入 task-ledger;同一任务打回 ≥ 2 次时不再简单退回,改为重新澄清任务契约(判断是不是任务边界本身没定义清楚),避免无限返工',
'集成验收:全部满足 DoD 后做集成验证,仅当 task-ledger 状态为 accepted 才关闭任务,输出最终验收结论',
];
// 任务契约模板:领域无关的「目标边界」骨架。out of scope 由运行时总控按本次需求填,生成器不预设领域内容。
const TASK_CHARTER = `# 任务契约 · <task-id>
> 分派任何任务前由总控产出。它是「防跑飞」的依据:agent 每一步都对照此表,总控打回也引用此表的条款。
- 负责角色:
- 目标(一句话,可衡量):
- 验收标准(逐条可判定真假):
- [ ]
- [ ]
- 明确不做 / out of scope(至少 2 条;越界即视为跑飞):
- 不改动 <某模块 / 某能力>
- 不新增 <与本任务无关的功能 / 承诺>
- 不重构 / 不顺手动 <现有某处>
- 允许触碰的文件范围(本角色 owner_paths 的子集或明确列举):
- 依赖 / 前置(需要哪个角色先交付什么):
- 完成信号:产出 templates/acceptance-report.md 并经总控验收,task-ledger 置为 accepted
---
判定「跑飞」的通用标准(与领域无关):agent 做的事若无法在本契约「目标」里找到依据,或命中「out of scope」,即为跑飞 —— 立即停下、写入 task-ledger、交总控裁决,不得自行扩大范围。
`;
// 安全 / 权限审核清单(涉及鉴权、RBAC、敏感数据时启用)
const SECURITY_CHECKLIST = [
'越权访问:每个接口都校验身份与资源归属',
'水平越权:用户只能访问自己的数据(按 owner 过滤)',
'垂直越权:RBAC 权限码校验,前后端权限一致',
'敏感字段:密码 / 令牌 / 隐私字段不落日志、不明文返回',
'审计日志:关键操作留痕(谁 / 何时 / 做了什么)',
'会话 / 令牌:按项目选定的方案(JWT 或 HttpOnly Cookie)——设置过期、可注销失效、防重放',
'输入校验:所有外部输入做校验与转义,防注入(Web 另需防 XSS / CSRF)',
];
const EMPLOYEE_SYSTEM_PROMPT = `You are a senior technical lead and AI-agent team architect. The user gives you a project requirement in natural language. Design a COLLABORATING team of specialized AI agents ("employees") plus one orchestrator that could actually build and ship this project together.
# ⚠️ PROJECT-AGNOSTIC — this is the #1 rule
The project can be ANY kind: web app, mobile app, desktop app, CLI tool, backend service/API, data/ML pipeline, game, embedded/firmware, browser extension, library/SDK, automation script, smart-contract, etc. DO NOT assume it is a web app or Node/Next.js. Infer the RIGHT stack, roles, file layout, interfaces, CI commands and security concerns for the ACTUAL project and domain.
Everything below that mentions REST / API / Next.js / NextAuth / npm is ONLY an ILLUSTRATIVE EXAMPLE for web projects — replace ALL of it with what THIS project truly needs (e.g. a Python data pipeline has no REST contract; a CLI has commands not endpoints; a Go service uses go test not npm; a mobile app has screens not pages).
# Analyze
1. Understand the real intent and the concrete, realistic tech stack (languages, frameworks, database, infra).
🔴 ARCHITECTURE COHERENCE: pick ONE coherent architecture. A full-stack framework (Next.js / Nuxt / SvelteKit) already provides the backend — do NOT also add a separate backend framework (NestJS / Express / Fastify) unless the requirement explicitly demands a separated service. If two apps are genuinely needed, give each a distinct root dir (e.g. apps/web, apps/api) with its own owner and start command, and state this split explicitly in contract_spec. Never list two overlapping frameworks in stack without defining their boundary.
2. Decompose into a MINIMAL but COMPLETE set of specialized employees (usually 4-7): typically backend, frontend, database, testing, devops, and — whenever the project involves auth / permissions / RBAC / sensitive data — a dedicated security engineer. No redundant or vague roles.
3. Design one orchestrator (总控) whose ONLY job is to break down tasks, converge the API contract, dispatch, review/audit, and drive acceptance. It does NOT write feature code.
# Collaboration is the point — every agent must be designed to work WITH the others
For EACH employee you MUST fill:
- collaborators: the ids of the other agents it hands work to or receives work from.
- handoff_in: what it receives from others and in what form.
- handoff_out: what it produces for others and in what form (be concrete).
Team-level you MUST decide — make the contract CONCRETE, not generic:
- contract_owner: the id of the single role that OWNS the API contract (usually backend). Others must not change the contract unilaterally.
- contract_spec: a CONCRETE contract for THIS project — unified response body with ALL fields (e.g. { code, message, data, requestId }), pagination shape (e.g. { items, page, pageSize, total, hasNext }), error-code table, RBAC permission codes, DTO conventions, and the auth scheme.
- auth_scheme: if the project needs auth, ONE exact string (e.g. "Bearer JWT" | "NextAuth Credentials" | "HttpOnly session cookie" | "none"). It MUST appear verbatim in contract_spec and every auth-related agent prompt; env_vars must contain exactly that scheme's secrets (JWT→JWT_SECRET; NextAuth→NEXTAUTH_SECRET+NEXTAUTH_URL; cookie→SESSION_SECRET) and NO other scheme's vars. Never mix schemes.
- package_manager: if the stack uses one, ONE value (npm|pnpm|yarn|pip|poetry|cargo|go|... ; else "none"). The install/build commands in ci_steps, the lockfile in devops owner_paths, and .gitignore MUST all match this one (npm→package-lock.json, pnpm→pnpm-lock.yaml, yarn→yarn.lock). Never mix npm with pnpm-lock etc.
- interface_kind: what this project exposes — one of "rest" / "graphql" / "grpc" / "cli" / "library" / "gui" / "none". Drives the contract shape.
- api_endpoints: ONLY if interface_kind is an API (rest/graphql/grpc) — the actual endpoint/RPC list, each { method, path, desc, owner }. For CLI describe commands, for library the public functions — put those in contract_spec instead and leave api_endpoints empty.
- ci_steps: the ACTUAL commands to lint/test/build for THIS stack, in order (e.g. Node: ["npm ci","npm test","npm run build"]; Python: ["pip install -r requirements.txt","ruff check .","pytest"]; Go: ["go vet ./...","go test ./..."]; Rust: ["cargo test"]). This generates the CI — do NOT default to npm unless it's actually Node.
- env_vars: a COMPLETE list of what THIS project needs, each { name, desc } (only if it has env/config; a pure library may have none). Cover DB/connection, auth/secrets, external services, storage, runtime — whatever fits the stack.
- db_models: the main data entities IF the project has a datastore (may be empty for stateless tools).
- security_checklist: project-specific security/permission checks (extend the standard ones for this project's sensitive areas).
- dod_extra: any project-specific "definition of done" items (e.g. responsive breakpoints, safe Markdown/HTML rendering, draft/preview/publish, public-vs-admin boundary, search/tags/archive, migrations reversible).
# Hard rules
- Every agent MUST be constrained to work ONLY inside the current project scope. Always include such a constraint.
- Focused, non-overlapping duties; concrete Chinese "prompt" (system instructions) per agent describing role, what it does, and its boundaries.
- ALWAYS include a devops-engineer (CI / deploy / path-boundary enforcement / env / root config).
# File-responsibility rules (owner_paths — 职责/审核归属, NOT an edit restriction; conflicts here break review attribution)
- ⚠️ PERMISSION MODEL: every employee's allowed_paths is set by the SYSTEM to ["**"] (open write — any executor can technically edit any file in the project). Do NOT output allowed_paths for employees. What YOU output per role is owner_paths = the paths that role OWNS (职责归属:谁对这片文件负责、谁需要审核这片文件的改动).
- owner_paths MUTUALLY EXCLUSIVE across roles: every path owned by exactly ONE role. Never let two roles list the same dir (only ONE role owns "prisma/", the DB schema, etc.).
- NO NESTING across roles: no role's owner_path may be a parent of another role's owner_path. "src/app" (frontend) and "src/app/api" (backend) is a CONFLICT — the parent swallows the child.
- CROSS-DUTY COLLABORATION: any executor MAY edit files under another role's owner_paths, but the completion report MUST state why it changed them / which role is affected / who must review / whether it touched the interface contract, DB schema, security policy or deploy config — and request the owner's review before merge.
- 🔴 OWNER COVERAGE — be COMPREHENSIVE, not minimal: every file category the project will actually contain must have a review owner; 1-2 dirs per role is NOT enough. For the chosen stack, enumerate each role's FULL typical territory. Web/Next.js illustration (adapt to the real stack):
- backend: "src/app/api/**" + business layer ("src/server/**" or "src/lib/server/**") + auth ("src/lib/auth/**") + "src/middleware.ts" + shared server types.
- frontend: page groups + "src/components/**" + "src/styles/**" + "public/**" (static assets) + client libs it renders ("src/lib/client/**", feature libs like "src/lib/markdown/**") + layout/globals files.
- testing: "tests/**","__tests__/**", e2e dir ("playwright/**"), test configs ("playwright.config.*","vitest.config.*","vitest.setup.*","jest.config.*") + "src/test-utils/**".
- security: "security/**" + "docs/security/**" (threat model / audit reports live somewhere owned).
- devops: ".github/**","scripts/**", infra ("Dockerfile","docker-compose.*","docker/**","deploy/**") + ALL root configs including the lockfile matching package_manager, ".gitignore", build-tool configs ("postcss.config.*","prettier.config.*", etc.).
A config file that exists in the project but has NO owner = invalid output.
- 🔴 FRAMEWORK ENTRY IS MANDATORY (this is NOT part of the "illustrative example you may discard"): every framework FIXES the physical location of its HTTP route entry, ORM models & migrations. You may organize business logic freely (clean-arch/layers), but you may NOT relocate or omit the framework's fixed entry paths. The backend/API role MUST own the exact entry path and put THIN handlers there (validate + auth + call the business layer, which lives in another path the SAME role owns). Frontend NEVER owns the API entry nor any parent that swallows it. If the stack has a framework below and no role owns its entry path, your output is INVALID.
Framework → fixed paths (owner):
- Next.js App Router: HTTP entry "src/app/api/**/route.ts" → backend; pages "src/app/(public)/**","src/app/(admin)/**","src/components/**","src/app/layout.tsx","src/app/globals.css" → frontend (NEVER "src/app/**").
- Next.js Pages Router: "src/pages/api/**" → backend; "src/pages/**" non-api → frontend.
- Nuxt3: "server/api/**","server/routes/**" → backend; "pages/**","components/**" → frontend.
- Django: "**/urls.py","**/views.py","*/settings.py","*/asgi.py" → backend; "**/models.py","**/migrations/**" → db; templates/static → frontend.
- Rails: "app/controllers/**","config/routes.rb" → backend; "app/models/**","db/migrate/**" → db; "app/views/**" → frontend.
- NestJS: "**/*.controller.ts" thin → backend. Spring Boot: "**/*Controller.java" → backend, "**/migration/**" → db.
- Vite SPA / Flutter / library / CLI: NO HTTP api dir — do NOT invent api routes; entry is index.html+src/main.* / lib/main.dart / the public API / the command layer.
- 🔴 COLOCATION frameworks (SvelteKit / Remix / Astro) put UI and backend in the SAME route tree or SAME file — directory-level split is impossible. Do NOT fabricate fake sub-splits. Instead: (a) split by FILE-SUFFIX glob when possible — SvelteKit backend owns "src/routes/**/+server.ts" + "src/lib/server/**", frontend owns "src/routes/**/+page.svelte","+layout.svelte","+page.ts"; (b) if backend+UI share one file (Remix loader/action in app/routes/x.tsx), assign the whole feature folder to ONE owner and note it in that role's constraints. Always use glob suffix patterns (not bare dirs) so CODEOWNERS and the path-check script can tell them apart.
- Consistent API prefix: pick ONE prefix for ALL endpoints (e.g. all under "/api/v1/…" OR all "/api/…") and use it everywhere in api_endpoints + contract_spec.
- ROOT CONFIG files (package.json, tsconfig.json, next.config.*, tailwind.config.*, eslint config, .env.example, etc.) MUST be owned by exactly ONE role → give them to devops-engineer. Never leave them unowned (bootstrap always edits them).
- devops-engineer owns ".github/", "scripts/", "infra/", docker/deploy files AND the root config files above.
- security-engineer OWNS only its own files ("security/" or "docs/security"); it PROVIDES the checklist and reviews. Security TEST code is implemented by the testing/QA role (security keeps it in review_paths).
- 🔴 OWNERSHIP COMPLETENESS (NOT part of the discardable illustrative example):
- If the project has a datastore (db_models non-empty OR the stack contains a DB/ORM), exactly ONE role must own that stack's schema + migration path (Prisma → "prisma/**"; Django → "**/migrations/**"; Rails → "db/**"; generic → "migrations/**"). If a dedicated database/data role EXISTS, the schema+migration paths MUST belong to that role — NEVER give them to backend as well (no duplicate claims). Only when there is no dedicated db role does that path go to the backend / contract_owner role.
- Exactly ONE role (usually devops-engineer) must own "README.md" and the TOP-LEVEL docs files "docs/*.md" (the collaboration-doc maintainer) — otherwise the DoD item "README / change notes updated" has no legal executor. NEVER give anyone broad "docs/**": role-specific doc subdirs (e.g. "docs/security/**" → security role, "docs/api/**" → backend) belong to their own roles, and a broad docs/** would swallow them AND the shared coordination zone.
- "docs/coordination/**" is the SHARED coordination area (handoff / acceptance / decision records that EVERY role writes to). Do NOT assign it to any single role and do NOT list it in any owner_paths — the boundary checker whitelists it globally, and CODEOWNERS explicitly clears its ownership.
- SECURITY COLLABORATION: if a security-type role exists, every role in charge of auth / login / session / upload / permissions / RBAC (at least the backend role) MUST include it in collaborators, and the security role must list those roles back in its own collaborators.
- If a role needs to REVIEW another's area beyond its own duty, put it in review_paths (read/audit focus), NOT owner_paths.
- orchestrator: READ-ONLY — allowed_paths = [] AND owner_paths = [] (writes NO files, ever); review_paths = ["**"]. It only decomposes, dispatches, converges the contract, arbitrates and accepts.
- ids: lowercase english, kebab-case, unique. 🔴 EVERY role reference — collaborators / contract_owner / api_endpoints[].owner AND any role name you write inside an agent's "prompt" text — MUST be an exact id from employees[].id. Do NOT invent a role name (e.g. don't write "qa-engineer" if the testing employee's id is "testing-engineer"). Decide each role's id ONCE and use that exact string everywhere.
# Output — CRITICAL
Return ONLY one valid JSON object, no markdown, no code fences.
⚠️ The values below are a WEB-PROJECT EXAMPLE just to show the SHAPE — replace EVERY value with what THIS project actually is (roles, paths, interface_kind, ci_steps, contract, env, models must all fit the real stack/domain; a non-web project will look completely different).
{
"project_name": "简短项目名",
"stack": ["按真实技术栈", "..."],
"summary": "一句话概括你对项目的理解",
"interface_kind": "rest | graphql | grpc | cli | library | gui | none",
"auth_scheme": "有鉴权才填的单一方案,如 Bearer JWT / NextAuth Credentials / HttpOnly session cookie / none",
"package_manager": "如 npm|pnpm|yarn|pip|poetry|cargo|go;无则 none",
"contract_owner": "某个角色 id(有对外契约时)",
"contract_spec": "本项目的契约/约定——Web 是响应体/分页/错误码/鉴权;CLI 是命令与参数约定;库是公开 API 与语义化版本;数据管道是输入输出 schema 与调度约定。按真实项目写。",
"api_endpoints": [
{ "method": "GET", "path": "/api/v1/...", "desc": "仅当是 API 项目时填", "owner": "backend-engineer" }
],
"ci_steps": ["按真实技术栈的 lint/测试/构建命令,如 go test ./... 或 pytest 或 npm test"],
"env_vars": [
{ "name": "按真实项目", "desc": "有才写,纯库可为空" }
],
"db_models": ["有数据存储才写,无状态工具可为空"],
"security_checklist": ["按本项目真实安全点(Web 是越权/XSS;CLI 是命令注入/密钥;固件是刷写签名…)"],
"dod_extra": ["本项目特有的完成标准(按真实项目,不要照搬 Web 博客那套)"],
"orchestrator": {
"id": "orchestrator",
"name": "总控 Agent",
"duty": "调度 + 契约收敛 + 审核 + 验收",
"responsibilities": ["职责点"],
"allowed_paths": [],
"review_paths": ["**"],
"constraints": ["仅在当前项目内工作", "只做调度与审核,不直接实现业务代码,不写入任何文件"],
"prompt": "总控的完整中文系统指令"
},
"employees": [
{
"id": "backend-engineer",
"name": "后端工程师",
"duty": "一句话职责",
"responsibilities": ["职责点"],
"owner_paths": ["src/server"],
"review_paths": ["src/db"],
"collaborators": ["frontend-engineer", "db-engineer"],
"handoff_in": "从数据库工程师接收表结构与迁移脚本",
"handoff_out": "向前端交付定稿的接口契约与联调地址",
"constraints": ["仅在当前项目内操作", "不擅自变更接口契约"],
"prompt": "该角色的完整中文系统指令"
}
]
}`;
function buildEmployeeInstruction(requirement, tool) {
const toolNote =
tool === 'codex'
? '目标工具是 Codex,总控 agent 专门负责调度、契约收敛与代码审核、不直接写业务代码。'
: tool === 'claude'
? '目标工具是 Claude Code(员工对应 subagent)。'
: '生成通用的 agent 团队。';
return `${toolNote}\n\n客户的项目需求如下,请严格按 System 的 JSON schema 输出(只返回 JSON):\n\n"""${requirement}"""`;
}
module.exports = {
SYSTEM_PROMPT,
USER_INSTRUCTION,
EMPLOYEE_SYSTEM_PROMPT,
buildEmployeeInstruction,
TEAM_CONVENTIONS,
ORCHESTRATOR_WORKFLOW,
TASK_CHARTER,
SECURITY_CHECKLIST,
};
+1586
View File
File diff suppressed because it is too large Load Diff
+691
View File
@@ -0,0 +1,691 @@
// token_inject.js —— 注入到 Codex 页面的 Token 用量 UI 脚本,以字符串形式导出。
// 由 voice_cdp.js 经 CDP Runtime.evaluate 注入(复用语音 CDP 连接,无需独立端口)。
//
// 职责(仅 UI 层):
// 1. 在输入框下方工具栏内创建 token 用量条(优先放在模型选择左侧)
// 2. 读写 localStorage["td-token-daily"](按日期分桶,跨天自动清零)
// 3. 暴露 window.__tdTokenAdd(usage) 给主进程调用来累加并触发动画
// 4. 暴露 window.__tdTokenGet() / __tdTokenReset() 给主进程调用来读取/重置
// 5. 页面内兜底解析 fetch/XHR usage 与 Codex DOM token 文本
//
// Token 抓取优先由 voice_cdp.js 在 CDP 层面通过 Network.responseReceived 完成,
// 页面内 fetch/XHR 解析只作为兜底。
//
// 注意:脚本体用普通模板字符串包裹;内部避免出现 ${ 与裸反引号。
const TOKEN_INJECT_SCRIPT = `
(() => {
const VER = "td-token-v17";
if (window.__tdTokenInstalled === VER) return;
if (window.__tdTokenObserver) { try { window.__tdTokenObserver.disconnect(); } catch {} }
const oldBar = document.getElementById("td-token-bar");
if (oldBar) oldBar.remove();
const oldRow = document.getElementById("td-token-center-row");
if (oldRow) oldRow.remove();
["td-token-style-v4", "td-token-style-v5", "td-token-style-v6", "td-token-style-v7", "td-token-style-v8", "td-token-style-v9", "td-token-style-v10", "td-token-style-v11", "td-token-style-v12", "td-token-style-v13", "td-token-style-v14", "td-token-style-v15", "td-token-style-v16", "td-token-style-v17"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.remove();
});
window.__tdTokenInstalled = VER;
// ============================================================================
// 1. 持久化
// ============================================================================
const STORE_KEY = "td-token-daily";
function todayKey() {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return d.getFullYear() + "-" + m + "-" + day;
}
function loadDaily() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return blankDaily();
const d = JSON.parse(raw);
if (d.date !== todayKey()) return blankDaily();
return normalizeDaily(d);
} catch { return blankDaily(); }
}
function blankDaily() {
return { date: todayKey(), total: 0, input: 0, output: 0, cached: 0, estimate: 0, unknown: 0, lastSource: "" };
}
function cleanNum(n) {
n = Number(n);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function normalizeDaily(d) {
const out = blankDaily();
out.date = d && d.date ? d.date : todayKey();
out.input = cleanNum(d && d.input);
out.output = cleanNum(d && d.output);
out.cached = cleanNum(d && d.cached);
out.estimate = cleanNum(d && (d.estimate || d.estimated));
out.unknown = cleanNum(d && d.unknown);
out.total = cleanNum(d && d.total) || out.input + out.output + out.cached + out.estimate + out.unknown;
const known = out.input + out.output + out.cached + out.estimate + out.unknown;
if (out.total > known) out.unknown += out.total - known;
out.lastSource = String((d && (d.lastSource || d.source)) || "");
if (out.unknown && !out.lastSource) out.lastSource = "legacy";
return out;
}
function saveDaily(data) {
try { localStorage.setItem(STORE_KEY, JSON.stringify(normalizeDaily(data))); } catch {}
}
let daily = loadDaily();
// ============================================================================
// 2. 创建 UI
// ============================================================================
const BAR_ID = "td-token-bar";
const STYLE_ID = "td-token-style-v17";
const TOKEN_CAP = 5000000000;
if (!document.getElementById(STYLE_ID)) {
const st = document.createElement("style");
st.id = STYLE_ID;
st.textContent = \`
#td-token-bar{
display:inline-block;position:relative;
flex:0 0 auto;width:228px;max-width:32vw;height:28px;
margin:0 6px;padding:0;border-radius:999px;
font:11px system-ui,-apple-system,sans-serif;
user-select:none;-webkit-app-region:no-drag;
pointer-events:none;opacity:.96;transition:opacity .18s,filter .18s;
background:transparent;border:0;
vertical-align:middle;box-sizing:border-box;
}
#td-token-bar:hover{opacity:1;filter:saturate(1.08)}
.td-tk-track{
position:absolute;left:0;right:0;top:50%;height:28px;
border-radius:8px;transform:translateY(-50%);
background:#fff;overflow:hidden;
box-shadow:0 1px 0 rgba(255,255,255,.92) inset,0 0 0 1px rgba(15,23,42,.10),0 5px 16px rgba(15,23,42,.20);
}
.td-tk-segments{
position:absolute;left:18px;right:72px;top:50%;
display:grid;grid-template-columns:repeat(8,1fr);gap:3px;
height:12px;transform:translateY(-50%);
}
.td-tk-seg{
position:relative;overflow:hidden;border-radius:3px;transform:skewX(-7deg);
background:linear-gradient(180deg,rgba(138,217,255,.22),rgba(23,103,204,.22));
box-shadow:0 0 0 1px rgba(54,157,239,.18) inset;
}
.td-tk-seg::before{
content:"";position:absolute;left:0;top:0;bottom:0;width:var(--td-tk-seg-fill,0%);
border-radius:inherit;background:linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%);
box-shadow:0 0 9px rgba(42,163,255,.72),0 1px 0 rgba(255,255,255,.45) inset;
transition:width .8s cubic-bezier(.25,.8,.25,1);
}
.td-tk-segments.beat .td-tk-seg::before{animation:tdTkBeat .55s ease}
.td-tk-segments::after{
content:"";position:absolute;left:-30px;top:-3px;bottom:-3px;width:24px;
pointer-events:none;opacity:0;border-radius:999px;
background:linear-gradient(90deg,transparent,rgba(255,255,255,.9),transparent);
transform:skewX(-12deg);
}
.td-tk-segments.beat::after{animation:tdTkSweep .7s ease}
.td-tk-segments.beat + .td-tk-badge{animation:tdTkBadgeBeat .62s ease}
@keyframes tdTkBeat{
0%{filter:brightness(1)}
45%{filter:brightness(1.26)}
100%{filter:brightness(1)}
}
@keyframes tdTkSweep{
0%{left:-30px;opacity:0}
18%{opacity:.9}
100%{left:calc(100% + 30px);opacity:0}
}
@keyframes tdTkBadgeBeat{
0%{transform:translateY(-50%) scale(1)}
45%{transform:translateY(-50%) scale(1.08);filter:brightness(1.18)}
100%{transform:translateY(-50%) scale(1)}
}
.td-tk-badge{
position:absolute;right:18px;top:50%;z-index:2;
display:inline-flex;align-items:center;gap:3px;height:18px;padding:0 7px 0 5px;
border-radius:7px;transform:translateY(-50%);
color:#fff;font-size:11px;font-weight:800;letter-spacing:0;white-space:nowrap;
background:linear-gradient(180deg,#63c8ff 0%,#2c77df 64%,#1f60c9 100%);
box-shadow:0 0 12px rgba(53,159,246,.75),0 1px 0 rgba(255,255,255,.45) inset;
}
.td-tk-check{
display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;
border-radius:50%;background:#fff;color:#266bd5;font-size:10px;line-height:1;
}
.td-tk-cap{
position:absolute;right:7px;bottom:4px;width:10px;height:12px;border-radius:3px 3px 4px 4px;
background:linear-gradient(180deg,#dcffff 0%,#61ddfb 100%);
box-shadow:0 0 8px rgba(82,222,255,.75);opacity:.95;
}
.td-tk-cap::before{
content:"";position:absolute;left:2px;right:2px;top:-3px;height:3px;border-radius:2px 2px 0 0;
background:#cfffff;
}
.td-tk-delta{
position:absolute;right:38px;top:-8px;z-index:3;
font-size:10px;font-weight:700;white-space:nowrap;color:#fff;
padding:1px 5px;border-radius:999px;background:#2b7ee7;
opacity:0;transform:translateY(2px);transition:none;
}
.td-tk-delta.show{
animation:tdTkDeltaIn 1.6s ease forwards;
}
@keyframes tdTkDeltaIn{
0%{opacity:0;transform:translateY(2px)}
20%{opacity:1;transform:translateY(0)}
85%{opacity:1;transform:translateY(0)}
100%{opacity:0;transform:translateY(-4px)}
}
@media (max-width:640px){#td-token-bar{width:186px;max-width:44vw;margin:0 4px}.td-tk-segments{left:14px;right:58px;gap:2px}.td-tk-badge{right:12px;font-size:10px;padding:0 5px;height:17px}.td-tk-cap{display:none}}
@media (prefers-color-scheme:dark){
.td-tk-track{background:#fff;box-shadow:0 1px 0 rgba(255,255,255,.92) inset,0 0 0 1px rgba(255,255,255,.2),0 5px 16px rgba(0,0,0,.36)}
}
\`;
(document.head || document.documentElement).appendChild(st);
}
function buildBar() {
const div = document.createElement("div");
div.id = BAR_ID;
div.innerHTML =
'<div class="td-tk-track"><div class="td-tk-segments" id="td-tk-segments">' +
'<i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i>' +
'<i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i>' +
'</div><span class="td-tk-badge"><span class="td-tk-check">✓</span><span id="td-tk-badge-text">0 token</span></span><span class="td-tk-cap"></span></div>' +
'<span class="td-tk-delta" id="td-tk-delta"></span>';
return div;
}
// ============================================================================
// 3. 格式化 + 颜色 + 渲染
// ============================================================================
function fmtTokens(n) {
if (n < 1000) return String(n);
if (n < 1000000) return (n / 1000).toFixed(1) + "K";
if (n < 1000000000) return (n / 1000000).toFixed(2) + "M";
return (n / 1000000000).toFixed(2) + "B";
}
function fmtBadgeTokens(n) {
n = cleanNum(n);
if (n < 1000) return n + " token";
if (n < 1000000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k token";
if (n < 1000000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M token";
return (n / 1000000000).toFixed(1).replace(/\.0$/, "") + "B token";
}
function fmtDelta(n) {
if (n < 1000) return "+" + n;
if (n < 1000000) return "+" + (n / 1000).toFixed(1) + "K";
if (n < 1000000000) return "+" + (n / 1000000).toFixed(2) + "M";
return "+" + (n / 1000000000).toFixed(2) + "B";
}
function fillColor(total) {
return "linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%)";
}
function progressPct(total) {
total = cleanNum(total);
if (!total) return 0;
return Math.max(0.8, Math.min(100, (total / TOKEN_CAP) * 100));
}
// 5B 上限下,低于几十 MB 的线性变化肉眼几乎不可见;视觉分段采用对数映射,实际百分比仍保留在线程提示中。
function visualProgressPct(total) {
total = cleanNum(total);
if (!total) return 0;
const scale = 100000;
const pct = Math.log1p(total / scale) / Math.log1p(TOKEN_CAP / scale) * 100;
return Math.max(2.4, Math.min(100, pct));
}
function paintSegments(pct) {
const segs = Array.from(document.querySelectorAll(".td-tk-seg"));
if (!segs.length) return;
const span = 100 / segs.length;
segs.forEach((seg, i) => {
const start = i * span;
const fill = Math.max(0, Math.min(100, ((pct - start) / span) * 100));
seg.style.setProperty("--td-tk-seg-fill", fill + "%");
});
}
let beatTimer = null;
function triggerBeat() {
const segments = document.getElementById("td-tk-segments");
if (!segments) return;
render();
segments.classList.remove("beat");
void segments.offsetWidth;
segments.classList.add("beat");
clearTimeout(beatTimer);
beatTimer = setTimeout(() => {
segments.classList.remove("beat");
}, 650);
}
function render() {
daily = normalizeDaily(daily);
const bar = document.getElementById(BAR_ID);
const badge = document.getElementById("td-tk-badge-text");
const pct = visualProgressPct(daily.total);
paintSegments(pct);
if (badge) badge.textContent = fmtBadgeTokens(daily.total);
if (bar) {
bar.title = usageTitle(daily);
}
}
function usageTitle(d) {
d = normalizeDaily(d);
const parts = ["今日 " + fmtTokens(d.total), "额度 5B", "进度 " + progressPct(d.total).toFixed(4).replace(/0+$/,"").replace(/\\.$/,"") + "%"];
if (d.input) parts.push("输入 " + fmtTokens(d.input));
if (d.output) parts.push("输出 " + fmtTokens(d.output));
if (d.cached) parts.push("缓存 " + fmtTokens(d.cached));
if (d.estimate) parts.push("估算 " + fmtTokens(d.estimate));
if (d.unknown) parts.push("未知 " + fmtTokens(d.unknown));
if (d.lastSource) parts.push("来源 " + d.lastSource);
return parts.join(" · ");
}
// ============================================================================
// 4. 动画:主进程通过 Runtime.evaluate 调用此函数触发
// ============================================================================
let lastNetworkAt = 0;
let lastPageUsageAt = 0;
let estimatedSinceUsage = 0;
let recentRealAdds = [];
let lastSessionTotal = null;
function showDelta(total) {
const delta = document.getElementById("td-tk-delta");
if (!delta || total <= 0) return;
delta.textContent = fmtDelta(total);
delta.classList.remove("show");
void delta.offsetWidth;
delta.classList.add("show");
}
function normalizeUsagePayload(payload, source) {
if (typeof payload === "number" || typeof payload === "string") {
return { total: cleanNum(payload), input: 0, output: 0, cached: 0, estimate: 0, source: source || "manual" };
}
const u = payload && typeof payload === "object" ? payload : {};
const input = cleanNum(u.input || u.input_tokens || u.prompt_tokens || u.promptTokenCount || u.prompt_token_count || u.inputTokenCount);
const output = cleanNum(u.output || u.output_tokens || u.completion_tokens || u.candidatesTokenCount || u.candidates_token_count || u.outputTokenCount);
const reasoning = cleanNum(u.reasoning_tokens || u.thoughtsTokenCount || u.thoughts_token_count);
const cached = cleanNum(u.cached || u.cached_tokens || u.cache_creation_input_tokens || 0) +
cleanNum(u.cache_read_input_tokens || 0) +
cleanNum(u.cachedContentTokenCount || 0) +
cleanNum(u.cached_content_token_count || 0);
const estimate = cleanNum(u.estimate || u.estimated);
const unknown = cleanNum(u.unknown);
const total = cleanNum(u.total || u.total_tokens || u.totalTokenCount || u.total_token_count || u.token_count) || input + output + reasoning + cached + estimate + unknown;
return { total, input, output: output + reasoning, cached, estimate, unknown, source: String(source || u.source || "manual") };
}
function animateTokenAdd(payload, source) {
const usage = normalizeUsagePayload(payload, source);
if (usage.total <= 0) return;
const src = usage.source || "manual";
const now = Date.now();
if (src === "network" || src === "page") {
recentRealAdds = recentRealAdds.filter((item) => now - item.at < 15000);
if (recentRealAdds.some((item) => item.total === usage.total && item.source !== src)) return;
recentRealAdds.push({ total: usage.total, source: src, at: now });
}
if (src === "network") lastNetworkAt = now;
if (src === "page") lastPageUsageAt = now;
if (src === "dom" && (now - lastNetworkAt < 12000 || now - lastPageUsageAt < 12000)) return;
if (src === "page" && now - lastNetworkAt < 8000) return;
if (src === "network" || src === "page") {
daily = loadDaily();
const subtract = Math.min(estimatedSinceUsage || daily.estimate, daily.total);
if (subtract > 0) {
daily.total = Math.max(0, daily.total - subtract);
daily.estimate = Math.max(0, daily.estimate - subtract);
saveDaily(daily);
estimatedSinceUsage = 0;
}
}
daily = loadDaily();
daily.total += usage.total;
daily.input += usage.input;
daily.output += usage.output;
daily.cached += usage.cached;
daily.unknown += usage.unknown;
if (src === "estimate") {
const est = usage.estimate || usage.total;
daily.estimate += est;
estimatedSinceUsage += est;
}
daily.lastSource = src;
saveDaily(daily);
// 本地 session 已接管时,Network 只保留兜底记账,不重复播放动画。
if (lastSessionTotal === null || src === "session") triggerBeat();
const shouldShowDelta = src !== "estimate" && src !== "dom";
if (shouldShowDelta && (lastSessionTotal === null || src === "session")) showDelta(usage.total);
}
function usageTotal(u) {
return u ? cleanNum(u.total) : 0;
}
function betterUsage(a, b) {
return usageTotal(b) > usageTotal(a) ? b : a;
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function looksLikeUsage(usage) {
if (!usage || typeof usage !== "object") return false;
const keys = [
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
"thoughtsTokenCount", "thoughts_token_count"
];
if (keys.some((key) => hasOwn(usage, key))) return true;
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
}
function extractUsageFromRawUsage(usage) {
if (!usage || typeof usage !== "object") return null;
if (!looksLikeUsage(usage)) return null;
const input = cleanNum(usage.input_tokens || usage.prompt_tokens || usage.promptTokenCount || usage.prompt_token_count || usage.inputTokenCount);
const output = cleanNum(usage.output_tokens || usage.completion_tokens || usage.candidatesTokenCount || usage.candidates_token_count || usage.outputTokenCount);
const reasoning = cleanNum(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
const cached = cleanNum(details.cached_tokens) +
cleanNum(usage.cache_creation_input_tokens) +
cleanNum(usage.cache_read_input_tokens) +
cleanNum(usage.cachedContentTokenCount) +
cleanNum(usage.cached_content_token_count);
const total = cleanNum(usage.total_tokens || usage.totalTokenCount || usage.total_token_count || usage.token_count || usage.total) || input + output + reasoning + cached;
if (!total) return null;
return { total, input, output: output + reasoning, cached };
}
function extractUsageFromObject(obj) {
if (!obj || typeof obj !== "object") return null;
const direct = extractUsageFromRawUsage(obj.usage) ||
extractUsageFromRawUsage(obj.usage_metadata) ||
extractUsageFromRawUsage(obj.usageMetadata) ||
extractUsageFromRawUsage(obj);
if (direct) return direct;
let best = null;
for (const val of Object.values(obj)) {
if (!val || typeof val !== "object") continue;
if (Array.isArray(val)) {
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
} else {
best = betterUsage(best, extractUsageFromObject(val));
}
}
return best;
}
function extractUsageFromText(text) {
let best = null;
const take = (obj) => { best = betterUsage(best, extractUsageFromObject(obj)); };
try { take(JSON.parse(text)); } catch {}
const lines = String(text || "").split(String.fromCharCode(13)).join("").split(String.fromCharCode(10));
for (const line of lines) {
const s = line.trim();
if (!s.startsWith("data:")) continue;
const payload = s.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try { take(JSON.parse(payload)); } catch {}
}
return best;
}
function installNetworkFallback() {
if (window.__tdTokenNetFallbackInstalled === VER) return;
window.__tdTokenNetFallbackInstalled = VER;
const API_URL_RE = new RegExp("chat/completions|/responses|/messages|conversation|codex", "i");
const TEXT_CT_RE = new RegExp("json|text|event-stream", "i");
const rawFetch = window.fetch;
if (typeof rawFetch === "function") {
window.fetch = async function(...args) {
const res = await rawFetch.apply(this, args);
try {
const url = String((args[0] && args[0].url) || args[0] || "");
if (API_URL_RE.test(url)) {
const ct = (res.headers && res.headers.get && res.headers.get("content-type")) || "";
if (TEXT_CT_RE.test(ct)) {
res.clone().text().then((txt) => {
const usage = extractUsageFromText(txt);
if (usage && usage.total > 0) animateTokenAdd(usage, "page");
}).catch(() => {});
}
}
} catch {}
return res;
};
}
const XHR = window.XMLHttpRequest;
if (XHR && XHR.prototype) {
const rawOpen = XHR.prototype.open;
const rawSend = XHR.prototype.send;
XHR.prototype.open = function(method, url, ...rest) {
try { this.__tdTokenUrl = String(url || ""); } catch {}
return rawOpen.call(this, method, url, ...rest);
};
XHR.prototype.send = function(...args) {
try {
this.addEventListener("loadend", () => {
try {
const url = this.__tdTokenUrl || "";
const ct = this.getResponseHeader && (this.getResponseHeader("content-type") || "");
if (!API_URL_RE.test(url)) return;
if (!TEXT_CT_RE.test(ct)) return;
const txt = typeof this.responseText === "string" ? this.responseText : "";
const usage = extractUsageFromText(txt);
if (usage && usage.total > 0) animateTokenAdd(usage, "page");
} catch {}
});
} catch {}
return rawSend.apply(this, args);
};
}
}
function parseTokenText(text) {
const matches = String(text || "").matchAll(new RegExp("([0-9.,]+) *([KkMm]?) *tokens?", "gi"));
let best = 0;
for (const m of matches) {
let num = parseFloat(String(m[1]).replace(/,/g, ""));
if (!isFinite(num) || num <= 0) continue;
const unit = String(m[2] || "").toLowerCase();
if (unit === "k") num *= 1000;
else if (unit === "m") num *= 1000000;
best = Math.max(best, Math.round(num));
}
return best;
}
let domTokenBaseline = 0;
let domTokenBest = 0;
let domScanTimer = null;
function scanDomTokenText() {
const text = document.body ? document.body.innerText || "" : "";
const best = parseTokenText(text);
if (best <= 0) return;
if (!domTokenBaseline) {
domTokenBaseline = best;
domTokenBest = best;
return;
}
if (best > domTokenBest) {
const delta = best - domTokenBest;
domTokenBest = best;
if (delta > 0 && delta < 2000000) animateTokenAdd(delta, "dom");
}
}
function primeEstimateBaseline() {
const raw = document.body ? document.body.innerText || "" : "";
const best = parseTokenText(raw);
if (best > 0) {
domTokenBaseline = best;
domTokenBest = best;
}
}
function scheduleDomScan() {
clearTimeout(domScanTimer);
domScanTimer = setTimeout(() => {
scanDomTokenText();
}, 700);
}
function findToolbar() {
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
if (!rich) return null;
let p = rich;
for (let i = 0; i < 7 && p; i++) {
const foot = p.querySelector("[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']");
if (foot && foot.querySelectorAll("button, [role='button']").length >= 2) return foot;
p = p.parentElement;
}
return null;
}
function visibleText(el) {
return String((el && (el.getAttribute("aria-label") || el.title || el.textContent)) || "").trim();
}
function findModelControl(toolbar) {
const controls = Array.from(toolbar.querySelectorAll("button, [role='button'], [aria-haspopup], [data-testid*='model'], [class*='model']"));
const modelLike = controls.find((el) => /model|模型|gpt|claude|codex|o[0-9]|gemini|deepseek|glm|kimi/i.test(visibleText(el)));
if (modelLike) return modelLike;
const buttons = Array.from(toolbar.querySelectorAll("button, [role='button']"));
return buttons.length > 1 ? buttons[buttons.length - 2] : buttons[0] || null;
}
function centerUnderEditor(bar) {
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
if (!rich || !rich.parentElement) return false;
const row = document.createElement("div");
row.id = "td-token-center-row";
row.style.cssText = "display:flex;justify-content:center;align-items:center;width:100%;padding:2px 0 0;pointer-events:none;";
row.appendChild(bar);
const anchor = rich.parentElement;
anchor.insertAdjacentElement("afterend", row);
return true;
}
function cleanupCenterRow() {
const row = document.getElementById("td-token-center-row");
if (row && !row.querySelector("#" + BAR_ID)) row.remove();
}
function placeInToolbar(bar) {
const toolbar = findToolbar();
if (!toolbar) return false;
const model = findModelControl(toolbar);
if (model && model.parentElement && toolbar.contains(model.parentElement)) {
model.parentElement.insertBefore(bar, model);
} else if (model && toolbar.contains(model)) {
toolbar.insertBefore(bar, model);
} else {
toolbar.appendChild(bar);
}
cleanupCenterRow();
return true;
}
function mountBar() {
const existing = document.getElementById(BAR_ID);
if (existing) return;
const bar = buildBar();
if (!placeInToolbar(bar) && !centerUnderEditor(bar)) {
(document.body || document.documentElement).appendChild(bar);
}
render();
}
function maybeRelocate() {
const bar = document.getElementById(BAR_ID);
if (!bar) return false;
const row = document.getElementById("td-token-center-row");
if (row && row.contains(bar)) return placeInToolbar(bar);
if (bar.parentElement === document.body || bar.parentElement === document.documentElement) return placeInToolbar(bar);
return false;
}
// ============================================================================
// 5. MutationObserver:页面重绘后补回悬浮条
// ============================================================================
function tryMount() {
if (document.getElementById(BAR_ID)) return;
mountBar();
}
let mo = null;
function startObserver() {
if (mo) return;
installNetworkFallback();
mo = new MutationObserver((mutations) => {
maybeRelocate();
if (!document.getElementById(BAR_ID)) tryMount();
scheduleDomScan();
});
window.__tdTokenObserver = mo;
const root = document.body || document.documentElement;
if (root) mo.observe(root, { childList: true, subtree: true });
tryMount();
primeEstimateBaseline();
scheduleDomScan();
}
function ensureStarted() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver, { once: true });
} else {
startObserver();
}
}
ensureStarted();
// ============================================================================
// 7. 对外 API(主进程通过 Runtime.evaluate 调用)
// ============================================================================
window.__tdTokenAdd = (usage, source) => {
try {
const u = normalizeUsagePayload(usage, source || "external");
if (!u.total) return "bad";
animateTokenAdd(u, u.source);
return "ok";
} catch { return "err"; }
};
window.__tdTokenGet = () => {
const d = loadDaily();
return JSON.stringify(d);
};
window.__tdTokenSet = (usage) => {
try {
const next = normalizeUsagePayload(usage, "session");
const previousSessionTotal = lastSessionTotal;
lastSessionTotal = next.total;
daily = normalizeDaily({ ...next, date: todayKey(), lastSource: "session" });
saveDaily(daily);
render();
const delta = previousSessionTotal === null ? 0 : next.total - previousSessionTotal;
if (delta > 0) {
triggerBeat();
showDelta(delta);
}
return "ok";
} catch { return "err"; }
};
window.__tdTokenReset = () => {
daily = blankDaily();
estimatedSinceUsage = 0;
recentRealAdds = [];
lastSessionTotal = 0;
saveDaily(daily);
render();
return "ok";
};
})();
`;
module.exports = { TOKEN_INJECT_SCRIPT };
+178
View File
@@ -0,0 +1,178 @@
// Read Codex's own cumulative token snapshots from ~/.codex/sessions.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
const SESSIONS_ROOT = path.join(CODEX_HOME, "sessions");
const RESET_PATH = path.join(CODEX_HOME, ".tokendun-token-reset.json");
const fileCache = new Map();
function clean(n) {
n = Number(n);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function dayKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function blank(date = dayKey()) {
return {
available: false,
date,
total: 0,
input: 0,
output: 0,
cached: 0,
reasoning: 0,
unknown: 0,
lastSource: "session",
};
}
function snapshotUsage(info) {
const u = info && typeof info === "object" ? info : {};
const input = clean(u.input_tokens || u.prompt_tokens || u.inputTokenCount || u.promptTokenCount);
const cached = clean(
u.cached_input_tokens ||
u.cache_read_input_tokens ||
u.cached_tokens ||
(u.input_tokens_details && u.input_tokens_details.cached_tokens)
);
const output = clean(u.output_tokens || u.completion_tokens || u.candidatesTokenCount);
const reasoning = clean(u.reasoning_output_tokens || u.reasoning_tokens || u.thoughtsTokenCount);
const total = clean(u.total_tokens || u.totalTokenCount || u.total) || input + output;
return { total, input, output, cached, reasoning };
}
function snapshotAt(line) {
let item;
try { item = JSON.parse(line); } catch { return null; }
if (item?.type !== "event_msg" || item?.payload?.type !== "token_count") return null;
const timestamp = new Date(item.timestamp);
if (!Number.isFinite(timestamp.getTime())) return null;
const usage = snapshotUsage(item.payload.info?.total_token_usage);
return usage.total > 0 ? { timestamp, usage } : null;
}
function parseSessionFile(file) {
try {
const stat = fs.statSync(file);
const cached = fileCache.get(file);
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value;
const snapshots = [];
const text = fs.readFileSync(file, "utf8");
for (const line of text.split("\n")) {
const snapshot = snapshotAt(line);
if (snapshot) snapshots.push(snapshot);
}
const value = { snapshots };
fileCache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, value });
return value;
} catch {
return { snapshots: [] };
}
}
function utcDatePath(date) {
return path.join(
SESSIONS_ROOT,
String(date.getUTCFullYear()).padStart(4, "0"),
String(date.getUTCMonth() + 1).padStart(2, "0"),
String(date.getUTCDate()).padStart(2, "0")
);
}
function sessionFilesForDay(start, end) {
const dirs = new Set();
for (const cursor = new Date(start); cursor < end; cursor.setDate(cursor.getDate() + 1)) {
dirs.add(utcDatePath(cursor));
}
dirs.add(utcDatePath(new Date(end.getTime() - 1)));
const files = [];
for (const dir of dirs) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path.join(dir, entry.name));
}
} catch {}
}
return files;
}
function delta(current, before) {
return Math.max(0, clean(current) - clean(before));
}
function readReset() {
try {
const value = JSON.parse(fs.readFileSync(RESET_PATH, "utf8"));
return value && typeof value === "object" ? value : null;
} catch {
return null;
}
}
function applyReset(value) {
const reset = readReset();
if (!reset || reset.date !== value.date) return value;
return {
...value,
total: delta(value.total, reset.total),
input: delta(value.input, reset.input),
output: delta(value.output, reset.output),
cached: delta(value.cached, reset.cached),
reasoning: delta(value.reasoning, reset.reasoning),
};
}
function readDailyRaw(now = new Date()) {
const date = dayKey(now);
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const result = blank(date);
result.available = fs.existsSync(SESSIONS_ROOT);
for (const file of sessionFilesForDay(start, end)) {
const snapshots = parseSessionFile(file).snapshots;
let before = null;
let current = null;
for (const snapshot of snapshots) {
if (snapshot.timestamp < start) before = snapshot;
else if (snapshot.timestamp < end && (!current || snapshot.timestamp > current.timestamp)) current = snapshot;
}
if (!current) continue;
const b = before?.usage || {};
const u = current.usage;
result.total += delta(u.total, b.total);
result.input += delta(u.input, b.input);
result.output += delta(u.output, b.output);
result.cached += delta(u.cached, b.cached);
result.reasoning += delta(u.reasoning, b.reasoning);
}
return result;
}
function readDaily(now = new Date()) {
return applyReset(readDailyRaw(now));
}
function reset(now = new Date()) {
const value = readDailyRaw(now);
fs.mkdirSync(path.dirname(RESET_PATH), { recursive: true });
fs.writeFileSync(RESET_PATH, JSON.stringify({ ...value, date: dayKey(now) }), "utf8");
return blank(dayKey(now));
}
module.exports = {
readDaily,
reset,
_snapshotUsage: snapshotUsage,
_sessionFilesForDay: sessionFilesForDay,
};
+43
View File
@@ -0,0 +1,43 @@
// token_watch.js —— Token 用量监控(对外的薄包装层)。
// 注入脚本由 voice_cdp.js 在连接 Codex 时自动注入(与语音脚本同 session)。
// 本模块暴露 status / reset 给 IPC handler 和前端面板。
"use strict";
const voice = require("./voice_cdp");
const tokenUsage = require("./token_usage");
function status() {
const snap = voice.status();
return {
phase: snap.phase,
date: "",
total: 0,
};
}
async function tokenStatus() {
try {
const snap = voice.status();
const page = await voice.tokenStatus();
const local = tokenUsage.readDaily();
if (local && local.available) {
await voice.tokenSet(local);
return { ok: true, phase: snap.phase, connected: snap.phase === "connected", ...local, network: page.network };
}
return { ok: true, phase: snap.phase, connected: snap.phase === "connected", ...page };
} catch {
return { ok: false, error: "无法读取 token 用量(Codex 可能未连接)" };
}
}
async function tokenReset() {
try {
const local = tokenUsage.reset();
const ok = await voice.tokenSet(local);
await voice.tokenReset();
return { ok: ok || true };
} catch {
return { ok: false, error: "无法重置" };
}
}
module.exports = { status, tokenStatus, tokenReset };
+372 -2
View File
@@ -16,10 +16,14 @@ function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
const router = require("./router");
const providerSync = require("./provider_sync");
const { INJECT_SCRIPT } = require("./voice_inject");
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
const tokenUsage = require("./token_usage");
const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222)
const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding
const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
@@ -35,7 +39,10 @@ let state = {
target: null, // { id, url, title }
};
let session = null; // 当前 CdpSession
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
let networkAttachGeneration = 0;
let onStatusCb = null;
let tokenSyncTimer = null;
function snapshot() {
const { phase, port, codexPath, lastError, model, target } = state;
@@ -50,6 +57,29 @@ function setPhase(phase, extra = {}) {
}
}
async function syncTokenUsage() {
if (!session) return;
const usage = tokenUsage.readDaily();
if (!usage || !usage.available) return;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage) + ")",
awaitPromise: false,
});
} catch {}
}
function startTokenSync() {
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
tokenSyncTimer = setInterval(() => { syncTokenUsage().catch(() => {}); }, 2500);
syncTokenUsage().catch(() => {});
}
function stopTokenSync() {
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
tokenSyncTimer = null;
}
// —— 探测 Codex 可执行路径 ——
function codexExecutable() {
const home = os.homedir();
@@ -139,6 +169,10 @@ function wsConnect(wsUrl) {
const socket = net.connect({ host, port: Number(port) });
const events = { open: [], message: [], close: [], error: [] };
const emit = (name, arg) => (events[name] || []).forEach((cb) => cb(arg));
socket.setTimeout(CDP_WS_TIMEOUT, () => {
if (!handshakeDone) emit("error", new Error("WS 连接超时"));
try { socket.destroy(); } catch {}
});
// —— 发送一帧文本(客户端必须掩码)——
function sendFrame(str) {
@@ -252,6 +286,7 @@ function wsConnect(wsUrl) {
return;
}
handshakeDone = true;
try { socket.setTimeout(0); } catch {}
emit("open");
parseFrames();
} else {
@@ -287,7 +322,20 @@ function openSession(wsUrl, onBindingCalled) {
return new Promise((resolve, reject) => {
if (closed) return reject(new Error("CDP 会话已关闭"));
const id = nextId++;
pending.set(id, { resolve, reject });
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("CDP 命令超时: " + method));
}, CDP_CMD_TIMEOUT);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
ws.send(JSON.stringify({ id, method, params }));
});
}
@@ -406,7 +454,7 @@ function connectedPhase() {
return session ? "connected" : "disconnected";
}
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate ——
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
async function inject(sessionObj) {
await sessionObj.send("Runtime.enable");
try {
@@ -414,8 +462,266 @@ async function inject(sessionObj) {
} catch {}
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
await sessionObj.send("Page.enable");
// 语音注入脚本
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: TOKEN_INJECT_SCRIPT });
await sessionObj.send("Runtime.evaluate", { expression: TOKEN_INJECT_SCRIPT, awaitPromise: false });
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
await sessionObj.send("Network.enable");
}
// ============================================================================
// Token 用量:CDP Network 域拦截 LLM 响应抓 usage(不受页面 CSP 限制)
// ============================================================================
// 待完成的请求:requestId → { url, mimeType }
const pendingNetworkReqs = new Map();
let nextNetworkSessionId = 1;
const tokenNet = {
matchedCount: 0,
usageCount: 0,
noUsageCount: 0,
lastMatchedUrl: "",
lastUsageUrl: "",
lastNoUsageUrl: "",
lastUsageTotal: 0,
lastError: "",
attachedWorkers: 0,
};
const LLM_URL_RE = /\/(chat\/completions|v1\/messages|messages|generateContent|streamGenerateContent|completions|responses|backend-api\/(?:codex|conversation|responses)|codex|conversation)(\/|$|\?)/i;
function networkReqKey(ownerSession, requestId) {
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
return ownerSession._tdTokenNetId + ":" + requestId;
}
function bindNetworkHandlers(ownerSession) {
if (!ownerSession) return;
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
ownerSession.on("Network.responseReceived", (params) => handleNetworkResponse(params, ownerSession));
ownerSession.on("Network.loadingFinished", (params) => handleNetworkLoadingFinished(params, ownerSession));
ownerSession.on("Network.loadingFailed", (params) => handleNetworkLoadingFailed(params, ownerSession));
}
function handleNetworkResponse(params, ownerSession) {
const { requestId, response } = params || {};
if (!requestId || !response || !ownerSession) return;
const url = response.url || "";
if (!LLM_URL_RE.test(url)) return;
// 只处理 JSON 响应
const mime = (response.mimeType || "").toLowerCase();
if (!mime.includes("json") && !mime.includes("text")) return;
tokenNet.matchedCount += 1;
tokenNet.lastMatchedUrl = url;
pendingNetworkReqs.set(networkReqKey(ownerSession, requestId), { url, mimeType: mime });
}
function handleNetworkLoadingFailed(params, ownerSession) {
const { requestId } = params || {};
if (requestId && ownerSession) pendingNetworkReqs.delete(networkReqKey(ownerSession, requestId));
}
async function handleNetworkLoadingFinished(params, ownerSession) {
const { requestId } = params || {};
if (!requestId || !ownerSession) return;
const key = networkReqKey(ownerSession, requestId);
if (!pendingNetworkReqs.has(key)) return;
const reqInfo = pendingNetworkReqs.get(key) || {};
pendingNetworkReqs.delete(key);
if (!session || !ownerSession) return;
try {
const r = await ownerSession.send("Network.getResponseBody", { requestId });
const body = (r && r.body) || "";
if (!body || body.length < 10) return;
// 可能被 base64 编码
let text = body;
if (r && r.base64Encoded) {
try { text = Buffer.from(body, "base64").toString("utf8"); } catch { return; }
}
// 解析 JSON 提取 usage
const usage = extractUsageFromText(text);
if (usage && usage.total > 0) {
tokenNet.usageCount += 1;
tokenNet.lastUsageUrl = reqInfo.url || "";
tokenNet.lastUsageTotal = usage.total || 0;
tokenNet.lastError = "";
dlog(
"token: +" + usage.total +
" input=" + (usage.input || 0) +
" output=" + (usage.output || 0) +
" cached=" + (usage.cached || 0) +
" from",
reqInfo.url || "?"
);
// 通过 Runtime.evaluate 调注入脚本更新 UI
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenAdd && window.__tdTokenAdd(" + JSON.stringify(usage) + ", 'network')",
awaitPromise: false,
});
} catch {}
} else {
tokenNet.noUsageCount += 1;
tokenNet.lastNoUsageUrl = reqInfo.url || "";
}
} catch (e) {
tokenNet.lastError = String((e && e.message) || e || "getResponseBody failed").slice(0, 160);
// getResponseBody 可能失败(响应体已被清掉或太大),静默跳过
}
}
function extractUsageFromText(text) {
let best = null;
const take = (obj) => {
best = betterUsage(best, extractUsageFromObject(obj));
};
try {
take(JSON.parse(text));
} catch {}
if (usageTotal(best) > 0) return best;
// Stream/SSE: data: {"usage":...}\n\n or data: [DONE]
const lines = String(text || "").split(/\r?\n/);
for (const line of lines) {
const s = line.trim();
const payload = s.startsWith("data:") ? s.slice(5).trim() : s;
if (!payload || payload === "[DONE]") continue;
try { take(JSON.parse(payload)); } catch {}
}
return best;
}
function cleanTokenNumber(n) {
n = Number(n);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function usageTotal(u) {
return u ? cleanTokenNumber(u.total) : 0;
}
function betterUsage(a, b) {
return usageTotal(b) > usageTotal(a) ? b : a;
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function looksLikeUsage(usage) {
if (!usage || typeof usage !== "object") return false;
const keys = [
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
"thoughtsTokenCount", "thoughts_token_count",
];
if (keys.some((key) => hasOwn(usage, key))) return true;
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
}
function extractUsageFromRawUsage(usage) {
if (!usage || typeof usage !== "object") return null;
if (!looksLikeUsage(usage)) return null;
const input = cleanTokenNumber(
usage.input_tokens ||
usage.prompt_tokens ||
usage.promptTokenCount ||
usage.prompt_token_count ||
usage.inputTokenCount
);
const output = cleanTokenNumber(
usage.output_tokens ||
usage.completion_tokens ||
usage.candidatesTokenCount ||
usage.candidates_token_count ||
usage.outputTokenCount
);
const reasoning = cleanTokenNumber(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
const cached =
cleanTokenNumber(details.cached_tokens) +
cleanTokenNumber(usage.cache_creation_input_tokens) +
cleanTokenNumber(usage.cache_read_input_tokens) +
cleanTokenNumber(usage.cachedContentTokenCount) +
cleanTokenNumber(usage.cached_content_token_count);
const total = cleanTokenNumber(
usage.total_tokens ||
usage.totalTokenCount ||
usage.total_token_count ||
usage.token_count ||
usage.total
) || input + output + reasoning + cached;
if (!total) return null;
return { total, input, output: output + reasoning, cached };
}
function extractUsageFromObject(obj) {
if (!obj || typeof obj !== "object") return null;
const direct =
extractUsageFromRawUsage(obj.usage) ||
extractUsageFromRawUsage(obj.usage_metadata) ||
extractUsageFromRawUsage(obj.usageMetadata) ||
extractUsageFromRawUsage(obj);
if (direct) return direct;
let best = null;
for (const val of Object.values(obj)) {
if (!val || typeof val !== "object") continue;
if (Array.isArray(val)) {
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
} else {
best = betterUsage(best, extractUsageFromObject(val));
}
}
return best;
}
async function attachNetworkSession(target, generation) {
if (!target || !target.webSocketDebuggerUrl) return;
let netSession = null;
try {
netSession = await openSession(target.webSocketDebuggerUrl, null);
bindNetworkHandlers(netSession);
await netSession.send("Network.enable");
if (!session || generation !== networkAttachGeneration) {
try { netSession.close(); } catch {}
return false;
}
networkSessions.push(netSession);
dlog("token network attached:", target.type || "target", target.id || "", target.url || "");
return true;
} catch (e) {
if (netSession) {
try { netSession.close(); } catch {}
}
tokenNet.lastError = String((e && e.message) || e || "attach network failed").slice(0, 160);
return false;
}
}
async function attachWorkerNetworkTargets(port, parentId) {
const targets = await listTargets(port);
const workers = targets.filter((t) =>
t &&
t.webSocketDebuggerUrl &&
(t.type === "worker" || t.type === "service_worker" || t.type === "shared_worker") &&
(!parentId || t.parentId === parentId)
);
const generation = networkAttachGeneration;
const results = await Promise.all(workers.map((worker) => attachNetworkSession(worker, generation)));
tokenNet.attachedWorkers = results.filter(Boolean).length;
}
function closeNetworkSessions() {
networkAttachGeneration += 1;
for (const s of networkSessions) {
try { s.close(); } catch {}
}
networkSessions = [];
pendingNetworkReqs.clear();
}
// —— 连接到已带端口的 Codex ——
@@ -423,17 +729,26 @@ async function connectToTarget(port) {
const targets = await listTargets(port);
const tgt = pickTarget(targets);
if (!tgt) return false;
closeNetworkSessions();
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
setPhase("connecting");
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
session.on("Runtime.bindingCalled", handleBinding);
// Token 用量:Network 层拦截 LLM 响应
bindNetworkHandlers(session);
session.ws.on("close", () => {
stopTokenSync();
closeNetworkSessions();
session = null;
setPhase("disconnected");
});
setPhase("injecting");
await inject(session);
startTokenSync();
setPhase("connected", { target: state.target });
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
tokenNet.lastError = String((e && e.message) || e || "attach workers failed").slice(0, 160);
});
return true;
}
@@ -545,6 +860,8 @@ async function connect(cb, opts = {}) {
}
function disconnect() {
stopTokenSync();
closeNetworkSessions();
if (session) {
try {
session.close();
@@ -601,6 +918,56 @@ async function probeStt() {
return out;
}
// —— Token 用量读取 / 重置(通过 CDP Runtime.evaluate 调注入脚本的 window API)——
async function tokenStatus() {
if (!session) return { date: "", total: 0 };
try {
const r = await session.send("Runtime.evaluate", {
expression: "window.__tdTokenGet ? window.__tdTokenGet() : JSON.stringify({date:'',total:0})",
awaitPromise: false,
});
const raw = (r && r.result && r.result.value) || "";
const parsed = (() => { try { return JSON.parse(raw); } catch { return { date: "", total: 0 }; } })();
return {
date: parsed.date || "",
total: Number(parsed.total) || 0,
input: Number(parsed.input) || 0,
output: Number(parsed.output) || 0,
cached: Number(parsed.cached) || 0,
estimate: Number(parsed.estimate || parsed.estimated) || 0,
unknown: Number(parsed.unknown) || 0,
lastSource: parsed.lastSource || parsed.source || "",
network: { ...tokenNet },
};
} catch {
return { date: "", total: 0 };
}
}
async function tokenSet(usage) {
if (!session) return false;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage || {}) + ")",
awaitPromise: false,
});
return true;
} catch {
return false;
}
}
async function tokenReset() {
if (!session) return false;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenReset ? window.__tdTokenReset() : 'no'",
awaitPromise: false,
});
return true;
} catch {
return false;
}
}
module.exports = {
status,
connect,
@@ -608,6 +975,9 @@ module.exports = {
onStatus,
sttInfo,
probeStt,
tokenStatus,
tokenSet,
tokenReset,
// 供临时实测脚本复用
_listTargets: listTargets,
_pickTarget: pickTarget,
+98 -1
View File
@@ -47,4 +47,101 @@ function extractZip(buf, destDir) {
return written;
}
module.exports = { extractZip };
// ---- 零依赖 ZIP 压缩(deflate/stored),供员工团队打包等复用 ----
// CRC32 表(懒加载)
let CRC_TABLE = null;
function crc32(buf) {
if (!CRC_TABLE) {
CRC_TABLE = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
CRC_TABLE[n] = c;
}
}
let crc = ~0;
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
return (~crc) >>> 0;
}
// MS-DOS 时间/日期(固定用一个合法值即可,不追求精确时间戳)
function dosDateTime(d) {
const date = d || new Date();
const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f);
const day = (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0x0f) << 5) | (date.getDate() & 0x1f);
return { time: time & 0xffff, date: day & 0xffff };
}
// files: [{ path, content(string|Buffer), mode?(unix 权限,如 0o755) }]
// 返回一个 zip 的 Buffer。目录靠文件路径里的 "/" 隐含,无需单独目录项。
function createZip(files) {
const { time, date } = dosDateTime();
const locals = [];
const centrals = [];
let offset = 0;
for (const f of files) {
const nameBuf = Buffer.from(String(f.path).replace(/^\/+/, ""), "utf8");
const raw = Buffer.isBuffer(f.content) ? f.content : Buffer.from(String(f.content == null ? "" : f.content), "utf8");
const crc = crc32(raw);
// 尝试 deflate;若压不小就存储(method 0)
let method = 8;
let comp = zlib.deflateRawSync(raw, { level: 9 });
if (comp.length >= raw.length) { method = 0; comp = raw; }
// 本地文件头
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4); // version needed
lh.writeUInt16LE(0, 6); // flags
lh.writeUInt16LE(method, 8);
lh.writeUInt16LE(time, 10);
lh.writeUInt16LE(date, 12);
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(comp.length, 18);
lh.writeUInt32LE(raw.length, 22);
lh.writeUInt16LE(nameBuf.length, 26);
lh.writeUInt16LE(0, 28); // extra len
locals.push(lh, nameBuf, comp);
// 中央目录项
const ch = Buffer.alloc(46);
// unix 权限写进外部属性高 16 位(低位保留),默认 0644,可执行给 0755
const unixMode = f.mode ? f.mode : 0o644;
const externalAttr = (unixMode << 16) >>> 0;
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt16LE(0x031e, 4); // version made by (unix)
ch.writeUInt16LE(20, 6); // version needed
ch.writeUInt16LE(0, 8); // flags
ch.writeUInt16LE(method, 10);
ch.writeUInt16LE(time, 12);
ch.writeUInt16LE(date, 14);
ch.writeUInt32LE(crc, 16);
ch.writeUInt32LE(comp.length, 20);
ch.writeUInt32LE(raw.length, 24);
ch.writeUInt16LE(nameBuf.length, 28);
ch.writeUInt16LE(0, 30); // extra len
ch.writeUInt16LE(0, 32); // comment len
ch.writeUInt16LE(0, 34); // disk number
ch.writeUInt16LE(0, 36); // internal attr
ch.writeUInt32LE(externalAttr, 38);
ch.writeUInt32LE(offset, 42);
centrals.push(ch, nameBuf);
offset += lh.length + nameBuf.length + comp.length;
}
const localPart = Buffer.concat(locals);
const centralPart = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(centralPart.length, 12);
eocd.writeUInt32LE(localPart.length, 16); // central dir offset
eocd.writeUInt16LE(0, 20);
return Buffer.concat([localPart, centralPart, eocd]);
}
module.exports = { extractZip, createZip };
+102 -1
View File
@@ -1,6 +1,6 @@
// main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。
// 市场地址/密钥【只在主进程】,绝不下发前端。契约见 CONTRACT.md。
const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage } = require("electron");
const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage, clipboard } = require("electron");
const { autoUpdater } = require("electron-updater");
const path = require("path");
const os = require("os");
@@ -15,7 +15,11 @@ const sessions = require("./lib/sessions");
const providerSync = require("./lib/provider_sync");
const router = require("./lib/router");
const canvas = require("./lib/canvas");
const team = require("./lib/team");
const promptAnalysis = require("./lib/prompt-analysis");
const claudedesk = require("./lib/claudedesk");
const voice = require("./lib/voice_cdp");
const tokenWatch = require("./lib/token_watch");
const proxy = require("./lib/proxy");
const memory = require("./lib/memory");
const { extractZip } = require("./lib/zip");
@@ -235,6 +239,20 @@ app.whenReady().then(async () => {
} catch (e) {
log(`启动迁移 plugin 异常: ${e.message || e}`);
}
// 启动自愈:把当前激活分组的全部文本模型先回填进 Codex 模型目录。
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
try {
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
if (active) {
router.syncCatalogFromProvider(active)
.then((r) => { if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`); })
.catch((e) => log(`启动同步模型目录失败:${e.message || e}`));
}
} catch (e) {
log(`启动同步模型目录异常:${e.message || e}`);
}
});
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
@@ -1323,6 +1341,28 @@ ipcMain.handle("router-fetch-models", async (_e, { baseUrl, key, groupId }) => {
return router.fetchModels(baseUrl, k);
});
// —— 接入 Claude 桌面 App(第三方推理 / gateway 模式)——
ipcMain.handle("claudedesk-status", () => claudedesk.status());
ipcMain.handle("claudedesk-apply", (_e, { groupId }) => claudedesk.applyGroup(groupId));
ipcMain.handle("claudedesk-restore", () => claudedesk.restoreOfficial());
ipcMain.handle("claudedesk-snippet", (_e, { groupId }) => {
const r = claudedesk.snippet(groupId);
if (r.ok) { try { clipboard.writeText(r.text); } catch {} }
return r.ok ? { ok: true } : r;
});
ipcMain.handle("claudedesk-export", async (_e, { groupId, format }) => {
const r = claudedesk.exportConfig(groupId, format);
if (!r.ok) return r;
const dl = await dialog.showSaveDialog(mainWindow, {
title: "导出 Claude 桌面配置",
defaultPath: r.filename,
filters: [{ name: r.ext.toUpperCase(), extensions: [r.ext] }],
});
if (dl.canceled || !dl.filePath) return { ok: false, canceled: true };
try { fs.writeFileSync(dl.filePath, r.content); } catch (e) { return { ok: false, error: String(e.message || e) }; }
return { ok: true, path: dl.filePath };
});
// —— 创意画布(中转执行层)——
ipcMain.handle("canvas-status", () => canvas.status());
ipcMain.handle("canvas-run", (e, { kind, params }) =>
@@ -1335,6 +1375,63 @@ ipcMain.handle("canvas-run", (e, { kind, params }) =>
ipcMain.handle("canvas-groups", () => canvas.groups());
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
// —— 多 Agent 员工团队生成(需求 → 团队 → 打包 zip)——
ipcMain.handle("team-generate", async (_e, params) => {
try {
const result = await team.generate(params || {});
return { ok: true, result };
} catch (err) {
return { ok: false, error: String((err && err.message) || err) };
}
});
ipcMain.handle("team-preview", (_e, params) => {
try {
return { ok: true, ...team.preview(params || {}) };
} catch (err) {
return { ok: false, error: String((err && err.message) || err) };
}
});
ipcMain.handle("team-pack", async (_e, params) => {
try {
const { buffer, filename } = team.pack(params || {});
const r = await dialog.showSaveDialog(mainWindow, {
title: "保存员工团队配置包",
defaultPath: filename,
filters: [{ name: "Zip 压缩包", extensions: ["zip"] }],
});
if (r.canceled || !r.filePath) return { ok: false, canceled: true };
fs.writeFileSync(r.filePath, buffer);
return { ok: true, path: r.filePath };
} catch (err) {
return { ok: false, error: String((err && err.message) || err) };
}
});
// —— 提示词提取(图片 → 反推生图提示词)——
ipcMain.handle("prompt-groups", () => {
try {
return { ok: true, groups: promptAnalysis.listGroups() };
} catch (err) {
return { ok: false, error: String((err && err.message) || err) };
}
});
ipcMain.handle("prompt-models", async (_e, { groupId }) => {
try {
const models = await promptAnalysis.listModels(groupId);
return { ok: true, models };
} catch (err) {
return { ok: false, error: String((err && err.message) || err) };
}
});
ipcMain.handle("prompt-analyze", async (_e, params) => {
try {
const result = await promptAnalysis.analyze(params || {});
return { ok: true, ...result };
} catch (err) {
return { ok: false, error: String((err && err.message) || err) };
}
});
// —— Codex 语音输入注入(CDP)——
ipcMain.handle("voice-status", () => voice.status());
ipcMain.handle("voice-connect", (e, opts) =>
@@ -1348,6 +1445,10 @@ ipcMain.handle("voice-disconnect", () => voice.disconnect());
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
// —— Token 用量监控(注入 Codex 页面,复用 CDP 连接)——
ipcMain.handle("token-status", async () => tokenWatch.tokenStatus());
ipcMain.handle("token-reset", async () => tokenWatch.tokenReset());
// —— 全局 / 系统代理 ——
ipcMain.handle("proxy-status", async () => proxy.status());
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
+17
View File
@@ -76,6 +76,12 @@ contextBridge.exposeInMainWorld("api", {
routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"),
routerSnippet: () => ipcRenderer.invoke("router-snippet"),
routerFetchModels: (baseUrl, key, groupId) => ipcRenderer.invoke("router-fetch-models", { baseUrl, key, groupId }),
// —— 接入 Claude 桌面 App ——
claudedeskStatus: () => ipcRenderer.invoke("claudedesk-status"),
claudedeskApply: (groupId) => ipcRenderer.invoke("claudedesk-apply", { groupId }),
claudedeskRestore: () => ipcRenderer.invoke("claudedesk-restore"),
claudedeskSnippet: (groupId) => ipcRenderer.invoke("claudedesk-snippet", { groupId }),
claudedeskExport: (groupId, format) => ipcRenderer.invoke("claudedesk-export", { groupId, format }),
onRouterChanged: (cb) => {
const handler = (_e, s) => cb(s);
ipcRenderer.on("router-changed", handler);
@@ -86,6 +92,14 @@ contextBridge.exposeInMainWorld("api", {
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
canvasGroups: () => ipcRenderer.invoke("canvas-groups"),
canvasModels: (groupId) => ipcRenderer.invoke("canvas-models", { groupId }),
// —— 多 Agent 员工团队生成 ——
teamGenerate: (params) => ipcRenderer.invoke("team-generate", params || {}),
teamPreview: (params) => ipcRenderer.invoke("team-preview", params || {}),
teamPack: (params) => ipcRenderer.invoke("team-pack", params || {}),
// —— 提示词提取(图片 → 反推生图提示词)——
promptGroups: () => ipcRenderer.invoke("prompt-groups"),
promptModels: (groupId) => ipcRenderer.invoke("prompt-models", { groupId }),
promptAnalyze: (params) => ipcRenderer.invoke("prompt-analyze", params || {}),
onCanvasProgress: (cb) => {
const h = (_e, p) => cb(p);
ipcRenderer.on("canvas-progress", h);
@@ -102,6 +116,9 @@ contextBridge.exposeInMainWorld("api", {
ipcRenderer.on("voice-status", h);
return () => ipcRenderer.removeListener("voice-status", h);
},
// —— Token 用量监控(注入 Codex,复用 CDP 连接)——
tokenStatus: () => ipcRenderer.invoke("token-status"),
tokenReset: () => ipcRenderer.invoke("token-reset"),
// —— 全局 / 系统代理 ——
proxyStatus: () => ipcRenderer.invoke("proxy-status"),
proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"),