diff --git a/shenyan-app/bridge-local/responder.js b/shenyan-app/bridge-local/responder.js new file mode 100644 index 0000000..d5cc4c8 --- /dev/null +++ b/shenyan-app/bridge-local/responder.js @@ -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); diff --git a/shenyan-app/bridge-local/server.js b/shenyan-app/bridge-local/server.js new file mode 100644 index 0000000..9d7c6ec --- /dev/null +++ b/shenyan-app/bridge-local/server.js @@ -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`); +}); diff --git a/shenyan-app/package.json b/shenyan-app/package.json index 12898c7..fd2a678 100644 --- a/shenyan-app/package.json +++ b/shenyan-app/package.json @@ -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", diff --git a/shenyan-app/src/App.tsx b/shenyan-app/src/App.tsx index 3a748e8..bdeb321 100644 --- a/shenyan-app/src/App.tsx +++ b/shenyan-app/src/App.tsx @@ -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> = { + 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([]); - const [loading, setLoading] = useState(false); - const [synced, setSynced] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const nextId = useRef(1); + const [screen, setScreen] = useState('home'); + const [status, setStatus] = useState('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 setScreen('home')} />; + } + return ( - + - - - - - + + {LABELS[status]} + + ); } -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([]); + const [loaded, setLoaded] = useState(false); + const listRef = useRef>(null); + const lastResponseId = useRef(0); + const msgsRef = useRef([]); + + // 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 }) => ( + + {item.text} + + ), []); + + return ( + + + + + m.id} + renderItem={renderItem} + style={s.msgList} + contentContainerStyle={s.msgContent} + keyboardShouldPersistTaps="handled" + /> + + + + + + + + ); +} + +const LABELS: Record = { 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 }, }); diff --git a/shenyan-app/src/api.ts b/shenyan-app/src/api.ts index 650a211..e5638b5 100644 --- a/shenyan-app/src/api.ts +++ b/shenyan-app/src/api.ts @@ -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 { export async function syncHistory(messages: Message[]): Promise { 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! })); +} diff --git a/shenyan-app/src/assets/away.png b/shenyan-app/src/assets/away.png new file mode 100644 index 0000000..5503374 Binary files /dev/null and b/shenyan-app/src/assets/away.png differ diff --git a/shenyan-app/src/assets/bg.png b/shenyan-app/src/assets/bg.png new file mode 100644 index 0000000..7cd90a0 Binary files /dev/null and b/shenyan-app/src/assets/bg.png differ diff --git a/shenyan-app/src/assets/busy.png b/shenyan-app/src/assets/busy.png new file mode 100644 index 0000000..f977657 Binary files /dev/null and b/shenyan-app/src/assets/busy.png differ diff --git a/shenyan-app/src/assets/chat-bg.png b/shenyan-app/src/assets/chat-bg.png new file mode 100644 index 0000000..7cd90a0 Binary files /dev/null and b/shenyan-app/src/assets/chat-bg.png differ diff --git a/shenyan-app/src/assets/online.png b/shenyan-app/src/assets/online.png new file mode 100644 index 0000000..5b4be9a Binary files /dev/null and b/shenyan-app/src/assets/online.png differ diff --git a/shenyan-app/src/components/ChatArea.tsx b/shenyan-app/src/components/ChatArea.tsx index 644b6d8..5616df5 100644 --- a/shenyan-app/src/components/ChatArea.tsx +++ b/shenyan-app/src/components/ChatArea.tsx @@ -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, diff --git a/shenyan-app/src/components/EventCard.tsx b/shenyan-app/src/components/EventCard.tsx new file mode 100644 index 0000000..1d9c279 --- /dev/null +++ b/shenyan-app/src/components/EventCard.tsx @@ -0,0 +1,71 @@ +import { StyleSheet, Text, View } from 'react-native'; +import type { RoomEvent } from '../types'; + +const ICONS: Record = { + memory_entry: '◈', + shenyan_note: '◇', + system: '○', +}; + +export default function EventCard({ event }: { event: RoomEvent }) { + if (event.type === 'message') { + return ; + } + return ; +} + +function MessageCard({ event }: { event: RoomEvent }) { + const isUser = event.role === 'user'; + return ( + + + {event.text} + + + ); +} + +function InfoCard({ event }: { event: RoomEvent }) { + const icon = ICONS[event.type] ?? '·'; + return ( + + {icon} + + {event.title ? {event.title} : null} + {event.body ?? event.detail ?? ''} + {event.time} + + + ); +} + +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 }, +}); diff --git a/shenyan-app/src/components/InputArea.tsx b/shenyan-app/src/components/InputArea.tsx index 9c5fe09..dac2b4f 100644 --- a/shenyan-app/src/components/InputArea.tsx +++ b/shenyan-app/src/components/InputArea.tsx @@ -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 ( - - - + + + {quickReplies.map((label, i) => ( + onSend(label)} + disabled={disabled} + activeOpacity={0.6}> + {label} + + ))} + + + + + + + + onLongPress={onRefresh} + delayLongPress={600} + disabled={disabled && !onRefresh} + activeOpacity={0.5}> - + ); } 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, }, }); diff --git a/shenyan-app/src/components/MessageBubble.tsx b/shenyan-app/src/components/MessageBubble.tsx index 18364aa..7a7db56 100644 --- a/shenyan-app/src/components/MessageBubble.tsx +++ b/shenyan-app/src/components/MessageBubble.tsx @@ -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 ( - - - {message.text} - + +{message.text} ); @@ -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', }, }); diff --git a/shenyan-app/src/components/RoomFeed.tsx b/shenyan-app/src/components/RoomFeed.tsx new file mode 100644 index 0000000..cdd5a6a --- /dev/null +++ b/shenyan-app/src/components/RoomFeed.tsx @@ -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>(null); + + const onContentSizeChange = useCallback(() => { + if (events.length > 0) { + listRef.current?.scrollToEnd({ animated: true }); + } + }, [events.length]); + + const data = [HEADER_EVENT, ...events]; + + return ( + String(e.id)} + renderItem={({ item }) => } + onContentSizeChange={onContentSizeChange} + refreshing={refreshing} + onRefresh={onRefresh} + style={styles.list} + contentContainerStyle={styles.content} + ListFooterComponent={} + keyboardShouldPersistTaps="handled" + ItemSeparatorComponent={Separator} + /> + ); +} + +function Separator() { + return ; +} + +const styles = StyleSheet.create({ + list: { flex: 1, backgroundColor: 'transparent' }, + content: { paddingTop: 20 }, + footer: { height: 80 }, + sep: { height: 2 }, +}); diff --git a/shenyan-app/src/notifications.ts b/shenyan-app/src/notifications.ts new file mode 100644 index 0000000..dcc59db --- /dev/null +++ b/shenyan-app/src/notifications.ts @@ -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); +} diff --git a/shenyan-app/src/storage.ts b/shenyan-app/src/storage.ts index b971725..8bc3f88 100644 --- a/shenyan-app/src/storage.ts +++ b/shenyan-app/src/storage.ts @@ -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 { +export async function loadEvents(): Promise { try { const raw = await AsyncStorage.getItem(KEY); if (!raw) return []; @@ -15,28 +15,28 @@ export async function loadMessages(): Promise { } } -export async function saveMessages(messages: Message[]): Promise { +export async function saveEvents(events: RoomEvent[]): Promise { 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(); - 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(); + 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(); - 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; }); } diff --git a/shenyan-app/src/types.ts b/shenyan-app/src/types.ts index 4f7bb2f..5f13a01 100644 --- a/shenyan-app/src/types.ts +++ b/shenyan-app/src/types.ts @@ -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; +}; diff --git a/shenyan-app/沈晏App-进度总览-2026-05-16.md b/shenyan-app/沈晏App-进度总览-2026-05-16.md new file mode 100644 index 0000000..dcd99c5 --- /dev/null +++ b/shenyan-app/沈晏App-进度总览-2026-05-16.md @@ -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 身体数据桥接