更新 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:
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <<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"
|
||||
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
|
||||
|
||||
103
shenyan-app/cyberboss/decide.py
Executable file
103
shenyan-app/cyberboss/decide.py
Executable 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
150
shenyan-app/cyberboss/pare.py
Executable 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()
|
||||
88
shenyan-app/ios/ShenYanApp/HealthKitBridge.m
Normal file
88
shenyan-app/ios/ShenYanApp/HealthKitBridge.m
Normal 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
|
||||
11
shenyan-app/ios/ShenYanApp/WeatherKitBridge.m
Normal file
11
shenyan-app/ios/ShenYanApp/WeatherKitBridge.m
Normal 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
|
||||
80
shenyan-app/ios/ShenYanApp/WeatherKitBridge.swift
Normal file
80
shenyan-app/ios/ShenYanApp/WeatherKitBridge.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Status, ReturnType<typeof require>> = {
|
||||
};
|
||||
|
||||
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<ReturnType<typeof setTimeout>>();
|
||||
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) => {
|
||||
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 (
|
||||
<View style={s.root}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||||
{screen === 'chat' ? (
|
||||
<ChatScreen onBack={() => setScreen('home')} onBanner={showBanner} />
|
||||
<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} />
|
||||
|
||||
{/* 三区布局 */}
|
||||
<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 && (
|
||||
<Animated.View style={[s.banner, { transform: [{ translateY: bannerAnim }] }]}>
|
||||
<TouchableOpacity style={s.bannerContent} onPress={() => {
|
||||
@@ -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<Msg[]>([]);
|
||||
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 }}
|
||||
/>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<View style={s.inputRow}>
|
||||
@@ -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 },
|
||||
|
||||
BIN
shenyan-app/src/assets/paper.png
Normal file
BIN
shenyan-app/src/assets/paper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
45
shenyan-app/src/health.ts
Normal file
45
shenyan-app/src/health.ts
Normal 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 };
|
||||
}
|
||||
39
shenyan-app/src/weather.ts
Normal file
39
shenyan-app/src/weather.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 真实数据验证
|
||||
|
||||
Reference in New Issue
Block a user