269 lines
7.7 KiB
JavaScript
269 lines
7.7 KiB
JavaScript
#!/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();
|
||
}
|