vision-client.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. /**
  2. * Fmode Vision API 通用客户端
  3. *
  4. * 功能:
  5. * - resolveApiToken() 按 token 加载链获取 API token(仓库内零密钥)
  6. * - detectHostVisionModel() 宿主多模态优先探测(Claude Code / Codex)
  7. * - resolveVisionModel() 模型选择策略入口:host 优先,回落 Fmode glm-5.3-flash
  8. * - analyze() 单次视觉分析总入口(自动走宿主或 Fmode API)
  9. * - callVisionAPI() 单轮 Fmode 视觉分析
  10. * - callMultiPass() 多轮聚焦分析(支持缓存)
  11. * - extractJSON() 从 LLM 响应提取 JSON
  12. */
  13. import fs from 'fs';
  14. import path from 'path';
  15. import os from 'os';
  16. // ============================================================
  17. // Token 加载链(与 voc / fmode-listen 共享层一致)
  18. // ============================================================
  19. //
  20. // 优先级(第0级为 sessionToken 自举,其余为回落链):
  21. // 0. sessionToken 自举:FMODE_SESSION_TOKEN 环境变量或 ~/.fmode/config.json 的
  22. // sessionToken → POST https://server.fmode.cn/api/fmode/voc-skill/install-prompt
  23. // → 从返回 prompt 中提取 fmode API token(sk- 开头,排除 sk-ant-)。
  24. // ⚠️ token 仅内存持有,不落盘不进日志。
  25. // 1. 环境变量 FMODE_API_TOKEN
  26. // 2. ~/.fmode/config.json → fmodeApiToken / newapiToken
  27. // 3. ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)
  28. // 的 env.ANTHROPIC_AUTH_TOKEN——即 Claude Code 的 sk- token
  29. // 4. 项目 ./.fmode/config.json → fmodeApiToken / newapiToken
  30. //
  31. // 关键:fmode 的 newapi SK 默认就是 Claude Code 的 env.ANTHROPIC_AUTH_TOKEN。
  32. // 校验规则:sk- 开头、排除真 Anthropic 官方 key(sk-ant- 开头)、
  33. // 若设了 ANTHROPIC_BASE_URL 则必须指向 fmode。
  34. //
  35. // 注意:本仓库内绝不出现任何真实密钥——token 只从上述用户目录/环境读取。
  36. // UTF-8 BOM(EF BB BF):用户手工保存的 config.json 可能带 BOM,解析前剥掉。
  37. const BOM_RE = /^/;
  38. const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
  39. /** 第0级:解析 sessionToken(env FMODE_SESSION_TOKEN → ~/.fmode/config.json)。找不到返回 null。 */
  40. function resolveSessionToken() {
  41. if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
  42. const p = path.join(os.homedir(), '.fmode', 'config.json');
  43. try {
  44. if (!fs.existsSync(p)) return null;
  45. const cfg = JSON.parse(fs.readFileSync(p, 'utf-8').replace(BOM_RE, ''));
  46. const t = cfg.sessionToken || (cfg.user && cfg.user.sessionToken) || null;
  47. return t && String(t).trim() ? String(t).trim() : null;
  48. } catch { return null; }
  49. }
  50. /**
  51. * 第0级:sessionToken → fmode API token(自举)。
  52. * 服务端唯一以 session 鉴权并返回 token 本体的端点是 voc-skill 安装指令生成器;
  53. * token 内嵌在返回 prompt 文本中,这里提取后仅内存持有(不落盘不进日志)。
  54. * @returns {Promise<string|null>} 提取失败返回 null(调用方回落下一级)。
  55. */
  56. async function fetchApiTokenFromSession(sessionToken) {
  57. if (!sessionToken) return null;
  58. try {
  59. const res = await fetch(`${FMODE_API_BASE}/api/fmode/voc-skill/install-prompt`, {
  60. method: 'POST',
  61. headers: { 'Content-Type': 'application/json', 'x-parse-session-token': sessionToken },
  62. body: JSON.stringify({ channel: 'claude-code', scope: 'user', source: 'skill-token-bootstrap' }),
  63. signal: AbortSignal.timeout(15000),
  64. });
  65. if (!res.ok) return null;
  66. const body = await res.json().catch(() => null);
  67. const prompt = body && body.data && typeof body.data.prompt === 'string' ? body.data.prompt : '';
  68. const m = prompt.match(/sk-(?!ant-)[A-Za-z0-9_-]{8,}/);
  69. return m ? m[0] : null;
  70. } catch { /* 网络失败一律回落,不泄露错误细节 */ }
  71. return null;
  72. }
  73. function readJsonMaybe(filePath) {
  74. try {
  75. if (!filePath || !fs.existsSync(filePath)) return {};
  76. return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(BOM_RE, ''));
  77. } catch {
  78. return {};
  79. }
  80. }
  81. function readTokenFromConfig(configPath) {
  82. try {
  83. if (!fs.existsSync(configPath)) return null;
  84. const raw = fs.readFileSync(configPath, 'utf-8').replace(BOM_RE, '');
  85. const cfg = JSON.parse(raw);
  86. return cfg.fmodeApiToken || cfg.newapiToken || null;
  87. } catch {
  88. return null;
  89. }
  90. }
  91. // 合并读取 Claude Code 的 settings env(用户级 + 项目级,含 .local 覆盖文件)。
  92. function readClaudeSettingsEnv() {
  93. const files = [
  94. path.join(os.homedir(), '.claude', 'settings.json'),
  95. path.join(os.homedir(), '.claude', 'settings.local.json'),
  96. path.join(process.cwd(), '.claude', 'settings.json'),
  97. path.join(process.cwd(), '.claude', 'settings.local.json'),
  98. ];
  99. const merged = {};
  100. for (const filePath of files) {
  101. const json = readJsonMaybe(filePath);
  102. const env = json && typeof json.env === 'object' && json.env ? json.env : null;
  103. if (!env) continue;
  104. for (const [key, value] of Object.entries(env)) {
  105. if (merged[key] === undefined && typeof value === 'string' && value.trim()) {
  106. merged[key] = value;
  107. }
  108. }
  109. }
  110. return merged;
  111. }
  112. // 仅当 ANTHROPIC_AUTH_TOKEN 看起来是 fmode 的 newapi SK 时才采纳:
  113. // - 必须 sk- 开头,且排除真 Anthropic 官方 key(sk-ant- 开头);
  114. // - 若设了 ANTHROPIC_BASE_URL,必须指向 fmode(否则这把 token 是发往别处的)。
  115. function pickFmodeAnthropicToken(env) {
  116. const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
  117. if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
  118. const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
  119. if (base && !base.includes('fmode')) return '';
  120. return token;
  121. }
  122. /**
  123. * 获取 fmode API token。加载链优先级(第0级自举 → 回落):
  124. * 0. sessionToken 自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json)→ fmode API 换取
  125. * 1. 环境变量 FMODE_API_TOKEN
  126. * 2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
  127. * 3. ~/.claude/settings.json 等 → env.ANTHROPIC_AUTH_TOKEN(sk- 开头非 sk-ant-)
  128. * 4. <project>/.fmode/config.json → fmodeApiToken / newapiToken
  129. *
  130. * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
  131. * @returns {Promise<{ token: string, source: string }>}
  132. */
  133. export async function resolveApiToken(projectRoot) {
  134. // 第0级自举:sessionToken → fmode API 换取
  135. const sessionToken = resolveSessionToken();
  136. if (sessionToken) {
  137. const bootstrapped = await fetchApiTokenFromSession(sessionToken);
  138. if (bootstrapped) {
  139. return { token: bootstrapped, source: 'level0:sessionToken->fmode-api' };
  140. }
  141. // 自举失败:明确报错指向重新登录,随后回落
  142. console.error('sessionToken 存在但换取 fmode API token 失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
  143. }
  144. // 1. 环境变量
  145. if (process.env.FMODE_API_TOKEN) {
  146. return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
  147. }
  148. // 2. 用户级配置 ~/.fmode/config.json
  149. const userConfigPath = path.join(os.homedir(), '.fmode', 'config.json');
  150. const userToken = readTokenFromConfig(userConfigPath);
  151. if (userToken) {
  152. return { token: userToken, source: userConfigPath };
  153. }
  154. // 3. Claude Code 默认入口:~/.claude/settings.json(含 .local / 项目级)里的
  155. // env.ANTHROPIC_AUTH_TOKEN(sk- token,Claude Code 会话内也常被注入进程环境)
  156. const injected = pickFmodeAnthropicToken(process.env);
  157. if (injected) {
  158. return { token: injected, source: 'env:ANTHROPIC_AUTH_TOKEN' };
  159. }
  160. const claudeEnv = readClaudeSettingsEnv();
  161. const fromSettings = pickFmodeAnthropicToken(claudeEnv);
  162. if (fromSettings) {
  163. return { token: fromSettings, source: '~/.claude/settings.json:env.ANTHROPIC_AUTH_TOKEN' };
  164. }
  165. // 4. 项目级配置 <project>/.fmode/config.json
  166. const root = projectRoot || process.cwd();
  167. const projectConfigPath = path.join(root, '.fmode', 'config.json');
  168. const projectToken = readTokenFromConfig(projectConfigPath);
  169. if (projectToken) {
  170. return { token: projectToken, source: projectConfigPath };
  171. }
  172. throw new Error(
  173. '未找到 Fmode API token。请通过以下任一方式提供(第0级自举 → 回落):\n' +
  174. ' 0. 登录 FMODE Studio 后自动自举(FMODE_SESSION_TOKEN 或 ~/.fmode/config.json 的 sessionToken)\n' +
  175. ' 1. 环境变量 FMODE_API_TOKEN\n' +
  176. ' 2. ~/.fmode/config.json 中 fmodeApiToken / newapiToken 字段(FmodeStudio 保存配置后写入)\n' +
  177. ' 3. ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(Claude Code 的 sk- token,会自动读取)\n' +
  178. ' 4. 项目 ./.fmode/config.json 中 fmodeApiToken 字段\n' +
  179. ' 注意:这是缺 token,不是「用不了」——请勿点任何付费/充值弹窗。'
  180. );
  181. }
  182. // ============================================================
  183. // 模型选择策略:宿主多模态优先探测
  184. // ============================================================
  185. //
  186. // 技能初始化时先探测运行环境:
  187. // ① Claude Code:~/.claude/settings.json 或 ./.claude/settings.json 的
  188. // model 字段 / env.ANTHROPIC_MODEL,命中多模态能力名单 → 用宿主模型,
  189. // 不调 GLM;
  190. // ② Codex:~/.codex/config.toml 的 model 配置;
  191. // ③ 环境变量 FMODE_VISION_MODEL(强制指定)。
  192. // 命中即返回 { provider: 'host', model };未命中回落
  193. // { provider: 'fmode', model: 'glm-5.3-flash' }(Fmode API 视觉模型)。
  194. const DEFAULT_CONFIG = {
  195. apiBase: 'https://api.fmode.cn',
  196. model: 'glm-5.3-flash',
  197. temperature: 0.12,
  198. maxTokens: 2000,
  199. };
  200. // 宿主模型多模态(视觉)能力名单。前缀匹配,大小写不敏感。
  201. const HOST_VISION_MODEL_PATTERNS = [
  202. /^claude-4/i,
  203. /^claude-opus/i,
  204. /^claude-sonnet-4/i,
  205. /^claude-haiku-4/i,
  206. /^claude-3[-.]5-sonnet/i,
  207. /^claude-3[-.](opus|sonnet)/i,
  208. /^gpt-4o/i,
  209. /^gpt-4-turbo/i,
  210. /^gpt-5/i,
  211. /^o3/i,
  212. /^gemini-2/i,
  213. /^gemini-3/i,
  214. ];
  215. // 已知纯文本模型:即便命中上面名单形态也判为不支持视觉。
  216. const HOST_TEXT_ONLY_PATTERNS = [/^o1(?!-vision)/i];
  217. function modelSupportsVision(name) {
  218. const n = String(name || '').toLowerCase();
  219. if (!n) return false;
  220. if (HOST_TEXT_ONLY_PATTERNS.some(re => re.test(n))) return false;
  221. return HOST_VISION_MODEL_PATTERNS.some(re => re.test(n));
  222. }
  223. function readClaudeHostModel() {
  224. const files = [
  225. path.join(process.cwd(), '.claude', 'settings.json'),
  226. path.join(process.cwd(), '.claude', 'settings.local.json'),
  227. path.join(os.homedir(), '.claude', 'settings.json'),
  228. path.join(os.homedir(), '.claude', 'settings.local.json'),
  229. ];
  230. for (const filePath of files) {
  231. const json = readJsonMaybe(filePath);
  232. // settings 的顶层 model 字段
  233. if (typeof json.model === 'string' && json.model.trim()) {
  234. return { model: json.model.trim(), source: filePath };
  235. }
  236. // env.ANTHROPIC_MODEL
  237. const envModel = json.env && typeof json.env === 'object'
  238. ? (json.env.ANTHROPIC_MODEL || json.env.ANTHROPIC_DEFAULT_OPUS_MODEL || json.env.ANTHROPIC_DEFAULT_SONNET_MODEL)
  239. : '';
  240. if (typeof envModel === 'string' && envModel.trim()) {
  241. return { model: envModel.trim(), source: `${filePath}:env.ANTHROPIC_MODEL` };
  242. }
  243. }
  244. return null;
  245. }
  246. // Codex:~/.codex/config.toml 里的 model = "..."(简单解析顶层 model 行)
  247. function readCodexHostModel() {
  248. const tomlPath = path.join(os.homedir(), '.codex', 'config.toml');
  249. try {
  250. if (!fs.existsSync(tomlPath)) return null;
  251. const raw = fs.readFileSync(tomlPath, 'utf-8');
  252. const m = raw.match(/^\s*model\s*=\s*["']([^"']+)["']/m);
  253. if (m && m[1].trim()) return { model: m[1].trim(), source: tomlPath };
  254. } catch { /* ignore */ }
  255. return null;
  256. }
  257. function isHostAgentSession() {
  258. // 运行在 Claude Code / Codex 会话内的常用信号
  259. return Boolean(
  260. process.env.CLAUDECODE ||
  261. process.env.CLAUDE_CODE_ENTRYPOINT ||
  262. process.env.CODEX_SANDBOX ||
  263. process.env.CODEX_HOME
  264. );
  265. }
  266. /**
  267. * 探测宿主是否自带多模态(视觉)模型。
  268. * 探测顺序:① Claude Code settings ② Codex config.toml ③ FMODE_VISION_MODEL。
  269. *
  270. * @returns {{ provider: 'host', model: string, source: string } | null}
  271. * 命中宿主多模态时返回,否则 null(回落 Fmode API)
  272. */
  273. export function detectHostVisionModel() {
  274. // ③ 环境变量强制指定(最高优先:用户显式声明的视觉模型,直接采纳)
  275. const forced = process.env.FMODE_VISION_MODEL;
  276. if (forced && forced.trim()) {
  277. return { provider: 'host', model: forced.trim(), source: 'env:FMODE_VISION_MODEL' };
  278. }
  279. // ① Claude Code settings(项目级优先于用户级)
  280. const claude = readClaudeHostModel();
  281. if (claude && modelSupportsVision(claude.model)) {
  282. return { provider: 'host', model: claude.model, source: claude.source };
  283. }
  284. // ② Codex config.toml
  285. const codex = readCodexHostModel();
  286. if (codex && modelSupportsVision(codex.model)) {
  287. return { provider: 'host', model: codex.model, source: codex.source };
  288. }
  289. return null;
  290. }
  291. /**
  292. * 模型选择策略总入口:
  293. * - 宿主(Claude Code / Codex)配置模型支持多模态 → 用宿主模型,不调 GLM
  294. * - 否则回落 Fmode API 的 glm-5.3-flash
  295. *
  296. * @param {string} [explicitModel] 调用方显式指定的模型(最高优先)
  297. * @returns {{ provider: 'host'|'fmode', model: string, source: string }}
  298. */
  299. export function resolveVisionModel(explicitModel) {
  300. if (explicitModel && explicitModel.trim()) {
  301. return { provider: 'fmode', model: explicitModel.trim(), source: 'explicit' };
  302. }
  303. const host = detectHostVisionModel();
  304. if (host) return host;
  305. return { provider: 'fmode', model: DEFAULT_CONFIG.model, source: 'fmode-default' };
  306. }
  307. // ============================================================
  308. // 宿主多模态直读(Claude Code / Codex 会话内)
  309. // ============================================================
  310. //
  311. // 当 detectHostVisionModel() 命中且运行在宿主 Agent 会话内时,
  312. // analyze() 不发网络请求,而是把图片交给宿主 Agent 用自带的 Read 工具读图、
  313. // 按传入的提示词完成识别。返回结构与 callVisionAPI() 一致,
  314. // 上层(SKILL.md 工作流)拿到结果后无感知。
  315. /**
  316. * 生成交给宿主 Agent 的读图指令。
  317. * AI(Claude Code / Codex)应使用自己的 Read 工具读取 imagePath 的图片,
  318. * 结合 systemPrompt + userPrompt 完成分析,并把输出喂回 analyze() 的宿主路径。
  319. *
  320. * @param {Object} opts 与 analyze() 相同的参数
  321. * @returns {{ provider: 'host', model: string, source: string,
  322. * instruction: string, imagePath?: string }}
  323. */
  324. export function buildHostReadInstruction(opts) {
  325. const resolved = resolveVisionModel(opts && opts.model);
  326. const imagePath = opts && (opts.imagePath || opts.imageUrl || opts.videoUrl) || '';
  327. const instruction = [
  328. `[宿主多模态模式] 请使用你的 Read 工具直接读取图片文件:${imagePath}`,
  329. `(已用宿主多模态模型:${resolved.model},来源:${resolved.source};本次不调用 Fmode API / GLM)`,
  330. '',
  331. '系统提示词:',
  332. String(opts && opts.systemPrompt || ''),
  333. '',
  334. '用户提示词:',
  335. String(opts && opts.userPrompt || ''),
  336. '',
  337. '请按提示词要求完成识别并输出结果。',
  338. ].join('\n');
  339. return { provider: 'host', model: resolved.model, source: resolved.source, instruction, imagePath };
  340. }
  341. // ============================================================
  342. // 分析总入口
  343. // ============================================================
  344. /**
  345. * 单次视觉分析总入口:宿主多模态优先,回落 Fmode API。
  346. *
  347. * - 宿主命中且运行在 Claude Code / Codex 会话内 → 返回 { provider:'host', ...,
  348. * instruction },AI 用自己的 Read 工具读图后按提示词分析,不再调 GLM;
  349. * - 否则走 Fmode API(glm-5.3-flash)。
  350. *
  351. * @param {Object} opts 同 callVisionAPI():
  352. * imagePath | imageBase64 | imageUrl | videoUrl + systemPrompt + userPrompt
  353. * + 可选 model / temperature / maxTokens / apiToken
  354. * @returns {Promise<{ provider: 'host'|'fmode', model: string,
  355. * raw?: string, parsed: object|null, error: string|null, usage: object|null,
  356. * instruction?: string }>}
  357. */
  358. export async function analyze(opts) {
  359. const resolved = resolveVisionModel(opts && opts.model);
  360. // 宿主多模态命中 + 运行在宿主 Agent 会话内 → 交给宿主读图,不调 Fmode API
  361. if (resolved.provider === 'host') {
  362. if (isHostAgentSession()) {
  363. const host = buildHostReadInstruction(opts);
  364. return {
  365. provider: 'host',
  366. model: host.model,
  367. raw: null,
  368. parsed: null,
  369. error: null,
  370. usage: null,
  371. instruction: host.instruction,
  372. imagePath: host.imagePath,
  373. };
  374. }
  375. // 探测到宿主多模态模型但不在会话内(如脚本独立运行)→ 回落 Fmode API
  376. return runFmodeVision({ ...opts, model: DEFAULT_CONFIG.model });
  377. }
  378. // Fmode API 路径
  379. return runFmodeVision(opts);
  380. }
  381. async function runFmodeVision(opts) {
  382. const result = await callVisionAPI(opts);
  383. return { provider: 'fmode', model: (opts && opts.model) || DEFAULT_CONFIG.model, ...result };
  384. }
  385. // ============================================================
  386. // Fmode API 调用
  387. // ============================================================
  388. /**
  389. * 调用 Fmode Vision API
  390. *
  391. * @param {Object} opts
  392. * @param {string} [opts.imagePath] 本地图片路径
  393. * @param {string} [opts.imageBase64] 图片 base64 数据(与 imagePath 二选一)
  394. * @param {string} [opts.imageUrl] 远程图片 URL
  395. * @param {string} [opts.videoUrl] 视频 URL
  396. * @param {string} opts.systemPrompt 系统提示词
  397. * @param {string} opts.userPrompt 用户提示词
  398. * @param {string} [opts.model] 模型名,默认 glm-5.3-flash
  399. * @param {number} [opts.temperature] 默认 0.12
  400. * @param {number} [opts.maxTokens] 默认 2000
  401. * @param {string} [opts.apiToken] 手动传入 token,否则自动解析
  402. * @returns {Promise<{ raw: string, parsed: object|null, error: string|null, usage: object|null }>}
  403. */
  404. export async function callVisionAPI(opts) {
  405. const {
  406. imagePath, imageBase64, imageUrl, videoUrl,
  407. systemPrompt, userPrompt,
  408. model, temperature, maxTokens, apiToken,
  409. } = opts;
  410. const token = apiToken || (await resolveApiToken()).token;
  411. const messages = [{ role: 'system', content: systemPrompt }];
  412. const userContent = [{ type: 'text', text: userPrompt }];
  413. // 视觉内容
  414. if (imagePath) {
  415. const buffer = fs.readFileSync(imagePath);
  416. const ext = path.extname(imagePath).slice(1).toLowerCase();
  417. const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
  418. const b64 = buffer.toString('base64');
  419. userContent.push({
  420. type: 'image_url',
  421. image_url: { url: `data:${mime};base64,${b64}` },
  422. });
  423. } else if (imageBase64) {
  424. userContent.push({
  425. type: 'image_url',
  426. image_url: { url: imageBase64 },
  427. });
  428. } else if (imageUrl) {
  429. userContent.push({
  430. type: 'image_url',
  431. image_url: { url: imageUrl },
  432. });
  433. } else if (videoUrl) {
  434. userContent.push({
  435. type: 'video_url',
  436. video_url: { url: videoUrl },
  437. });
  438. }
  439. messages.push({ role: 'user', content: userContent });
  440. const body = {
  441. model: model || DEFAULT_CONFIG.model,
  442. messages,
  443. temperature: temperature ?? DEFAULT_CONFIG.temperature,
  444. max_tokens: maxTokens || DEFAULT_CONFIG.maxTokens,
  445. };
  446. const res = await fetch(`${DEFAULT_CONFIG.apiBase}/v1/chat/completions`, {
  447. method: 'POST',
  448. headers: {
  449. 'Content-Type': 'application/json',
  450. Authorization: `Bearer ${token}`,
  451. },
  452. body: JSON.stringify(body),
  453. });
  454. if (!res.ok) {
  455. const errText = await res.text();
  456. throw new Error(`API ${res.status}: ${errText}`);
  457. }
  458. const data = await res.json();
  459. const content = data.choices?.[0]?.message?.content || '';
  460. const { parsed, error } = extractJSON(content);
  461. return { raw: content, parsed, error, usage: data.usage || null };
  462. }
  463. // ============================================================
  464. // JSON 提取
  465. // ============================================================
  466. /**
  467. * 从 LLM 响应中提取 JSON 对象
  468. * 容忍 markdown 代码块包裹、前后文字
  469. */
  470. export function extractJSON(rawContent) {
  471. const m = rawContent.match(/\{[\s\S]*\}/);
  472. if (!m) return { parsed: null, error: 'No JSON object in response' };
  473. try {
  474. return { parsed: JSON.parse(m[0]), error: null };
  475. } catch (e) {
  476. return { parsed: null, error: e.message };
  477. }
  478. }
  479. // ============================================================
  480. // 多轮分析
  481. // ============================================================
  482. const sleep = ms => new Promise(r => setTimeout(r, ms));
  483. /**
  484. * 多轮聚焦分析
  485. * 每轮独立调用 API,中间结果写入缓存目录。已有缓存则跳过。
  486. *
  487. * @param {Object} opts
  488. * @param {string} opts.imagePath 图片路径
  489. * @param {Array} opts.passes 轮次配置数组
  490. * [{ name: string, systemPrompt: string, userPrompt: string, maxTokens?: number }]
  491. * @param {string} opts.cacheDir 缓存目录
  492. * @param {string} [opts.model] 模型名
  493. * @param {number} [opts.delayMs=1500] 轮次间延迟
  494. * @returns {Promise<Array<{ pass: number, name: string, raw: string, parsed: object|null, error: string|null, usage: object|null }>>}
  495. */
  496. export async function callMultiPass(opts) {
  497. const { imagePath, passes, cacheDir, model, delayMs = 1500 } = opts;
  498. if (!fs.existsSync(cacheDir)) {
  499. fs.mkdirSync(cacheDir, { recursive: true });
  500. }
  501. const results = [];
  502. for (let i = 0; i < passes.length; i++) {
  503. const p = passes[i];
  504. const passNum = i + 1;
  505. const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
  506. // 检查缓存
  507. if (fs.existsSync(cacheFile)) {
  508. console.log(` Pass ${passNum} (${p.name}): 已有缓存,跳过`);
  509. results.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
  510. continue;
  511. }
  512. console.log(` Pass ${passNum} (${p.name}, ${p.maxTokens || 2000}t)...`);
  513. try {
  514. const result = await analyze({
  515. imagePath,
  516. systemPrompt: p.systemPrompt,
  517. userPrompt: p.userPrompt,
  518. maxTokens: p.maxTokens,
  519. model,
  520. });
  521. const entry = { pass: passNum, name: p.name, ...result };
  522. fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
  523. results.push(entry);
  524. console.log(` ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
  525. } catch (e) {
  526. console.log(` ✗ ${e.message}`);
  527. const entry = { pass: passNum, name: p.name, error: e.message, parsed: null, usage: null };
  528. fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
  529. results.push(entry);
  530. }
  531. if (i < passes.length - 1) await sleep(delayMs);
  532. }
  533. return results;
  534. }