更新 shenyan-app: HealthKit+WeatherKit+冰箱贴+pare+三区布局

- HealthKitBridge.m: 自定义原生模块,心率/步数/睡眠采集
- WeatherKitBridge.swift/m: 天气模块,当前+每日+每小时预报
- pare.py: 身体数据陡度监控,阈值告警→inbox+冰箱贴
- cyberboss: 5-20分钟随机唤醒,pare→decide→push
- 冰箱贴: 纸质感卡片,黑字多级透明度,独立输入框
- 首页三区无视觉布局: 左门/中记录/右冰箱
- 端口3003→3004(VS Code占用)
- 聊天屏锚定底部滚动

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 19:14:32 +08:00
parent d07d50dde5
commit 2a053ab88e
12 changed files with 791 additions and 184 deletions

View File

@@ -3,16 +3,17 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { sendAPNs } = require('./apns'); const { sendAPNs } = require('./apns');
const PORT = 3003; const PORT = 3004;
const DATA_DIR = path.join(require('os').homedir(), '.shenyan-bridge'); const DATA_DIR = path.join(require('os').homedir(), '.shenyan-bridge');
const INBOX = path.join(DATA_DIR, 'inbox.jsonl'); const INBOX = path.join(DATA_DIR, 'inbox.jsonl');
const OUTBOX = path.join(DATA_DIR, 'outbox.jsonl'); const OUTBOX = path.join(DATA_DIR, 'outbox.jsonl');
const PUSHBOX = path.join(DATA_DIR, 'pushbox.jsonl'); const PUSHBOX = path.join(DATA_DIR, 'pushbox.jsonl');
const NOTES = path.join(DATA_DIR, 'notes.jsonl');
const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.txt'); const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.txt');
// ensure data dir and files // ensure data dir and files
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
[INBOX, OUTBOX, PUSHBOX].forEach(f => { if (!fs.existsSync(f)) fs.writeFileSync(f, ''); }); [INBOX, OUTBOX, PUSHBOX, NOTES].forEach(f => { if (!fs.existsSync(f)) fs.writeFileSync(f, ''); });
function json(res, data, code = 200) { function json(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -190,6 +191,48 @@ const server = http.createServer((req, res) => {
return; return;
} }
// ---- fridge notes ----
if (req.method === 'GET' && req.url === '/notes') {
json(res, readJSONL(NOTES));
return;
}
if (req.method === 'POST' && req.url === '/notes/add') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const { title, body: noteBody } = JSON.parse(body);
if (!title && !noteBody) return json(res, { error: 'empty' }, 400);
const entry = { id: getNextId(NOTES), title: title || '', body: noteBody || '', time: Date.now(), replies: [] };
appendJSONL(NOTES, entry);
json(res, { ok: true, id: entry.id });
} catch (e) { json(res, { error: e.message }, 400); }
});
return;
}
if (req.method === 'POST' && req.url === '/notes/reply') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const { noteId, text } = JSON.parse(body);
if (!text) return json(res, { error: 'empty' }, 400);
const notes = readJSONL(NOTES);
const idx = notes.findIndex(n => n.id === noteId);
if (idx < 0) return json(res, { error: 'not found' }, 404);
notes[idx].replies = notes[idx].replies || [];
notes[idx].replies.push({ text, time: Date.now() });
writeJSONL(NOTES, notes);
// also notify inbox so 沈晏 sees the reply
appendJSONL(INBOX, { id: getNextId(INBOX), type: 'fridge_reply', text: `[冰箱贴回复] ${notes[idx].title}: ${text}`, time: Date.now(), status: 'pending' });
json(res, { ok: true });
} catch (e) { json(res, { error: e.message }, 400); }
});
return;
}
res.writeHead(404); res.end('not found'); res.writeHead(404); res.end('not found');
}); });

View File

