Przeglądaj źródła

feat: fmode-vision 0.2.0 — 宿主多模态优先 + glm-5.3-flash 回落 + token 加载链

- 默认视觉模型改为 glm-5.3-flash(替代 doubao-seed-2-0-pro-260215)
- 新增智能模型选择:detectHostVisionModel() 探测 Claude Code settings /
  Codex config.toml / FMODE_VISION_MODEL,命中多模态名单时宿主直接读图,
  不调 GLM;未命中回落 Fmode API
- analyze() 总入口:host 命中且在 Claude Code/Codex 会话内返回读图指令,
  否则走 Fmode API
- token 加载链:FMODE_API_TOKEN > ~/.fmode/config.json >
  ~/.claude/settings.json env.ANTHROPIC_AUTH_TOKEN > 项目 .fmode/config.json
- README 按工具分节安装指南(Claude Code / Codex / Gemini CLI / WorkBuddy / Hermes)
- 密钥零残留:全仓扫描通过,.gitignore 覆盖 .fmode/ 与 .env

Co-Authored-By: Claude Code <noreply@anthropic.com>
ryananemax 1 tydzień temu
commit
92722eca95

+ 8 - 0
.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "name": "fmode-vision",
+  "description": "Analyze images and videos via Fmode API vision models. Single-pass and multi-pass focused analysis with structured JSON output, plus a renovation room-measurement prompt pipeline.",
+  "version": "0.2.0",
+  "author": {
+    "name": "fmode"
+  }
+}

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+node_modules/
+*.tgz
+output/
+.env
+.env.*
+.fmode/
+.claude/settings.local.json
+.DS_Store

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 fmodecn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 170 - 0
README.md

