vision-bridge.mjs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 图片理解桥(复用兄弟技能 skill-vision)
  14. *
  15. * 职责:把一张图交给 skill-vision 的 analyze(),产出结构化结果:
  16. * - ocrText 图里出现的文字(尽量逐字)
  17. * - description 画面/内容语义描述
  18. * - usageSuggestion 能不能作为素材、适合发给谁
  19. * - label 给素材起一个短标签
  20. * - canBeMaterial 是否可作为可发送素材(false → role=description)
  21. * - piiHints 疑似 PII 片段(姓名/手机/微信/邮箱/头像),仅记录不外发
  22. *
  23. * analyze() 有两种返回:
  24. * 1) 宿主多模态:{ provider:'host', instruction, imagePath } —— 由本脚本打印指令,
  25. * 由宿主 Agent 用自己的 Read 工具读图后按提示词产出 JSON(不调 Fmode API);
  26. * 2) 网关:{ provider:'fmode', raw, parsed } —— 直接拿到结构化 JSON。
  27. * 两条路径的输出契约一致,调用方(SKILL.md 工作流)无感知。
  28. *
  29. * 绝不打印 token、绝不把图片内容写进仓库。
  30. */
  31. import fs from 'node:fs';
  32. import path from 'node:path';
  33. import { parseArgs } from 'node:util';
  34. import {
  35. readJson, writeJson, ensureDir, resolveSiblingScript, truncate, shortHash,
  36. } from './lib.mjs';
  37. const VISION_REL = path.join('skill-vision', 'scripts', 'vision-client.mjs');
  38. const SYSTEM_PROMPT = [
  39. '你是案例库采集助手。你看到的图片来自留学咨询/课程辅导机构的真实沟通素材(聊天截图、成绩单、反馈截图、海报、笔记等)。',
  40. '你要做两件事:① 如实 OCR 出图里的文字;② 判断这张图能不能作为「可发给客户的案例素材」,并给出使用建议。',
  41. '严格输出 JSON,不要输出多余文字。字段:',
  42. '{"ocrText":"图内文字,逐字,保留换行","description":"画面与内容说明,2-4 句",',
  43. ' "label":"不超过 16 字的短标签","usageSuggestion":"什么时候发给什么客户,一句话",',
  44. ' "canBeMaterial":true, "role":"material|description",',
  45. ' "piiHints":[{"field":"姓名|手机号|邮箱|微信号|头像|其它","snippet":"疑似片段"}]}',
  46. '判据:canBeMaterial=false 的典型情况——纯说明性配图、logo、无信息量的装饰图、与课程辅导无关。',
  47. 'piiHints 只记录疑似片段,不要脑补补全。',
  48. ].join('\n');
  49. export function buildUserPrompt(context = {}) {
  50. const lines = [
  51. '请分析这张图片,按系统提示词输出 JSON。',
  52. context.index ? `这是同一批素材中的第 ${context.index} 张${context.total ? `(共 ${context.total} 张)` : ''}。` : '',
  53. context.batchHint ? `同批其它图的初步内容:${truncate(context.batchHint, 300)}。请据此判断这张图在整组里的作用。` : '',
  54. context.extra ? String(context.extra) : '',
  55. ].filter(Boolean);
  56. return lines.join('\n');
  57. }
  58. /** 从 vision 返回里取出结构化对象(宿主路径拿不到时回落为一个空壳)。 */
  59. export function normalizeVisionResult(result, fallbackLabel = '') {
  60. const parsed = (result && result.parsed) || null;
  61. if (!parsed) {
  62. const needsHostRead = Boolean(result && result.instruction);
  63. return {
  64. ok: false,
  65. provider: result && result.provider,
  66. model: result && result.model,
  67. ocrText: '',
  68. description: '',
  69. label: fallbackLabel,
  70. usageSuggestion: '',
  71. // 还没真正分析过:不下结论,role 留空交给调用方保持默认(material)
  72. canBeMaterial: null,
  73. role: '',
  74. piiHints: [],
  75. needsHostRead,
  76. instruction: result && result.instruction,
  77. raw: (result && result.raw) || '',
  78. error: (result && result.error) || (needsHostRead ? 'HOST_READ_PENDING' : 'NO_PARSED_OUTPUT'),
  79. };
  80. }
  81. const role = String(parsed.role || (parsed.canBeMaterial === false ? 'description' : 'material')).toLowerCase();
  82. return {
  83. ok: true,
  84. provider: result && result.provider,
  85. model: result && result.model,
  86. ocrText: String(parsed.ocrText || ''),
  87. description: String(parsed.description || ''),
  88. label: String(parsed.label || fallbackLabel || ''),
  89. usageSuggestion: String(parsed.usageSuggestion || ''),
  90. canBeMaterial: parsed.canBeMaterial !== false && role === 'material',
  91. role: role === 'description' ? 'description' : 'material',
  92. piiHints: Array.isArray(parsed.piiHints) ? parsed.piiHints.filter((h) => h && h.field) : [],
  93. needsHostRead: false,
  94. instruction: '',
  95. raw: result && result.raw ? String(result.raw) : '',
  96. error: null,
  97. };
  98. }
  99. /**
  100. * 分析一张图(或一组图)。
  101. * @returns {Promise<{available:boolean, source:string, skillPath:string|null, results:object[], error:string|null}>}
  102. */
  103. export async function analyzeImages(items, options = {}) {
  104. const visionPath = resolveSiblingScript('CASE_VISION_SCRIPT', VISION_REL);
  105. if (!visionPath) {
  106. return {
  107. available: false,
  108. source: 'missing',
  109. skillPath: null,
  110. results: [],
  111. error: `未找到 skill-vision(期望 ${VISION_REL})。请先安装:npx skill-vision@latest install`,
  112. };
  113. }
  114. const analyze = (await import(pathToFileUrl(visionPath))).analyze;
  115. const results = [];
  116. for (let i = 0; i < items.length; i++) {
  117. const item = items[i];
  118. const context = { index: i + 1, total: items.length, batchHint: options.batchHint || '' };
  119. let result;
  120. try {
  121. result = await analyze({
  122. imagePath: item.localPath || undefined,
  123. imageUrl: !item.localPath ? item.url || undefined : undefined,
  124. systemPrompt: SYSTEM_PROMPT,
  125. userPrompt: buildUserPrompt(context),
  126. model: options.model || undefined,
  127. maxTokens: options.maxTokens || 1800,
  128. });
  129. } catch (error) {
  130. result = { provider: 'error', parsed: null, error: error.message };
  131. }
  132. const normalized = normalizeVisionResult(result, item.label || `素材${i + 1}`);
  133. results.push({ file: item.localPath || item.url || '', index: i + 1, ...normalized });
  134. }
  135. const provider = results.find((r) => r.ok)?.provider || results[0]?.provider || 'unknown';
  136. const needsHost = results.some((r) => r.needsHostRead);
  137. return {
  138. available: true,
  139. source: provider,
  140. skillPath: visionPath,
  141. needsHostRead: needsHost,
  142. results,
  143. error: results.every((r) => !r.ok) ? '全部图片分析未产出结构化结果' : null,
  144. };
  145. }
  146. function pathToFileUrl(p) {
  147. return new URL(`file://${p.split(path.sep).join('/')}`).href;
  148. }
  149. // ---------------------------------------------------------------------------
  150. // CLI
  151. // ---------------------------------------------------------------------------
  152. async function main() {
  153. const { values } = parseArgs({
  154. options: {
  155. image: { type: 'string', multiple: true, default: [] },
  156. 'image-list': { type: 'string' },
  157. out: { type: 'string' },
  158. model: { type: 'string' },
  159. 'batch-hint': { type: 'string' },
  160. help: { type: 'boolean', default: false },
  161. },
  162. allowPositionals: true,
  163. });
  164. if (values.help) {
  165. process.stdout.write([
  166. 'skill-case-get / vision-bridge — 逐图 OCR + 语义理解(复用 skill-vision)',
  167. '',
  168. ' node vision-bridge.mjs --image a.jpg --image b.jpg [--batch-hint "同批都是考前冲刺截图"] [--out vision.json]',
  169. ' node vision-bridge.mjs --image-list batch.json [--out vision.json] # batch.json: {items:[{localPath|url,label}], batchHint}',
  170. '',
  171. '说明:若宿主(FmodeCode / Claude Code)自带多模态模型,analyze() 会返回读图指令,',
  172. ' 此时输出里的 needsHostRead=true,由宿主 Agent 用自己的 Read 工具读图后补全。',
  173. ].join('\n'));
  174. return 0;
  175. }
  176. const items = values.image.map((p) => ({ localPath: p }));
  177. if (values['image-list']) {
  178. const bundle = readJson(values['image-list'], {});
  179. for (const item of bundle.items || []) items.push(item);
  180. if (bundle.batchHint && !values['batch-hint']) values['batch-hint'] = bundle.batchHint;
  181. }
  182. if (!items.length) {
  183. process.stderr.write('至少需要一个 --image 或 --image-list\n');
  184. return 2;
  185. }
  186. const payload = await analyzeImages(items, { model: values.model, batchHint: values['batch-hint'] });
  187. const text = JSON.stringify(payload, null, 2);
  188. if (values.out) {
  189. ensureDir(path.dirname(path.resolve(values.out)));
  190. writeJson(path.resolve(values.out), payload);
  191. } else {
  192. process.stdout.write(`${text}\n`);
  193. }
  194. if (!payload.available) return 3;
  195. return 0;
  196. }
  197. const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'vision-bridge.mjs'));
  198. if (invokedDirectly) {
  199. main().then((code) => process.exit(code)).catch((error) => {
  200. process.stderr.write(`vision-bridge 失败:${error.message}\n`);
  201. process.exit(1);
  202. });
  203. }
  204. export { SYSTEM_PROMPT };