bookworm-smart-assistant/hooks/clipboard-image-hook.js

269 lines
7.7 KiB
JavaScript
Raw Normal View History

#!/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. 关键词触发: 截图图片screenshotpaste粘贴看这看看这
* 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();
}