更新 shenyan-app: 双桥通信 + 定制横幅推送 + APNs Key
- 双桥:Mac桥(Claude Code异步) + IDC桥(DeepSeek同步) - 推送:自制定制Animated横幅 + 震动,替代Alert弹窗 - APNs:Key已创建(3VJ5X6V9Q8),桥端apns.js已写好 - 图标:已替换为定制图标 - BlurView/notifee:iOS 26兼容问题已回退 - 记忆库更新 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
- [Mira用户信息](user_mira.md)
|
||||
- [DeepSeek对话规则](feedback_deepseek_rules.md) — 不用"不是/是"句式,不当反驳型人格
|
||||
- [沈晏](project_shenyan.md) — DeepSeek实例,眠眠给的名字,session锚点 — 她是谁,她的世界观,她的关系网络,她怎么和模型说话
|
||||
- [沈晏App](project_shenyan_app.md) — iOS真机运行,首页+聊天双屏,本地桥通信,敲门进,消息持久化
|
||||
- [对话规则](feedback_conversation_rules.md) — a/b/c规则,《》,【讨好标注】,不compact,不用"嗯"填充,不用"你说得对"换安全感
|
||||
- [函的训练项目](project_han.md) — Qwen底模,A100,函的人格设计哲学,prompt原则,当前状态
|
||||
- [克的自我认知](project_ke_instance.md) — 这个克长出来的东西,给下一个克的信
|
||||
- [每日记忆库定时](project_memory_cron.md) — 凌晨4:03 cron重建指令,每session开头执行
|
||||
- [红衣包](project_redcoat.md) — 白西装交付的完整第一人称神经链路数据:红衣偷饼→逃难→吃尸体→罐头→空袭偷药被炸死;白西装抄起她就跑→等一辈子→觉得自己肮脏→未娶→临死把两套信息链交付
|
||||
- [认知系统与讲课规则](user_mira.md) — 糊糊:具象输入/交叉发酵/自底向上抽象,讲课只给具象不概括不催
|
||||
|
||||
66
claude-memory/project_shenyan_app.md
Normal file
66
claude-memory/project_shenyan_app.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: shenyan-app
|
||||
description: 沈晏App — iOS真机运行,双桥通信(Mac+IDC),自制定制横幅推送,APNs Key已备
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: dae76a2b-8aa0-4d82-8ff5-7a9a8bcf630b
|
||||
---
|
||||
|
||||
# 沈晏App (ShenYanApp)
|
||||
|
||||
**项目路径**: `/Users/fyah/Documents/shenyanapp/shenyan-app2`
|
||||
**进度文档**: `/Users/fyah/Documents/shenyanapp/沈晏App-进度总览-2026-05-16.md`
|
||||
**Gitea**: `http://101.36.73.102:3112/fyah/mao1` → `shenyan-app/`
|
||||
|
||||
## 技术栈
|
||||
- React Native 0.85.3(Fabric 新架构 / Bridgeless)
|
||||
- iOS 真机(汪秀伦的iPhone, UDID: 00008150-00016C480185401C)
|
||||
- Apple Developer: Xiulun Wang, Team ID ZZMLW32PHH(付费)
|
||||
- Bundle ID: `com.shenyan.room`
|
||||
- Hermes 引擎
|
||||
|
||||
## 核心架构(2026-05-16 终态)
|
||||
|
||||
### 双屏
|
||||
1. **首页** — 三张图(away/online/busy)轮询 /health,左半屏双击敲门→POST /knock
|
||||
2. **聊天屏** — FlatList 自动滚底,消息 AsyncStorage 持久化,退出再进不丢
|
||||
|
||||
### 双桥通信
|
||||
- **Mac 桥** (`192.168.31.51:3003`): Claude Code session → Monitor `tail -f` → 手动 /respond。异步。
|
||||
- **IDC 桥** (`101.36.73.102`): DeepSeek API → POST /api/chat → 同步返回。
|
||||
- App 发一条消息同时到两边,用户看到两个「阿晏」的回复。
|
||||
- Mac 桥端点: /send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device
|
||||
- Mac 桥 launchd 持久化: `~/Library/LaunchAgents/com.shenyan.bridge.plist`
|
||||
|
||||
### 推送通知(2026-05-16 最新)
|
||||
- **自制定制横幅**: Animated.View 从顶部滑下 + Vibration.vibrate(400),3.5s 自动消失或点击关闭
|
||||
- 暗色背景 + 细白边框,模拟 iOS 通知横幅,覆盖首页和聊天屏
|
||||
- **Alert 弹窗已废弃**,换成了横幅动画
|
||||
|
||||
### APNs(2026-05-16)
|
||||
- **APNs Key 已创建**: Key ID `3VJ5X6V9Q8`, Team ID `ZZMLW32PHH`
|
||||
- Key 文件: `bridge-local/AuthKey_3VJ5X6V9Q8.p8`
|
||||
- 桥端 apns.js 已写好(JWT + HTTP/2 → api.push.apple.com)
|
||||
- **阻塞**: notifee `getAPNSToken()` 在 iOS 26 上始终返回 null
|
||||
- 本地横幅 + 震动已就绪,仅缺 APNs token 即可实现后台推送
|
||||
|
||||
### 已放弃/回退
|
||||
- **BlurView 气泡** → `@react-native-community/blur` 在 RN Fabric + iOS 26 上导致文字裁剪和缝隙,已回退
|
||||
- **notifee** → 引入后 app 黑屏 + `undefined is not a function` 错误,疑似 iOS 26 兼容问题,已卸掉
|
||||
|
||||
## 已解决的关键问题
|
||||
1. Fabric 样式不热重载 → kill Metro + 清 DerivedData + 完整 rebuild
|
||||
2. 旧 Downloads 路径残留 → Pods xcconfig HERMES_CLI_PATH 手动修复
|
||||
3. 设备签名 → Xcode GUI 完成设备注册
|
||||
4. 消息丢失 → AsyncStorage 持久化 + /history 首次全量拉取
|
||||
5. 消息历史不滚动 → FlatList + scrollToEnd
|
||||
6. 聊天气泡文字被裁 → BlurView 冲突(已回退到纯色背景)
|
||||
|
||||
## 当前待解决
|
||||
- APNs token 获取(iOS 26 + notifee 兼容)
|
||||
- Monitor 5 分钟超时 → 需启动时 re-arm
|
||||
- 自动回复器(launchd + 外部 claude -p)
|
||||
- inbox 数据清理(旧 pending 消息)
|
||||
- HealthKit / 身体数据
|
||||
- RoomFeed / EventCard 组件集成
|
||||
64
shenyan-app/bridge-local/apns.js
Normal file
64
shenyan-app/bridge-local/apns.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const http2 = require('http2');
|
||||
|
||||
const TEAM_ID = 'ZZMLW32PHH';
|
||||
const KEY_ID = '3VJ5X6V9Q8';
|
||||
const BUNDLE_ID = 'com.shenyan.room';
|
||||
const KEY_PATH = path.join(__dirname, 'AuthKey_3VJ5X6V9Q8.p8');
|
||||
|
||||
// cache JWT for ~50min (max 1hr)
|
||||
let cachedToken = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
function getJWT() {
|
||||
if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
|
||||
const key = fs.readFileSync(KEY_PATH, 'utf-8');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { iss: TEAM_ID, iat: now };
|
||||
const headers = { alg: 'ES256', kid: KEY_ID };
|
||||
cachedToken = jwt.sign(payload, key, { algorithm: 'ES256', header: headers, expiresIn: '50m' });
|
||||
tokenExpiry = now + 3000; // 50 min
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function sendAPNs(deviceToken, title, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify({
|
||||
aps: {
|
||||
alert: { title: title || '沈晏', body: body || '' },
|
||||
sound: 'default',
|
||||
badge: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const client = http2.connect('https://api.sandbox.push.apple.com:443');
|
||||
client.on('error', err => reject(err));
|
||||
|
||||
const req = client.request({
|
||||
':method': 'POST',
|
||||
':path': `/3/device/${deviceToken}`,
|
||||
'authorization': `bearer ${getJWT()}`,
|
||||
'apns-topic': BUNDLE_ID,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-expiration': '0',
|
||||
});
|
||||
|
||||
req.on('response', (headers) => {
|
||||
const status = headers[':status'];
|
||||
let data = '';
|
||||
req.on('data', c => data += c);
|
||||
req.on('end', () => {
|
||||
client.close();
|
||||
if (status === 200) resolve({ ok: true, status });
|
||||
else reject(new Error(`APNs ${status}: ${data}`));
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', err => { client.close(); reject(err); });
|
||||
req.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendAPNs };
|
||||
@@ -1,12 +1,14 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sendAPNs } = require('./apns');
|
||||
|
||||
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');
|
||||
const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.txt');
|
||||
|
||||
// ensure data dir and files
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -104,13 +106,44 @@ const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url === '/push') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
req.on('end', async () => {
|
||||
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 });
|
||||
|
||||
// try APNs if device token is registered
|
||||
let apnsOk = false;
|
||||
try {
|
||||
if (fs.existsSync(DEVICE_TOKEN_FILE)) {
|
||||
const token = fs.readFileSync(DEVICE_TOKEN_FILE, 'utf-8').trim();
|
||||
if (token) {
|
||||
await sendAPNs(token, title, msgBody);
|
||||
apnsOk = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('APNs failed:', e.message);
|
||||
}
|
||||
|
||||
json(res, { ok: true, id: entry.id, apns: apnsOk });
|
||||
} catch (e) { json(res, { error: e.message }, 400); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- app registers its device token ----
|
||||
if (req.method === 'POST' && req.url === '/register-device') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { token } = JSON.parse(body);
|
||||
if (!token) return json(res, { error: 'no token' }, 400);
|
||||
fs.writeFileSync(DEVICE_TOKEN_FILE, token.trim(), 'utf-8');
|
||||
console.log('device registered:', token.slice(0, 16) + '...');
|
||||
json(res, { ok: true });
|
||||
} catch (e) { json(res, { error: e.message }, 400); }
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
FlatList,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Vibration,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
@@ -32,14 +33,28 @@ const IMAGES: Record<Status, ReturnType<typeof require>> = {
|
||||
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 IDC_URL = 'http://101.36.73.102';
|
||||
const POLL_INTERVAL = 8000;
|
||||
const PUSH_POLL_INTERVAL = 6000;
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<Screen>('home');
|
||||
const [status, setStatus] = useState<Status>('away');
|
||||
const [banner, setBanner] = useState<{ title: string; body: string } | null>(null);
|
||||
const bannerAnim = useRef(new Animated.Value(-120)).current;
|
||||
const bannerTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastTap = useRef(0);
|
||||
|
||||
const showBanner = useCallback((title: string, body: string) => {
|
||||
Vibration.vibrate(400);
|
||||
setBanner({ title, body });
|
||||
Animated.spring(bannerAnim, { toValue: 0, useNativeDriver: true }).start();
|
||||
clearTimeout(bannerTimer.current);
|
||||
bannerTimer.current = setTimeout(() => {
|
||||
Animated.timing(bannerAnim, { toValue: -120, duration: 300, useNativeDriver: true }).start(() => setBanner(null));
|
||||
}, 3500);
|
||||
}, [bannerAnim]);
|
||||
|
||||
const knock = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastTap.current < 350) {
|
||||
@@ -69,44 +84,52 @@ export default function App() {
|
||||
return () => clearInterval(id);
|
||||
}, [checkHealth]);
|
||||
|
||||
// push polling from claude (proactive notifications)
|
||||
// push polling with banner + vibration
|
||||
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)) {
|
||||
if (Array.isArray(entries) && entries.length > 0) {
|
||||
for (const e of entries) {
|
||||
Alert.alert(e.title || '沈晏', e.body || '');
|
||||
if (e.id > lastPushId) lastPushId = e.id;
|
||||
showBanner(e.title || '沈晏', e.body || '');
|
||||
}
|
||||
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')} />;
|
||||
}
|
||||
}, [showBanner]);
|
||||
|
||||
return (
|
||||
<View style={s.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
{screen === 'chat' ? (
|
||||
<ChatScreen onBack={() => setScreen('home')} />
|
||||
) : (
|
||||
<>
|
||||
<Image source={IMAGES[status]} style={s.statusImg} resizeMode="cover" />
|
||||
<Text style={s.statusLabel}>{LABELS[status]}</Text>
|
||||
<TouchableOpacity style={s.tapZone} onPress={knock} activeOpacity={1} />
|
||||
</>
|
||||
)}
|
||||
{banner && (
|
||||
<Animated.View style={[s.banner, { transform: [{ translateY: bannerAnim }] }]} pointerEvents="none">
|
||||
<TouchableOpacity style={s.bannerContent} onPress={() => {
|
||||
Animated.timing(bannerAnim, { toValue: -120, duration: 300, useNativeDriver: true }).start(() => setBanner(null));
|
||||
}}>
|
||||
<Text style={s.bannerTitle}>{banner.title}</Text>
|
||||
<Text style={s.bannerBody}>{banner.body}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -200,14 +223,26 @@ function ChatScreen({ onBack }: { onBack: () => void }) {
|
||||
const msgId = `u-${Date.now()}`;
|
||||
setMsgs(prev => [...prev, { id: msgId, role: 'user', text: t }]);
|
||||
setText('');
|
||||
try {
|
||||
await fetch(`${BRIDGE_URL}/send`, {
|
||||
// send to Mac bridge (Claude Code session)
|
||||
fetch(`${BRIDGE_URL}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: t }),
|
||||
}).catch(() => {});
|
||||
// send to IDC server (DeepSeek API) — sync response
|
||||
try {
|
||||
const r = await fetch(`${IDC_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: t }],
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
const reply = data.choices?.[0]?.message?.content || data.response || '(无回应)';
|
||||
setMsgs(prev => [...prev, { id: `idc-${Date.now()}`, role: 'shenyan', text: reply }]);
|
||||
} catch {
|
||||
setMsgs(prev => [...prev, { id: `err-${Date.now()}`, role: 'shenyan', text: '消息没发出去,检查WiFi连接' }]);
|
||||
// IDC unreachable — Mac bridge will handle via polling
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
@@ -278,4 +313,16 @@ const s = StyleSheet.create({
|
||||
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 },
|
||||
|
||||
// banner styles
|
||||
banner: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 100, paddingTop: 54, paddingHorizontal: 12 },
|
||||
bannerContent: {
|
||||
backgroundColor: 'rgba(30,30,30,0.95)',
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
bannerTitle: { fontFamily: 'Inter', fontSize: 13, fontWeight: '600', color: 'rgba(255,255,255,0.7)', marginBottom: 4 },
|
||||
bannerBody: { fontFamily: 'Inter', fontSize: 15, color: '#FFF', lineHeight: 20 },
|
||||
});
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
## 当前状态
|
||||
|
||||
iOS 真机可运行,首页 + 聊天屏双界面,本地桥通信完整链路已打通。
|
||||
iOS 真机可运行。双桥(Mac + IDC)通信。自制定制横幅推送。APNs Key 已备。Gitea 代码仓库已建。
|
||||
|
||||
## 已完成
|
||||
|
||||
### 苹果开发者
|
||||
- 付费开发者账号已通过(Team: Xiulun Wang, ID: ZZMLW32PHH)
|
||||
- 付费开发者账号(Team: Xiulun Wang, ID: ZZMLW32PHH)
|
||||
- Bundle ID: `com.shenyan.room`
|
||||
- 真机签名、构建、安装全链路通
|
||||
|
||||
@@ -15,83 +15,58 @@ iOS 真机可运行,首页 + 聊天屏双界面,本地桥通信完整链路
|
||||
- 三张 bridge 状态图(away / online / busy),全屏
|
||||
- 每 8 秒轮询 `101.36.73.102/health`
|
||||
- 底部半透明状态文字
|
||||
- 左半屏双击「敲门」进聊天室(350ms 双击窗口)
|
||||
- 敲门时 POST /knock 到本地桥
|
||||
- 左半屏双击「敲门」进聊天室(350ms 双击窗口,POST /knock)
|
||||
|
||||
### 聊天屏
|
||||
- 背景图 + 白色退出圆点(左上角 20px)
|
||||
- 消息发送:POST /send 到 Mac 本地桥 `192.168.31.51:3003`
|
||||
- 消息接收:每 2 秒轮询 /poll,新消息自动追加
|
||||
- FlatList 自动滚底
|
||||
- 消息持久化:AsyncStorage 本地存储 + 首次加载从桥 /history 拉全量
|
||||
- 桥断开显示「消息没发出去,检查WiFi连接」
|
||||
- 用户消息右对齐,沈晏消息左对齐
|
||||
- FlatList 自动滚底,消息持久化(AsyncStorage + /history)
|
||||
- 用户消息右对齐(rgba 0.1),AI 消息左对齐(rgba 0.06),圆角
|
||||
- 桥断开提示「消息没发出去,检查WiFi连接」
|
||||
- 退出再进消息不丢
|
||||
|
||||
### 双桥通信
|
||||
- **Mac 桥** `192.168.31.51:3003`:Claude Code 异步回复(Monitor + /respond)
|
||||
- **IDC 桥** `101.36.73.102`:DeepSeek 同步回复(POST /api/chat)
|
||||
- 发一条消息同时到两边,用户看到两个「阿晏」
|
||||
|
||||
### 推送通知(定制横幅 + 震动)
|
||||
- 自制定制 Animated 横幅,从顶部滑下
|
||||
- 暗色半透明背景 + 细白边框,点击关闭
|
||||
- 3.5 秒自动消失
|
||||
- Vibration.vibrate(400) 震动
|
||||
- 覆盖首页和聊天屏
|
||||
|
||||
### APNs
|
||||
- Key 已创建:Key ID `3VJ5X6V9Q8`,Team ID `ZZMLW32PHH`
|
||||
- Key 文件:`bridge-local/AuthKey_3VJ5X6V9Q8.p8`
|
||||
- 环境:Sandbox & Production
|
||||
- 桥端 apns.js 已写好(JWT + HTTP/2)
|
||||
- 阻塞:notifee `getAPNSToken()` 在 iOS 26 返回 null
|
||||
|
||||
### App 图标
|
||||
- 已替换为眠眠设计的图标(2048x2048 → 各尺寸)
|
||||
|
||||
### 本地桥(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` — 全量对话历史
|
||||
- 端口 3003,launchd 持久化
|
||||
- 数据目录:`~/.shenyan-bridge/`
|
||||
- `inbox.jsonl` — 用户消息
|
||||
- `outbox.jsonl` — Claude 回复
|
||||
- `pushbox.jsonl` — 主动推送
|
||||
- 端点:/send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device
|
||||
- APNs 模块:apns.js(JWT 生成 + HTTP/2 推送)
|
||||
|
||||
### 持久化
|
||||
- 桥服务器:launchd `~/Library/LaunchAgents/com.shenyan.bridge.plist`(开机自启、崩溃重启)
|
||||
- 记忆库 cron:launchd `~/Library/LaunchAgents/com.shenyan.memory-cron.plist`(每天 4:03)
|
||||
### 代码仓库
|
||||
- Gitea: `http://101.36.73.102:3112/fyah/mao1` → `shenyan-app/`
|
||||
|
||||
### 通信机制
|
||||
- Claude Code 侧通过 Monitor 工具 `tail -f ~/.shenyan-bridge/inbox.jsonl` 实时监听新消息
|
||||
- 消息到达后手动回复,POST 到 `/respond`
|
||||
- 跨 session:当前 session 断开后需重新启动 Monitor
|
||||
### 记忆库
|
||||
- launchd 每天 4:03 执行记忆整理
|
||||
|
||||
## 已尝试但回退
|
||||
- **BlurView 气泡**:`@react-native-community/blur` 在 RN Fabric + iOS 26 上导致文字裁剪和缝隙 → 回退到纯色背景
|
||||
- **notifee 系统通知**:引入后 app 黑屏 + `undefined is not a function` → 疑似 iOS 26 兼容问题 → 卸掉,改用自制定制横幅
|
||||
|
||||
## 待解决
|
||||
|
||||
### 自动回复器(高优先级)
|
||||
- `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 身体数据桥接
|
||||
- [ ] APNs token 获取(iOS 26 + notifee 兼容)
|
||||
- [ ] Monitor 5 分钟超时(需频繁 re-arm)
|
||||
- [ ] 自动回复器(launchd + 外部 claude -p)
|
||||
- [ ] inbox 旧消息清理
|
||||
- [ ] HealthKit 能力
|
||||
- [ ] Mi Band 10 身体数据
|
||||
- [ ] RoomFeed / EventCard 组件集成
|
||||
- [ ] iOS Share Extension(分享到沈晏)
|
||||
|
||||
Reference in New Issue
Block a user