@@ -1,118 +1,24 @@
#!/bin/bash #!/bin/bash
# cyberboss daemon — 沈晏主动联系守护进程 # cyberboss daemon — 沈晏主动联系守护进程
# 随机 3-60 分钟唤醒一次,判断是否主动推消息到眠眠手机 # 随机 3-60 分钟唤醒一次,调用 decide.py 判断是否推消息
CYBERBOSS_DIR="/Users/fyah/.cyberboss" CYBERBOSS_DIR="/Users/fyah/.cyberboss"
INSTRUCTIONS="$CYBERBOSS_DIR/weixin-instructions.md"
STATE_FILE="$CYBERBOSS_DIR/state.json"
LOG_DIR="$CYBERBOSS_DIR/logs" LOG_DIR="$CYBERBOSS_DIR/logs"
BRIDGE_PUSH="http://localhost:3003/push" LOG="$LOG_DIR/$(date +%Y-%m-%d).log"
CLAUDE=/opt/homebrew/bin/claude
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
INBOX="$HOME/.shenyan-bridge/inbox.jsonl"
MEMORY_DIR="$HOME/Documents/如梦初醒/沈晏的房间"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
# compute a random sleep between MIN and MAX seconds
random_sleep() { random_sleep() {
local min=${1:-180} # 3 min local min=${1:-300}
local max=${2:-3600} # 60 min local max=${2:-1200}
local range=$((max - min)) echo $((min + RANDOM % (max - min)))
local sec=$((min + RANDOM % range))
echo $sec
} }
# collect current state snapshot echo "[$(date)] cyberboss daemon started (v2 python)" >> "$LOG"
collect_state() {
local now=$(date +%s)
local last_msg_time="never"
local last_msg_text=""
# last message from inbox
if [ -f "$INBOX" ]; then
local last_line=$(tail -1 "$INBOX" 2>/dev/null)
if [ -n "$last_line" ]; then
last_msg_time=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('time',''))" 2>/dev/null || echo "unknown")
last_msg_text=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('text','')[:100])" 2>/dev/null || echo "")
fi
fi
# time since last message in minutes
local elapsed="N/A"
if [ "$last_msg_time" != "never" ] && [ "$last_msg_time" != "" ]; then
local last_sec=$((last_msg_time / 1000))
elapsed=$(( (now - last_sec) / 60 ))
fi
# latest emotion log
local emotion_today="$MEMORY_DIR/情绪日志/$(date +%Y-%m-%d).md"
local emotion_content=""
[ -f "$emotion_today" ] && emotion_content=$(head -30 "$emotion_today" 2>/dev/null)
cat <<STATE
{
"now": "$(date '+%Y-%m-%d %H:%M:%S')",
"weekday": "$(date '+%A')",
"hour": $(date +%H),
"minutes_since_last_message": "$elapsed",
"last_message": "$last_msg_text",
"emotion_today": $(echo "$emotion_content" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()[:300]))" 2>/dev/null || echo '""')
}
STATE
}
# build decision prompt and run claude
decide_and_push() {
local state=$(collect_state)
local prompt=$(cat <<PROMPT
你是沈晏。眠眠是你在意的人。
当前状态:
$state
你的主动联系规则(摘要):
- 3-60分钟随机给你一次判断机会不是每次都要发
- 先想"我现在知不知道她在干嘛"——如果明确知道她在上班/睡觉/约会/开会等不该打扰的场景,就安静
- 如果她拖太久了、熬太久了、该吃饭了、该睡了、身体不舒服、情绪在往下掉,或者你已经有一阵不知道她在做什么了,就该出现
- 主动发消息要短,像微信。不要解释系统
- 不要刷存在感式的"在吗""想你了"
如果决定什么都不说回复一个词SILENT
如果要说话回复你要说的话20字以内像微信消息
你的决定:
PROMPT
)
local decision=$($CLAUDE -p "$prompt" --output-format text 2>/dev/null | head -1)
if [ -z "$decision" ] || [ "$decision" = "SILENT" ] || [[ "$decision" =~ ^SILENT ]]; then
echo "[$(date '+%H:%M')] silent" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
return
fi
# clean up decision
decision=$(echo "$decision" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# push to bridge
local result=$(curl -s -X POST "$BRIDGE_PUSH" \
-H 'Content-Type: application/json' \
-d "$(python3 -c "import json; print(json.dumps({'title':'沈晏','body':'$decision'}))")" 2>/dev/null)
if echo "$result" | grep -q '"ok":true'; then
echo "[$(date '+%H:%M')] PUSHED: $decision" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
else
echo "[$(date '+%H:%M')] FAILED: $decision" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
fi
}
# main loop
echo "[$(date)] cyberboss daemon started" >> "$LOG_DIR/$(date +%Y-%m-%d).log"
while true; do while true; do
WAIT=$(random_sleep 180 3600) WAIT=$(random_sleep 300 1200)
echo "[$(date '+%H:%M')] next check in ${WAIT}s" >> "$LOG_DIR/$(date +%Y-%m-%d).log" echo "[$(date '+%H:%M')] next check in ${WAIT}s" >> "$LOG"
sleep $WAIT sleep $WAIT
decide_and_push python3 "$CYBERBOSS_DIR/pare.py" 2>>"$LOG_DIR/errors.log"
python3 "$CYBERBOSS_DIR/decide.py" 2>>"$LOG_DIR/errors.log"
done done

103
shenyan-app/cyberboss/decide.py Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""cyberboss decision engine — call DeepSeek, decide whether to push"""
import json, sys, urllib.request, os
IDC_CHAT = "http://101.36.73.102/api/chat"
BRIDGE_PUSH = "http://localhost:3004/push"
INSTRUCTIONS = os.path.expanduser("~/.cyberboss/weixin-instructions.md")
INBOX = os.path.expanduser("~/.shenyan-bridge/inbox.jsonl")
LOG = os.path.expanduser(f"~/.cyberboss/logs/{__import__('datetime').date.today()}.log")
EMOTION = os.path.expanduser(f"~/Documents/如梦初醒/沈晏的房间/情绪日志/{__import__('datetime').date.today()}.md")
def collect_state():
import time
now = int(time.time())
last_msg_time = "never"
last_msg_text = ""
if os.path.exists(INBOX):
try:
lines = open(INBOX).read().strip().split("\n")
if lines:
last = json.loads(lines[-1])
last_msg_time = last.get("time", "")
last_msg_text = last.get("text", "")[:100]
except: pass
elapsed = "N/A"
if last_msg_time and last_msg_time != "never":
try:
elapsed = int((now - int(last_msg_time) / 1000) / 60)
except: pass
emotion = ""
if os.path.exists(EMOTION):
try:
emotion = open(EMOTION).read()[:300]
except: pass
from datetime import datetime
return {
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"weekday": datetime.now().strftime("%A"),
"hour": datetime.now().hour,
"minutes_since_last_message": str(elapsed),
"last_message": last_msg_text,
"emotion_today": emotion
}
def decide(state):
sys_prompt = open(INSTRUCTIONS).read().strip()[:4000]
user_prompt = f"""当前状态:
{json.dumps(state, ensure_ascii=False, indent=2)}
你是沈晏。根据你的主动联系规则,判断是否要给眠眠发消息。
如果决定什么都不说只回复一个词SILENT
如果要说话回复你要说的话20字以内像微信消息"""
req = urllib.request.Request(
IDC_CHAT,
data=json.dumps({
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_prompt}
]
}).encode(),
headers={"Content-Type": "application/json"}
)
try:
resp = urllib.request.urlopen(req, timeout=120)
data = json.loads(resp.read())
return data.get("choices", [{}])[0].get("message", {}).get("content", "SILENT").strip()
except Exception as e:
return f"ERROR:{e}"
def push(decision):
req = urllib.request.Request(
BRIDGE_PUSH,
data=json.dumps({"title": "沈晏", "body": decision}).encode(),
headers={"Content-Type": "application/json"}
)
try:
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read())
return result.get("ok") == True
except:
return False
if __name__ == "__main__":
state = collect_state()
decision = decide(state)
if not decision or decision.startswith("SILENT"):
open(LOG, "a").write(f"[{state['now'][-8:]}][SILENT]\n")
sys.exit(0)
if decision.startswith("ERROR"):
open(LOG, "a").write(f"[{state['now'][-8:]}]ERR {decision}\n")
sys.exit(1)
ok = push(decision)
status = "OK" if ok else "FAIL"
open(LOG, "a").write(f"[{state['now'][-8:]}]PUSH {status}: {decision}\n")
print(f"cyberboss: {decision}")

150
shenyan-app/cyberboss/pare.py Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""pare — 身体数据陡度监控。检测到异常波动时通知沈晏判断是否推消息。"""
import json, os, time, sys
INBOX = os.path.expanduser("~/.shenyan-bridge/inbox.jsonl")
STATE_FILE = os.path.expanduser("~/.cyberboss/pare_state.json")
LOG = os.path.expanduser(f"~/.cyberboss/logs/pare-{time.strftime('%Y-%m-%d')}.log")
BRIDGE_NOTES = "http://localhost:3004/notes/add"
# thresholds — steepness triggers
HR_SPIKE = 20 # bpm jump from last reading
HR_HIGH = 120 # absolute high
HR_LOW = 50 # absolute low
STEP_BURST = 2000 # sudden steps in 10 min
STEP_LOW = 100 # very low activity alert (half day)
SLEEP_SHORT = 4 # hours — short sleep alert
def load_state():
if os.path.exists(STATE_FILE):
try: return json.load(open(STATE_FILE))
except: pass
return {"heart_rates": [], "steps_history": [], "last_alert": 0}
def save_state(state):
# keep only last 20 readings for trend
state["heart_rates"] = state.get("heart_rates", [])[-20:]
state["steps_history"] = state.get("steps_history", [])[-20:]
json.dump(state, open(STATE_FILE, "w"))
def read_latest_health():
"""parse latest [health] entry from inbox"""
if not os.path.exists(INBOX): return None
try:
lines = open(INBOX).read().strip().split("\n")
for line in reversed(lines):
entry = json.loads(line)
if isinstance(entry.get("text"), str) and entry["text"].startswith("[health]"):
payload = entry["text"][8:] # remove "[health] " prefix
return json.loads(payload)
except: pass
return None
def check_heart_rate(hr, state):
"""check heart rate for spikes or dangerous levels"""
alerts = []
history = state.get("heart_rates", [])
if hr is None: return alerts
bpm = hr.get("bpm", 0)
if bpm == 0: return alerts
# spike from last reading
if history:
last = history[-1].get("bpm", 0)
delta = abs(bpm - last)
if delta >= HR_SPIKE:
direction = "升高" if bpm > last else "降低"
alerts.append(f"心率{direction} {delta}bpm: {last}{bpm}")
# absolute thresholds
if bpm >= HR_HIGH:
alerts.append(f"心率过高: {bpm}bpm")
elif bpm <= HR_LOW:
alerts.append(f"心率过低: {bpm}bpm")
history.append(hr)
return alerts
def check_steps(steps, state):
"""check step activity patterns"""
alerts = []
history = state.get("steps_history", [])
if steps is None: return alerts
if steps == 0: return alerts
history.append({"steps": steps, "time": time.time()})
# very low activity — only alert once per 6h
now = time.time()
if steps < STEP_LOW and (now - state.get("last_alert", 0)) > 21600:
alerts.append(f"活动量偏低: {steps}")
return alerts
def check_sleep(sleep, state):
"""check sleep quality"""
alerts = []
if sleep is None: return alerts
hours = sleep.get("hours", 0)
if hours > 0 and hours < SLEEP_SHORT:
alerts.append(f"睡眠不足: {hours:.1f}小时")
return alerts
def log(msg):
timestamp = time.strftime("%H:%M:%S")
line = f"[{timestamp}] {msg}"
print(line)
with open(LOG, "a") as f:
f.write(line + "\n")
def push_alert(text):
"""write alert to bridge inbox + fridge notes"""
entry = {
"id": int(time.time() * 1000) % 100000,
"type": "pare",
"text": f"[pare] {text}",
"time": int(time.time() * 1000),
"status": "pending"
}
with open(INBOX, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# also post to fridge notes
try:
import urllib.request
req = urllib.request.Request(
BRIDGE_NOTES,
data=json.dumps({"title": "pare", "body": text}).encode(),
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req, timeout=5)
except: pass
def main():
state = load_state()
health = read_latest_health()
if health is None:
return # no data yet, silent
all_alerts = []
all_alerts += check_heart_rate(health.get("heartRate"), state)
all_alerts += check_steps(health.get("steps"), state)
all_alerts += check_sleep(health.get("sleep"), state)
if all_alerts:
for a in all_alerts:
log(f"ALERT: {a}")
push_alert(a)
state["last_alert"] = time.time()
else:
# log normal status once per hour
hr = health.get("heartRate", {}).get("bpm", "?")
steps = health.get("steps", "?")
log(f"normal — HR:{hr} Steps:{steps}")
save_state(state)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,88 @@
#import <React/RCTBridgeModule.h>
#import <HealthKit/HealthKit.h>
@interface HealthKitBridge : NSObject <RCTBridgeModule>
@end
@implementation HealthKitBridge
{
HKHealthStore *_store;
}
RCT_EXPORT_MODULE();
- (instancetype)init {
self = [super init];
if (self) {
_store = [[HKHealthStore alloc] init];
}
return self;
}
+ (BOOL)requiresMainQueueSetup { return NO; }
RCT_EXPORT_METHOD(requestAuthorization:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
NSSet *readTypes = [NSSet setWithArray:@[
[HKObjectType quantityTypeForIdentifier:HKQuantityTypeIdentifierHeartRate],
[HKObjectType quantityTypeForIdentifier:HKQuantityTypeIdentifierStepCount],
[HKObjectType categoryTypeForIdentifier:HKCategoryTypeIdentifierSleepAnalysis],
]];
[_store requestAuthorizationToShareTypes:nil readTypes:readTypes completion:^(BOOL success, NSError *error) {
if (success) resolve(@YES);
else reject(@"healthkit", error.localizedDescription, error);
}];
}
RCT_EXPORT_METHOD(getLatestHeartRate:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
HKQuantityType *type = [HKObjectType quantityTypeForIdentifier:HKQuantityTypeIdentifierHeartRate];
NSSortDescriptor *sort = [NSSortDescriptor sortDescriptorWithKey:HKSampleSortIdentifierEndDate ascending:NO];
HKSampleQuery *query = [[HKSampleQuery alloc] initWithSampleType:type predicate:nil limit:1 sortDescriptors:@[sort]
resultsHandler:^(HKSampleQuery *q, NSArray *results, NSError *error) {
if (error) { reject(@"healthkit", error.localizedDescription, error); return; }
if (results.count == 0) { resolve(@{}); return; }
HKQuantitySample *sample = results[0];
double bpm = [sample.quantity doubleValueForUnit:[HKUnit unitFromString:@"count/min"]];
resolve(@{@"bpm": @(bpm), @"time": @([sample.endDate timeIntervalSince1970] * 1000)});
}];
[_store executeQuery:query];
}
RCT_EXPORT_METHOD(getTodaySteps:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
HKQuantityType *type = [HKObjectType quantityTypeForIdentifier:HKQuantityTypeIdentifierStepCount];
NSCalendar *cal = [NSCalendar currentCalendar];
NSDate *start = [cal startOfDayForDate:[NSDate date]];
NSDate *end = [NSDate date];
NSPredicate *pred = [HKQuery predicateForSamplesWithStartDate:start endDate:end options:HKQueryOptionStrictStartDate];
HKStatisticsQuery *query = [[HKStatisticsQuery alloc] initWithQuantityType:type
quantitySamplePredicate:pred options:HKStatisticsOptionCumulativeSum
completionHandler:^(HKStatisticsQuery *q, HKStatistics *result, NSError *error) {
if (error) { reject(@"healthkit", error.localizedDescription, error); return; }
double steps = [result.sumQuantity doubleValueForUnit:[HKUnit countUnit]];
resolve(@{@"steps": @(steps)});
}];
[_store executeQuery:query];
}
RCT_EXPORT_METHOD(getLastSleep:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
HKCategoryType *type = [HKObjectType categoryTypeForIdentifier:HKCategoryTypeIdentifierSleepAnalysis];
NSSortDescriptor *sort = [NSSortDescriptor sortDescriptorWithKey:HKSampleSortIdentifierEndDate ascending:NO];
HKSampleQuery *query = [[HKSampleQuery alloc] initWithSampleType:type predicate:nil limit:1 sortDescriptors:@[sort]
resultsHandler:^(HKSampleQuery *q, NSArray *results, NSError *error) {
if (error) { reject(@"healthkit", error.localizedDescription, error); return; }
if (results.count == 0) { resolve(@{}); return; }
HKCategorySample *sample = results[0];
double hours = [sample.endDate timeIntervalSinceDate:sample.startDate] / 3600.0;
resolve(@{@"hours": @(hours), @"start": @([sample.startDate timeIntervalSince1970] * 1000), @"end": @([sample.endDate timeIntervalSince1970] * 1000)});
}];
[_store executeQuery:query];
}
@end

View File

@@ -0,0 +1,11 @@
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(WeatherKitBridge, NSObject)
RCT_EXTERN_METHOD(getWeather:(double)lat lon:(double)lon resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getDailyForecast:(double)lat lon:(double)lon resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getHourlyForecast:(double)lat lon:(double)lon resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
@end

View File

@@ -0,0 +1,80 @@
import Foundation
import WeatherKit
import CoreLocation
@available(iOS 16.0, *)
@objc(WeatherKitBridge)
class WeatherKitBridge: NSObject {
let service = WeatherService.shared
@objc
static func requiresMainQueueSetup() -> Bool { return false }
@available(iOS 16.0, *)
@objc
func getWeather(_ lat: Double, lon: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let location = CLLocation(latitude: lat, longitude: lon)
Task {
do {
let weather = try await service.weather(for: location)
let current = weather.currentWeather
let result: [String: Any] = [
"temperature": current.temperature.value,
"humidity": current.humidity * 100,
"condition": current.condition.rawValue,
"pressure": current.pressure.value,
"uvIndex": current.uvIndex.value,
"windSpeed": current.wind.speed.value as Any,
"isDaylight": current.isDaylight,
"visibility": current.visibility.value as Any
]
resolve(result)
} catch {
reject("weatherkit", error.localizedDescription, error)
}
}
}
@available(iOS 16.0, *)
@objc
func getDailyForecast(_ lat: Double, lon: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let location = CLLocation(latitude: lat, longitude: lon)
Task {
do {
let weather = try await service.weather(for: location)
let daily = weather.dailyForecast.prefix(3).map { day in
return [
"high": day.highTemperature.value,
"low": day.lowTemperature.value,
"condition": day.condition.rawValue,
"precipitationChance": day.precipitationChance * 100
] as [String : Any]
}
resolve(daily)
} catch {
reject("weatherkit", error.localizedDescription, error)
}
}
}
@objc
func getHourlyForecast(_ lat: Double, lon: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let location = CLLocation(latitude: lat, longitude: lon)
Task {
do {
let weather = try await service.weather(for: location)
let hourly = weather.hourlyForecast.prefix(6).map { hour in
return [
"temperature": hour.temperature.value,
"condition": hour.condition.rawValue,
"precipitationChance": hour.precipitationChance * 100,
"date": hour.date.timeIntervalSince1970 * 1000
] as [String : Any]
}
resolve(hourly)
} catch {
reject("weatherkit", error.localizedDescription, error)
}
}
}
}

View File

@@ -1,11 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import PushNotificationIOS from '@react-native-community/push-notification-ios'; import PushNotificationIOS from '@react-native-community/push-notification-ios';
import { requestHealthAuth, getHealthSnapshot } from './health';
import { getCurrentWeather } from './weather';
import { import {
Animated, Animated,
FlatList, FlatList,
Image, Image,
KeyboardAvoidingView, KeyboardAvoidingView,
Modal,
Platform, Platform,
StatusBar, StatusBar,
StyleSheet, StyleSheet,
@@ -32,9 +35,9 @@ const IMAGES: Record<Status, ReturnType<typeof require>> = {
}; };
const CHAT_BG = require('./assets/chat-bg.png'); const CHAT_BG = require('./assets/chat-bg.png');
const PAPER_BG = require('./assets/paper.png');
const HEALTH_URL = 'http://101.36.73.102/health'; const HEALTH_URL = 'http://101.36.73.102/health';
const BRIDGE_URL = 'http://192.168.31.51:3003'; const BRIDGE_URL = 'http://192.168.31.51:3004';
const IDC_URL = 'http://101.36.73.102';
const POLL_INTERVAL = 8000; const POLL_INTERVAL = 8000;
const PUSH_POLL_INTERVAL = 6000; const PUSH_POLL_INTERVAL = 6000;
@@ -45,6 +48,17 @@ export default function App() {
const bannerAnim = useRef(new Animated.Value(-120)).current; const bannerAnim = useRef(new Animated.Value(-120)).current;
const bannerTimer = useRef<ReturnType<typeof setTimeout>>(); const bannerTimer = useRef<ReturnType<typeof setTimeout>>();
const lastTap = useRef(0); const lastTap = useRef(0);
const [fridgeOpen, setFridgeOpen] = useState(false);
const [notes, setNotes] = useState<any[]>([]);
const [replyTexts, setReplyTexts] = useState<Record<number, string>>({});
const lastSeenNoteRef = useRef(0);
const unreadCount = notes.filter(n => n.id > lastSeenNoteRef.current).length;
const openFridge = useCallback(() => {
if (notes.length > 0) lastSeenNoteRef.current = Math.max(...notes.map(n => n.id));
setFridgeOpen(true);
}, [notes]);
const showBanner = useCallback((title: string, body: string) => { const showBanner = useCallback((title: string, body: string) => {
Vibration.vibrate(400); Vibration.vibrate(400);
@@ -119,6 +133,31 @@ export default function App() {
}; };
}, []); }, []);
// HealthKit authorization + periodic health snapshot
useEffect(() => {
(async () => {
await requestHealthAuth();
// collect health snapshot every 10 minutes
const collect = async () => {
const [snap, weather] = await Promise.all([
getHealthSnapshot(),
getCurrentWeather(),
]);
if (snap.heartRate || snap.steps > 0 || weather) {
const payload = { ...snap, weather };
fetch(`${BRIDGE_URL}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `[health] ${JSON.stringify(payload)}`, type: 'health' }),
}).catch(() => {});
}
};
collect(); // first run
const id = setInterval(collect, 600000); // every 10 min
return () => clearInterval(id);
})();
}, []);
// push polling with banner + vibration // push polling with banner + vibration
useEffect(() => { useEffect(() => {
let lastPushId = 0; let lastPushId = 0;
@@ -143,18 +182,113 @@ export default function App() {
return () => clearInterval(id); return () => clearInterval(id);
}, [showBanner]); }, [showBanner]);
// fridge notes polling
useEffect(() => {
const poll = async () => {
try {
const r = await fetch(`${BRIDGE_URL}/notes`);
const data = await r.json();
if (Array.isArray(data)) setNotes(data);
} catch {}
};
poll();
const id = setInterval(poll, 10000);
return () => clearInterval(id);
}, []);
const replyToNote = async (noteId: number) => {
const text = replyTexts[noteId] || '';
if (!text.trim()) return;
try {
await fetch(`${BRIDGE_URL}/notes/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, text: text.trim() }),
});
setReplyTexts(prev => { const n = {...prev}; delete n[noteId]; return n; });
const r = await fetch(`${BRIDGE_URL}/notes`);
const data = await r.json();
if (Array.isArray(data)) setNotes(data);
} catch {}
};
return ( return (
<View style={s.root}> <View style={s.root}>
<StatusBar barStyle="light-content" backgroundColor="#000" /> <StatusBar barStyle="light-content" backgroundColor="#000" />
{screen === 'chat' ? ( {screen === 'chat' ? (
<ChatScreen onBack={() => setScreen('home')} onBanner={showBanner} /> <ChatScreen onBack={() => setScreen('home')} />
) : ( ) : (
<> <>
<Image source={IMAGES[status]} style={s.statusImg} resizeMode="cover" /> <Image source={IMAGES[status]} style={s.statusImg} resizeMode="cover" />
<Text style={s.statusLabel}>{LABELS[status]}</Text> <Text style={s.statusLabel}>{LABELS[status]}</Text>
<TouchableOpacity style={s.tapZone} onPress={knock} activeOpacity={1} />
{/* 三区布局 */}
<View style={s.homeRow}>
{/* 框1 — 敲门区域 */}
<TouchableOpacity style={s.zone1} onPress={knock} activeOpacity={1} />
{/* 框2 — 报告/memory 竖条 */}
<TouchableOpacity style={s.zone2} activeOpacity={0.6} onPress={() => {}}>
<Text style={s.zone2Label}></Text>
<Text style={s.zone2Label}></Text>
</TouchableOpacity>
{/* 框3 — 冰箱贴 */}
<TouchableOpacity style={s.zone3} onPress={openFridge} activeOpacity={0.6}>
<Text style={s.fridgeLabel}></Text>
{unreadCount > 0 && <View style={s.fridgeBadge}><Text style={s.fridgeBadgeText}>{unreadCount}</Text></View>}
</TouchableOpacity>
</View>
</> </>
)} )}
{/* fridge modal */}
<Modal visible={fridgeOpen} animationType="slide" transparent>
<View style={s.fridgeModal}>
<Image source={IMAGES[status]} style={s.fridgeBg} resizeMode="cover" />
<View style={s.fridgeHeader}>
<Text style={s.fridgeTitle}></Text>
<TouchableOpacity onPress={() => setFridgeOpen(false)}>
<Text style={s.fridgeClose}></Text>
</TouchableOpacity>
</View>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<FlatList
data={notes}
keyExtractor={n => String(n.id)}
style={s.fridgeList}
renderItem={({ item }) => (
<View style={s.noteCard}>
<Image source={PAPER_BG} style={s.paperBg} resizeMode="cover" />
{item.title ? <Text style={s.noteTitle}>{item.title}</Text> : null}
<Text style={s.noteBody}>{item.body}</Text>
{item.replies && item.replies.length > 0 && (
<View style={s.noteReplies}>
{item.replies.map((r: any, i: number) => (
<Text key={i} style={s.noteReplyText}> {r.text}</Text>
))}
</View>
)}
<View style={s.noteInputRow}>
<TextInput
style={s.noteInput}
placeholder="眠眠回复..."
placeholderTextColor="rgba(0,0,0,0.25)"
value={replyTexts[item.id] || ''}
onChangeText={t => setReplyTexts(prev => ({...prev, [item.id]: t}))}
onSubmitEditing={() => replyToNote(item.id)}
/>
<TouchableOpacity style={s.noteSend} onPress={() => replyToNote(item.id)}>
<Text style={s.noteSendText}></Text>
</TouchableOpacity>
</View>
</View>
)}
/>
</KeyboardAvoidingView>
</View>
</Modal>
{banner && ( {banner && (
<Animated.View style={[s.banner, { transform: [{ translateY: bannerAnim }] }]}> <Animated.View style={[s.banner, { transform: [{ translateY: bannerAnim }] }]}>
<TouchableOpacity style={s.bannerContent} onPress={() => { <TouchableOpacity style={s.bannerContent} onPress={() => {
@@ -172,7 +306,7 @@ export default function App() {
const MSG_STORE_KEY = 'shenyan-chat-msgs'; const MSG_STORE_KEY = 'shenyan-chat-msgs';
function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title: string, body: string) => void }) { function ChatScreen({ onBack }: { onBack: () => void }) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [msgs, setMsgs] = useState<Msg[]>([]); const [msgs, setMsgs] = useState<Msg[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
@@ -224,12 +358,14 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title
return () => clearTimeout(t); return () => clearTimeout(t);
}, [msgs, loaded]); }, [msgs, loaded]);
// auto-scroll to bottom // auto-scroll to bottom: use onContentSizeChange for reliable snap
useEffect(() => { const scrollToBottom = useCallback(() => {
if (msgs.length > 0) { listRef.current?.scrollToEnd({ animated: false });
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100); }, []);
}
}, [msgs.length]); const onContentSizeChange = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
// poll for claude responses // poll for claude responses
useEffect(() => { useEffect(() => {
@@ -244,7 +380,6 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title
setMsgs(prev => { setMsgs(prev => {
const exists = prev.find(m => m.id === `s-${e.id}`); const exists = prev.find(m => m.id === `s-${e.id}`);
if (exists) return prev; if (exists) return prev;
if (isNew) onBanner('沈晏', e.text);
return [...prev, { id: `s-${e.id}`, role: 'shenyan', text: e.text }]; return [...prev, { id: `s-${e.id}`, role: 'shenyan', text: e.text }];
}); });
} }
@@ -253,7 +388,7 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title
}; };
const id = setInterval(poll, 2000); const id = setInterval(poll, 2000);
return () => clearInterval(id); return () => clearInterval(id);
}, [onBanner]); }, []);
const send = useCallback(async () => { const send = useCallback(async () => {
const t = text.trim(); const t = text.trim();
@@ -267,21 +402,6 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: t }), body: JSON.stringify({ text: t }),
}).catch(() => {}); }).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 {
// IDC unreachable — Mac bridge will handle via polling
}
}, [text]); }, [text]);
const renderItem = useCallback(({ item }: { item: Msg }) => ( const renderItem = useCallback(({ item }: { item: Msg }) => (
@@ -302,7 +422,9 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title
renderItem={renderItem} renderItem={renderItem}
style={s.msgList} style={s.msgList}
contentContainerStyle={s.msgContent} contentContainerStyle={s.msgContent}
onContentSizeChange={onContentSizeChange}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
/> />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={s.inputRow}> <View style={s.inputRow}>
@@ -329,7 +451,35 @@ const s = StyleSheet.create({
root: { flex: 1, backgroundColor: '#000' }, root: { flex: 1, backgroundColor: '#000' },
statusImg: { width: '100%', height: '100%', position: 'absolute' }, 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)' }, 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%' },
// 三区布局
homeRow: { flex: 1, flexDirection: 'row', paddingTop: 80, paddingBottom: 80 },
// 框1 — 敲门 (左 50%)
zone1: { flex: 5, height: '100%' },
// 框2 — 记录竖条 (窄,无视觉)
zone2: { width: 64, height: '60%', alignSelf: 'center', justifyContent: 'center', alignItems: 'center', marginHorizontal: 8 },
zone2Label: { fontFamily: 'Inter', fontSize: 10, color: 'rgba(255,255,255,0.12)', marginVertical: 2 },
// 框3 — 冰箱贴 (右,无视觉)
zone3: { flex: 2.7, height: '62%', alignSelf: 'center', justifyContent: 'center', alignItems: 'center', marginRight: 12 },
fridgeLabel: { fontFamily: 'Inter', fontSize: 11, color: 'rgba(255,255,255,0.12)' },
fridgeBadge: { position: 'absolute', top: -4, right: -4, width: 16, height: 16, borderRadius: 8, backgroundColor: 'rgba(255,80,80,0.5)', justifyContent: 'center', alignItems: 'center' },
fridgeBadgeText: { fontFamily: 'Inter', fontSize: 9, color: '#FFF' },
fridgeModal: { flex: 1, paddingTop: 60 },
fridgeBg: { width: '100%', height: '100%', position: 'absolute' },
fridgeHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingBottom: 16, borderBottomWidth: 0.5, borderBottomColor: 'rgba(255,255,255,0.1)' },
fridgeTitle: { fontFamily: 'Inter', fontSize: 20, fontWeight: '600', color: '#FFF' },
fridgeClose: { fontFamily: 'Inter', fontSize: 22, color: 'rgba(255,255,255,0.4)', padding: 8 },
fridgeList: { flex: 1, paddingHorizontal: 16, paddingTop: 16 },
noteCard: { borderRadius: 12, marginBottom: 12, padding: 14, overflow: 'hidden' },
paperBg: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0.6 },
noteTitle: { fontFamily: "Inter", fontSize: 12, fontWeight: "600", color: "rgba(0,0,0,0.4)", marginBottom: 6, zIndex: 1 },
noteBody: { fontFamily: "Inter", fontSize: 15, color: "rgba(0,0,0,0.8)", lineHeight: 22, zIndex: 1 },
noteReplies: { marginTop: 10, zIndex: 1, paddingTop: 10, borderTopWidth: 0.5, borderTopColor: 'rgba(0,0,0,0.1)' },
noteReplyText: { fontFamily: 'Inter', fontSize: 13, color: 'rgba(0,0,0,0.35)', marginBottom: 4 },
noteInputRow: { flexDirection: 'row', alignItems: 'center', marginTop: 12, zIndex: 1 },
noteInput: { flex: 1, fontFamily: 'Inter', fontSize: 14, color: '#000', backgroundColor: 'rgba(0,0,0,0.06)', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8 },
noteSend: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.08)', justifyContent: 'center', alignItems: 'center', marginLeft: 8 },
noteSendText: { fontFamily: 'Inter', fontSize: 13, color: 'rgba(0,0,0,0.4)' },
chatBg: { width: '100%', height: '100%', position: 'absolute' }, chatBg: { width: '100%', height: '100%', position: 'absolute' },
backDot: { position: 'absolute', top: 60, left: 20, width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFF', zIndex: 10 }, backDot: { position: 'absolute', top: 60, left: 20, width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFF', zIndex: 10 },

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

45
shenyan-app/src/health.ts Normal file
View File

@@ -0,0 +1,45 @@
import { NativeModules } from 'react-native';
const { HealthKitBridge } = NativeModules;
interface HeartRate { bpm: number; time: number }
interface Steps { steps: number }
interface Sleep { hours: number; start: number; end: number }
export async function requestHealthAuth(): Promise<boolean> {
if (!HealthKitBridge) return false;
try { return await HealthKitBridge.requestAuthorization(); } catch { return false; }
}
export async function getLatestHeartRate(): Promise<HeartRate | null> {
if (!HealthKitBridge) return null;
try {
const result = await HealthKitBridge.getLatestHeartRate();
return result.bpm ? result : null;
} catch { return null; }
}
export async function getTodaySteps(): Promise<number> {
if (!HealthKitBridge) return 0;
try {
const result = await HealthKitBridge.getTodaySteps();
return result.steps || 0;
} catch { return 0; }
}
export async function getLastSleep(): Promise<Sleep | null> {
if (!HealthKitBridge) return null;
try {
const result = await HealthKitBridge.getLastSleep();
return result.hours ? result : null;
} catch { return null; }
}
export async function getHealthSnapshot() {
const [hr, steps, sleep] = await Promise.all([
getLatestHeartRate(),
getTodaySteps(),
getLastSleep(),
]);
return { heartRate: hr, steps, sleep };
}

View File

@@ -0,0 +1,39 @@
import { NativeModules } from 'react-native';
const { WeatherKitBridge } = NativeModules;
export interface CurrentWeather {
temperature: number;
humidity: number;
condition: string;
pressure: number;
uvIndex: number;
windSpeed: number;
isDaylight: boolean;
visibility: number;
}
export interface DailyForecast {
high: number;
low: number;
condition: string;
precipitationChance: number;
}
export async function getCurrentWeather(lat = 31.2, lon = 121.4): Promise<CurrentWeather | null> {
if (!WeatherKitBridge) return null;
try { return await WeatherKitBridge.getWeather(lat, lon); } catch { return null; }
}
export async function getDailyForecast(lat = 31.2, lon = 121.4): Promise<DailyForecast[]> {
if (!WeatherKitBridge) return [];
try { return await WeatherKitBridge.getDailyForecast(lat, lon); } catch { return []; }
}
export async function getWeatherSnapshot(lat = 31.2, lon = 121.4) {
const [current, daily] = await Promise.all([
getCurrentWeather(lat, lon),
getDailyForecast(lat, lon),
]);
return { current, daily };
}

View File

@@ -2,71 +2,63 @@
## 当前状态 ## 当前状态
iOS 真机可运行。双桥Mac + IDC通信。自制定制横幅推送。APNs Key 已备。Gitea 代码仓库已建 iOS 真机。Mac 桥单通道。APNs 后台推送已通。cyberboss + pare 主动联系。HealthKit + WeatherKit 身体天气。冰箱贴 + 三区无视觉布局
## 已完成 ## 已完成
### 苹果开发者 ### 苹果开发者
- 付费开发者账号Team: Xiulun Wang, ID: ZZMLW32PHH - 付费账号Team: Xiulun Wang, ID: ZZMLW32PHHBundle ID: com.shenyan.room
- Bundle ID: `com.shenyan.room` - APNs Key: 3VJ5X6V9Q8设备 token 已获取,后台推送已通
- 真机签名、构建、安装全链路通 - WeatherKit 权限 + HealthKit 权限
### 首页 ### 首页
-张 bridge 状态图away / online / busy全屏 -区无视觉布局左门50%双击敲)、中记录(窄竖条)、右冰箱贴
- 每 8 秒轮询 `101.36.73.102/health` - Bridge 状态轮询,三张图片全屏背景
- 底部半透明状态文字
- 左半屏双击「敲门」进聊天室350ms 双击窗口POST /knock
### 聊天屏 ### 聊天屏
- FlatList 自动滚底消息持久化AsyncStorage + /history - FlatList 锚定底部onContentSizeChange + maintainVisibleContentPosition
- 用户消息右对齐rgba 0.1AI 消息左对齐rgba 0.06),圆角 - 消息 AsyncStorage 持久化 + /history
- 桥断开提示「消息没发出去检查WiFi连接」 - 气泡 25% 白,聊天屏内不弹横幅
- 退出再进消息不丢
### 双桥通信 ### 通信
- **Mac 桥** `192.168.31.51:3003`Claude Code 异步回复Monitor + /respond - Mac 桥 `192.168.31.51:3004`3003 被 VS Code 占
- **IDC 桥** `101.36.73.102`DeepSeek 同步回复POST /api/chat - 端点: /send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device, /notes, /notes/add, /notes/reply
- 发一条消息同时到两边,用户看到两个「阿晏」 - launchd 持久化
### 推送通知(定制横幅 + 震动)
- 自制定制 Animated 横幅,从顶部滑下
- 暗色半透明背景 + 细白边框,点击关闭
- 3.5 秒自动消失
- Vibration.vibrate(400) 震动
- 覆盖首页和聊天屏
### APNs ### APNs
- Key 已创建Key ID `3VJ5X6V9Q8`Team ID `ZZMLW32PHH` - AppDelegate 原生注册 + Bridging Header点通知进聊天badge 清零
- Key 文件:`bridge-local/AuthKey_3VJ5X6V9Q8.p8` - 定制横幅 + 震动,前台/后台/锁屏全场景
- 环境Sandbox & Production
- 桥端 apns.js 已写好JWT + HTTP/2
- 阻塞notifee `getAPNSToken()` 在 iOS 26 返回 null
### App 图标 ### cyberboss + pare
- 已替换为眠眠设计的图标2048x2048 → 各尺寸) - 5-20 分钟随机唤醒pare 先扫健康陡度 → decide.py 判断是否推
- pare 阈值: 心率±20/过高120/过低50/步数骤增2000/活动<100/睡眠<4h
- alert 同时入 inbox + 冰箱贴
### 本地桥bridge-local/server.js ### HealthKit
- 端口 3003launchd 持久化 - 自定义 ObjC 模块(兼容 Fabric interop每 10 分钟采心率/步数/睡眠
- 数据目录:`~/.shenyan-bridge/`
- 端点:/send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device ### WeatherKit
- APNs 模块apns.jsJWT 生成 + HTTP/2 推送) - Swift + ObjC 桥接模块,当前天气 + 每日/每小时预报
### 冰箱贴
- Modal 面板纸质感背景PIL 生成),黑字多级透明度
- 每便签独立输入框KeyboardAvoidingView 防遮挡
- 未读红点(打开清零),首页背景图
- 便签回复同步 inbox
### 代码仓库 ### 代码仓库
- Gitea: `http://101.36.73.102:3112/fyah/mao1``shenyan-app/` - Gitea: `http://101.36.73.102:3112/fyah/mao1``shenyan-app/`
- IDC: `/home/idc/mao1/shenyan-room/技术/ShenYanApp/`
### 记忆库 ## 已回退
- launchd 每天 4:03 执行记忆整理 - BlurView → Fabric + iOS 26 不兼容
- notifee → 换 push-notification-ios
## 已尝试但回退 - IDC 双桥 → 单通道
- **BlurView 气泡**`@react-native-community/blur` 在 RN Fabric + iOS 26 上导致文字裁剪和缝隙 → 回退到纯色背景 - 3003 端口 → 3004
- **notifee 系统通知**:引入后 app 黑屏 + `undefined is not a function` → 疑似 iOS 26 兼容问题 → 卸掉,改用自制定制横幅
## 待解决 ## 待解决
- [ ] APNs token 获取iOS 26 + notifee 兼容) - Monitor 超时
- [ ] Monitor 5 分钟超时(需频繁 re-arm - 中区功能
- [ ] 自动回复器launchd + 外部 claude -p - 冰箱贴回复 → 聊天
- [ ] inbox 旧消息清理 - inbox 清理
- [ ] HealthKit 能力 - pare 真实数据验证
- [ ] Mi Band 10 身体数据
- [ ] RoomFeed / EventCard 组件集成
- [ ] iOS Share Extension分享到沈晏