#!/usr/bin/env node /** * UserPromptSubmit Hook: 剪贴板图片自动检测 (v2.0 — Python 加速版) * * 解决 MINGW64/Git Bash 环境下 Ctrl+V 无法粘贴图片的问题。 * v2.0: PowerShell (~500ms) → Python ctypes+PIL (~80ms),提速 6x * * 触发方式: * 1. 显式触发: 消息中包含 [img] 或 [截图] * 2. 关键词触发: 截图、图片、screenshot、paste、粘贴、看这、看看这 等 * 3. 组合触发: Ctrl+V 字符 (\x16) + 任意文本 * * 工作流程: * 1. 解析用户 prompt,检测触发条件 * 2. Python ctypes 快速检测剪贴板是否有图片 (~50ms) * 3. Python PIL 保存图片到临时目录 (~80ms) * 4. MD5 去重,注入 additionalContext * * stdin: { session_id, prompt, ... } * stdout: JSON { hookSpecificOutput: { additionalContext } } * 退出码: 0 (始终放行, fail-open) */ 'use strict'; const { execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); // === 配置 === const SCRIPTS_DIR = path.join(__dirname, '..', 'scripts'); const CHECK_SCRIPT = path.join(SCRIPTS_DIR, 'clipboard-check.py'); const SAVE_SCRIPT = path.join(SCRIPTS_DIR, 'clipboard-save.py'); const TEMP_DIR = path.join( process.env.TEMP || process.env.TMP || 'C:\\Temp', 'claude-clipboard-images' ); const STATE_FILE = path.join(TEMP_DIR, '.clipboard-state.json'); const MAX_IMAGES = 10; // Python 路径缓存 let _pythonPath = null; function getPython() { if (_pythonPath) return _pythonPath; // 优先使用完整路径,避免 PATH 查找开销 [CLIPBOARD_PYTHON_PORTABLE_2026_04_21] const _os = require('os'); const _path = require('path'); const candidates = [ process.env.PYTHON_EXE, _path.join(_os.homedir(), 'AppData/Local/Programs/Python/Python312/python.exe'), 'python', 'python3', ].filter(Boolean); for (const p of candidates) { try { const r = spawnSync(p, ['--version'], { timeout: 2000, encoding: 'utf8', windowsHide: true }); if (r.status === 0) { _pythonPath = p; return p; } } catch {} } return null; } // === 触发检测 === const EXPLICIT_MARKERS = /\[img\]|\[截图\]|\[paste\]|\[image\]/i; const KEYWORD_TRIGGERS = /截图|图片|看图|screenshot|paste image|粘贴图|看这[张个]|看看这|这[张个]图|帮我看|分析.{0,4}图|识别.{0,4}图|图[中里上下]|图片[中里]|看下|看一下|这是什么|什么意思|帮我分析/i; const CTRL_V_CHAR = /\x16/; function shouldTrigger(prompt) { if (EXPLICIT_MARKERS.test(prompt)) return 'explicit'; if (CTRL_V_CHAR.test(prompt)) return 'ctrlv'; if (KEYWORD_TRIGGERS.test(prompt)) return 'keyword'; return null; } // === 文件管理 === function ensureDir(dir) { try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch {} } function readState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return { lastHash: '', lastPath: '', lastTime: 0 }; } } function writeState(state) { try { fs.writeFileSync(STATE_FILE, JSON.stringify(state)); } catch {} } function cleanOldImages() { try { const files = fs.readdirSync(TEMP_DIR) .filter(f => f.startsWith('cb_') && f.endsWith('.png')) .map(f => ({ name: f, full: path.join(TEMP_DIR, f), time: fs.statSync(path.join(TEMP_DIR, f)).mtimeMs })) .sort((a, b) => b.time - a.time); files.slice(MAX_IMAGES).forEach(f => { try { fs.unlinkSync(f.full); } catch {} }); } catch {} } // === 剪贴板操作 (Python 加速) === function hasClipboardImage() { const python = getPython(); if (!python) return hasClipboardImageFallback(); try { const r = spawnSync(python, [CHECK_SCRIPT], { timeout: 2000, encoding: 'utf8', windowsHide: true }); return r.stdout.trim() === 'true'; } catch { return hasClipboardImageFallback(); } } function saveClipboardImage(imgPath) { const python = getPython(); if (!python) return saveClipboardImageFallback(imgPath); try { const winPath = imgPath.replace(/\//g, '\\'); const r = spawnSync(python, [SAVE_SCRIPT, winPath], { timeout: 5000, encoding: 'utf8', windowsHide: true }); return r.status === 0 && fs.existsSync(imgPath); } catch { return saveClipboardImageFallback(imgPath); } } // === PowerShell 回退 (Python 不可用时) === function hasClipboardImageFallback() { try { const result = execSync( 'powershell.exe -NoProfile -Command "(Get-Clipboard -Format Image) -ne $null"', { timeout: 3000, encoding: 'utf8', windowsHide: true } ).trim(); return result === 'True'; } catch { return false; } } function saveClipboardImageFallback(imgPath) { try { const winPath = imgPath.replace(/\//g, '\\').replace(/'/g, "''"); execSync( `powershell.exe -NoProfile -Command "$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${winPath}', [System.Drawing.Imaging.ImageFormat]::Png) }"`, { timeout: 5000, windowsHide: true } ); return fs.existsSync(imgPath); } catch { return false; } } function getFileHash(filePath) { try { const content = fs.readFileSync(filePath); return crypto.createHash('md5').update(content).digest('hex'); } catch { return ''; } } // === 主流程 === function main() { let rawInput = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { rawInput += chunk; if (rawInput.length > 128 * 1024) { process.stdout.write(JSON.stringify({})); process.exit(0); } }); process.stdin.on('end', () => { try { const input = JSON.parse(rawInput); const prompt = (input.prompt || '').trim(); // 检查触发条件 const trigger = shouldTrigger(prompt); if (!trigger) { process.stdout.write(JSON.stringify({})); process.exit(0); return; } ensureDir(TEMP_DIR); // 检测剪贴板图片 (Python: ~50ms, 回退 PowerShell: ~500ms) if (!hasClipboardImage()) { process.stdout.write(JSON.stringify({})); process.exit(0); return; } // 保存图片 (Python: ~80ms, 回退 PowerShell: ~300ms) const timestamp = Date.now(); const imgPath = path.join(TEMP_DIR, `cb_${timestamp}.png`); if (!saveClipboardImage(imgPath)) { process.stdout.write(JSON.stringify({})); process.exit(0); return; } // MD5 去重 const hash = getFileHash(imgPath); const state = readState(); if (hash === state.lastHash && state.lastPath && fs.existsSync(state.lastPath)) { // 相同图片,删除新文件,复用旧路径 try { fs.unlinkSync(imgPath); } catch {} // 任何触发方式都返回旧图片路径(用户可能多次引用同一张截图) outputResult(state.lastPath, trigger); process.exit(0); return; } // 新图片 writeState({ lastHash: hash, lastPath: imgPath, lastTime: timestamp }); cleanOldImages(); outputResult(imgPath, trigger); } catch { process.stdout.write(JSON.stringify({})); } process.exit(0); }); } function outputResult(imgPath, trigger) { const cleanPrompt = trigger === 'explicit' ? '(用户使用了 [img] 标记请求查看剪贴板图片)' : '(检测到用户可能想分享剪贴板中的截图)'; process.stdout.write(JSON.stringify({ hookSpecificOutput: { additionalContext: [ `[CLIPBOARD_IMAGE_DETECTED]`, `触发方式: ${trigger}`, `${cleanPrompt}`, `剪贴板截图已保存到: ${imgPath}`, `请用 Read 工具读取此图片文件查看截图内容,然后回答用户的问题。` ].join('\n') } })); } if (require.main === module) { main(); }