Files
codex-market-app/electron/lib/canvas.js
T
2026-07-15 18:01:56 +08:00

570 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// canvas.js —— 创意画布主进程中转执行层:复用 router.llmGroup() 的当前中转分组,
// 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。
// key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。
const router = require("./router");
// —— 从 baseUrl 解析出主机名(失败则原样返回)——
function hostOf(baseUrl) {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl || "";
}
}
// —— 去尾斜杠 ——
function trimSlash(u) {
return (u || "").replace(/\/$/, "");
}
// —— 可复用的异步 sleep(不留定时器句柄泄漏隐患)——
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// —— 统一请求头(与 router.fetchModels 的 User-Agent 一致)——
function headers(key) {
return {
Authorization: "Bearer " + (key || ""),
"User-Agent": "codex-market",
"Content-Type": "application/json",
};
}
// —— 下载任意远程资源 → base64 字符串 ——
async function downloadAsBase64(url) {
const res = await fetch(url);
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString("base64");
}
// —— 组装 data URL ——
function toDataUrl(mime, b64) {
return `data:${mime};base64,${b64}`;
}
// ============================================================================
// 素材外链托管:兰空图床(Lsky Pro)——图生图参考图 / 图生视频素材统一走这里换公网直链
// 桌面端没有对外 Web 服务器,Seedance/火山上游只接受「可访问的图片 URL」,base64 会被拒收;
// 部分中转的图生图模型也用图片 URL 传参考图。所以 base64 素材先 POST 到兰空 /api/v1/upload
// 换成 http(s) 直链,再交给上游。
// 兰空契约:POST multipart/form-data(字段 file)+ 头 Authorization: Bearer <token>,
// 返回体公网直链在 data.links.url。
// 默认写死用户自建图床;可用环境变量 CANVAS_UPLOAD_ENDPOINT / CANVAS_UPLOAD_AUTH 覆盖(开发期切换)。
// ============================================================================
const MEDIA_HOST = {
// 兰空图床基址(不带尾斜杠)。上传端点固定为 {base}/api/v1/upload。
base: trimSlash(process.env.CANVAS_UPLOAD_ENDPOINT || "https://tc.tokendun.com"),
// 兰空上传 token(个人令牌,长期有效)。留空则上传时报错。
token: process.env.CANVAS_UPLOAD_AUTH || "9|u2ZJoV8p2KJU6zBdy1MtTbiqMV9TgbHBLvURVWwt",
};
// 从图床响应里挑出公网 URL(兰空 data.links.url,兼容 {url}/{data.url}/{data:[{url}]} 等形态)
function pickUploadedUrl(j) {
if (!j || typeof j !== "object") return "";
if (typeof j.url === "string") return j.url;
const d = j.data;
if (d && typeof d === "object") {
// 兰空 Pro:data.links.url
if (d.links && typeof d.links.url === "string") return d.links.url;
if (typeof d.url === "string") return d.url;
if (Array.isArray(d) && d[0] && typeof d[0].url === "string") return d[0].url;
}
return "";
}
// —— base64 dataURL → 兰空公网直链 ——
// 端点/令牌未配置 / 上传失败 / 未拿到合法 URL → 抛错(绝不把 base64 兜底发给上游)。
async function uploadMedia(dataUrl) {
if (!MEDIA_HOST.base || !MEDIA_HOST.token) {
throw new Error(
"素材需要图床外链:图床地址或上传令牌未配置(桌面端无法把 base64 图直接交给上游)。"
);
}
// dataURL → mime + Buffer → Blob,组 multipart(Node 18+ 原生 FormData/Blob)
const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(String(dataUrl || ""));
if (!m) throw new Error("参考图不是合法的 data URL");
const mime = m[1] || "image/png";
const buf = Buffer.from(m[3], m[2] ? "base64" : "utf-8");
const ext = (mime.split("/")[1] || "png").replace(/[^a-z0-9]/gi, "") || "png";
const fd = new FormData();
fd.append("file", new Blob([buf], { type: mime }), "ref." + ext);
const endpoint = MEDIA_HOST.base + "/api/v1/upload";
let res;
try {
// 注意:不手设 Content-Type,让 fetch 自动带 multipart boundary
res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: "Bearer " + MEDIA_HOST.token, Accept: "application/json", "User-Agent": "codex-market" },
body: fd,
});
} catch (e) {
throw new Error("素材上传请求失败:" + String((e && e.message) || e));
}
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`素材上传失败(${res.status}):${detail.slice(0, 200)}`);
}
let j;
try {
j = await res.json();
} catch {
throw new Error("图床返回非 JSON,无法解析上传结果");
}
// 兰空失败时也返回 200 + {status:false,message},据此给出明确错误
if (j && j.status === false) throw new Error("图床上传被拒:" + (j.message || "未知原因"));
const url = pickUploadedUrl(j);
if (!url || !/^https?:\/\//i.test(url)) throw new Error("图床未返回合法的公网 URL");
return url;
}
// —— 单个素材 → 公网直链:data URL 上传兰空换外链;http(s) 原样;空串返回 "" ——
async function resolveMat(v) {
const s = String(v || "").trim();
if (!s) return "";
if (/^data:/i.test(s)) return await uploadMedia(s);
if (/^https?:\/\//i.test(s)) return s;
throw new Error("素材需为 data URL 或公网 http(s) 地址");
}
// 当前中转是否可用:ready = 有可用 llmGroup。host 从 baseUrl 解析域名。
function status() {
const llm = router.llmGroup();
if (!llm) return { ready: false, provider: null, reason: "未配置可用中转分组" };
return {
ready: true,
provider: { name: llm.name || "", model: llm.model || "", host: hostOf(llm.baseUrl) },
};
}
// —— 读图片流式 SSE:逐行取 data:{...},partial 帧回调 onPartial,completed/末帧作最终图 ——
// 返回 { ok:true, mime, dataUrl } 或 null(非 SSE / 没拿到任何帧 → 交由调用方回退非流式)。
async function readImageSse(res, onPartial) {
const decoder = new TextDecoder();
let buf = "";
let lastB64 = ""; // 最近一帧(流自然结束时用作最终图)
let finalB64 = ""; // completed 事件的最终图
let sawData = false;
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line.startsWith("data:")) continue;
sawData = true;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
let ev;
try {
ev = JSON.parse(data);
} catch {
continue;
}
const b64 = ev && (ev.b64_json || (ev.data && ev.data[0] && ev.data[0].b64_json));
if (!b64) continue;
const type = String((ev && ev.type) || "");
lastB64 = b64;
if (/partial/i.test(type)) {
if (onPartial) {
try {
onPartial(toDataUrl("image/png", b64));
} catch {}
}
} else if (/completed/i.test(type)) {
finalB64 = b64;
}
}
}
if (!sawData) return null; // 首包/全程都不是 data: → 不是 SSE,回退
const b64 = finalB64 || lastB64;
if (!b64) return null;
return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64) };
}
// —— 回退:非流式单次 POST —— gpt-image 不加 response_format,其它模型加 b64_json 容错 ——
async function runImageNonStream(base, llm, params, url, model) {
const isGptImage = /gpt-image/i.test(model);
const body = {
model,
prompt: params.prompt,
size: params.size || "1024x1024",
...(params.extra || {}),
};
if (!isGptImage) body.response_format = "b64_json";
// 参考图外链(图生图)——runImage 已解析好 _refUrl,非流式回退同样带上
if (params._refUrl) body[params.refField || "image"] = params._refUrl;
try {
const res = await fetch(url, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body) });
if (!res.ok) {
const detail = await res.text().catch(() => "");
return { ok: false, error: `图像接口 ${res.status}:POST ${url} model=${model}${detail ? " → " + detail.slice(0, 240) : ""}` };
}
const j = await res.json();
const item = (j && j.data && j.data[0]) || {};
let b64 = item.b64_json;
const sourceUrl = item.url || "";
if (!b64 && item.url) b64 = await downloadAsBase64(item.url); // url 形式:主进程再下载转 base64
if (!b64) return { ok: false, error: `图像接口未返回 b64_json 或 url:POST ${url} model=${model}` };
return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64), url: sourceUrl, _model: model };
} catch (e) {
return { ok: false, error: `图像接口请求失败:POST ${url} model=${model} → ${String((e && e.message) || e)}` };
}
}
// —— 文生图:stream-first —— POST {base}{endpointPath||"/v1/images/generations"} ——
// model 必须由用户填写(图像模型),为空不回退聊天模型,直接报错。
// 优先流式(partial 渐进帧经 onPartial 回传);非 2xx / 非 SSE / 未取到帧 → 回退非流式。
async function runImage(base, llm, params, onPartial) {
const model = params.model;
if (!model) return { ok: false, error: "请先填写图像模型" };
// 图生图:参考图(base64) → 兰空图床外链,只换一次,供流式/非流式两条路复用
if (params.image && !params._refUrl) {
const img = String(params.image).trim();
if (/^data:/i.test(img)) {
try {
params._refUrl = await uploadMedia(img);
} catch (e) {
return { ok: false, error: "参考图上传图床失败:" + String((e && e.message) || e) };
}
} else if (/^https?:\/\//i.test(img)) {
params._refUrl = img; // 已是公网URL,直接用
}
}
const path = params.endpointPath || "/v1/images/generations";
const url = base + path;
try {
const body = {
model,
prompt: params.prompt,
size: params.size || "1024x1024",
n: 1,
stream: true,
partial_images: 2,
...(params.extra || {}),
};
// 参考图外链塞进请求体(字段名可配,默认 image)
if (params._refUrl) body[params.refField || "image"] = params._refUrl;
const res = await fetch(url, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
const ct = res.headers.get("content-type") || "";
if (res.ok && res.body && /event-stream/i.test(ct)) {
const streamed = await readImageSse(res, onPartial);
if (streamed && streamed.ok) return { ...streamed, _model: model };
}
// 非 2xx / 非 SSE / 流里没拿到帧 → 回退非流式
} catch {
// 流式请求异常 → 回退非流式
}
return runImageNonStream(base, llm, params, url, model);
}
// —— TTS 配音:POST {base}/v1/audio/speech(响应为二进制音频)——
async function runTts(base, llm, params) {
const model = params.model || "tts-1";
const body = {
model,
input: params.text,
voice: params.voice || "alloy",
response_format: params.format || "mp3",
};
if (params.speed !== undefined && params.speed !== null && params.speed !== "") body.speed = params.speed;
const res = await fetch(base + "/v1/audio/speech", {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
return { ok: false, error: `TTS 接口返回 HTTP ${res.status}:${detail.slice(0, 300)}` };
}
const b64 = Buffer.from(await res.arrayBuffer()).toString("base64");
return { ok: true, mime: "audio/mpeg", dataUrl: toDataUrl("audio/mpeg", b64), _model: model };
}
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
// port 自 提示词提取/server.js
function findVideoUrl(o) {
if (!o || typeof o === "string") return "";
if (Array.isArray(o)) {
for (const v of o) {
const u = findVideoUrl(v);
if (u) return u;
}
return "";
}
if (typeof o === "object") {
for (const k of ["video_url", "result_url", "url"]) {
const v = o[k];
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
}
for (const v of Object.values(o)) {
const u = findVideoUrl(v);
if (u) return u;
}
}
return "";
}
// —— 归一任务状态:各中转大小写/用词不一 —— port 自 提示词提取/server.js
function normalizeVideoStatus(raw) {
const s = String(raw || "").toLowerCase();
if (["succeeded", "success", "succeed", "done", "completed", "complete", "finished"].includes(s)) return "succeeded";
if (["failed", "fail", "failure", "error", "cancelled", "canceled", "timeout", "timed_out", "expired"].includes(s)) return "failed";
if (["in_progress", "running", "processing", "generating"].includes(s)) return "running";
return s || "queued";
}
function joinUrl(base, p) {
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p);
}
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// 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");
// 输入模式: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 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
try {
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
body.image = img;
} else if (mode === "firstlast") {
// 本中转文档无「首尾帧」字段,退化为多图参考 images:[首,尾]
const first = await resolveMat(params.image);
const last = await resolveMat(params.lastFrameImage);
const arr = [first, last].filter(Boolean);
if (arr.length < 2) return { ok: false, error: "首尾帧模式需首帧和尾帧两张图(本中转会作多图参考处理)" };
body.images = arr;
} 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: "多参考图模式需至少 1 张参考图" };
body.images = resolved;
} else if (mode === "video") {
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
}
// 文/图生视频 prompt 都必填(文档要求)
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
// —— 建任务 ——
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
}
let data;
try {
data = await cres.json();
} catch {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务返回非 JSON:${detail.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)}` };
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
const pollUrl = createUrl + "/" + encodeURIComponent(taskId);
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), "User-Agent": "codex-market" };
let lastPoll = ""; // 记录最近一次轮询的异常/状态,超时时回传便于排查
for (let i = 0; i < 120; i++) {
await sleep(5000);
let pd;
try {
const pr = await fetch(pollUrl, { headers: pollHeaders });
if (!pr.ok) {
lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200);
continue;
}
pd = await pr.json();
} catch (e) {
lastPoll = "请求异常:" + String((e && e.message) || e);
continue;
}
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
lastPoll = "status=" + st;
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
const b64 = await downloadAsBase64(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);
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
}
// queued/running → 继续等
}
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
}
}
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
async function runGrokVideo(base, llm, params) {
const model = params.model || "grok-imagine-video";
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;
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
) {
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
}
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
body.image = { image_url: img };
} else if (mode !== "text") {
return { ok: false, error: "Grok 视频新增接口只支持「文生视频」和「首帧图生视频」" };
}
const createUrl = joinUrl(base, params.endpointPath || "/v1/videos/generations");
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `Grok 视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
}
const data = await cres.json();
const requestId = data.request_id || data.id || (data.data && (data.data.request_id || data.data.id));
if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` };
const pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId));
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" };
let lastPoll = "";
for (let i = 0; i < 120; i++) {
await sleep(5000);
let pd;
try {
const pr = await fetch(pollUrl, { headers: pollHeaders });
if (!pr.ok) {
lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200);
continue;
}
pd = await pr.json();
} catch (e) {
lastPoll = "请求异常:" + String((e && e.message) || e);
continue;
}
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
lastPoll = "status=" + st;
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `Grok 视频已完成但未找到视频地址:${JSON.stringify(pd).slice(0, 500)}` };
const b64 = await downloadAsBase64(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;
return { ok: false, error: "Grok 视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) };
}
}
return { ok: false, error: "Grok 视频轮询 10 分钟未完成(request_id=" + requestId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) };
}
}
// 统一执行入口:dispatch 到 image/video/tts,返回 { ok, mime, dataUrl, meta } 或 { ok:false, error }。
// onPartial:可选,仅 image 流式生成时用来回传渐进帧(undefined 时安全忽略)。
async function run({ kind, params }, onPartial) {
const p = params || {};
// 解析渠道:指定 groupId 则按 id 取该分组,否则回退当前可用 llmGroup
const g = p.groupId ? router.groupById(p.groupId) : router.llmGroup();
if (!g) return { ok: false, error: "未配置可用中转分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" };
const base = trimSlash(g.baseUrl);
const host = hostOf(g.baseUrl);
const t0 = Date.now();
try {
let r;
if (kind === "image") r = await runImage(base, g, p, onPartial);
else if (kind === "tts") r = await runTts(base, g, p);
else if (kind === "video") r = await runVideo(base, g, p);
else if (kind === "grokVideo") r = await runGrokVideo(base, g, p);
else return { ok: false, error: "未知的画布任务类型:" + kind };
const model = (r && r._model) || p.model || g.model;
if (r && r._model !== undefined) delete r._model;
if (r && r.ok) r.meta = { model, ms: Date.now() - t0, host };
return r;
} catch (e) {
return { ok: false, error: String((e && e.message) || e), meta: { model: p.model || g.model, ms: Date.now() - t0, host } };
}
}
// 列出全部可用中转分组(供前端每个节点的「分组」下拉):返回 activeId 与精简分组信息。
function groups() {
const list = router.usableGroups();
if (!list || !list.length) return { ok: false, error: "未配置可用中转分组" };
const st = router.status();
const activeId = (st && st.activeId) || (router.llmGroup() || {}).id || null;
return {
ok: true,
activeId,
groups: list.map((g) => ({ id: g.id, name: g.name || "", model: g.model || "", host: hostOf(g.baseUrl) })),
};
}
// 拉取指定/当前中转分组可用的模型列表(复用 router.fetchModels),供前端填充模型下拉/datalist。
async function listModels(groupId) {
const g = groupId ? router.groupById(groupId) : router.llmGroup();
if (!g) return { ok: false, error: "未配置可用中转分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" };
try {
const models = await router.fetchModels(g.baseUrl, g.key);
return { ok: true, models };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
}
module.exports = { status, groups, run, listModels, downloadAsBase64 };