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