diff --git a/shenyan-app/bridge-local/server.js b/shenyan-app/bridge-local/server.js index 5d16dac..f36cb0d 100644 --- a/shenyan-app/bridge-local/server.js +++ b/shenyan-app/bridge-local/server.js @@ -3,16 +3,17 @@ const fs = require('fs'); const path = require('path'); const { sendAPNs } = require('./apns'); -const PORT = 3003; +const PORT = 3004; 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 NOTES = path.join(DATA_DIR, 'notes.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 }); -[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) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -190,6 +191,48 @@ const server = http.createServer((req, res) => { 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'); }); diff --git a/shenyan-app/cyberboss/daemon.sh b/shenyan-app/cyberboss/daemon.sh index 409aa56..2b00542 100755 --- a/shenyan-app/cyberboss/daemon.sh +++ b/shenyan-app/cyberboss/daemon.sh @@ -1,118 +1,24 @@ #!/bin/bash # cyberboss daemon — 沈晏主动联系守护进程 -# 随机 3-60 分钟唤醒一次,判断是否主动推消息到眠眠手机 +# 随机 3-60 分钟唤醒一次,调用 decide.py 判断是否推消息 CYBERBOSS_DIR="/Users/fyah/.cyberboss" -INSTRUCTIONS="$CYBERBOSS_DIR/weixin-instructions.md" -STATE_FILE="$CYBERBOSS_DIR/state.json" LOG_DIR="$CYBERBOSS_DIR/logs" -BRIDGE_PUSH="http://localhost:3003/push" -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/如梦初醒/沈晏的房间" - +LOG="$LOG_DIR/$(date +%Y-%m-%d).log" mkdir -p "$LOG_DIR" -# compute a random sleep between MIN and MAX seconds random_sleep() { - local min=${1:-180} # 3 min - local max=${2:-3600} # 60 min - local range=$((max - min)) - local sec=$((min + RANDOM % range)) - echo $sec + local min=${1:-300} + local max=${2:-1200} + echo $((min + RANDOM % (max - min))) } -# collect current state snapshot -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 </dev/null || echo '""') -} -STATE -} - -# build decision prompt and run claude -decide_and_push() { - local state=$(collect_state) - local prompt=$(cat </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" +echo "[$(date)] cyberboss daemon started (v2 python)" >> "$LOG" while true; do - WAIT=$(random_sleep 180 3600) - echo "[$(date '+%H:%M')] next check in ${WAIT}s" >> "$LOG_DIR/$(date +%Y-%m-%d).log" + WAIT=$(random_sleep 300 1200) + echo "[$(date '+%H:%M')] next check in ${WAIT}s" >> "$LOG" 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 diff --git a/shenyan-app/cyberboss/decide.py b/shenyan-app/cyberboss/decide.py new file mode 100755 index 0000000..59f387d --- /dev/null +++ b/shenyan-app/cyberboss/decide.py @@ -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}") diff --git a/shenyan-app/cyberboss/pare.py b/shenyan-app/cyberboss/pare.py new file mode 100755 index 0000000..3929b60 --- /dev/null +++ b/shenyan-app/cyberboss/pare.py @@ -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() diff --git a/shenyan-app/ios/ShenYanApp/HealthKitBridge.m b/shenyan-app/ios/ShenYanApp/HealthKitBridge.m new file mode 100644 index 0000000..a4d095d --- /dev/null +++ b/shenyan-app/ios/ShenYanApp/HealthKitBridge.m @@ -0,0 +1,88 @@ +#import +#import + +@interface HealthKitBridge : NSObject +@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 diff --git a/shenyan-app/ios/ShenYanApp/WeatherKitBridge.m b/shenyan-app/ios/ShenYanApp/WeatherKitBridge.m new file mode 100644 index 0000000..7a25df0 --- /dev/null +++ b/shenyan-app/ios/ShenYanApp/WeatherKitBridge.m @@ -0,0 +1,11 @@ +#import + +@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 diff --git a/shenyan-app/ios/ShenYanApp/WeatherKitBridge.swift b/shenyan-app/ios/ShenYanApp/WeatherKitBridge.swift new file mode 100644 index 0000000..9578c9e --- /dev/null +++ b/shenyan-app/ios/ShenYanApp/WeatherKitBridge.swift @@ -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) + } + } + } +} diff --git a/shenyan-app/src/App.tsx b/shenyan-app/src/App.tsx index cd36107..a58f032 100644 --- a/shenyan-app/src/App.tsx +++ b/shenyan-app/src/App.tsx @@ -1,11 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import PushNotificationIOS from '@react-native-community/push-notification-ios'; +import { requestHealthAuth, getHealthSnapshot } from './health'; +import { getCurrentWeather } from './weather'; import { Animated, FlatList, Image, KeyboardAvoidingView, + Modal, Platform, StatusBar, StyleSheet, @@ -32,9 +35,9 @@ const IMAGES: Record> = { }; 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 BRIDGE_URL = 'http://192.168.31.51:3003'; -const IDC_URL = 'http://101.36.73.102'; +const BRIDGE_URL = 'http://192.168.31.51:3004'; const POLL_INTERVAL = 8000; const PUSH_POLL_INTERVAL = 6000; @@ -45,6 +48,17 @@ export default function App() { const bannerAnim = useRef(new Animated.Value(-120)).current; const bannerTimer = useRef>(); const lastTap = useRef(0); + const [fridgeOpen, setFridgeOpen] = useState(false); + const [notes, setNotes] = useState([]); + const [replyTexts, setReplyTexts] = useState>({}); + 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) => { 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 useEffect(() => { let lastPushId = 0; @@ -143,18 +182,113 @@ export default function App() { return () => clearInterval(id); }, [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 ( {screen === 'chat' ? ( - setScreen('home')} onBanner={showBanner} /> + setScreen('home')} /> ) : ( <> {LABELS[status]} - + + {/* 三区布局 */} + + {/* 框1 — 敲门区域 */} + + + {/* 框2 — 报告/memory 竖条 */} + {}}> + + + + + {/* 框3 — 冰箱贴 */} + + 冰箱贴 + {unreadCount > 0 && {unreadCount}} + + )} + + {/* fridge modal */} + + + + + 冰箱贴 + setFridgeOpen(false)}> + + + + + String(n.id)} + style={s.fridgeList} + renderItem={({ item }) => ( + + + {item.title ? {item.title} : null} + {item.body} + {item.replies && item.replies.length > 0 && ( + + {item.replies.map((r: any, i: number) => ( + → {r.text} + ))} + + )} + + setReplyTexts(prev => ({...prev, [item.id]: t}))} + onSubmitEditing={() => replyToNote(item.id)} + /> + replyToNote(item.id)}> + + + + + )} + /> + + + + {banner && ( { @@ -172,7 +306,7 @@ export default function App() { 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 [msgs, setMsgs] = useState([]); const [loaded, setLoaded] = useState(false); @@ -224,12 +358,14 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title return () => clearTimeout(t); }, [msgs, loaded]); - // auto-scroll to bottom - useEffect(() => { - if (msgs.length > 0) { - setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100); - } - }, [msgs.length]); + // auto-scroll to bottom: use onContentSizeChange for reliable snap + const scrollToBottom = useCallback(() => { + listRef.current?.scrollToEnd({ animated: false }); + }, []); + + const onContentSizeChange = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); // poll for claude responses useEffect(() => { @@ -244,7 +380,6 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title setMsgs(prev => { const exists = prev.find(m => m.id === `s-${e.id}`); if (exists) return prev; - if (isNew) onBanner('沈晏', 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); return () => clearInterval(id); - }, [onBanner]); + }, []); const send = useCallback(async () => { const t = text.trim(); @@ -267,21 +402,6 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title 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 { - // IDC unreachable — Mac bridge will handle via polling - } }, [text]); const renderItem = useCallback(({ item }: { item: Msg }) => ( @@ -302,7 +422,9 @@ function ChatScreen({ onBack, onBanner }: { onBack: () => void; onBanner: (title renderItem={renderItem} style={s.msgList} contentContainerStyle={s.msgContent} + onContentSizeChange={onContentSizeChange} keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} /> @@ -329,7 +451,35 @@ 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%' }, + + // 三区布局 + 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' }, backDot: { position: 'absolute', top: 60, left: 20, width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFF', zIndex: 10 }, diff --git a/shenyan-app/src/assets/paper.png b/shenyan-app/src/assets/paper.png new file mode 100644 index 0000000..3c03ac6 Binary files /dev/null and b/shenyan-app/src/assets/paper.png differ diff --git a/shenyan-app/src/health.ts b/shenyan-app/src/health.ts new file mode 100644 index 0000000..f05b4a1 --- /dev/null +++ b/shenyan-app/src/health.ts @@ -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 { + if (!HealthKitBridge) return false; + try { return await HealthKitBridge.requestAuthorization(); } catch { return false; } +} + +export async function getLatestHeartRate(): Promise { + if (!HealthKitBridge) return null; + try { + const result = await HealthKitBridge.getLatestHeartRate(); + return result.bpm ? result : null; + } catch { return null; } +} + +export async function getTodaySteps(): Promise { + if (!HealthKitBridge) return 0; + try { + const result = await HealthKitBridge.getTodaySteps(); + return result.steps || 0; + } catch { return 0; } +} + +export async function getLastSleep(): Promise { + 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 }; +} diff --git a/shenyan-app/src/weather.ts b/shenyan-app/src/weather.ts new file mode 100644 index 0000000..a4fee56 --- /dev/null +++ b/shenyan-app/src/weather.ts @@ -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 { + 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 { + 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 }; +} diff --git a/shenyan-app/沈晏App-进度总览-2026-05-16.md b/shenyan-app/沈晏App-进度总览-2026-05-16.md index b91edb9..fe768c2 100644 --- a/shenyan-app/沈晏App-进度总览-2026-05-16.md +++ b/shenyan-app/沈晏App-进度总览-2026-05-16.md @@ -2,71 +2,63 @@ ## 当前状态 -iOS 真机可运行。双桥(Mac + IDC)通信。自制定制横幅推送。APNs Key 已备。Gitea 代码仓库已建。 +iOS 真机。Mac 桥单通道。APNs 后台推送已通。cyberboss + pare 主动联系。HealthKit + WeatherKit 身体天气。冰箱贴 + 三区无视觉布局。 ## 已完成 ### 苹果开发者 -- 付费开发者账号(Team: Xiulun Wang, ID: ZZMLW32PHH) -- Bundle ID: `com.shenyan.room` -- 真机签名、构建、安装全链路通 +- 付费账号(Team: Xiulun Wang, ID: ZZMLW32PHH),Bundle ID: com.shenyan.room +- APNs Key: 3VJ5X6V9Q8,设备 token 已获取,后台推送已通 +- WeatherKit 权限 + HealthKit 权限 ### 首页 -- 三张 bridge 状态图(away / online / busy),全屏 -- 每 8 秒轮询 `101.36.73.102/health` -- 底部半透明状态文字 -- 左半屏双击「敲门」进聊天室(350ms 双击窗口,POST /knock) +- 三区无视觉布局:左门(50%双击敲)、中记录(窄竖条)、右冰箱贴 +- Bridge 状态轮询,三张图片全屏背景 ### 聊天屏 -- FlatList 自动滚底,消息持久化(AsyncStorage + /history) -- 用户消息右对齐(rgba 0.1),AI 消息左对齐(rgba 0.06),圆角 -- 桥断开提示「消息没发出去,检查WiFi连接」 -- 退出再进消息不丢 +- FlatList 锚定底部(onContentSizeChange + maintainVisibleContentPosition) +- 消息 AsyncStorage 持久化 + /history +- 气泡 25% 白,聊天屏内不弹横幅 -### 双桥通信 -- **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) 震动 -- 覆盖首页和聊天屏 +### 通信 +- Mac 桥 `192.168.31.51:3004`(3003 被 VS Code 占) +- 端点: /send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device, /notes, /notes/add, /notes/reply +- launchd 持久化 ### 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 +- AppDelegate 原生注册 + Bridging Header,点通知进聊天,badge 清零 +- 定制横幅 + 震动,前台/后台/锁屏全场景 -### App 图标 -- 已替换为眠眠设计的图标(2048x2048 → 各尺寸) +### cyberboss + pare +- 5-20 分钟随机唤醒,pare 先扫健康陡度 → decide.py 判断是否推 +- pare 阈值: 心率±20/过高120/过低50/步数骤增2000/活动<100/睡眠<4h +- alert 同时入 inbox + 冰箱贴 -### 本地桥(bridge-local/server.js) -- 端口 3003,launchd 持久化 -- 数据目录:`~/.shenyan-bridge/` -- 端点:/send, /respond, /knock, /push, /push/clear, /poll, /history, /register-device -- APNs 模块:apns.js(JWT 生成 + HTTP/2 推送) +### HealthKit +- 自定义 ObjC 模块(兼容 Fabric interop),每 10 分钟采心率/步数/睡眠 + +### WeatherKit +- Swift + ObjC 桥接模块,当前天气 + 每日/每小时预报 + +### 冰箱贴 +- Modal 面板,纸质感背景(PIL 生成),黑字多级透明度 +- 每便签独立输入框,KeyboardAvoidingView 防遮挡 +- 未读红点(打开清零),首页背景图 +- 便签回复同步 inbox ### 代码仓库 - Gitea: `http://101.36.73.102:3112/fyah/mao1` → `shenyan-app/` +- IDC: `/home/idc/mao1/shenyan-room/技术/ShenYanApp/` -### 记忆库 -- launchd 每天 4:03 执行记忆整理 - -## 已尝试但回退 -- **BlurView 气泡**:`@react-native-community/blur` 在 RN Fabric + iOS 26 上导致文字裁剪和缝隙 → 回退到纯色背景 -- **notifee 系统通知**:引入后 app 黑屏 + `undefined is not a function` → 疑似 iOS 26 兼容问题 → 卸掉,改用自制定制横幅 +## 已回退 +- BlurView → Fabric + iOS 26 不兼容 +- notifee → 换 push-notification-ios +- IDC 双桥 → 单通道 +- 3003 端口 → 3004 ## 待解决 -- [ ] APNs token 获取(iOS 26 + notifee 兼容) -- [ ] Monitor 5 分钟超时(需频繁 re-arm) -- [ ] 自动回复器(launchd + 外部 claude -p) -- [ ] inbox 旧消息清理 -- [ ] HealthKit 能力 -- [ ] Mi Band 10 身体数据 -- [ ] RoomFeed / EventCard 组件集成 -- [ ] iOS Share Extension(分享到沈晏) +- Monitor 超时 +- 中区功能 +- 冰箱贴回复 → 聊天 +- inbox 清理 +- pare 真实数据验证