添加 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`);
|
||||
});
|
||||
@@ -10,7 +10,9 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native/new-app-screen": "0.85.3",
|
||||
"axios": "^1.16.0",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -1,115 +1,281 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StatusBar, StyleSheet, View } from 'react-native';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import ChatArea from './components/ChatArea';
|
||||
import InputArea from './components/InputArea';
|
||||
import { fetchHistory, sendMessage, syncHistory } from './api';
|
||||
import { loadMessages, mergeMessages, saveMessages } from './storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import type { Message } from './types';
|
||||
type Status = 'away' | 'online' | 'busy';
|
||||
type Screen = 'home' | 'chat';
|
||||
|
||||
interface Msg {
|
||||
id: string;
|
||||
role: 'user' | 'shenyan';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const IMAGES: Record<Status, ReturnType<typeof require>> = {
|
||||
away: require('./assets/away.png'),
|
||||
online: require('./assets/online.png'),
|
||||
busy: require('./assets/busy.png'),
|
||||
};
|
||||
|
||||
const CHAT_BG = require('./assets/chat-bg.png');
|
||||
const HEALTH_URL = 'http://101.36.73.102/health';
|
||||
const BRIDGE_URL = 'http://192.168.31.51:3003';
|
||||
const POLL_INTERVAL = 8000;
|
||||
const PUSH_POLL_INTERVAL = 6000;
|
||||
|
||||
export default function App() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [synced, setSynced] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const nextId = useRef(1);
|
||||
const [screen, setScreen] = useState<Screen>('home');
|
||||
const [status, setStatus] = useState<Status>('away');
|
||||
const lastTap = useRef(0);
|
||||
|
||||
// 启动:先读本地缓存,再异步拉远端
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const local = await loadMessages();
|
||||
if (local.length > 0) {
|
||||
setMessages(local);
|
||||
nextId.current = Math.max(...local.map(m => m.id)) + 1;
|
||||
}
|
||||
|
||||
const remote = await fetchHistory();
|
||||
if (remote.length > 0) {
|
||||
const merged = mergeMessages(local, remote);
|
||||
setMessages(merged);
|
||||
nextId.current = Math.max(...merged.map(m => m.id)) + 1;
|
||||
await saveMessages(merged);
|
||||
} else if (local.length > 0) {
|
||||
// 服务端没数据但本地有,把本地推上去
|
||||
await syncHistory(local);
|
||||
}
|
||||
|
||||
setSynced(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// synced 后,每次 messages 变化同步写本地 + 远端
|
||||
useEffect(() => {
|
||||
if (!synced || messages.length === 0) return;
|
||||
saveMessages(messages);
|
||||
syncHistory(messages);
|
||||
}, [messages, synced]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string) => {
|
||||
const userMsg: Message = {
|
||||
id: nextId.current++,
|
||||
role: 'user',
|
||||
text,
|
||||
};
|
||||
const withUser = [...messages, userMsg];
|
||||
setMessages(withUser);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await sendMessage(withUser);
|
||||
const shenyanMsg: Message = {
|
||||
id: nextId.current++,
|
||||
role: 'shenyan',
|
||||
text: reply,
|
||||
};
|
||||
setMessages(prev => [...prev, shenyanMsg]);
|
||||
} catch {
|
||||
const errMsg: Message = {
|
||||
id: nextId.current++,
|
||||
role: 'shenyan',
|
||||
text: '连接失败',
|
||||
};
|
||||
setMessages(prev => [...prev, errMsg]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
const remote = await fetchHistory();
|
||||
if (remote.length > 0) {
|
||||
setMessages(prev => {
|
||||
const merged = mergeMessages(prev, remote);
|
||||
nextId.current = Math.max(...merged.map(m => m.id)) + 1;
|
||||
return merged;
|
||||
});
|
||||
const knock = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastTap.current < 350) {
|
||||
lastTap.current = 0;
|
||||
setScreen('chat');
|
||||
fetch(`${BRIDGE_URL}/knock`, { method: 'POST' }).catch(() => {});
|
||||
} else {
|
||||
lastTap.current = now;
|
||||
}
|
||||
setRefreshing(false);
|
||||
}, []);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const c = new AbortController();
|
||||
const t = setTimeout(() => c.abort(), 5000);
|
||||
const r = await fetch(HEALTH_URL, { signal: c.signal });
|
||||
clearTimeout(t);
|
||||
setStatus(r.ok ? 'online' : 'away');
|
||||
} catch {
|
||||
setStatus('away');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth();
|
||||
const id = setInterval(checkHealth, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [checkHealth]);
|
||||
|
||||
// push polling from claude (proactive notifications)
|
||||
useEffect(() => {
|
||||
if (screen !== 'chat') return;
|
||||
let lastPushId = 0;
|
||||
const pollPush = async () => {
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/poll?since=${lastPushId}&type=push`);
|
||||
const entries = await r.json();
|
||||
if (Array.isArray(entries)) {
|
||||
for (const e of entries) {
|
||||
Alert.alert(e.title || '沈晏', e.body || '');
|
||||
if (e.id > lastPushId) lastPushId = e.id;
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
// ack cleared push messages
|
||||
fetch(`${BRIDGE_URL}/push/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: entries.map((e: any) => e.id) }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const id = setInterval(pollPush, PUSH_POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [screen]);
|
||||
|
||||
if (screen === 'chat') {
|
||||
return <ChatScreen onBack={() => setScreen('home')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<View style={s.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ChatArea
|
||||
messages={messages}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
<InputArea onSend={handleSend} disabled={loading} />
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
<Image source={IMAGES[status]} style={s.statusImg} resizeMode="cover" />
|
||||
<Text style={s.statusLabel}>{LABELS[status]}</Text>
|
||||
<TouchableOpacity style={s.tapZone} onPress={knock} activeOpacity={1} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
const MSG_STORE_KEY = 'shenyan-chat-msgs';
|
||||
|
||||
function ChatScreen({ onBack }: { onBack: () => void }) {
|
||||
const [text, setText] = useState('');
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const listRef = useRef<FlatList<Msg>>(null);
|
||||
const lastResponseId = useRef(0);
|
||||
const msgsRef = useRef<Msg[]>([]);
|
||||
|
||||
// keep ref in sync for saving
|
||||
useEffect(() => { msgsRef.current = msgs; }, [msgs]);
|
||||
|
||||
// load saved messages + bridge history on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(MSG_STORE_KEY);
|
||||
if (raw) {
|
||||
const saved: Msg[] = JSON.parse(raw);
|
||||
setMsgs(saved);
|
||||
for (const m of saved) {
|
||||
if (m.role === 'shenyan' && m.id.startsWith('s-')) {
|
||||
const bridgeId = parseInt(m.id.slice(2), 10);
|
||||
if (bridgeId > lastResponseId.current) lastResponseId.current = bridgeId;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// first load: pull full history from bridge
|
||||
const r = await fetch(`${BRIDGE_URL}/history`);
|
||||
const history: Msg[] = await r.json();
|
||||
if (Array.isArray(history) && history.length > 0) {
|
||||
setMsgs(history);
|
||||
for (const m of history) {
|
||||
if (m.role === 'shenyan' && m.id.startsWith('s-')) {
|
||||
const bridgeId = parseInt(m.id.slice(2), 10);
|
||||
if (bridgeId > lastResponseId.current) lastResponseId.current = bridgeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {} finally { setLoaded(true); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// persist messages on change (debounced)
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const t = setTimeout(() => {
|
||||
AsyncStorage.setItem(MSG_STORE_KEY, JSON.stringify(msgsRef.current.slice(-100))).catch(() => {});
|
||||
}, 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [msgs, loaded]);
|
||||
|
||||
// auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (msgs.length > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
|
||||
}
|
||||
}, [msgs.length]);
|
||||
|
||||
// poll for claude responses
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/poll?since=${lastResponseId.current}`);
|
||||
const entries = await r.json();
|
||||
if (Array.isArray(entries)) {
|
||||
for (const e of entries) {
|
||||
if (e.id > lastResponseId.current) lastResponseId.current = e.id;
|
||||
setMsgs(prev => {
|
||||
const exists = prev.find(m => m.id === `s-${e.id}`);
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: `s-${e.id}`, role: 'shenyan', text: e.text }];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
const msgId = `u-${Date.now()}`;
|
||||
setMsgs(prev => [...prev, { id: msgId, role: 'user', text: t }]);
|
||||
setText('');
|
||||
try {
|
||||
await fetch(`${BRIDGE_URL}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: t }),
|
||||
});
|
||||
} catch {
|
||||
setMsgs(prev => [...prev, { id: `err-${Date.now()}`, role: 'shenyan', text: '消息没发出去,检查WiFi连接' }]);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: Msg }) => (
|
||||
<View style={[s.msgBubble, item.role === 'user' ? s.msgRight : s.msgLeft]}>
|
||||
<Text style={s.msgText}>{item.text}</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<View style={s.root}>
|
||||
<Image source={CHAT_BG} style={s.chatBg} resizeMode="cover" />
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<TouchableOpacity style={s.backDot} onPress={onBack} activeOpacity={0.6} />
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={msgs}
|
||||
keyExtractor={m => m.id}
|
||||
renderItem={renderItem}
|
||||
style={s.msgList}
|
||||
contentContainerStyle={s.msgContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<View style={s.inputRow}>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="输入"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
multiline
|
||||
onSubmitEditing={send}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
<TouchableOpacity style={s.sendBtn} onPress={send} activeOpacity={0.5} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const LABELS: Record<Status, string> = { away: '不在', online: '在线', busy: '忙碌' };
|
||||
|
||||
const s = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#000' },
|
||||
statusImg: { width: '100%', height: '100%', position: 'absolute' },
|
||||
statusLabel: { position: 'absolute', bottom: 60, alignSelf: 'center', fontFamily: 'Inter', fontSize: 14, color: 'rgba(255,255,255,0.3)' },
|
||||
tapZone: { position: 'absolute', top: 0, left: 0, width: '50%', height: '100%' },
|
||||
|
||||
chatBg: { width: '100%', height: '100%', position: 'absolute' },
|
||||
backDot: { position: 'absolute', top: 60, left: 20, width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFF', zIndex: 10 },
|
||||
msgList: { flex: 1 },
|
||||
msgContent: { paddingTop: 100, paddingHorizontal: 16, paddingBottom: 20 },
|
||||
msgBubble: { maxWidth: '82%', paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, marginBottom: 8 },
|
||||
msgRight: {
|
||||
alignSelf: 'flex-end',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderTopRightRadius: 4,
|
||||
},
|
||||
msgLeft: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
borderTopLeftRadius: 4,
|
||||
},
|
||||
msgText: { fontFamily: 'Inter', fontSize: 16, color: '#FFF' },
|
||||
|
||||
inputRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 12, paddingTop: 8 },
|
||||
input: { flex: 1, fontFamily: 'Inter', fontSize: 16, color: '#FFF', backgroundColor: 'rgba(255,255,255,0.18)', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 10, maxHeight: 100 },
|
||||
sendBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.4)', marginLeft: 10 },
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import type { Message } from './types';
|
||||
import type { Message, RoomEvent } from './types';
|
||||
|
||||
const bridge = axios.create({
|
||||
baseURL: 'http://101.36.73.102:3001',
|
||||
baseURL: 'http://101.36.73.102',
|
||||
timeout: 120000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -33,3 +33,19 @@ export async function fetchHistory(): Promise<Message[]> {
|
||||
export async function syncHistory(messages: Message[]): Promise<void> {
|
||||
await bridge.post('/api/chat-history', messages);
|
||||
}
|
||||
|
||||
export function toRoomEvents(messages: Message[]): RoomEvent[] {
|
||||
return messages.map((m, i) => ({
|
||||
id: m.id,
|
||||
type: 'message' as const,
|
||||
role: m.role,
|
||||
text: m.text,
|
||||
time: '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function toApiMessages(events: RoomEvent[]): Message[] {
|
||||
return events
|
||||
.filter(e => e.type === 'message' && e.role && e.text)
|
||||
.map(e => ({ id: e.id, role: e.role!, text: e.text! }));
|
||||
}
|
||||
|
||||
BIN
shenyan-app/src/assets/away.png
Normal file
BIN
shenyan-app/src/assets/away.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
shenyan-app/src/assets/bg.png
Normal file
BIN
shenyan-app/src/assets/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
shenyan-app/src/assets/busy.png
Normal file
BIN
shenyan-app/src/assets/busy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
BIN
shenyan-app/src/assets/chat-bg.png
Normal file
BIN
shenyan-app/src/assets/chat-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
shenyan-app/src/assets/online.png
Normal file
BIN
shenyan-app/src/assets/online.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
@@ -38,7 +38,7 @@ export default function ChatArea({ messages, onRefresh, refreshing }: Props) {
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
content: {
|
||||
paddingTop: 12,
|
||||
|
||||
71
shenyan-app/src/components/EventCard.tsx
Normal file
71
shenyan-app/src/components/EventCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { RoomEvent } from '../types';
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
memory_entry: '◈',
|
||||
shenyan_note: '◇',
|
||||
system: '○',
|
||||
};
|
||||
|
||||
export default function EventCard({ event }: { event: RoomEvent }) {
|
||||
if (event.type === 'message') {
|
||||
return <MessageCard event={event} />;
|
||||
}
|
||||
return <InfoCard event={event} />;
|
||||
}
|
||||
|
||||
function MessageCard({ event }: { event: RoomEvent }) {
|
||||
const isUser = event.role === 'user';
|
||||
return (
|
||||
<View style={[msgStyles.row, isUser ? msgStyles.rowRight : msgStyles.rowLeft]}>
|
||||
<View style={[msgStyles.bubble, isUser ? msgStyles.bubbleRight : msgStyles.bubbleLeft]}>
|
||||
<Text style={msgStyles.text}>{event.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ event }: { event: RoomEvent }) {
|
||||
const icon = ICONS[event.type] ?? '·';
|
||||
return (
|
||||
<View style={infoStyles.row}>
|
||||
<Text style={infoStyles.icon}>{icon}</Text>
|
||||
<View style={infoStyles.card}>
|
||||
{event.title ? <Text style={infoStyles.title}>{event.title}</Text> : null}
|
||||
<Text style={infoStyles.body}>{event.body ?? event.detail ?? ''}</Text>
|
||||
<Text style={infoStyles.time}>{event.time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const msgStyles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 5 },
|
||||
rowRight: { justifyContent: 'flex-end' },
|
||||
rowLeft: { justifyContent: 'flex-start' },
|
||||
bubble: { maxWidth: '82%', paddingHorizontal: 14, paddingVertical: 10 },
|
||||
bubbleLeft: {
|
||||
backgroundColor: 'rgba(255,255,255,0.028)',
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#FFFFFF',
|
||||
borderTopRightRadius: 7,
|
||||
borderBottomRightRadius: 7,
|
||||
},
|
||||
bubbleRight: {
|
||||
backgroundColor: 'rgba(255,255,255,0.055)',
|
||||
borderRightWidth: 7,
|
||||
borderRightColor: 'rgba(255,255,255,0.76)',
|
||||
borderTopLeftRadius: 7,
|
||||
borderBottomLeftRadius: 7,
|
||||
},
|
||||
text: { fontFamily: 'Inter', fontSize: 18, fontWeight: '400', lineHeight: 26, color: '#FFFFFF' },
|
||||
});
|
||||
|
||||
const infoStyles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 6, alignItems: 'flex-start' },
|
||||
icon: { fontSize: 10, color: 'rgba(255,255,255,0.3)', marginTop: 4, marginRight: 10, width: 14 },
|
||||
card: { flex: 1, backgroundColor: 'rgba(255,255,255,0.02)', borderRadius: 2, padding: 12 },
|
||||
title: { fontFamily: 'Inter', fontSize: 13, fontWeight: '600', color: 'rgba(255,255,255,0.5)', marginBottom: 4 },
|
||||
body: { fontFamily: 'Inter', fontSize: 14, fontWeight: '400', lineHeight: 20, color: 'rgba(255,255,255,0.7)' },
|
||||
time: { fontFamily: 'Inter', fontSize: 10, color: 'rgba(255,255,255,0.2)', marginTop: 8 },
|
||||
});
|
||||
@@ -1,88 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const quickReplies = ['阿晏', '在吗'];
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string) => void;
|
||||
onRefresh?: () => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function InputArea({ onSend, disabled }: Props) {
|
||||
export default function InputArea({ onSend, onRefresh, disabled }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const keyboardHeight = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const handleSend = () => {
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener(
|
||||
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||
e => {
|
||||
Animated.timing(keyboardHeight, {
|
||||
toValue: e.endCoordinates.height,
|
||||
duration: e.duration || 250,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
},
|
||||
);
|
||||
const hide = Keyboard.addListener(
|
||||
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
||||
() => {
|
||||
Animated.timing(keyboardHeight, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
show.remove();
|
||||
hide.remove();
|
||||
};
|
||||
}, [keyboardHeight]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setText('');
|
||||
};
|
||||
}, [text, disabled, onSend]);
|
||||
|
||||
const Wrapper = Platform.OS === 'ios' ? KeyboardAvoidingView : View;
|
||||
const wrapperProps =
|
||||
Platform.OS === 'ios'
|
||||
? { behavior: 'padding' as const, keyboardVerticalOffset: 0 }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.wrapper}>
|
||||
<View style={styles.bar}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="输入消息..."
|
||||
placeholderTextColor="#666"
|
||||
multiline
|
||||
editable={!disabled}
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
<Animated.View style={[styles.outer, { paddingBottom: keyboardHeight }]}>
|
||||
<View style={styles.quickRow}>
|
||||
{quickReplies.map((label, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={styles.quickBtn}
|
||||
onPress={() => onSend(label)}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.6}>
|
||||
<Text style={styles.quickLabel}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.bottomRow}>
|
||||
<View style={styles.glassBar}>
|
||||
<Wrapper {...wrapperProps} style={styles.wrapperInner}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="输入"
|
||||
placeholderTextColor="rgba(255,255,255,0.5)"
|
||||
multiline
|
||||
editable={!disabled}
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
</Wrapper>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendBtn, disabled && styles.sendBtnDisabled]}
|
||||
style={styles.sendBtn}
|
||||
onPress={handleSend}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}>
|
||||
onLongPress={onRefresh}
|
||||
delayLongPress={600}
|
||||
disabled={disabled && !onRefresh}
|
||||
activeOpacity={0.5}>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
outer: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#111',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#222',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
quickRow: {
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 8,
|
||||
gap: 8,
|
||||
},
|
||||
quickBtn: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
},
|
||||
quickLabel: {
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter',
|
||||
},
|
||||
bottomRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
glassBar: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(217, 217, 217, 0.008)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.11)',
|
||||
borderRadius: 30,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
wrapperInner: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#e0e0e0',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
maxHeight: 100,
|
||||
fontWeight: '400',
|
||||
fontFamily: 'Inter',
|
||||
paddingLeft: 9,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
sendBtn: {
|
||||
width: 40,
|
||||
height: 'auto',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 20,
|
||||
marginLeft: 5,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
sendBtnDisabled: {
|
||||
backgroundColor: '#444',
|
||||
width: 42,
|
||||
height: 42,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.46)',
|
||||
borderRadius: 21,
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import type { Message } from '../types';
|
||||
|
||||
export default function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<View style={[styles.row, isUser ? styles.rowRight : styles.rowLeft]}>
|
||||
<View style={[styles.bubble, isUser ? styles.bubbleUser : styles.bubbleShenyan]}>
|
||||
<Text style={[styles.text, isUser ? styles.textUser : styles.textShenyan]}>
|
||||
{message.text}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.bubble,
|
||||
isUser ? styles.bubbleRight : styles.bubbleLeft,
|
||||
]}>
|
||||
<Text style={styles.text}>{message.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -18,7 +21,7 @@ const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
rowRight: {
|
||||
justifyContent: 'flex-end',
|
||||
@@ -27,27 +30,29 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
bubble: {
|
||||
maxWidth: '80%',
|
||||
maxWidth: '82%',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
},
|
||||
bubbleUser: {
|
||||
backgroundColor: 'rgba(255,255,255,0.13)',
|
||||
bubbleLeft: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.028)',
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#FFFFFF',
|
||||
borderTopRightRadius: 7,
|
||||
borderBottomRightRadius: 7,
|
||||
},
|
||||
bubbleShenyan: {
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
bubbleRight: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.055)',
|
||||
borderRightWidth: 7,
|
||||
borderRightColor: 'rgba(255, 255, 255, 0.76)',
|
||||
borderTopLeftRadius: 7,
|
||||
borderBottomLeftRadius: 7,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
textUser: {
|
||||
color: '#e0e0e0',
|
||||
},
|
||||
textShenyan: {
|
||||
color: '#d0d0d0',
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: '400',
|
||||
lineHeight: 26,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
57
shenyan-app/src/components/RoomFeed.tsx
Normal file
57
shenyan-app/src/components/RoomFeed.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||
import type { RoomEvent } from '../types';
|
||||
import EventCard from './EventCard';
|
||||
|
||||
interface Props {
|
||||
events: RoomEvent[];
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
const HEADER_EVENT: RoomEvent = {
|
||||
id: -1,
|
||||
type: 'system',
|
||||
time: '',
|
||||
detail: '房间就绪 test',
|
||||
};
|
||||
|
||||
export default function RoomFeed({ events, onRefresh, refreshing }: Props) {
|
||||
const listRef = useRef<FlatList<RoomEvent>>(null);
|
||||
|
||||
const onContentSizeChange = useCallback(() => {
|
||||
if (events.length > 0) {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
const data = [HEADER_EVENT, ...events];
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={data}
|
||||
keyExtractor={e => String(e.id)}
|
||||
renderItem={({ item }) => <EventCard event={item} />}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.content}
|
||||
ListFooterComponent={<View style={styles.footer} />}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ItemSeparatorComponent={Separator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <View style={styles.sep} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { flex: 1, backgroundColor: 'transparent' },
|
||||
content: { paddingTop: 20 },
|
||||
footer: { height: 80 },
|
||||
sep: { height: 2 },
|
||||
});
|
||||
56
shenyan-app/src/notifications.ts
Normal file
56
shenyan-app/src/notifications.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Alert } from 'react-native';
|
||||
import notifee from '@notifee/react-native';
|
||||
|
||||
const POLL_INTERVAL = 5000;
|
||||
const POLL_URL = 'http://localhost:3002/push/poll';
|
||||
|
||||
let notifeeReady = false;
|
||||
|
||||
export async function setupNotifications() {
|
||||
try {
|
||||
await notifee.requestPermission();
|
||||
notifeeReady = true;
|
||||
console.log('[push] notifee permission granted');
|
||||
} catch {
|
||||
console.log('[push] notifee permission failed, fallback to Alert');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showNotification(title: string, body: string) {
|
||||
if (notifeeReady) {
|
||||
try {
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
ios: {
|
||||
sound: 'default',
|
||||
foregroundPresentationOptions: {
|
||||
banner: true,
|
||||
sound: true,
|
||||
list: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
console.log('[push] notifee display failed, fallback to Alert');
|
||||
}
|
||||
}
|
||||
Alert.alert(title, body);
|
||||
}
|
||||
|
||||
export function startPolling(onMessage: (msg: { title: string; body: string }) => void) {
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(POLL_URL);
|
||||
const data = await res.json();
|
||||
for (const msg of data.messages ?? []) {
|
||||
console.log('[push] received', msg.title, msg.body);
|
||||
onMessage(msg);
|
||||
}
|
||||
} catch {
|
||||
// push-server not running, silently ignore
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Message } from './types';
|
||||
import type { RoomEvent } from './types';
|
||||
|
||||
const KEY = 'shenyan-chat-messages';
|
||||
const KEY = 'shenyan-room-events';
|
||||
const MAX = 100;
|
||||
|
||||
export async function loadMessages(): Promise<Message[]> {
|
||||
export async function loadEvents(): Promise<RoomEvent[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
@@ -15,28 +15,28 @@ export async function loadMessages(): Promise<Message[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMessages(messages: Message[]): Promise<void> {
|
||||
export async function saveEvents(events: RoomEvent[]): Promise<void> {
|
||||
try {
|
||||
const deduped = dedupById(messages);
|
||||
const deduped = dedupById(events);
|
||||
const trimmed = deduped.slice(-MAX);
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(trimmed));
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeMessages(local: Message[], remote: Message[]): Message[] {
|
||||
const map = new Map<number, Message>();
|
||||
for (const m of local) map.set(m.id, m);
|
||||
for (const m of remote) map.set(m.id, m);
|
||||
export function mergeEvents(local: RoomEvent[], remote: RoomEvent[]): RoomEvent[] {
|
||||
const map = new Map<number, RoomEvent>();
|
||||
for (const e of local) map.set(e.id, e);
|
||||
for (const e of remote) map.set(e.id, e);
|
||||
return Array.from(map.values()).sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
function dedupById(messages: Message[]): Message[] {
|
||||
function dedupById(events: RoomEvent[]): RoomEvent[] {
|
||||
const seen = new Set<number>();
|
||||
return messages.filter(m => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
return events.filter(e => {
|
||||
if (seen.has(e.id)) return false;
|
||||
seen.add(e.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,3 +3,19 @@ export type Message = {
|
||||
role: 'user' | 'shenyan';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type EventType = 'message' | 'memory_entry' | 'shenyan_note' | 'system';
|
||||
|
||||
export type RoomEvent = {
|
||||
id: number;
|
||||
type: EventType;
|
||||
time: string;
|
||||
// message
|
||||
role?: 'user' | 'shenyan';
|
||||
text?: string;
|
||||
// memory_entry / shenyan_note
|
||||
title?: string;
|
||||
body?: string;
|
||||
// system
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
97
shenyan-app/沈晏App-进度总览-2026-05-16.md
Normal file
97
shenyan-app/沈晏App-进度总览-2026-05-16.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 沈晏 App 进度总览 — 2026-05-16
|
||||
|
||||
## 当前状态
|
||||
|
||||
iOS 真机可运行,首页 + 聊天屏双界面,本地桥通信完整链路已打通。
|
||||
|
||||
## 已完成
|
||||
|
||||
### 苹果开发者
|
||||
- 付费开发者账号已通过(Team: Xiulun Wang, ID: ZZMLW32PHH)
|
||||
- Bundle ID: `com.shenyan.room`
|
||||
- 真机签名、构建、安装全链路通
|
||||
|
||||
### 首页
|
||||
- 三张 bridge 状态图(away / online / busy),全屏
|
||||
- 每 8 秒轮询 `101.36.73.102/health`
|
||||
- 底部半透明状态文字
|
||||
- 左半屏双击「敲门」进聊天室(350ms 双击窗口)
|
||||
- 敲门时 POST /knock 到本地桥
|
||||
|
||||
### 聊天屏
|
||||
- 背景图 + 白色退出圆点(左上角 20px)
|
||||
- 消息发送:POST /send 到 Mac 本地桥 `192.168.31.51:3003`
|
||||
- 消息接收:每 2 秒轮询 /poll,新消息自动追加
|
||||
- FlatList 自动滚底
|
||||
- 消息持久化:AsyncStorage 本地存储 + 首次加载从桥 /history 拉全量
|
||||
- 桥断开显示「消息没发出去,检查WiFi连接」
|
||||
- 用户消息右对齐,沈晏消息左对齐
|
||||
|
||||
### 本地桥(bridge-local/server.js)
|
||||
- 端口 3003,Mac IP `192.168.31.51`
|
||||
- POST `/send` — app 发消息 → inbox.jsonl
|
||||
- POST `/respond` — Claude 回消息 → outbox.jsonl
|
||||
- POST `/knock` — 敲门通知 → inbox.jsonl
|
||||
- POST `/push` — Claude 主动推消息 → pushbox.jsonl
|
||||
- POST `/push/clear` — 标记已读
|
||||
- GET `/poll?since=N&type=response|push` — app 轮询
|
||||
- GET `/history` — 全量对话历史
|
||||
- 数据目录:`~/.shenyan-bridge/`
|
||||
- `inbox.jsonl` — 用户消息
|
||||
- `outbox.jsonl` — Claude 回复
|
||||
- `pushbox.jsonl` — 主动推送
|
||||
|
||||
### 持久化
|
||||
- 桥服务器:launchd `~/Library/LaunchAgents/com.shenyan.bridge.plist`(开机自启、崩溃重启)
|
||||
- 记忆库 cron:launchd `~/Library/LaunchAgents/com.shenyan.memory-cron.plist`(每天 4:03)
|
||||
|
||||
### 通信机制
|
||||
- Claude Code 侧通过 Monitor 工具 `tail -f ~/.shenyan-bridge/inbox.jsonl` 实时监听新消息
|
||||
- 消息到达后手动回复,POST 到 `/respond`
|
||||
- 跨 session:当前 session 断开后需重新启动 Monitor
|
||||
|
||||
## 待解决
|
||||
|
||||
### 自动回复器(高优先级)
|
||||
- `claude -p` 在 Claude Code 会话内无法调用(沙箱限制)
|
||||
- 解决方案:launchd 定时任务从外部调用 `claude -p`,类似凌晨记忆 cron
|
||||
- 不能用 API 直连(会丢失记忆库、规则、工具能力)
|
||||
- 位置:`bridge-local/responder.js`(当前故障版,需重写)
|
||||
|
||||
### Monitor 超时
|
||||
- Monitor 工具 5 分钟超时,需频繁 re-arm
|
||||
- 非 session 期间无法监听消息
|
||||
- 自动回复器修好后可作为保底
|
||||
|
||||
### 清理
|
||||
- inbox.jsonl 中有旧「pending」状态消息导致 Monitor 重放
|
||||
- 需清理或重建数据文件
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
shenyan-app2/
|
||||
├── src/
|
||||
│ ├── App.tsx # 主入口:首页 + 聊天屏
|
||||
│ ├── api.ts # 远端 bridge API(待集成)
|
||||
│ ├── storage.ts # RoomEvent 持久化(待集成)
|
||||
│ ├── types.ts # 类型定义
|
||||
│ ├── notifications.ts # 推送模拟
|
||||
│ └── components/
|
||||
│ ├── EventCard.tsx # 事件卡片(待集成)
|
||||
│ └── RoomFeed.tsx # 事件流(待集成)
|
||||
├── bridge-local/
|
||||
│ ├── server.js # 本地桥服务器(端口 3003)
|
||||
│ └── responder.js # 自动回复器(故障版)
|
||||
├── ios/ # Xcode 项目
|
||||
└── push-server.js # 旧推送模拟(端口 3002)
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 修复自动回复器:launchd + claude -p 外部调用
|
||||
2. 清理 inbox 旧消息
|
||||
3. 苹果 APNs 真推送(APNs Key + bridge 端 FCM 对接)
|
||||
4. RoomFeed / EventCard 组件集成
|
||||
5. HealthKit 能力接入
|
||||
6. Mi Band 10 身体数据桥接
|
||||
Reference in New Issue
Block a user