@@ -0,0 +1,170 @@
+# fmode-vision · 视觉识别技能(宿主多模态优先 × Fmode API 回落)
+
+> 给 AI Agent 装上**眼睛**——分析图片、视频帧、视觉素材并输出结构化 JSON。
+> 优先用宿主 Agent(Claude Code / Codex)配置的多模态模型直接读图,**零额外调用**;
+> 宿主模型不支持视觉时回落 Fmode API 视觉模型 `glm-5.3-flash`。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+[![npm](https://img.shields.io/badge/npm-fmode--vision-blue)](https://www.npmjs.com/package/fmode-vision)
+
+## 能力
+
+- 👁️ 图片内容识别与结构化提取(严格 JSON 输出)
+- 🎯 多轮聚焦分析(每轮专注一个维度,精度高于单轮全量)
+- 🎬 视频帧分析(`video_url` 模型自动抽帧)
+- 📦 视觉素材批量处理(中间结果缓存、断点续跑)
+- 🏠 内置毛坯房量尺 5-pass 提示词管线(透视/吊顶/门窗洞口/障碍物/测量计划)
+
+## 模型选择策略(0.2.0 新增)
+
+技能初始化时**先探测运行环境**,宿主多模态优先:
+
+| 优先级 | 来源 | 结果 |
+|-------|------|------|
+| ① | Claude Code `./.claude/settings.json` / `~/.claude/settings.json`(含 `.local`)的 `model` / `env.ANTHROPIC_MODEL` | 命中多模态名单 → 用宿主模型 |
+| ② | Codex `~/.codex/config.toml` 的 `model` | 命中多模态名单 → 用宿主模型 |
+| ③ | 环境变量 `FMODE_VISION_MODEL` | 用户显式指定 → 直接采纳 |
+| — | 以上未命中 | 回落 Fmode API `glm-5.3-flash` |
+
+多模态能力名单:`claude-4*` / `claude-opus` / `claude-sonnet-4` / `gpt-4o` / `gpt-5*` / `gemini-2*` / `gemini-3*` / `o3` 等(见 `skills/fmode-vision/scripts/vision-client.mjs` 的 `HOST_VISION_MODEL_PATTERNS`)。
+
+**宿主命中且在 Claude Code / Codex 会话内**:AI 用自带的 Read 工具读图,结合提示词完成分析——不调 GLM、不发网络请求、不消耗 Fmode token,输出注明「已用宿主多模态模型」。
+**未命中**:走 Fmode API `glm-5.3-flash`(替代旧的 `doubao-seed-2-0-pro-260215`),按 Fmode token 计费。
+
+## Token 加载链(仅 Fmode API 路径需要)
+
+按优先级自动解析(**仓库与代码中无任何密钥**):
+
+1. 环境变量 `FMODE_API_TOKEN`
+2. `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken`(FmodeStudio 保存配置后写入)
+3. `~/.claude/settings.json`(含 `settings.local.json` / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN` —— 即 Claude Code 的 `sk-` token(仅当 `sk-` 开头、非 `sk-ant-`、base 指向 fmode 时采纳,零配置自动命中)
+4. 项目 `./.fmode/config.json` → `fmodeApiToken` / `newapiToken`
+
+> 报「未找到 token」= 缺 token,不是技能坏——按上面任一来源补上即可。**不要把任何密钥写进本仓库、issue 或 PR。**
+
+## 各工具安装指南
+
+本技能遵循各 Agent 工具的 Skill 规范。**AI 拿到本仓库后,按自己工具对应的节安装。**
+
+### Claude Code(推荐 npx 安装器)
+
+| 项 | 值 |
+|----|----|
+| 技能规范路径 | `~/.claude/skills/fmode-vision/`(用户级)或 `./.claude/skills/fmode-vision/`(项目级) |
+| token 配置 | 通常零配置(`env.ANTHROPIC_AUTH_TOKEN` 自动命中);或 `FMODE_API_TOKEN` / `~/.fmode/config.json` |
+
+```bash
+npx --yes fmode-vision@latest install          # 装到 ~/.claude/skills/fmode-vision
+npx --yes fmode-vision@latest workspace        # 或装到当前项目 ./.claude/skills/
+npx --yes fmode-vision@latest check            # 校验安装
+```
+
+装完重启 Claude Code 会话,直接说「帮我分析这张图片里的关键内容」即可触发。
+
+### Codex
+
+Codex 无 skill 机制,用 **CLI 直连**方式。在 `~/.codex/AGENTS.md` 加一段:
+
+```markdown
+## 视觉识别
+分析图片用:把图片路径交给 fmode-vision 技能
+(git clone https://github.com/fmodecn/skill-vision.git 后按 skills/fmode-vision/SKILL.md 操作),
+或在 Node 里:node --input-type=module -e "import {analyze} from '<repo>/skills/fmode-vision/scripts/vision-client.mjs'; ..."
+token 从 FMODE_API_TOKEN 或 ~/.fmode/config.json 读取。
+```
+
+Codex 的视觉模型配置在 `~/.codex/config.toml` 的 `model` 字段——若该模型在多模态名单内,技能会优先用宿主模型读图。
+
+### Gemini CLI
+
+Gemini CLI 支持自定义命令(`~/.gemini/commands/`)。把技能装到自定义命令:
+
+```bash
+git clone https://github.com/fmodecn/skill-vision.git
+mkdir -p ~/.gemini/commands
+# 新建 ~/.gemini/commands/vision.toml,prompt 段写:
+#   分析图片 {{args}}:按 skills/fmode-vision/SKILL.md 的工作流,
+#   用 vision-client.mjs 的 analyze() 完成识别,输出结构化 JSON。
+```
+
+Gemini CLI 的模型在 `~/.gemini/settings.json` 的 `model` 字段配置(`gemini-2*`/`gemini-3*` 均在多模态名单内)。
+
+### WorkBuddy
+
+凡支持「SKILL.md + scripts/」目录规范的工具(WorkBuddy 等):
+
+| 项 | 值 |
+|----|----|
+| 技能规范路径 | `<工具技能目录>/fmode-vision/`(含 SKILL.md + scripts/) |
+| token 配置 | `FMODE_API_TOKEN` 环境变量,或 `~/.fmode/config.json` |
+
+```bash
+git clone https://github.com/fmodecn/skill-vision.git
+cp -r skill-vision/skills/fmode-vision <你的工具技能目录>/fmode-vision
+```
+
+### Hermes
+
+复制技能目录到 Hermes 的 skills 目录,Hermes 的 skill 加载器会读取 SKILL.md:
+
+| 项 | 值 |
+|----|----|
+| 技能规范路径 | `~/.hermes/skills/fmode-vision/`(或 profile 对应 skills 目录) |
+| token 配置 | `FMODE_API_TOKEN` 环境变量,或 `~/.fmode/config.json` |
+
+```bash
+git clone https://github.com/fmodecn/skill-vision.git
+cp -r skill-vision/skills/fmode-vision ~/.hermes/skills/
+hermes skills   # 确认 fmode-vision 出现在列表
+```
+
+### 技能目录结构(所有工具通用)
+
+```
+fmode-vision/
+├── SKILL.md            # 技能说明(frontmatter: name/description)
+├── README.md           # 维护文档
+└── scripts/
+    ├── vision-client.mjs   # 运行器(Node ≥18,零依赖)
+    └── prompts/
+        └── room-measurement.mjs   # 毛坯房量尺 5-pass 提示词
+```
+
+## 用法
+
+在 Claude Code 里直接自然语言触发:
+
+```
+帮我分析这张图片里的关键内容,输出结构化信息。
+```
+
+在 Node 脚本中调用:
+
+```js
+import { analyze, resolveVisionModel } from './skills/fmode-vision/scripts/vision-client.mjs';
+
+console.log(resolveVisionModel());   // 先看会走宿主还是 Fmode API
+const result = await analyze({
+  imagePath: '/path/to/image.jpg',
+  systemPrompt: '你是影像分析专家,输出严格 JSON',
+  userPrompt: '描述图片中的关键元素',
+});
+// provider==='host' → 按 result.instruction 用 Read 工具读图
+// provider==='fmode' → result.raw / result.parsed(API 返回)
+```
+
+## 验证
+
+```bash
+npm run smoke    # 包结构 + 模块导出 + 探测/解析链自检
+```
+
+## 安全
+
+- **密钥零残留**:本仓库任何文件不写入真实 token/密钥;`.fmode/config.json`、`.env` 已列入 `.gitignore`
+- token 只从用户目录与环境变量读取,见上方「Token 加载链」
+- 发现密钥泄露请立即在 FmodeStudio 重置 token
+
+## License
+
+MIT

+ 155 - 0
bin/fmode-vision.js

@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const SKILL_NAME = 'fmode-vision';
+const SOURCE_ROOT = path.resolve(__dirname, '..');
+const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME);
+const WORKSPACE_ROOT = process.cwd();
+const GLOBAL_TARGET = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
+const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'skills', SKILL_NAME);
+const WORKSPACE_SKILLS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'skills');
+const GLOBAL_SKILLS_ROOT = path.join(os.homedir(), '.claude', 'skills');
+
+function expandHome(value) {
+  return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
+}
+
+function parseArgs(argv) {
+  const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
+  const args = { command: first, target: GLOBAL_TARGET, smoke: false, force: false, help: false };
+  if (first === 'workspace' || first === 'install-workspace') {
+    args.command = 'install';
+    args.target = WORKSPACE_TARGET;
+  }
+  for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (token === '--target' && argv[i + 1]) args.target = argv[++i];
+    else if (token.startsWith('--target=')) args.target = token.slice('--target='.length);
+    else if (token === '--workspace') args.target = WORKSPACE_TARGET;
+    else if (token === '--global') args.target = GLOBAL_TARGET;
+    else if (token === '--smoke') args.smoke = true;
+    else if (token === '--force') args.force = true;
+    else if (token === '--help' || token === '-h') args.help = true;
+  }
+  args.target = path.resolve(expandHome(args.target));
+  return args;
+}
+
+function printHelp() {
+  console.log([
+    'fmode-vision skill installer',
+    '',
+    'Usage:',
+    '  npx fmode-vision@latest workspace [--smoke]   # install into ./.claude/skills/fmode-vision',
+    '  npx fmode-vision@latest install [--smoke]      # install into ~/.claude/skills/fmode-vision',
+    '  npx fmode-vision@latest install --target <dir> [--force]',
+    '  npx fmode-vision@latest check',
+    '  npx fmode-vision@latest smoke',
+    '  npx fmode-vision@latest path',
+    '',
+    'Options:',
+    '  --workspace      Install into ./.claude/skills/fmode-vision',
+    '  --global         Install into ~/.claude/skills/fmode-vision (default)',
+    '  --target <dir>   Install into a custom directory',
+    '  --force          Allow overwriting a custom target',
+    '  --smoke          Run smoke checks after install',
+    '  --help, -h       Show help'
+  ].join('\n'));
+}
+
+function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
+
+function isInside(parentDir, childDir) {
+  const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
+  return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
+}
+
+function canOverwriteTarget(targetDir, force) {
+  return force
+    || path.resolve(targetDir) === path.resolve(GLOBAL_TARGET)
+    || isInside(WORKSPACE_SKILLS_ROOT, targetDir)
+    || isInside(GLOBAL_SKILLS_ROOT, targetDir);
+}
+
+function copyDirRecursive(source, destination) {
+  const stat = fs.statSync(source);
+  if (stat.isDirectory()) {
+    ensureDir(destination);
+    for (const child of fs.readdirSync(source)) {
+      if (child === 'node_modules' || child === 'outputs' || child === '.git') continue;
+      copyDirRecursive(path.join(source, child), path.join(destination, child));
+    }
+    return;
+  }
+  ensureDir(path.dirname(destination));
+  fs.copyFileSync(source, destination);
+}
+
+function installSkill(target, force) {
+  if (!fs.existsSync(SKILL_SOURCE)) {
+    throw new Error(`Skill source missing: ${SKILL_SOURCE}`);
+  }
+  if (fs.existsSync(target)) {
+    if (!canOverwriteTarget(target, force)) {
+      throw new Error(`Refusing to overwrite custom target without --force: ${target}`);
+    }
+    fs.rmSync(target, { recursive: true, force: true });
+  }
+  ensureDir(target);
+  copyDirRecursive(SKILL_SOURCE, target);
+}
+
+function checkSkill(target) {
+  const required = ['SKILL.md', 'scripts/vision-client.mjs'];
+  const missing = required.filter(entry => !fs.existsSync(path.join(target, entry)));
+  if (missing.length) {
+    throw new Error(`Install target is missing required files: ${missing.join(', ')}`);
+  }
+  return { status: 'ok', skill: SKILL_NAME, target, required };
+}
+
+function runSmoke() {
+  const result = spawnSync(process.execPath, ['scripts/smoke.js'], { cwd: SOURCE_ROOT, stdio: 'inherit', shell: false });
+  if (result.status !== 0) throw new Error('smoke failed');
+}
+
+function printNextSteps(target) {
+  const workspaceMode = isInside(WORKSPACE_SKILLS_ROOT, target);
+  console.log('');
+  console.log('Install complete.');
+  console.log(`Skill installed at: ${target}`);
+  console.log('');
+  if (workspaceMode) {
+    console.log('Project-level skill is ready. Restart the VSCode Claude Code session if it was open.');
+  } else {
+    console.log('User-level skill is ready for all Claude Code workspaces.');
+  }
+  console.log('');
+  console.log('Token: set FMODE_API_TOKEN, or ~/.fmode/config.json -> fmodeApiToken, or rely on ANTHROPIC_AUTH_TOKEN.');
+  console.log('');
+  console.log('Try this prompt in Claude Code:');
+  console.log('  帮我分析这张图片里的关键内容,输出结构化信息。');
+}
+
+function main() {
+  const args = parseArgs(process.argv.slice(2));
+  if (args.help || args.command === 'help') { printHelp(); return; }
+  if (args.command === 'path') { console.log(args.target); return; }
+  if (args.command === 'install') {
+    installSkill(args.target, args.force);
+    console.log(JSON.stringify(checkSkill(args.target), null, 2));
+    if (args.smoke) runSmoke();
+    printNextSteps(args.target);
+    return;
+  }
+  if (args.command === 'check') { console.log(JSON.stringify(checkSkill(args.target), null, 2)); return; }
+  if (args.command === 'smoke') { runSmoke(); return; }
+  printHelp();
+  process.exitCode = 1;
+}
+
+try { main(); }
+catch (error) { console.error(`fmode-vision failed: ${error.message}`); process.exit(1); }

+ 39 - 0
package.json

@@ -0,0 +1,39 @@
+{
+  "name": "fmode-vision",
+  "version": "0.2.0",
+  "description": "Claude Code skill: analyze images and videos with host-multimodal-first detection (Claude Code / Codex vision models) falling back to Fmode API glm-5.3-flash. Single-pass and multi-pass focused analysis with structured JSON output. Token chain: FMODE_API_TOKEN, ~/.fmode/config.json, ~/.claude/settings.json env.ANTHROPIC_AUTH_TOKEN, project .fmode/config.json.",
+  "type": "commonjs",
+  "bin": {
+    "fmode-vision": "bin/fmode-vision.js"
+  },
+  "scripts": {
+    "smoke": "node scripts/smoke.js"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/fmodecn/fmode-vision.git"
+  },
+  "homepage": "https://github.com/fmodecn/fmode-vision#readme",
+  "bugs": {
+    "url": "https://github.com/fmodecn/fmode-vision/issues"
+  },
+  "files": [
+    ".claude-plugin/",
+    "bin/",
+    "README.md",
+    "scripts/",
+    "skill-package-manifest.json",
+    "skills/"
+  ],
+  "keywords": [
+    "claude-code",
+    "claude-skill",
+    "fmode",
+    "vision",
+    "image-analysis",
+    "multimodal",
+    "glm"
+  ],
+  "license": "MIT",
+  "dependencies": {}
+}

+ 79 - 0
scripts/smoke.js

@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { pathToFileURL } = require('url');
+
+const ROOT = path.resolve(__dirname, '..');
+const SKILL_DIR = path.join(ROOT, 'skills', 'fmode-vision');
+
+function fail(msg) { console.error('SMOKE FAIL: ' + msg); process.exit(1); }
+
+const required = [
+  'SKILL.md',
+  'scripts/vision-client.mjs',
+  'scripts/prompts/room-measurement.mjs'
+];
+for (const rel of required) {
+  if (!fs.existsSync(path.join(SKILL_DIR, rel))) fail('missing ' + rel);
+}
+
+(async () => {
+  const mod = await import(pathToFileURL(path.join(SKILL_DIR, 'scripts', 'vision-client.mjs')).href);
+  for (const fn of ['resolveApiToken', 'callVisionAPI', 'callMultiPass', 'extractJSON', 'detectHostVisionModel', 'resolveVisionModel', 'analyze']) {
+    if (typeof mod[fn] !== 'function') fail('export ' + fn + ' is not a function');
+  }
+  const { parsed } = mod.extractJSON('text {"a":1} tail');
+  if (!parsed || parsed.a !== 1) fail('extractJSON did not parse JSON');
+
+  // 默认模型:glm-5.3-flash(无宿主配置时回落)
+  const cleanEnv = { ...process.env };
+  delete cleanEnv.FMODE_VISION_MODEL;
+  const savedEnv = process.env;
+  for (const k of Object.keys(cleanEnv)) if (k === 'FMODE_VISION_MODEL') delete process.env[k];
+  const fallback = mod.resolveVisionModel();
+  if (fallback.provider !== 'fmode' || fallback.model !== 'glm-5.3-flash') {
+    fail('fallback model should be fmode/glm-5.3-flash, got ' + JSON.stringify(fallback));
+  }
+
+  // 宿主探测:FMODE_VISION_MODEL 命中 → host
+  process.env.FMODE_VISION_MODEL = 'claude-sonnet-4-5';
+  const host = mod.resolveVisionModel();
+  if (host.provider !== 'host' || host.model !== 'claude-sonnet-4-5') {
+    fail('FMODE_VISION_MODEL should resolve to host, got ' + JSON.stringify(host));
+  }
+  delete process.env.FMODE_VISION_MODEL;
+
+  // analyze() 宿主会话内 → 返回 instruction,不调 API
+  if (process.env.CLAUDECODE || process.env.CODEX_SANDBOX || process.env.CODEX_HOME) {
+    process.env.FMODE_VISION_MODEL = 'claude-opus-4-1';
+    const r = await mod.analyze({ imagePath: '/nonexistent.jpg', systemPrompt: 's', userPrompt: 'u' });
+    if (r.provider !== 'host' || !r.instruction) fail('analyze() host path did not return instruction');
+    delete process.env.FMODE_VISION_MODEL;
+  }
+
+  // Token 加载链:隔离 HOME 下无任何 token → 应抛错并列出四级来源
+  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-vision-smoke-'));
+  const oldHome = process.env.HOME;
+  process.env.HOME = tmpHome;
+  const savedToken = process.env.ANTHROPIC_AUTH_TOKEN;
+  delete process.env.ANTHROPIC_AUTH_TOKEN;
+  const savedBase = process.env.ANTHROPIC_BASE_URL;
+  delete process.env.ANTHROPIC_BASE_URL;
+  const savedFmodeToken = process.env.FMODE_API_TOKEN;
+  delete process.env.FMODE_API_TOKEN;
+  try {
+    mod.resolveApiToken(tmpHome);
+    fail('resolveApiToken should throw when no token source exists');
+  } catch (e) {
+    if (!/FMODE_API_TOKEN/.test(e.message)) fail('error message should mention FMODE_API_TOKEN');
+  } finally {
+    process.env.HOME = oldHome;
+    if (savedToken) process.env.ANTHROPIC_AUTH_TOKEN = savedToken;
+    if (savedBase) process.env.ANTHROPIC_BASE_URL = savedBase;
+    if (savedFmodeToken) process.env.FMODE_API_TOKEN = savedFmodeToken;
+    fs.rmSync(tmpHome, { recursive: true, force: true });
+  }
+
+  console.log('SMOKE OK: fmode-vision package structure + module exports + model selection + token chain verified');
+})().catch(e => fail(e.message));

+ 17 - 0
skill-package-manifest.json

@@ -0,0 +1,17 @@
+{
+  "name": "fmode-vision",
+  "version": "0.2.0",
+  "description": "Claude Code 独立技能包:通过 Fmode API 视觉模型对图片、视频进行分析。支持单轮分析、多轮聚焦分析、视频帧分析与批量处理,并内置毛坯房量尺 5-pass 分析提示词。",
+  "plugin": "fmode-vision",
+  "skills": [
+    "fmode-vision"
+  ],
+  "entrySkill": "fmode-vision",
+  "npmPackage": "fmode-vision",
+  "smokeCommand": "npm run smoke",
+  "installCommand": "npx fmode-vision@latest install",
+  "workspaceInstallCommand": "npx fmode-vision@latest workspace",
+  "workspaceSkillPath": ".claude/skills/fmode-vision/SKILL.md",
+  "globalSkillPath": "%USERPROFILE%/.claude/skills/fmode-vision/SKILL.md",
+  "installHint": "工作区安装:npx fmode-vision@latest workspace,会写入 ./.claude/skills/fmode-vision/。用户级安装:npx fmode-vision@latest install,会写入 ~/.claude/skills/fmode-vision/。模型选择:宿主多模态优先(Claude Code / Codex 配置的视觉模型直接读图),未命中回落 Fmode API glm-5.3-flash。Token 优先级:FMODE_API_TOKEN > ~/.fmode/config.json > ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(sk- token,自动读取)> 项目 .fmode/config.json。"
+}

+ 201 - 0
skills/fmode-vision/README.md

@@ -0,0 +1,201 @@
+# Fmode Vision Skill — 维护文档
+
+## 项目结构
+
+```
+.claude/skills/fmode-vision/
+├── SKILL.md                       # 技能入口,Claude 读取后知道何时及如何使用本技能
+├── README.md                      # 本文件:开发者维护文档
+├── .skillfish.json                # 技能元信息(版本、来源仓库)
+└── scripts/
+    ├── vision-client.mjs           # 核心:通用视觉 API 客户端
+    └── prompts/
+        └── room-measurement.mjs    # 领域模块:毛坯房量尺 5-pass 提示词
+```
+
+## 核心逻辑
+
+### 1. Token 解析链 (`resolveApiToken`)
+
+四级优先级,短路返回:
+
+```
+FMODE_API_TOKEN 环境变量
+  → ~/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
+    → ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)
+      的 env.ANTHROPIC_AUTH_TOKEN(sk- 开头、非 sk-ant-、base 指向 fmode)
+      (Claude Code 会话内注入的进程环境变量也在此级命中)
+      → <project>/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
+        → 抛出异常(提示用户配置)
+```
+
+设计原因:环境变量适合 CI/CD;用户级配置适合个人开发机;Claude Code 的 `sk-` token 零配置自动命中;项目级配置适合团队共享(加入 .gitignore)。
+
+### 1.5 模型选择策略 (`detectHostVisionModel` / `resolveVisionModel` / `analyze`)
+
+宿主多模态优先,回落 Fmode API:
+
+```
+resolveVisionModel():
+  显式传入 model 参数        → { provider:'fmode', model }(强制 Fmode API)
+  ③ FMODE_VISION_MODEL 环境变量 → { provider:'host', model }(用户显式指定)
+  ① Claude Code settings 的 model / env.ANTHROPIC_MODEL
+     命中多模态名单且在会话内  → { provider:'host', model }
+  ② ~/.codex/config.toml 的 model 命中名单 → { provider:'host', model }
+  未命中                    → { provider:'fmode', model:'glm-5.3-flash' }
+```
+
+`analyze()` 是总入口:`provider==='host'` 且在 Claude Code / Codex 会话内时返回
+`{ provider:'host', model, instruction, imagePath }`,AI 用自己的 Read 工具读图完成分析
+(不调 GLM、不消耗 Fmode token);否则走 Fmode API。探测到宿主多模态但独立脚本运行时
+回落 Fmode API。多模态能力名单见 `vision-client.mjs` 的 `HOST_VISION_MODEL_PATTERNS`。
+
+### 2. API 调用流程 (`callVisionAPI`)
+
+```
+输入: imagePath | imageBase64 | imageUrl | videoUrl
+  |
+  ├─ 解析 token
+  ├─ 构造 messages 数组
+  │   ├─ system prompt
+  │   └─ user content:
+  │       ├─ text part(用户提示词)
+  │       └─ image_url / video_url part(视觉内容)
+  ├─ POST https://api.fmode.cn/v1/chat/completions
+  │   body: { model, messages, temperature, max_tokens }
+  ├─ 响应的 content 字符串 → extractJSON()
+  └─ 返回 { raw, parsed, error, usage }
+```
+
+### 3. 多轮分析模式 (`callMultiPass`)
+
+核心理念:每轮独立调用 API,各自聚焦一个分析维度,最后一轮合并。这比单轮全量分析精度更高。
+
+```
+输入: imagePath + passes[{name, systemPrompt, userPrompt, maxTokens}]
+  |
+  for each pass:
+  ├─ 检查 cacheDir/pass<N>.json 是否存在
+  │   ├─ 存在 → 跳过,读取缓存
+  │   └─ 不存在 → callVisionAPI() → 写入缓存
+  ├─ sleep(delayMs) 避免限流
+  |
+  └─ 返回 results[]
+```
+
+缓存设计:
+- 每轮结果独立缓存,支持断点续跑
+- 缓存 key = pass 序号,与提示词内容无关
+- 如需强制重新分析,删除对应缓存文件即可
+- 提示词迭代时,建议手动清理缓存
+
+### 4. JSON 提取 (`extractJSON`)
+
+LLM 响应可能被 markdown 代码块包裹(```json ... ```),也可能前后有解释文字。用正则 `/\{[\s\S]*\}/` 提取第一个 JSON 对象。
+
+### 5. 毛坯房 5-pass 专用流程 (`room-measurement.mjs`)
+
+继承自 `analyze-photos-v4.mjs`,5 轮各有独立职责:
+
+| Pass | 名称 | 分析焦点 | tokens |
+|------|------|---------|--------|
+| 1 | spatial | 空间结构:透视类型、墙面多边形、阴阳角、天地面 | 2000 |
+| 2 | ceiling | 吊顶特征:cornice/trayStep/beam/bulkhead | 1000 |
+| 3 | openings | 门窗洞口:双层框架(outer+inner polygon) | 2500 |
+| 4 | obstacles | 障碍物:插座/开关/电箱/踢脚线/风口等 | 1500 |
+| 5 | merge | 文本合并:场景描述、房间类型、测量计划、质量评估 | 2000 |
+
+质量验证:
+- 踢脚线 height > 10% → 警告(应为 2-5%)
+- 吊顶特征 polygon 顶点 > 4 → 警告
+
+## 配置说明
+
+### API Token
+
+方式一:环境变量
+```bash
+export FMODE_API_TOKEN="sk-****(占位符,换成你自己的 token)"
+```
+
+方式二:用户级配置 `~/.fmode/config.json`
+```json
+{
+  "fmodeApiToken": "sk-****(占位符,换成你自己的 token)"
+}
+```
+
+方式三:项目级配置 `<project>/.fmode/config.json`(需加入 .gitignore)
+```json
+{
+  "fmodeApiToken": "sk-****(占位符,换成你自己的 token)"
+}
+```
+
+### 可用模型
+
+| 模型 ID | 用途 | 备注 |
+|---------|------|------|
+| `glm-5.3-flash` | 视觉理解(默认回落) | Fmode API 默认视觉模型,替代旧 doubao |
+| 宿主配置模型 | 视觉理解(优先) | Claude Code / Codex 配置的多模态模型,零额外计费 |
+| `glm-4.6v` 等 | 视觉理解 | 调用时传 `model` 参数覆盖 |
+
+模型列表可能更新,以 Fmode API 返回为准。
+
+## 扩展指南
+
+### 添加新的提示词模板
+
+在 `scripts/prompts/` 下新建 `.mjs` 文件:
+
+```js
+import { callVisionAPI, callMultiPass } from '../vision-client.mjs';
+
+export const MY_SYSTEM_PROMPT = `...`;
+export const MY_USER_PROMPT = `...`;
+
+export async function analyzeSomething(imagePath) {
+  const result = await callVisionAPI({
+    imagePath,
+    systemPrompt: MY_SYSTEM_PROMPT,
+    userPrompt: MY_USER_PROMPT,
+    maxTokens: 1000,
+  });
+  return result.parsed;
+}
+```
+
+### 添加新模型
+
+在 `vision-client.mjs` 的 `DEFAULT_CONFIG` 中调整默认模型,或调用时传入 `model` 参数:
+
+```js
+const result = await callVisionAPI({
+  imagePath: '/path/to/img.jpg',
+  systemPrompt: '...',
+  userPrompt: '...',
+  model: 'glm-4.6v',  // 覆盖默认模型(强制走 Fmode API)
+});
+```
+
+### 多轮分析自定义
+
+```js
+import { callMultiPass } from './vision-client.mjs';
+
+const results = await callMultiPass({
+  imagePath: '/path/to/img.jpg',
+  cacheDir: '/tmp/my-analysis/img-001/',
+  passes: [
+    { name: 'overview', systemPrompt: '...', userPrompt: '描述整体场景', maxTokens: 500 },
+    { name: 'details', systemPrompt: '...', userPrompt: '标注细节元素', maxTokens: 1500 },
+    { name: 'verify',  systemPrompt: '...', userPrompt: '验证前两轮一致性', maxTokens: 1000 },
+  ],
+});
+```
+
+## 依赖
+
+仅使用 Node.js 内置模块:`fs`, `path`, `os`。无需 `npm install`。
+
+全局 `fetch` 需要 Node.js 18+(已内置)。

+ 205 - 0
skills/fmode-vision/SKILL.md

@@ -0,0 +1,205 @@
+---
+name: fmode-vision
+description: "通过 Fmode API 调用视觉模型对图片、视频进行分析。适用场景:(1) 图片内容识别与结构化提取, (2) 多轮聚焦分析获取高精度结果, (3) 视频帧分析, (4) 视觉素材批量处理"
+description_en: "Analyze images and videos via Fmode API vision models. Use for: (1) Image content recognition and structured extraction, (2) Multi-pass focused analysis for high-precision results, (3) Video frame analysis, (4) Batch visual material processing"
+---
+
+# Fmode Vision — 视觉识别技能
+
+## Overview
+
+本技能封装视觉识别能力:优先用**宿主 Agent 自带的多模态模型**读图(Claude Code / Codex 配置的模型支持视觉时直接用,不产生任何额外调用),否则回落 Fmode API (api.fmode.cn) 的视觉模型 `glm-5.3-flash`,支持单轮和多轮分析。用户可能要求你分析图片、处理视频帧、或对视觉素材进行结构化信息提取。
+
+## 模型选择策略(初始化时必读)
+
+技能初始化时**先探测运行环境**,决定视觉识别走哪条路:
+
+```
+detectHostVisionModel() 探测顺序:
+① Claude Code 配置:./.claude/settings.json 或 ~/.claude/settings.json
+   (含 settings.local.json)的 model 字段 / env.ANTHROPIC_MODEL
+② Codex 配置:~/.codex/config.toml 的 model 字段
+③ 环境变量 FMODE_VISION_MODEL(用户显式指定的视觉模型,直接采纳)
+```
+
+- **宿主模型命中多模态能力名单**(`claude-4*` / `claude-opus` / `claude-sonnet-4` / `gpt-4o` / `gpt-5*` / `gemini-2*` / `gemini-3*` / `o3` 等)且运行在 Claude Code / Codex 会话内
+  → **直接用宿主模型读图**:用你自己的 Read 工具读取图片文件,结合提示词完成分析。
+  **不调 GLM、不发网络请求、不消耗 Fmode token。** 输出时注明「已用宿主多模态模型」。
+- **未命中**(宿主模型不支持视觉,或独立脚本运行)
+  → 回落 **Fmode API 的 `glm-5.3-flash`**(替代旧的 `doubao-seed-2-0-pro-260215`)。
+
+代码入口:
+
+```js
+import { resolveVisionModel, analyze } from './scripts/vision-client.mjs';
+
+resolveVisionModel();   // => { provider: 'host'|'fmode', model, source }
+                        //    host: 宿主多模态模型;fmode: glm-5.3-flash(默认回落)
+
+// analyze() 是总入口:自动按上面的策略分发
+const result = await analyze({ imagePath, systemPrompt, userPrompt });
+if (result.provider === 'host') {
+  // 用 Read 工具读 result.imagePath,按 systemPrompt+userPrompt 分析,输出注明「已用宿主多模态模型」
+} else {
+  // result.raw / result.parsed —— Fmode API 返回
+}
+```
+
+## Token 获取(加载链)
+
+仅 Fmode API 路径需要 token(宿主多模态路径零 token)。按以下优先级查找:
+
+1. **环境变量** `FMODE_API_TOKEN`(最高优先级)
+   ```bash
+   echo $FMODE_API_TOKEN
+   ```
+2. **用户级配置** `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken` 字段
+   ```bash
+   cat ~/.fmode/config.json
+   ```
+3. **Claude Code 配置** `~/.claude/settings.json`(含 settings.local.json / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN` —— 即 Claude Code 的 `sk-` token(`sk-` 开头、非 `sk-ant-`、base 指向 fmode 时自动采纳,无需手动配置)
+4. **项目级配置** `<project>/.fmode/config.json` → `fmodeApiToken` / `newapiToken`
+
+如果四级都未找到,提示用户提供 token。**仓库与文档中不出现任何真实密钥。**
+
+## API 调用规范(Fmode 回落路径)
+
+- **Base URL**: `https://api.fmode.cn`
+- **Endpoint**: `POST /v1/chat/completions`
+- **Auth**: `Authorization: Bearer <token>`
+- **默认视觉模型**: `glm-5.3-flash`
+- **备选模型**: 调用时传 `model` 参数覆盖(如 `glm-4.6v`,以账号可用列表为准)
+
+### 请求体结构
+
+```json
+{
+  "model": "glm-5.3-flash",
+  "messages": [
+    { "role": "system", "content": "系统提示词" },
+    {
+      "role": "user",
+      "content": [
+        { "type": "text", "text": "用户提示词" },
+        { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,<base64>" } }
+      ]
+    }
+  ],
+  "temperature": 0.12,
+  "max_tokens": 2000
+}
+```
+
+### 视觉内容支持
+
+| 类型 | 传递方式 | 适用场景 |
+|------|---------|---------|
+| 本地图片 | `data:image/<fmt>;base64,<data>` | jpg/png/webp |
+| 远程图片 | 直接 URL | 需模型支持公网访问 |
+| 视频 | `type: "video_url"` | 模型自动抽帧 |
+
+## 核心工作流
+
+### 决策树
+
+```
+需要分析视觉内容?
+├── 宿主多模态命中(Claude Code / Codex 视觉模型)→ Read 工具直接读图
+├── 简单描述/单维度提取 → 单轮分析 (analyze / callVisionAPI)
+├── 多维度精确标注 → 多轮聚焦分析 (callMultiPass)
+│   ├── 每轮独立调用,专注一个维度
+│   ├── 中间结果写入缓存目录
+│   └── 最后一轮合并所有结果
+└── 批量处理 → 遍历 + 单轮/多轮
+```
+
+### 单轮分析
+
+使用 `scripts/vision-client.mjs` 的 `analyze()`(推荐,自动选路)或 `callVisionAPI()`(强制走 Fmode API):
+
+```js
+import { analyze, resolveApiToken } from './scripts/vision-client.mjs';
+
+const result = await analyze({
+  imagePath: '/path/to/image.jpg',
+  systemPrompt: '你是一位影像分析专家...',
+  userPrompt: '请描述这张图片中的关键元素...',
+  maxTokens: 1000,
+});
+// result = { provider, model, raw, parsed, error, usage, instruction? }
+// provider==='host' 时按 instruction 用 Read 工具读图完成分析
+```
+
+### 多轮聚焦分析
+
+使用 `callMultiPass()` 封装,适用于需要从不同维度精确分析的场景:
+
+```js
+import { callMultiPass } from './scripts/vision-client.mjs';
+
+const passes = [
+  { name: 'structure', systemPrompt: '...', userPrompt: '...', maxTokens: 2000 },
+  { name: 'details', systemPrompt: '...', userPrompt: '...', maxTokens: 1000 },
+];
+
+const results = await callMultiPass({
+  imagePath: '/path/to/image.jpg',
+  passes,
+  cacheDir: '/tmp/analysis/image-id/',
+});
+```
+
+## 提示词工程
+
+### 结构化输出
+
+始终要求模型输出严格 JSON,在 system prompt 中给出完整 schema:
+
+```
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "field1": "value",
+  "field2": [{ "sub": "value" }]
+}
+```
+
+### 聚焦原则
+
+多轮分析中每轮只关注一个维度,明确告知模型忽略其他内容:
+```
+## 规则
+1. 只标注 X 类元素,忽略 Y、Z 等其他所有元素
+2. 每个元素标注精确的 boundingBox
+```
+
+### JSON 提取
+
+模型可能包裹 markdown 代码块,使用 `extractJSON()` 提取:
+
+```js
+import { extractJSON } from './scripts/vision-client.mjs';
+const parsed = extractJSON(rawResponse);
+```
+
+## 结果缓存
+
+多轮分析支持中间结果缓存:
+- 每轮结果写入 `cacheDir/pass<N>.json`
+- 重新运行时自动跳过已有缓存
+- 如需强制重新分析,删除对应缓存文件
+
+## 领域模块
+
+### 毛坯房量尺分析
+
+`scripts/prompts/room-measurement.mjs` 提供 5-pass 量尺分析提示词:
+- Pass 1: 空间结构(透视/墙面/阴阳角)
+- Pass 2: 吊顶特征(cornice/trayStep/beam/bulkhead)
+- Pass 3: 门窗洞口(双层框架)
+- Pass 4: 障碍物(精确 boundingBox)
+- Pass 5: 合并 + 测量计划
+
+```js
+import { processPhoto } from './scripts/prompts/room-measurement.mjs';
+const merged = await processPhoto('/path/to/photo.jpg', 'photo-001', 'photo-001.jpg');
+```

+ 408 - 0
skills/fmode-vision/scripts/prompts/room-measurement.mjs

@@ -0,0 +1,408 @@
+/**
+ * 毛坯房量尺 — 5轮聚焦提示词模块
+ *
+ * 从 analyze-photos-v4.mjs 移植,API 调用委托给 vision-client.mjs。
+ *
+ * 使用示例:
+ *   import { processPhoto, PASS_CONFIGS } from './prompts/room-measurement.mjs';
+ *   const result = await processPhoto('/path/to/photo.jpg', 'img-001', 'room-a.jpg');
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { callVisionAPI } from '../vision-client.mjs';
+
+// ============================================================
+// 5轮聚焦提示词
+// ============================================================
+
+export const PASS1_SYSTEM = `你是一位建筑空间分析专家。你的任务是精确分析毛坯房照片的**空间结构**。
+
+## 规则
+1. **透视类型**:判断一点透视/两点透视/三点透视。
+   - 一点透视:正面墙正对镜头,水平线汇聚到画面中心
+   - 两点透视:墙角在画面中心附近,两侧墙面分别向左右消失
+   - 三点透视:仰拍/俯拍导致垂直线也汇聚
+   - 特别注意:如果看到两个墙面以夹角呈现(墙角在画面中心附近),必须报告 twoPoint
+
+2. **墙面多边形**:每面可见墙标注**精确的4个角点**(四边形),沿建筑实际边缘。
+   - surfaceType: facing(正面)/leftWall(左墙)/rightWall(右墙)
+   - 每条边放3个等分测量点(measurePoints)
+
+3. **天花/地面区域**:各标注4个角点的多边形
+
+4. **阴阳角**:标注位置(x,y)
+
+5. **忽略**所有小物件、家具、装饰、门窗、吊顶细节——这些会在后续分析中处理
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 1,
+  "perspective": {"type": "onePoint|twoPoint|threePoint", "description": "透视说明", "vanishingPoints": [{"x": 50, "y": 40}]},
+  "surfaces": {
+    "walls": [
+      {"id": "w1", "label": "正面主墙", "surfaceType": "facing",
+       "polygon": [{"x":20,"y":25},{"x":75,"y":25},{"x":75,"y":82},{"x":20,"y":80}],
+       "measureLines": [
+         {"label":"顶边3点","type":"horizontal","edge":"top","startPoint":{"x":20,"y":25},"endPoint":{"x":75,"y":25},"measurePoints":[{"x":20,"y":25},{"x":47.5,"y":25},{"x":75,"y":25}]},
+         {"label":"底边3点","type":"horizontal","edge":"bottom","startPoint":{"x":20,"y":80},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":20,"y":80},{"x":47.5,"y":81},{"x":75,"y":82}]},
+         {"label":"左边3点","type":"vertical","edge":"left","startPoint":{"x":20,"y":25},"endPoint":{"x":20,"y":80},"measurePoints":[{"x":20,"y":25},{"x":20,"y":52.5},{"x":20,"y":80}]},
+         {"label":"右边3点","type":"vertical","edge":"right","startPoint":{"x":75,"y":25},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":75,"y":25},{"x":75,"y":53.5},{"x":75,"y":82}]}
+       ]}
+    ],
+    "floorRegion": {"polygon": [{"x":0,"y":80},{"x":100,"y":80},{"x":100,"y":100},{"x":0,"y":100}], "label": "可见地面"},
+    "ceilingRegion": {"polygon": [{"x":0,"y":0},{"x":100,"y":0},{"x":100,"y":20},{"x":0,"y":20}], "label": "可见天花"}
+  },
+  "corners": [
+    {"id":"c1","type":"internal","label":"左阴角","position":{"x":20,"y":55}},
+    {"id":"c2","type":"internal","label":"右阴角","position":{"x":75,"y":55}}
+  ]
+}`;
+
+export const PASS1_USER = `请分析这张照片的**空间结构**:
+1. 判断透视类型(一点/两点/三点),找消失点
+2. 标注每面可见墙的4角多边形,区分facing/leftWall/rightWall
+3. 标注天花/地面区域
+4. 标注阴阳角位置
+
+只输出JSON,不包含其他内容:`;
+
+export const PASS2_SYSTEM = `你是一位吊顶与天花结构分析专家。你的任务是精确分析照片中的**天花板特征**。
+
+## 规则
+1. **只标注天花板上的结构特征**,忽略墙面、地面、门窗、障碍物
+2. **关键:每个特征必须用4个角点的简单四边形标注**。即使实际形状不规则,也只能用4点近似。禁止使用5点或更多点。
+3. 特征类型:
+   - cornice: 石膏线/阴角线(天花与墙面交界处的装饰线条)
+   - trayStep: 吊顶叠级/双眼皮(不同高度的吊顶分界线)
+   - beam: 梁/下返结构
+   - bulkhead: 窗帘盒/设备带(局部下返区域)
+   - soffit: 管道包封/检修口
+4. polygon的4个点按顺时针方向标注
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 2,
+  "ceilingFeatures": [
+    {"id":"cf1","type":"cornice","label":"石膏阴角线",
+     "polygon": [{"x":0,"y":8},{"x":100,"y":8},{"x":100,"y":12},{"x":0,"y":12}]},
+    {"id":"cf2","type":"trayStep","label":"第一层叠级线",
+     "polygon": [{"x":20,"y":22},{"x":80,"y":22},{"x":80,"y":26},{"x":20,"y":26}]}
+  ]
+}
+
+如果没有可见的天花特征,返回空数组:{"pass":2,"ceilingFeatures":[]}`;
+
+export const PASS2_USER = `请分析这张照片的**天花板特征**:
+1. 石膏线/阴角线(cornice)
+2. 吊顶叠级/双眼皮(trayStep)
+3. 梁/下返结构(beam)
+4. 窗帘盒/设备带(bulkhead)
+
+记住:每个特征只能用4个角点标注!简单四边形!
+
+只输出JSON:`;
+
+export const PASS3_SYSTEM = `你是一位门窗洞口测量专家。你的任务是精确分析照片中的**所有门洞和窗洞**。
+
+## 规则
+1. **只标注门洞和窗洞**,忽略其他所有元素(墙壁、天花、障碍物等)
+2. 每个洞口标注**双层框架**:
+   - outerPolygon: 洞口在墙面上的外轮廓(4个角点,即墙面上的实际开口边缘)
+   - innerPolygon: 门扇/窗扇/玻璃区域的内轮廓(4个角点)
+   - frameThickness: 门套/窗套线宽度(百分比),如无套线则为0
+3. 测量线沿外框放置:上中下宽度3点 + 左中右高度3点
+4. 如果无可见洞口,返回空数组
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 3,
+  "openings": [
+    {"id":"d1","type":"door","label":"入户门",
+     "frame": {
+       "outerPolygon": [{"x":35,"y":20},{"x":55,"y":18},{"x":55,"y":80},{"x":35,"y":82}],
+       "innerPolygon": [{"x":37,"y":22},{"x":53,"y":20},{"x":53,"y":78},{"x":37,"y":80}],
+       "frameThickness": 2.0
+     },
+     "measureLines": [
+       {"label":"门洞上口宽","type":"horizontal","startPoint":{"x":35,"y":20},"endPoint":{"x":55,"y":18},"measurePoints":[{"x":35,"y":20},{"x":45,"y":19},{"x":55,"y":18}]},
+       {"label":"门洞左口高","type":"vertical","startPoint":{"x":35,"y":20},"endPoint":{"x":35,"y":82},"measurePoints":[{"x":35,"y":20},{"x":35,"y":51},{"x":35,"y":82}]}
+     ]}
+  ]
+}`;
+
+export const PASS3_USER = `请分析这张照片的**所有门洞和窗洞**:
+1. 标注外层框架(outerPolygon,墙上开口的精确边缘)
+2. 标注内层框架(innerPolygon,门扇/玻璃边缘)
+3. 标注门套/窗套厚度(frameThickness)
+4. 放置测量点
+
+只输出JSON:`;
+
+export const PASS4_SYSTEM = `你是一位全屋定制障碍物检测专家。你的任务是精确标注照片中**所有可见障碍物**的包围盒。
+
+## 核心原则
+每个包围盒(boundingBox)告诉测量人员"需要测量这个矩形区域的实际尺寸"。你必须非常精确——贴合物体的真实可见边缘。
+
+## 障碍物类型
+- outlet(插座): 86型约2%×2%, 118型约3%×2%
+- switch(开关): 同插座
+- electricBox(电箱): 箱体外框,通常5-15%
+- vent(风口): 格栅外框在吊顶/墙上
+- pipe(管道): 管道与墙/地接触范围
+- baseboard(踢脚线): 墙底水平条带
+- doorFrame(门套线): 门套在墙上的宽度条带
+- windowFrame(窗套线): 窗套在墙上的范围
+- gasMeter(燃气表): 表箱外框
+- floorDrain(地漏): 地面位置
+- downlight(筒灯): 天花位置
+
+## ⚠️ 踢脚线高度规则(非常重要!)
+- 踢脚线(baseboard)的高度必须在 2%-5% 之间
+- 这是踢脚线条带**本身**的高度,不是从踢脚线到墙顶的距离
+- 正面墙(facing)踢脚线:沿着墙底的水平窄条,height = 2-4%
+- 侧墙(leftWall/rightWall)踢脚线:height = 2-5%(不要被透视缩短误导!)
+- **如果标注的height > 10%,一定是错误的——请重新检查!**那是整面墙的高度,不是踢脚线
+- 侧墙的踢脚线:看墙底部那条水平的细线/条带,标注那条条带的高度
+
+## 包围盒格式
+boundingBox: { x, y, width, height } — 全部百分比
+- x, y: 包围盒左上角相对于图片的百分比位置
+- width, height: 包围盒的宽高百分比
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 4,
+  "obstacles": [
+    {"id":"obs1","type":"outlet","label":"五孔插座(86型)","boundingBox":{"x":42,"y":56,"width":2.5,"height":3.2}},
+    {"id":"obs2","type":"baseboard","label":"木质踢脚线","boundingBox":{"x":20,"y":80,"width":55,"height":3}},
+    {"id":"obs3","type":"vent","label":"空调出风口","boundingBox":{"x":8,"y":10,"width":14,"height":4}}
+  ]
+}`;
+
+export const PASS4_USER = `请分析这张照片的**所有障碍物**:
+1. 插座、开关、电箱
+2. 风口(空调、新风、排风)
+3. 管道
+4. 踢脚线(⚠️ height必须2-5%,不能是整面墙高度!)
+5. 门套线、窗套线
+6. 燃气表、地漏
+7. 筒灯、射灯
+
+每个障碍物用精确的boundingBox{x,y,width,height}标注。
+只输出JSON:`;
+
+export const PASS5_SYSTEM = `你是一位全屋定制测量专家。你有4份针对同一房间的分析数据,分别来自不同专家的独立观察。请将它们合并为一份完整的测量分析报告。
+
+## 你的任务
+1. 阅读4份数据,理解空间结构
+2. 写出 sceneDescription(完整的场景描述,2-3句话)
+3. 判断 roomType(卧室/客厅/厨房/卫生间/阳台/走廊/储物间/其他)
+4. 生成 measurementPlan(测量计划),将所有元素关联到测量步骤
+5. 评估 photoQuality(是否广角、畸变程度、是否需要补拍)
+6. 列出 issues(如有遮挡、光线不足等问题)
+
+## 测量计划规则
+- 每面墙至少一个步骤(3点宽+3点高)
+- 每个门洞/窗洞一个步骤
+- 每组同类障碍物可以合并为一个步骤(如"测量所有插座位置")
+- 步骤按重要性排序:required > recommended > optional
+- elementIds必须引用实际存在的ID(来自输入数据)
+- 工具:激光测距仪(长距离)、卷尺(小尺寸)、水平仪(垂直度)
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 5,
+  "sceneDescription": "完整的场景描述...",
+  "roomType": "卧室",
+  "measurementPlan": [
+    {"step":1,"action":"测量正面主墙顶中底3点宽度与左中右3点高度","target":"w1","tool":"激光测距仪","priority":"required","elementIds":["w1"]}
+  ],
+  "photoQuality": {"isWideAngle":true,"distortionLevel":"low","recommendReshoot":false,"reshootAdvice":""},
+  "issues": []
+}`;
+
+export const PASS5_USER_TEMPLATE = `以下是一个房间的4份独立分析数据。请将它们合并:
+
+=== 空间结构 ===
+__PASS1__
+
+=== 吊顶特征 ===
+__PASS2__
+
+=== 门窗洞口 ===
+__PASS3__
+
+=== 障碍物 ===
+__PASS4__
+
+请生成完整的测量分析报告。只输出JSON:`;
+
+// ============================================================
+// 轮次配置(供 callMultiPass 使用)
+// ============================================================
+
+export const PASS_CONFIGS = [
+  { name: 'spatial', systemPrompt: PASS1_SYSTEM, userPrompt: PASS1_USER, maxTokens: 2000 },
+  { name: 'ceiling', systemPrompt: PASS2_SYSTEM, userPrompt: PASS2_USER, maxTokens: 1000 },
+  { name: 'openings', systemPrompt: PASS3_SYSTEM, userPrompt: PASS3_USER, maxTokens: 2500 },
+  { name: 'obstacles', systemPrompt: PASS4_SYSTEM, userPrompt: PASS4_USER, maxTokens: 1500 },
+];
+
+// ============================================================
+// 合并函数
+// ============================================================
+
+export function mergeResults(photoId, fileName, passResults) {
+  const p1 = passResults[0]?.parsed || {};
+  const p2 = passResults[1]?.parsed || {};
+  const p3 = passResults[2]?.parsed || {};
+  const p4 = passResults[3]?.parsed || {};
+  const p5 = passResults[4]?.parsed || {};
+
+  const merged = {
+    version: 'v4-multipass',
+    photoId,
+    fileName,
+    analyzedAt: new Date().toISOString(),
+    passes: passResults.map((p, i) => ({
+      pass: i + 1,
+      name: p.name || `pass${i + 1}`,
+      status: p.error ? 'error' : 'ok',
+      error: p.error || null,
+      usage: p.usage || null,
+    })),
+    parsed: {
+      sceneDescription: p5.sceneDescription || '',
+      roomType: p5.roomType || '',
+      perspective: p1.perspective || { type: 'onePoint', description: '', vanishingPoints: [] },
+      surfaces: p1.surfaces || { walls: [], floorRegion: null, ceilingRegion: null },
+      openings: p3.openings || [],
+      ceilingFeatures: p2.ceilingFeatures || [],
+      corners: p1.corners || [],
+      obstacles: p4.obstacles || [],
+      measurementPlan: p5.measurementPlan || [],
+      issues: p5.issues || [],
+      photoQuality: p5.photoQuality || { isWideAngle: false, distortionLevel: 'unknown', recommendReshoot: false, reshootAdvice: '' },
+    },
+  };
+
+  // 质量验证:踢脚线高度检查
+  const suspiciousBaseboards = (merged.parsed.obstacles || []).filter(
+    o => o.type === 'baseboard' && o.boundingBox?.height > 10
+  );
+  if (suspiciousBaseboards.length > 0) {
+    console.log(`  ⚠ 发现 ${suspiciousBaseboards.length} 个异常踢脚线高度>10%:`);
+    suspiciousBaseboards.forEach(o => {
+      console.log(`    ${o.id}: height=${o.boundingBox.height}% (预计2-5%)`);
+    });
+  }
+
+  // 质量验证:吊顶特征顶点数检查
+  const complexCeilings = (merged.parsed.ceilingFeatures || []).filter(
+    cf => cf.polygon && cf.polygon.length > 4
+  );
+  if (complexCeilings.length > 0) {
+    console.log(`  ⚠ 发现 ${complexCeilings.length} 个吊顶特征顶点>4:`);
+    complexCeilings.forEach(cf => {
+      console.log(`    ${cf.id}: ${cf.polygon.length}点 (期望4点)`);
+    });
+  }
+
+  return merged;
+}
+
+// ============================================================
+// 主流程:处理单张照片
+// ============================================================
+
+/**
+ * 对单张毛坯房照片执行 5-pass 分析
+ *
+ * @param {string} imagePath  图片路径
+ * @param {string} photoId    照片 ID(用于缓存目录命名)
+ * @param {string} fileName   原始文件名
+ * @param {Object} [opts]
+ * @param {string} [opts.cacheDir]   缓存目录,默认 './output/v4/<photoId>'
+ * @param {string} [opts.model]      模型名
+ * @returns {Promise<Object>} 合并后的分析结果
+ */
+export async function processPhoto(imagePath, photoId, fileName, opts = {}) {
+  const cacheDir = opts.cacheDir || path.resolve('./output/v4', photoId);
+
+  // Pass 1-4: 视觉分析
+  const passResults = [];
+
+  for (const cfg of PASS_CONFIGS) {
+    const passNum = cfg.name === 'spatial' ? 1 : cfg.name === 'ceiling' ? 2 : cfg.name === 'openings' ? 3 : 4;
+    const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
+
+    if (fs.existsSync(cacheFile)) {
+      console.log(`  Pass ${passNum} (${cfg.name}): 已有缓存,跳过`);
+      passResults.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
+      continue;
+    }
+
+    console.log(`  Pass ${passNum} (${cfg.name}, ${cfg.maxTokens}t)...`);
+    try {
+      const result = await callVisionAPI({
+        imagePath,
+        systemPrompt: cfg.systemPrompt,
+        userPrompt: cfg.userPrompt,
+        maxTokens: cfg.maxTokens,
+        model: opts.model,
+      });
+      const entry = { pass: passNum, name: cfg.name, ...result };
+      if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: passNum, name: cfg.name, error: e.message, parsed: null, usage: null };
+      if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+    }
+
+    await new Promise(r => setTimeout(r, 1500));
+  }
+
+  // Pass 5: 文本合并
+  const pass5File = path.join(cacheDir, 'pass5.json');
+  if (fs.existsSync(pass5File)) {
+    console.log('  Pass 5 (merge): 已有缓存,跳过');
+    passResults.push(JSON.parse(fs.readFileSync(pass5File, 'utf-8')));
+  } else {
+    console.log('  Pass 5 (merge, 2000t)...');
+    const p1Json = JSON.stringify(passResults[0]?.parsed || {}, null, 2);
+    const p2Json = JSON.stringify(passResults[1]?.parsed || {}, null, 2);
+    const p3Json = JSON.stringify(passResults[2]?.parsed || {}, null, 2);
+    const p4Json = JSON.stringify(passResults[3]?.parsed || {}, null, 2);
+    const mergePrompt = PASS5_USER_TEMPLATE
+      .replace('__PASS1__', p1Json)
+      .replace('__PASS2__', p2Json)
+      .replace('__PASS3__', p3Json)
+      .replace('__PASS4__', p4Json);
+
+    try {
+      const result = await callVisionAPI({
+        systemPrompt: PASS5_SYSTEM,
+        userPrompt: mergePrompt,
+        maxTokens: 2000,
+        model: opts.model,
+      });
+      const entry = { pass: 5, name: 'merge', ...result };
+      fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: 5, name: 'merge', error: e.message, parsed: null, usage: null };
+      fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+    }
+  }
+
+  return mergeResults(photoId, fileName, passResults);
+}

+ 540 - 0
skills/fmode-vision/scripts/vision-client.mjs

@@ -0,0 +1,540 @@
+/**
+ * Fmode Vision API 通用客户端
+ *
+ * 功能:
+ *   - resolveApiToken()         按 token 加载链获取 API token(仓库内零密钥)
+ *   - detectHostVisionModel()   宿主多模态优先探测(Claude Code / Codex)
+ *   - resolveVisionModel()      模型选择策略入口:host 优先,回落 Fmode glm-5.3-flash
+ *   - analyze()                 单次视觉分析总入口(自动走宿主或 Fmode API)
+ *   - callVisionAPI()           单轮 Fmode 视觉分析
+ *   - callMultiPass()           多轮聚焦分析(支持缓存)
+ *   - extractJSON()             从 LLM 响应提取 JSON
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+// ============================================================
+// Token 加载链(与 voc / fmode-listen 共享层一致)
+// ============================================================
+//
+// 优先级(任务书规定):
+//   1. 环境变量 FMODE_API_TOKEN
+//   2. ~/.fmode/config.json → fmodeApiToken / newapiToken
+//   3. ~/.claude/settings.json(含 settings.local.json / 项目级 .claude/)
+//      的 env.ANTHROPIC_AUTH_TOKEN——即 Claude Code 的 sk- token
+//   4. 项目 ./.fmode/config.json → fmodeApiToken / newapiToken
+//
+// 关键:fmode 的 newapi SK 默认就是 Claude Code 的 env.ANTHROPIC_AUTH_TOKEN。
+// 校验规则:sk- 开头、排除真 Anthropic 官方 key(sk-ant- 开头)、
+// 若设了 ANTHROPIC_BASE_URL 则必须指向 fmode。
+//
+// 注意:本仓库内绝不出现任何真实密钥——token 只从上述用户目录/环境读取。
+
+// UTF-8 BOM(EF BB BF):用户手工保存的 config.json 可能带 BOM,解析前剥掉。
+const BOM_RE = /^/;
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(BOM_RE, ''));
+  } catch {
+    return {};
+  }
+}
+
+function readTokenFromConfig(configPath) {
+  try {
+    if (!fs.existsSync(configPath)) return null;
+    const raw = fs.readFileSync(configPath, 'utf-8').replace(BOM_RE, '');
+    const cfg = JSON.parse(raw);
+    return cfg.fmodeApiToken || cfg.newapiToken || null;
+  } catch {
+    return null;
+  }
+}
+
+// 合并读取 Claude Code 的 settings env(用户级 + 项目级,含 .local 覆盖文件)。
+function readClaudeSettingsEnv() {
+  const files = [
+    path.join(os.homedir(), '.claude', 'settings.json'),
+    path.join(os.homedir(), '.claude', 'settings.local.json'),
+    path.join(process.cwd(), '.claude', 'settings.json'),
+    path.join(process.cwd(), '.claude', 'settings.local.json'),
+  ];
+  const merged = {};
+  for (const filePath of files) {
+    const json = readJsonMaybe(filePath);
+    const env = json && typeof json.env === 'object' && json.env ? json.env : null;
+    if (!env) continue;
+    for (const [key, value] of Object.entries(env)) {
+      if (merged[key] === undefined && typeof value === 'string' && value.trim()) {
+        merged[key] = value;
+      }
+    }
+  }
+  return merged;
+}
+
+// 仅当 ANTHROPIC_AUTH_TOKEN 看起来是 fmode 的 newapi SK 时才采纳:
+// - 必须 sk- 开头,且排除真 Anthropic 官方 key(sk-ant- 开头);
+// - 若设了 ANTHROPIC_BASE_URL,必须指向 fmode(否则这把 token 是发往别处的)。
+function pickFmodeAnthropicToken(env) {
+  const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
+  if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
+  const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
+  if (base && !base.includes('fmode')) return '';
+  return token;
+}
+
+/**
+ * 获取 fmode API token。加载链优先级:
+ *   1. 环境变量 FMODE_API_TOKEN
+ *   2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
+ *   3. ~/.claude/settings.json 等 → env.ANTHROPIC_AUTH_TOKEN(sk- 开头非 sk-ant-)
+ *   4. <project>/.fmode/config.json → fmodeApiToken / newapiToken
+ *
+ * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
+ * @returns {{ token: string, source: string }}
+ */
+export function resolveApiToken(projectRoot) {
+  // 1. 环境变量
+  if (process.env.FMODE_API_TOKEN) {
+    return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
+  }
+
+  // 2. 用户级配置 ~/.fmode/config.json
+  const userConfigPath = path.join(os.homedir(), '.fmode', 'config.json');
+  const userToken = readTokenFromConfig(userConfigPath);
+  if (userToken) {
+    return { token: userToken, source: userConfigPath };
+  }
+
+  // 3. Claude Code 默认入口:~/.claude/settings.json(含 .local / 项目级)里的
+  //    env.ANTHROPIC_AUTH_TOKEN(sk- token,Claude Code 会话内也常被注入进程环境)
+  const injected = pickFmodeAnthropicToken(process.env);
+  if (injected) {
+    return { token: injected, source: 'env:ANTHROPIC_AUTH_TOKEN' };
+  }
+  const claudeEnv = readClaudeSettingsEnv();
+  const fromSettings = pickFmodeAnthropicToken(claudeEnv);
+  if (fromSettings) {
+    return { token: fromSettings, source: '~/.claude/settings.json:env.ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  // 4. 项目级配置 <project>/.fmode/config.json
+  const root = projectRoot || process.cwd();
+  const projectConfigPath = path.join(root, '.fmode', 'config.json');
+  const projectToken = readTokenFromConfig(projectConfigPath);
+  if (projectToken) {
+    return { token: projectToken, source: projectConfigPath };
+  }
+
+  throw new Error(
+    '未找到 Fmode API token。请通过以下任一方式提供(优先级从高到低):\n' +
+    '  1. 环境变量 FMODE_API_TOKEN\n' +
+    '  2. ~/.fmode/config.json 中 fmodeApiToken / newapiToken 字段(FmodeStudio 保存配置后写入)\n' +
+    '  3. ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(Claude Code 的 sk- token,会自动读取)\n' +
+    '  4. 项目 ./.fmode/config.json 中 fmodeApiToken 字段\n' +
+    '  注意:这是缺 token,不是「用不了」——请勿点任何付费/充值弹窗。'
+  );
+}
+
+// ============================================================
+// 模型选择策略:宿主多模态优先探测
+// ============================================================
+//
+// 技能初始化时先探测运行环境:
+//   ① Claude Code:~/.claude/settings.json 或 ./.claude/settings.json 的
+//      model 字段 / env.ANTHROPIC_MODEL,命中多模态能力名单 → 用宿主模型,
+//      不调 GLM;
+//   ② Codex:~/.codex/config.toml 的 model 配置;
+//   ③ 环境变量 FMODE_VISION_MODEL(强制指定)。
+// 命中即返回 { provider: 'host', model };未命中回落
+// { provider: 'fmode', model: 'glm-5.3-flash' }(Fmode API 视觉模型)。
+
+const DEFAULT_CONFIG = {
+  apiBase: 'https://api.fmode.cn',
+  model: 'glm-5.3-flash',
+  temperature: 0.12,
+  maxTokens: 2000,
+};
+
+// 宿主模型多模态(视觉)能力名单。前缀匹配,大小写不敏感。
+const HOST_VISION_MODEL_PATTERNS = [
+  /^claude-4/i,
+  /^claude-opus/i,
+  /^claude-sonnet-4/i,
+  /^claude-haiku-4/i,
+  /^claude-3[-.]5-sonnet/i,
+  /^claude-3[-.](opus|sonnet)/i,
+  /^gpt-4o/i,
+  /^gpt-4-turbo/i,
+  /^gpt-5/i,
+  /^o3/i,
+  /^gemini-2/i,
+  /^gemini-3/i,
+];
+
+// 已知纯文本模型:即便命中上面名单形态也判为不支持视觉。
+const HOST_TEXT_ONLY_PATTERNS = [/^o1(?!-vision)/i];
+
+function modelSupportsVision(name) {
+  const n = String(name || '').toLowerCase();
+  if (!n) return false;
+  if (HOST_TEXT_ONLY_PATTERNS.some(re => re.test(n))) return false;
+  return HOST_VISION_MODEL_PATTERNS.some(re => re.test(n));
+}
+
+function readClaudeHostModel() {
+  const files = [
+    path.join(process.cwd(), '.claude', 'settings.json'),
+    path.join(process.cwd(), '.claude', 'settings.local.json'),
+    path.join(os.homedir(), '.claude', 'settings.json'),
+    path.join(os.homedir(), '.claude', 'settings.local.json'),
+  ];
+  for (const filePath of files) {
+    const json = readJsonMaybe(filePath);
+    // settings 的顶层 model 字段
+    if (typeof json.model === 'string' && json.model.trim()) {
+      return { model: json.model.trim(), source: filePath };
+    }
+    // env.ANTHROPIC_MODEL
+    const envModel = json.env && typeof json.env === 'object'
+      ? (json.env.ANTHROPIC_MODEL || json.env.ANTHROPIC_DEFAULT_OPUS_MODEL || json.env.ANTHROPIC_DEFAULT_SONNET_MODEL)
+      : '';
+    if (typeof envModel === 'string' && envModel.trim()) {
+      return { model: envModel.trim(), source: `${filePath}:env.ANTHROPIC_MODEL` };
+    }
+  }
+  return null;
+}
+
+// Codex:~/.codex/config.toml 里的 model = "..."(简单解析顶层 model 行)
+function readCodexHostModel() {
+  const tomlPath = path.join(os.homedir(), '.codex', 'config.toml');
+  try {
+    if (!fs.existsSync(tomlPath)) return null;
+    const raw = fs.readFileSync(tomlPath, 'utf-8');
+    const m = raw.match(/^\s*model\s*=\s*["']([^"']+)["']/m);
+    if (m && m[1].trim()) return { model: m[1].trim(), source: tomlPath };
+  } catch { /* ignore */ }
+  return null;
+}
+
+function isHostAgentSession() {
+  // 运行在 Claude Code / Codex 会话内的常用信号
+  return Boolean(
+    process.env.CLAUDECODE ||
+    process.env.CLAUDE_CODE_ENTRYPOINT ||
+    process.env.CODEX_SANDBOX ||
+    process.env.CODEX_HOME
+  );
+}
+
+/**
+ * 探测宿主是否自带多模态(视觉)模型。
+ * 探测顺序:① Claude Code settings ② Codex config.toml ③ FMODE_VISION_MODEL。
+ *
+ * @returns {{ provider: 'host', model: string, source: string } | null}
+ *          命中宿主多模态时返回,否则 null(回落 Fmode API)
+ */
+export function detectHostVisionModel() {
+  // ③ 环境变量强制指定(最高优先:用户显式声明的视觉模型,直接采纳)
+  const forced = process.env.FMODE_VISION_MODEL;
+  if (forced && forced.trim()) {
+    return { provider: 'host', model: forced.trim(), source: 'env:FMODE_VISION_MODEL' };
+  }
+
+  // ① Claude Code settings(项目级优先于用户级)
+  const claude = readClaudeHostModel();
+  if (claude && modelSupportsVision(claude.model)) {
+    return { provider: 'host', model: claude.model, source: claude.source };
+  }
+
+  // ② Codex config.toml
+  const codex = readCodexHostModel();
+  if (codex && modelSupportsVision(codex.model)) {
+    return { provider: 'host', model: codex.model, source: codex.source };
+  }
+
+  return null;
+}
+
+/**
+ * 模型选择策略总入口:
+ * - 宿主(Claude Code / Codex)配置模型支持多模态 → 用宿主模型,不调 GLM
+ * - 否则回落 Fmode API 的 glm-5.3-flash
+ *
+ * @param {string} [explicitModel] 调用方显式指定的模型(最高优先)
+ * @returns {{ provider: 'host'|'fmode', model: string, source: string }}
+ */
+export function resolveVisionModel(explicitModel) {
+  if (explicitModel && explicitModel.trim()) {
+    return { provider: 'fmode', model: explicitModel.trim(), source: 'explicit' };
+  }
+  const host = detectHostVisionModel();
+  if (host) return host;
+  return { provider: 'fmode', model: DEFAULT_CONFIG.model, source: 'fmode-default' };
+}
+
+// ============================================================
+// 宿主多模态直读(Claude Code / Codex 会话内)
+// ============================================================
+//
+// 当 detectHostVisionModel() 命中且运行在宿主 Agent 会话内时,
+// analyze() 不发网络请求,而是把图片交给宿主 Agent 用自带的 Read 工具读图、
+// 按传入的提示词完成识别。返回结构与 callVisionAPI() 一致,
+// 上层(SKILL.md 工作流)拿到结果后无感知。
+
+/**
+ * 生成交给宿主 Agent 的读图指令。
+ * AI(Claude Code / Codex)应使用自己的 Read 工具读取 imagePath 的图片,
+ * 结合 systemPrompt + userPrompt 完成分析,并把输出喂回 analyze() 的宿主路径。
+ *
+ * @param {Object} opts 与 analyze() 相同的参数
+ * @returns {{ provider: 'host', model: string, source: string,
+ *              instruction: string, imagePath?: string }}
+ */
+export function buildHostReadInstruction(opts) {
+  const resolved = resolveVisionModel(opts && opts.model);
+  const imagePath = opts && (opts.imagePath || opts.imageUrl || opts.videoUrl) || '';
+  const instruction = [
+    `[宿主多模态模式] 请使用你的 Read 工具直接读取图片文件:${imagePath}`,
+    `(已用宿主多模态模型:${resolved.model},来源:${resolved.source};本次不调用 Fmode API / GLM)`,
+    '',
+    '系统提示词:',
+    String(opts && opts.systemPrompt || ''),
+    '',
+    '用户提示词:',
+    String(opts && opts.userPrompt || ''),
+    '',
+    '请按提示词要求完成识别并输出结果。',
+  ].join('\n');
+  return { provider: 'host', model: resolved.model, source: resolved.source, instruction, imagePath };
+}
+
+// ============================================================
+// 分析总入口
+// ============================================================
+
+/**
+ * 单次视觉分析总入口:宿主多模态优先,回落 Fmode API。
+ *
+ * - 宿主命中且运行在 Claude Code / Codex 会话内 → 返回 { provider:'host', ...,
+ *   instruction },AI 用自己的 Read 工具读图后按提示词分析,不再调 GLM;
+ * - 否则走 Fmode API(glm-5.3-flash)。
+ *
+ * @param {Object} opts 同 callVisionAPI():
+ *   imagePath | imageBase64 | imageUrl | videoUrl + systemPrompt + userPrompt
+ *   + 可选 model / temperature / maxTokens / apiToken
+ * @returns {Promise<{ provider: 'host'|'fmode', model: string,
+ *   raw?: string, parsed: object|null, error: string|null, usage: object|null,
+ *   instruction?: string }>}
+ */
+export async function analyze(opts) {
+  const resolved = resolveVisionModel(opts && opts.model);
+
+  // 宿主多模态命中 + 运行在宿主 Agent 会话内 → 交给宿主读图,不调 Fmode API
+  if (resolved.provider === 'host') {
+    if (isHostAgentSession()) {
+      const host = buildHostReadInstruction(opts);
+      return {
+        provider: 'host',
+        model: host.model,
+        raw: null,
+        parsed: null,
+        error: null,
+        usage: null,
+        instruction: host.instruction,
+        imagePath: host.imagePath,
+      };
+    }
+    // 探测到宿主多模态模型但不在会话内(如脚本独立运行)→ 回落 Fmode API
+    return runFmodeVision({ ...opts, model: DEFAULT_CONFIG.model });
+  }
+
+  // Fmode API 路径
+  return runFmodeVision(opts);
+}
+
+async function runFmodeVision(opts) {
+  const result = await callVisionAPI(opts);
+  return { provider: 'fmode', model: (opts && opts.model) || DEFAULT_CONFIG.model, ...result };
+}
+
+// ============================================================
+// Fmode API 调用
+// ============================================================
+
+/**
+ * 调用 Fmode Vision API
+ *
+ * @param {Object} opts
+ * @param {string} [opts.imagePath]    本地图片路径
+ * @param {string} [opts.imageBase64]  图片 base64 数据(与 imagePath 二选一)
+ * @param {string} [opts.imageUrl]     远程图片 URL
+ * @param {string} [opts.videoUrl]     视频 URL
+ * @param {string} opts.systemPrompt   系统提示词
+ * @param {string} opts.userPrompt     用户提示词
+ * @param {string} [opts.model]        模型名,默认 glm-5.3-flash
+ * @param {number} [opts.temperature]  默认 0.12
+ * @param {number} [opts.maxTokens]    默认 2000
+ * @param {string} [opts.apiToken]     手动传入 token,否则自动解析
+ * @returns {Promise<{ raw: string, parsed: object|null, error: string|null, usage: object|null }>}
+ */
+export async function callVisionAPI(opts) {
+  const {
+    imagePath, imageBase64, imageUrl, videoUrl,
+    systemPrompt, userPrompt,
+    model, temperature, maxTokens, apiToken,
+  } = opts;
+
+  const token = apiToken || resolveApiToken().token;
+  const messages = [{ role: 'system', content: systemPrompt }];
+
+  const userContent = [{ type: 'text', text: userPrompt }];
+
+  // 视觉内容
+  if (imagePath) {
+    const buffer = fs.readFileSync(imagePath);
+    const ext = path.extname(imagePath).slice(1).toLowerCase();
+    const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
+    const b64 = buffer.toString('base64');
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: `data:${mime};base64,${b64}` },
+    });
+  } else if (imageBase64) {
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: imageBase64 },
+    });
+  } else if (imageUrl) {
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: imageUrl },
+    });
+  } else if (videoUrl) {
+    userContent.push({
+      type: 'video_url',
+      video_url: { url: videoUrl },
+    });
+  }
+
+  messages.push({ role: 'user', content: userContent });
+
+  const body = {
+    model: model || DEFAULT_CONFIG.model,
+    messages,
+    temperature: temperature ?? DEFAULT_CONFIG.temperature,
+    max_tokens: maxTokens || DEFAULT_CONFIG.maxTokens,
+  };
+
+  const res = await fetch(`${DEFAULT_CONFIG.apiBase}/v1/chat/completions`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${token}`,
+    },
+    body: JSON.stringify(body),
+  });
+
+  if (!res.ok) {
+    const errText = await res.text();
+    throw new Error(`API ${res.status}: ${errText}`);
+  }
+
+  const data = await res.json();
+  const content = data.choices?.[0]?.message?.content || '';
+
+  const { parsed, error } = extractJSON(content);
+
+  return { raw: content, parsed, error, usage: data.usage || null };
+}
+
+// ============================================================
+// JSON 提取
+// ============================================================
+
+/**
+ * 从 LLM 响应中提取 JSON 对象
+ * 容忍 markdown 代码块包裹、前后文字
+ */
+export function extractJSON(rawContent) {
+  const m = rawContent.match(/\{[\s\S]*\}/);
+  if (!m) return { parsed: null, error: 'No JSON object in response' };
+
+  try {
+    return { parsed: JSON.parse(m[0]), error: null };
+  } catch (e) {
+    return { parsed: null, error: e.message };
+  }
+}
+
+// ============================================================
+// 多轮分析
+// ============================================================
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+/**
+ * 多轮聚焦分析
+ * 每轮独立调用 API,中间结果写入缓存目录。已有缓存则跳过。
+ *
+ * @param {Object} opts
+ * @param {string} opts.imagePath       图片路径
+ * @param {Array}  opts.passes           轮次配置数组
+ *   [{ name: string, systemPrompt: string, userPrompt: string, maxTokens?: number }]
+ * @param {string} opts.cacheDir         缓存目录
+ * @param {string} [opts.model]          模型名
+ * @param {number} [opts.delayMs=1500]   轮次间延迟
+ * @returns {Promise<Array<{ pass: number, name: string, raw: string, parsed: object|null, error: string|null, usage: object|null }>>}
+ */
+export async function callMultiPass(opts) {
+  const { imagePath, passes, cacheDir, model, delayMs = 1500 } = opts;
+
+  if (!fs.existsSync(cacheDir)) {
+    fs.mkdirSync(cacheDir, { recursive: true });
+  }
+
+  const results = [];
+
+  for (let i = 0; i < passes.length; i++) {
+    const p = passes[i];
+    const passNum = i + 1;
+    const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
+
+    // 检查缓存
+    if (fs.existsSync(cacheFile)) {
+      console.log(`  Pass ${passNum} (${p.name}): 已有缓存,跳过`);
+      results.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
+      continue;
+    }
+
+    console.log(`  Pass ${passNum} (${p.name}, ${p.maxTokens || 2000}t)...`);
+    try {
+      const result = await analyze({
+        imagePath,
+        systemPrompt: p.systemPrompt,
+        userPrompt: p.userPrompt,
+        maxTokens: p.maxTokens,
+        model,
+      });
+      const entry = { pass: passNum, name: p.name, ...result };
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      results.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: passNum, name: p.name, error: e.message, parsed: null, usage: null };
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      results.push(entry);
+    }
+
+    if (i < passes.length - 1) await sleep(delayMs);
+  }
+
+  return results;
+}