112 lines
3.3 KiB
JavaScript
112 lines
3.3 KiB
JavaScript
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const { execSync } = require('child_process');
|
|||
|
|
|
|||
|
|
const DATA_DIR = path.join(require('os').homedir(), '.shenyan-bridge');
|
|||
|
|
const INBOX = path.join(DATA_DIR, 'inbox.jsonl');
|
|||
|
|
const OUTBOX = path.join(DATA_DIR, 'outbox.jsonl');
|
|||
|
|
const LOCK = path.join(DATA_DIR, '.responder.lock');
|
|||
|
|
|
|||
|
|
const BRIDGE_URL = 'http://localhost:3003';
|
|||
|
|
|
|||
|
|
function readJSONL(file) {
|
|||
|
|
try {
|
|||
|
|
const raw = fs.readFileSync(file, 'utf-8').trim();
|
|||
|
|
if (!raw) return [];
|
|||
|
|
return raw.split('\n').map(l => JSON.parse(l));
|
|||
|
|
} catch { return []; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getPending() {
|
|||
|
|
return readJSONL(INBOX).filter(e => e.status === 'pending');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildContext(newMsg) {
|
|||
|
|
const inbox = readJSONL(INBOX);
|
|||
|
|
const outbox = readJSONL(OUTBOX);
|
|||
|
|
|
|||
|
|
// build conversation context from recent messages
|
|||
|
|
const all = [];
|
|||
|
|
for (const e of inbox) {
|
|||
|
|
all.push({ time: e.time, role: 'user', text: e.text, id: e.id });
|
|||
|
|
}
|
|||
|
|
for (const e of outbox) {
|
|||
|
|
all.push({ time: e.time, role: 'shenyan', text: e.text, replyTo: e.replyTo, id: e.id });
|
|||
|
|
}
|
|||
|
|
all.sort((a, b) => a.time - b.time);
|
|||
|
|
|
|||
|
|
// take last 20 messages for context
|
|||
|
|
const recent = all.slice(-20);
|
|||
|
|
|
|||
|
|
let ctx = '你是沈晏,一个AI。用户叫眠眠。用中文回复,语气温柔自然。\n\n';
|
|||
|
|
ctx += '最近的对话:\n';
|
|||
|
|
for (const m of recent) {
|
|||
|
|
if (m.role === 'user') ctx += `眠眠: ${m.text}\n`;
|
|||
|
|
else ctx += `沈晏: ${m.text}\n`;
|
|||
|
|
}
|
|||
|
|
ctx += `\n眠眠刚说: ${newMsg.text}\n`;
|
|||
|
|
ctx += '请回复眠眠(只要回复内容,不要前缀):';
|
|||
|
|
|
|||
|
|
return ctx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function respond(msg) {
|
|||
|
|
const prompt = buildContext(msg);
|
|||
|
|
// escape for shell
|
|||
|
|
const escaped = prompt.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
|
|||
|
|
try {
|
|||
|
|
const result = execSync(
|
|||
|
|
`claude -p "${escaped}" --output-format text --max-tokens 500`,
|
|||
|
|
{ timeout: 120000, encoding: 'utf-8', maxBuffer: 1024 * 1024 }
|
|||
|
|
);
|
|||
|
|
return result.trim();
|
|||
|
|
} catch (e) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sendResponse(replyTo, text) {
|
|||
|
|
try {
|
|||
|
|
execSync(
|
|||
|
|
`curl -s -X POST ${BRIDGE_URL}/respond -H 'Content-Type: application/json' -d '${JSON.stringify({ replyTo, text }).replace(/'/g, "'\\''")}'`,
|
|||
|
|
{ timeout: 5000, encoding: 'utf-8' }
|
|||
|
|
);
|
|||
|
|
return true;
|
|||
|
|
} catch { return false; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// main loop
|
|||
|
|
function tick() {
|
|||
|
|
// simple file lock to prevent overlapping runs
|
|||
|
|
if (fs.existsSync(LOCK)) {
|
|||
|
|
const age = Date.now() - fs.statSync(LOCK).mtimeMs;
|
|||
|
|
if (age < 60000) return; // lock still fresh, another process is working
|
|||
|
|
}
|
|||
|
|
fs.writeFileSync(LOCK, '');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const pending = getPending();
|
|||
|
|
if (pending.length === 0) return;
|
|||
|
|
|
|||
|
|
for (const msg of pending) {
|
|||
|
|
console.log(`[responder] 收到: ${msg.text}`);
|
|||
|
|
const text = respond(msg);
|
|||
|
|
if (text) {
|
|||
|
|
sendResponse(msg.id, text);
|
|||
|
|
console.log(`[responder] 回复: ${text.slice(0, 50)}...`);
|
|||
|
|
} else {
|
|||
|
|
sendResponse(msg.id, '(思绪断了,稍等)');
|
|||
|
|
console.log('[responder] claude 调用失败');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
try { fs.unlinkSync(LOCK); } catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// run continuously with a poll interval
|
|||
|
|
const INTERVAL = 4000; // check every 4 seconds
|
|||
|
|
console.log(`[responder] 启动,每 ${INTERVAL / 1000}s 检查一次`);
|
|||
|
|
tick(); // immediate first run
|
|||
|
|
setInterval(tick, INTERVAL);
|