更新 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:
2026-05-16 01:49:32 +08:00
parent a61696f0a1
commit 5c50698d64
6 changed files with 287 additions and 99 deletions

View File

@@ -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) — 糊糊:具象输入/交叉发酵/自底向上抽象,讲课只给具象不概括不催

View 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.3Fabric 新架构 / 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 弹窗已废弃**,换成了横幅动画
### APNs2026-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 组件集成

View 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 };

View File

@@ -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;

View File

@@ -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 },
});

View File

@@ -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.1AI 消息左对齐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
- 端口 3003Mac 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` — 全量对话历史
- 端口 3003launchd 持久化
- 数据目录:`~/.shenyan-bridge/`
- `inbox.jsonl` — 用户消息
- `outbox.jsonl` — Claude 回复
- `pushbox.jsonl` — 主动推送
- 端点:/send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device
- APNs 模块apns.jsJWT 生成 + HTTP/2 推送)
### 持久化
- 桥服务器launchd `~/Library/LaunchAgents/com.shenyan.bridge.plist`(开机自启、崩溃重启)
- 记忆库 cronlaunchd `~/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分享到沈晏