添加 shenyan-app 项目源码
- 首页 bridge 状态轮询 + 双击敲门进聊天 - 聊天屏本地桥通信(192.168.31.51:3003) - 消息持久化(AsyncStorage + 桥历史接口) - 桥服务器(port 3003)launchd 持久化 - 进度总览文档 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
111
shenyan-app/bridge-local/responder.js
Normal file
111
shenyan-app/bridge-local/responder.js
Normal file
@@ -0,0 +1,111 @@
|
||||
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);
|
||||
169
shenyan-app/bridge-local/server.js
Normal file
169
shenyan-app/bridge-local/server.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 3003;
|
||||
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 PUSHBOX = path.join(DATA_DIR, 'pushbox.jsonl');
|
||||
|
||||
// ensure data dir and files
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
[INBOX, OUTBOX, PUSHBOX].forEach(f => { if (!fs.existsSync(f)) fs.writeFileSync(f, ''); });
|
||||
|
||||
function json(res, data, code = 200) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
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 appendJSONL(file, obj) {
|
||||
fs.appendFileSync(file, JSON.stringify(obj) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function writeJSONL(file, arr) {
|
||||
const lines = arr.map(e => JSON.stringify(e)).join('\n');
|
||||
fs.writeFileSync(file, lines + (arr.length > 0 ? '\n' : ''), 'utf-8');
|
||||
}
|
||||
|
||||
function getNextId(file) {
|
||||
const entries = readJSONL(file);
|
||||
return entries.length > 0 ? Math.max(...entries.map(e => e.id)) + 1 : 1;
|
||||
}
|
||||
|
||||
function getMacIP() {
|
||||
try {
|
||||
const c = require('child_process');
|
||||
return c.execSync('ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1').toString().trim();
|
||||
} catch { return 'unknown'; }
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// ---- app sends message to claude ----
|
||||
if (req.method === 'POST' && req.url === '/send') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { text } = JSON.parse(body);
|
||||
if (!text || !text.trim()) return json(res, { error: 'empty' }, 400);
|
||||
const entry = { id: getNextId(INBOX), text: text.trim(), time: Date.now(), status: 'pending' };
|
||||
appendJSONL(INBOX, entry);
|
||||
json(res, { ok: true, id: entry.id });
|
||||
} catch (e) { json(res, { error: e.message }, 400); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- app polls for claude's responses ----
|
||||
if (req.method === 'GET' && req.url.startsWith('/poll')) {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const since = parseInt(url.searchParams.get('since') || '0');
|
||||
const type = url.searchParams.get('type') || 'response'; // 'response' or 'push'
|
||||
if (type === 'push') {
|
||||
const entries = readJSONL(PUSHBOX).filter(e => e.id > since);
|
||||
json(res, entries);
|
||||
} else {
|
||||
const entries = readJSONL(OUTBOX).filter(e => e.id > since);
|
||||
json(res, entries);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- claude writes a response back ----
|
||||
if (req.method === 'POST' && req.url === '/respond') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { replyTo, text } = JSON.parse(body);
|
||||
if (!text) return json(res, { error: 'empty' }, 400);
|
||||
const entry = { id: getNextId(OUTBOX), replyTo, text, time: Date.now() };
|
||||
appendJSONL(OUTBOX, entry);
|
||||
json(res, { ok: true, id: entry.id });
|
||||
} catch (e) { json(res, { error: e.message }, 400); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- claude proactively pushes to app ----
|
||||
if (req.method === 'POST' && req.url === '/push') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { title, body: msgBody } = JSON.parse(body);
|
||||
if (!title && !msgBody) return json(res, { error: 'empty' }, 400);
|
||||
const entry = { id: getNextId(PUSHBOX), title: title || '', body: msgBody || '', time: Date.now() };
|
||||
appendJSONL(PUSHBOX, entry);
|
||||
json(res, { ok: true, id: entry.id });
|
||||
} catch (e) { json(res, { error: e.message }, 400); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- clear push messages that have been read ----
|
||||
if (req.method === 'POST' && req.url === '/push/clear') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { ids } = JSON.parse(body);
|
||||
if (!Array.isArray(ids)) return json(res, { error: 'bad ids' }, 400);
|
||||
const entries = readJSONL(PUSHBOX);
|
||||
const idSet = new Set(ids);
|
||||
writeJSONL(PUSHBOX, entries.filter(e => !idSet.has(e.id)));
|
||||
json(res, { ok: true });
|
||||
} catch (e) { json(res, { error: e.message }, 400); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- app knocks on the door ----
|
||||
if (req.method === 'POST' && req.url === '/knock') {
|
||||
const entry = { id: getNextId(INBOX), type: 'knock', text: '—— 叩门 ——', time: Date.now(), status: 'pending' };
|
||||
appendJSONL(INBOX, entry);
|
||||
json(res, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- get full conversation history ----
|
||||
if (req.method === 'GET' && req.url === '/history') {
|
||||
const inbox = readJSONL(INBOX).filter(e => e.type !== 'knock');
|
||||
const outbox = readJSONL(OUTBOX);
|
||||
const msgs = [];
|
||||
for (const e of inbox) {
|
||||
if (e.text) msgs.push({ id: `u-${e.id}`, role: 'user', text: e.text, time: e.time });
|
||||
}
|
||||
for (const e of outbox) {
|
||||
if (e.text) msgs.push({ id: `s-${e.id}`, role: 'shenyan', text: e.text, time: e.time });
|
||||
}
|
||||
msgs.sort((a, b) => a.time - b.time);
|
||||
json(res, msgs);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404); res.end('not found');
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`shenyan-bridge :${PORT} | mac ip: ${getMacIP()}`);
|
||||
console.log(` POST /send - app -> claude`);
|
||||
console.log(` GET /poll - app polls responses / pushes`);
|
||||
console.log(` POST /respond - claude -> app`);
|
||||
console.log(` POST /push - claude proactive notify`);
|
||||
});
|
||||
Reference in New Issue
Block a user