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

269 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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();
}