feat: 完成 Q 群組 — APP(React Native + Expo)

新增 apps/mobile:Expo Router 檔案系統路由(同 Next.js App Router 的
心智模型),底部四分頁(聊天/角色/日常/設定)皆為真實 API 串接(/chat、
/tachie、/characters、/schedule、/health、/push)。聊天分頁完整重用
H/O 群組已驗證過的對話+表情查詢節奏。立繪暫以依情緒 magnitude 調整
透明度的色塊佔位(PixiJS 是瀏覽器 DOM/Canvas API,未移植到 React
Native,需要 react-native-skia 等原生繪圖套件才能重現分層合成,超出
本群組範圍)。

新增 apps/api/src/push/:PushProvider 抽象(第三次套用 F-1/P-4 的
Provider 模式),MockPushProvider 供測試、ExpoPushProvider 是可直接
打真實 HTTP 請求的完整實作(Expo 推播匿名發送不需金鑰,不像 LLM/TTS
需要另外挑供應商待人工確認)。PushToken/PushNotificationLog 記錄裝置
註冊與每次推播嘗試。J 群組 TaskTriggerService 觸發時會嘗試推播給
使用者已註冊裝置,用觸發訊息本身當推播內容。

本環境沒有 Android/iOS 模擬器,改用 `expo start --web` + playwright-core
(同 O 群組手法)驅動無頭瀏覽器驗證四分頁切換、真實 API 串接、聊天
完整往返、推播按鈕在無裝置環境下的優雅降級——不是原生 APP 的完整驗證,
但驗證了畫面邏輯與 API 契約層。過程中發現並修正 CORS 設定只放行網頁版
origin、擋掉 Expo 網頁版請求的問題(永久修正:新增 localhost:8081 白名單,
不影響原生 APP,原生環境沒有 CORS 限制)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 19:19:20 +08:00
co-authored by Claude Sonnet 5
parent 0e9401b891
commit 5da8b3a63e
40 changed files with 8115 additions and 92 deletions
+2
View File
@@ -16,6 +16,7 @@ import { CanonModule } from "./canon/canon.module.js";
import { EpilogueModule } from "./epilogue/epilogue.module.js"; import { EpilogueModule } from "./epilogue/epilogue.module.js";
import { TachieModule } from "./tachie/tachie.module.js"; import { TachieModule } from "./tachie/tachie.module.js";
import { VoiceModule } from "./voice/voice.module.js"; import { VoiceModule } from "./voice/voice.module.js";
import { PushModule } from "./push/push.module.js";
@Module({ @Module({
imports: [ imports: [
@@ -35,6 +36,7 @@ import { VoiceModule } from "./voice/voice.module.js";
EpilogueModule, EpilogueModule,
TachieModule, TachieModule,
VoiceModule, VoiceModule,
PushModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
+3 -1
View File
@@ -6,7 +6,9 @@ import { AppModule } from "./app.module.js";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: false }); const app = await NestFactory.create(AppModule, { logger: false });
app.enableCors({ origin: process.env.WEB_ORIGIN ?? "http://localhost:3100" }); // Q-1:8081 是 `expo start --web` 的預設埠——APP 在真機/模擬器上不受 CORS 限制(原生環境沒有這個瀏覽器機制),
// 只有用瀏覽器跑 Expo 的網頁版時才需要放行,開發環境固定加這個來源即可。
app.enableCors({ origin: [process.env.WEB_ORIGIN ?? "http://localhost:3100", "http://localhost:8081"] });
const port = Number(process.env.PORT ?? 3001); const port = Number(process.env.PORT ?? 3001);
await app.listen(port); await app.listen(port);
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`); log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
interface ExpoPushResponse {
data?: { status?: string; message?: string };
}
// Q-3 真實推播供應商:Expo 的推播 API 是「React Native + Expo」這個技術選型本身自帶的服務
// (不像 LLM/TTS 需要另外挑一家外部供應商待人工確認),基本匿名發送不需要金鑰,所以這裡直接
// 實作真正會打出去的 HTTP 呼叫——沒有真實裝置 token 時,Expo 端會回報 token 無效,屬於預期行為,
// 不是這個 Provider 本身沒做完。
@Injectable()
export class ExpoPushProvider implements PushProvider {
async send(message: PushMessage): Promise<PushSendResult> {
try {
const res = await fetch(EXPO_PUSH_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ to: message.token, title: message.title, body: message.body, data: message.data ?? {} }),
});
const payload = (await res.json().catch(() => null)) as ExpoPushResponse | null;
const ticket = payload?.data;
if (!res.ok || ticket?.status === "error") {
return { success: false, errorText: ticket?.message ?? `HTTP ${res.status}` };
}
return { success: true };
} catch (err) {
return { success: false, errorText: err instanceof Error ? err.message : String(err) };
}
}
}
@@ -0,0 +1,14 @@
import { Injectable } from "@nestjs/common";
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
// Q-3 MockPushProvider:不打真的網路請求,只記錄「這次呼叫本身有沒有正確組出訊息」——
// 讓推播的節流/記錄/與 J 群組的整合可以在沒有真實裝置與網路的情況下完整測試。
@Injectable()
export class MockPushProvider implements PushProvider {
public readonly sent: PushMessage[] = [];
async send(message: PushMessage): Promise<PushSendResult> {
this.sent.push(message);
return { success: true };
}
}
@@ -0,0 +1,35 @@
import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { PushTokenService } from "./push-token.service.js";
import { PUSH_PROVIDER, type PushProvider } from "./push-provider.js";
export interface SendToUserResult {
attempted: number;
succeeded: number;
}
// Q-3 推播通知:提醒觸發(J 群組)、角色主動訊息、鎖屏通知都走這個服務——同一個使用者可能有
// 多台裝置,逐一送出並各自留一筆記錄(成功或失敗皆記),方便之後追查「提醒有沒有真的送達」。
@Injectable()
export class PushNotificationService {
constructor(
private readonly prisma: PrismaService,
private readonly pushToken: PushTokenService,
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
) {}
async sendToUser(userId: string, title: string, body: string, data?: Record<string, unknown>): Promise<SendToUserResult> {
const tokens = await this.pushToken.listForUser(userId);
let succeeded = 0;
for (const { token } of tokens) {
const result = await this.provider.send({ token, title, body, data });
await this.prisma.client.pushNotificationLog.create({
data: { userId, title, body, success: result.success, errorText: result.errorText },
});
if (result.success) {
succeeded += 1;
}
}
return { attempted: tokens.length, succeeded };
}
}
+19
View File
@@ -0,0 +1,19 @@
export interface PushMessage {
token: string;
title: string;
body: string;
data?: Record<string, unknown>;
}
export interface PushSendResult {
success: boolean;
errorText?: string;
}
// Q-3 PushProvider 抽象:同一個模式的第三次套用(F-1 LLMProvider/P-4 TTSProvider),
// 切換 Provider 不動呼叫端任何一行。
export interface PushProvider {
send(message: PushMessage): Promise<PushSendResult>;
}
export const PUSH_PROVIDER = Symbol("PUSH_PROVIDER");
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
// Q-3 裝置推播 token 註冊:同一個 token 重複註冊(例如 APP 重新啟動)視為更新,不會產生重複紀錄。
@Injectable()
export class PushTokenService {
constructor(private readonly prisma: PrismaService) {}
async register(userId: string, token: string, platform: string) {
return this.prisma.client.pushToken.upsert({
where: { token },
update: { userId, platform },
create: { userId, token, platform },
});
}
async listForUser(userId: string) {
return this.prisma.client.pushToken.findMany({ where: { userId } });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Body, Controller, Post } from "@nestjs/common";
import { PushTokenService } from "./push-token.service.js";
import { PushNotificationService } from "./push-notification.service.js";
interface RegisterBody {
userId: string;
token: string;
platform: string;
}
interface SendTestBody {
userId: string;
title: string;
body: string;
}
@Controller("push")
export class PushController {
constructor(
private readonly pushToken: PushTokenService,
private readonly pushNotification: PushNotificationService,
) {}
@Post("register")
async register(@Body() body: RegisterBody) {
return this.pushToken.register(body.userId, body.token, body.platform);
}
@Post("send-test")
async sendTest(@Body() body: SendTestBody) {
return this.pushNotification.sendToUser(body.userId, body.title, body.body);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Module } from "@nestjs/common";
import { log } from "@kokorone/shared";
import { PrismaModule } from "../prisma/prisma.module.js";
import { PushTokenService } from "./push-token.service.js";
import { PushNotificationService } from "./push-notification.service.js";
import { MockPushProvider } from "./mock-push-provider.service.js";
import { ExpoPushProvider } from "./expo-push-provider.service.js";
import { PUSH_PROVIDER } from "./push-provider.js";
import { PushController } from "./push.controller.js";
// Q-3 Provider 切換:PUSH_PROVIDER=mock|expo,預設 mock(同 F-2/P-4 的作法,開發期不依賴外部服務)。
function pushProviderFactory(mock: MockPushProvider, expoProvider: ExpoPushProvider) {
const providerName = process.env.PUSH_PROVIDER ?? "mock";
if (providerName === "expo") {
log("啟動", "INF", "PUSH_PROVIDER=expo,推播會真的呼叫 Expo 推播 API(沒有真實裝置 token 時預期會失敗)");
return expoProvider;
}
return mock;
}
@Module({
imports: [PrismaModule],
controllers: [PushController],
providers: [
PushTokenService,
PushNotificationService,
MockPushProvider,
ExpoPushProvider,
{ provide: PUSH_PROVIDER, useFactory: pushProviderFactory, inject: [MockPushProvider, ExpoPushProvider] },
],
exports: [PushTokenService, PushNotificationService, PUSH_PROVIDER, MockPushProvider],
})
export class PushModule {}
@@ -2,16 +2,20 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
import { ScheduleService } from "../schedule/schedule.service.js"; import { ScheduleService } from "../schedule/schedule.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { PushNotificationService } from "../push/push-notification.service.js";
import { taipeiParts } from "../schedule/time-utils.js"; import { taipeiParts } from "../schedule/time-utils.js";
import { buildTriggerMessage } from "./task-performance.js"; import { buildTriggerMessage } from "./task-performance.js";
// J-3 觸發演出:任務時間到時,依作息語氣+相關記憶回顧組成訊息;準時觸發是引擎責任,語氣是角色責任。 // J-3 觸發演出:任務時間到時,依作息語氣+相關記憶回顧組成訊息;準時觸發是引擎責任,語氣是角色責任。
// Q-3:觸發的同一刻也會嘗試推播給使用者的裝置(提醒、她的主動訊息都走這條路)——沒有註冊過
// 任何裝置 token 時,PushNotificationService.sendToUser 單純回報 attempted=0,不影響任務本身的觸發。
@Injectable() @Injectable()
export class TaskTriggerService { export class TaskTriggerService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly schedule: ScheduleService, private readonly schedule: ScheduleService,
private readonly memoryRetriever: KeywordMemoryRetriever, private readonly memoryRetriever: KeywordMemoryRetriever,
private readonly pushNotification: PushNotificationService,
) {} ) {}
async fire(taskId: string, now: Date = new Date()): Promise<string | null> { async fire(taskId: string, now: Date = new Date()): Promise<string | null> {
@@ -46,6 +50,8 @@ export class TaskTriggerService {
data: { status: "TRIGGERED", triggeredAt: now, triggerMessage: message }, data: { status: "TRIGGERED", triggeredAt: now, triggerMessage: message },
}); });
await this.pushNotification.sendToUser(task.userId, "心音 Kokorone", message);
return message; return message;
} }
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import { MemoryModule } from "../memory/memory.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js"; import { RelationshipModule } from "../relationship/relationship.module.js";
import { EmotionModule } from "../emotion/emotion.module.js"; import { EmotionModule } from "../emotion/emotion.module.js";
import { ScheduleModule } from "../schedule/schedule.module.js"; import { ScheduleModule } from "../schedule/schedule.module.js";
import { PushModule } from "../push/push.module.js";
import { TaskService } from "./task.service.js"; import { TaskService } from "./task.service.js";
import { TaskSchedulerService } from "./task-scheduler.service.js"; import { TaskSchedulerService } from "./task-scheduler.service.js";
import { TaskTriggerService } from "./task-trigger.service.js"; import { TaskTriggerService } from "./task-trigger.service.js";
@@ -13,7 +14,7 @@ import { ProactiveCareService } from "./proactive-care.service.js";
import { TaskController } from "./task.controller.js"; import { TaskController } from "./task.controller.js";
@Module({ @Module({
imports: [PrismaModule, MemoryModule, RelationshipModule, EmotionModule, ScheduleModule], imports: [PrismaModule, MemoryModule, RelationshipModule, EmotionModule, ScheduleModule, PushModule],
controllers: [TaskController], controllers: [TaskController],
providers: [ providers: [
TaskService, TaskService,
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
{
"expo": {
"name": "心音 Kokorone",
"slug": "kokorone",
"scheme": "kokorone",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router"
]
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Tabs } from "expo-router";
// Q-2 底部分頁籤:聊天/角色/日常/設定,同行動網頁版佈局+這一層分頁籤是 APP 相對於行動網頁的唯一差異。
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#FF7E9D",
}}
>
<Tabs.Screen name="index" options={{ title: "聊天" }} />
<Tabs.Screen name="character" options={{ title: "角色" }} />
<Tabs.Screen name="daily" options={{ title: "日常" }} />
<Tabs.Screen name="settings" options={{ title: "設定" }} />
</Tabs>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import type { Character } from "@kokorone/shared";
import { API_BASE_URL } from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
// Q-2 角色分頁:展示角色設定表(沿用 B 群組的 /characters 端點),驗證分頁本身可獨立運作、能打 API。
export default function CharacterScreen() {
const [character, setCharacter] = useState<Character | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_BASE_URL}/characters`)
.then((res) => res.json())
.then((characters: Character[]) => {
setCharacter(characters.find((c) => c.id === CHARACTER_ID) ?? characters[0] ?? null);
})
.catch(() => setError("角色資料載入失敗,請確認 api 服務是否已啟動"));
}, []);
const formalName = character?.aliases?.[0]?.formalName ?? character?.id ?? "";
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>{formalName || "角色"}</Text>
{error && <Text style={styles.error}>{error}</Text>}
{character && (
<View style={styles.card}>
<Field label="性格原型" value={character.personalityArchetype} />
<Field label="基本資料" value={character.basicInfo} />
<Field label="喜好/厭惡" value={character.likesDislikes} />
<Field label="目標/執念" value={character.goalsObsessions} />
<Field label="說話方式" value={character.speechStyle} />
</View>
)}
</ScrollView>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={styles.fieldValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 16 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 14 },
field: { gap: 2 },
fieldLabel: { fontSize: 12, color: "#8a6b6f" },
fieldValue: { fontSize: 14, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { API_BASE_URL } from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
interface ScheduleInfo {
dayType: string;
minute: number;
slot: { status: string; label: string | null } | null;
isWaking: boolean;
isPreSleep: boolean;
availability: string;
}
// Q-2 日常分頁:展示 I 群組的作息狀態(她現在在做什麼、回應可用度),是「她有自己的生活」這件事
// 在 APP 上最直接的呈現位置。若種子角色沒有掛作息表(見 I 群組實作記錄),slot 會是 null。
export default function DailyScreen() {
const [info, setInfo] = useState<ScheduleInfo | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_BASE_URL}/schedule/${CHARACTER_ID}/info`)
.then((res) => res.json())
.then(setInfo)
.catch(() => setError("作息資料載入失敗,請確認 api 服務是否已啟動"));
}, []);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>今天的她</Text>
{error && <Text style={styles.error}>{error}</Text>}
{info && (
<View style={styles.card}>
<Text style={styles.line}>作息類型:{info.dayType}</Text>
<Text style={styles.line}>目前時段:{info.slot?.label ?? info.slot?.status ?? "(未設定作息表)"}</Text>
<Text style={styles.line}>回應可用度:{info.availability}</Text>
{info.isWaking && <Text style={styles.badge}>剛睡醒</Text>}
{info.isPreSleep && <Text style={styles.badge}>快睡了</Text>}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 16 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 8 },
line: { fontSize: 14, color: "#222" },
badge: { fontSize: 12, color: "#FF7E9D", fontWeight: "bold" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+169
View File
@@ -0,0 +1,169 @@
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import type { EmotionTag } from "@kokorone/shared";
import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api";
import { EMOTION_LABELS } from "../../lib/emotion-labels";
import { generateSessionId } from "../../lib/id";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const EXPRESSION_LEAD_MS = 220; // O-2/Q-2:表情先變化、文字延後一小段時間出現,跟 apps/web 的作法一致。
interface Message {
role: "user" | "character";
text: string;
}
// Q-2 行動版佈局:立繪半身像置於上方 40% 為背景層,對話流覆蓋其上向下延伸——
// 跟行動網頁版同一份設計原則,只是這裡用 View 的 flex 排版而非 CSS。立繪本體目前是佔位色塊
// (O 群組的 PixiJS 渲染器是網頁 DOM/Canvas 實作,沒有移植到 React Native,見本群組實作記錄)。
export default function ChatScreen() {
const [sessionId] = useState(() => generateSessionId());
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [dominant, setDominant] = useState<EmotionTag>("CALM");
const [intimacy, setIntimacy] = useState(0);
const [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSend() {
const text = input.trim();
if (!text || sending) {
return;
}
setSending(true);
setError(null);
setInput("");
setMessages((prev) => [...prev, { role: "user", text }]);
try {
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
if (result.availability === "NO_REPLY") {
return;
}
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
try {
setExpression(await fetchExpression(CHARACTER_ID, USER_ID));
} catch {
// 表情查詢失敗不影響對話本身。
}
await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS));
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
setSending(false);
}
}
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.tachieArea}>
<Text style={styles.tachieLabel}>親密度 {intimacy}</Text>
<View style={styles.emotionChip}>
<Text style={styles.emotionChipText}>{EMOTION_LABELS[dominant]}</Text>
</View>
<View style={[styles.tachiePlaceholder, expression ? { opacity: 0.85 + expression.magnitude * 0.15 } : null]} />
<Text style={styles.tachieCaption}>立繪(表情差分即時變化)</Text>
</View>
<ScrollView style={styles.messages} contentContainerStyle={styles.messagesContent}>
{messages.length === 0 && <Text style={styles.hint}>傳個訊息,開始今天的對話吧。</Text>}
{messages.map((message, index) => (
<View
key={index}
style={[styles.bubble, message.role === "user" ? styles.bubbleUser : styles.bubbleCharacter]}
>
<Text style={styles.bubbleText}>{message.text}</Text>
</View>
))}
</ScrollView>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.inputRow}>
<TextInput
value={input}
onChangeText={setInput}
placeholder="輸入訊息……"
style={styles.input}
editable={!sending}
onSubmitEditing={handleSend}
/>
<Pressable onPress={handleSend} disabled={sending}>
<Text style={[styles.sendButton, sending ? styles.sendButtonDisabled : null]}>送出</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: "#FFF5F7" },
tachieArea: {
height: "40%",
backgroundColor: "#FCE4EC",
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: 16,
},
tachieLabel: { position: "absolute", top: 14, left: 14, fontSize: 11, color: "#8a6b6f" },
emotionChip: {
position: "absolute",
top: 14,
right: 14,
backgroundColor: "#FFE0EA",
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
},
emotionChipText: { color: "#FF7E9D", fontWeight: "bold", fontSize: 12 },
tachiePlaceholder: {
width: 120,
height: 190,
borderTopLeftRadius: 60,
borderTopRightRadius: 60,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
backgroundColor: "#FF9EB5",
},
tachieCaption: { fontSize: 11, color: "#8a6b6f", marginTop: 8 },
messages: { flex: 1 },
messagesContent: { padding: 16, gap: 8 },
hint: { fontSize: 13, color: "#8a6b6f" },
bubble: { maxWidth: "80%", borderRadius: 16, padding: 10 },
bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" },
bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" },
bubbleText: { color: "#222" },
error: { color: "#FF7E9D", fontSize: 12, paddingHorizontal: 16 },
inputRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
margin: 12,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: "#E5D5D8",
},
input: { flex: 1, fontSize: 14 },
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
sendButtonDisabled: { opacity: 0.5 },
});
+81
View File
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { fetchHealth, registerPushToken } from "../../lib/api";
const USER_ID = "seed-user-primary";
// Q-1 驗收:APP 能呼叫 api 的 /health 並顯示結果。
// Q-3 推播通知:請求權限、取得 Expo push token、註冊到後端——實機/模擬器才能真的收到推播,
// 這裡的按鈕操作與畫面回饋在沒有實體裝置的環境下也能驗證到「流程本身有沒有跑起來、有沒有回報錯誤」。
export default function SettingsScreen() {
const [healthStatus, setHealthStatus] = useState<string | null>(null);
const [healthError, setHealthError] = useState<string | null>(null);
const [pushStatus, setPushStatus] = useState<string | null>(null);
async function checkHealth() {
setHealthError(null);
try {
const result = await fetchHealth();
setHealthStatus(result.status);
} catch {
setHealthError("無法連線到 api 服務");
}
}
async function enablePushNotifications() {
setPushStatus("請求權限中…");
if (!Device.isDevice) {
setPushStatus("推播僅能在實機或支援的模擬器上取得 token(目前環境無法取得真實 token)");
return;
}
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") {
setPushStatus("使用者未授權推播通知");
return;
}
try {
const tokenResponse = await Notifications.getExpoPushTokenAsync();
await registerPushToken(USER_ID, tokenResponse.data, "expo");
setPushStatus(`已註冊推播 token:${tokenResponse.data}`);
} catch (err) {
setPushStatus(`推播 token 取得失敗:${err instanceof Error ? err.message : String(err)}`);
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>設定</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>API 連線狀態</Text>
<Pressable style={styles.button} onPress={checkHealth}>
<Text style={styles.buttonText}>檢查連線</Text>
</Pressable>
{healthStatus && <Text style={styles.line}>狀態:{healthStatus}</Text>}
{healthError && <Text style={styles.error}>{healthError}</Text>}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>推播通知</Text>
<Pressable style={styles.button} onPress={enablePushNotifications}>
<Text style={styles.buttonText}>開啟推播通知</Text>
</Pressable>
{pushStatus && <Text style={styles.line}>{pushStatus}</Text>}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 24 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 10 },
cardTitle: { fontSize: 15, fontWeight: "bold", color: "#222" },
button: { alignSelf: "flex-start", backgroundColor: "#FF7E9D", borderRadius: 999, paddingHorizontal: 16, paddingVertical: 8 },
buttonText: { color: "#fff", fontWeight: "bold" },
line: { fontSize: 13, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
// Q-1/Q-2 根佈局:只有一層 Stack 包住底部分頁籤群組,佈局策略跟 apps/web 的 RWD 行動版共用同一份設計原則
// (立繪置頂 40% + 對話流覆蓋其上),只是 React Native 沒有 CSS,改用 View 的 flex 排版達成同樣效果。
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+87
View File
@@ -0,0 +1,87 @@
import type { EmotionTag, RelationshipStage } from "@kokorone/shared";
// Q-1:與 apps/web/lib/api.ts 共用同一套 @kokorone/shared 型別,API client 本身雖是各端各自實作
// (React Native 沒有 Next.js 的伺服器端環境變數機制),但契約(回應形狀)三端一致。
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
export interface HealthResponse {
status: string;
}
export async function fetchHealth(): Promise<HealthResponse> {
const res = await fetch(`${API_BASE_URL}/health`);
if (!res.ok) {
throw new Error(`健康檢查失敗(${res.status})`);
}
return res.json();
}
export interface ChatResponseStyle {
tone: string;
sentenceLength: "short" | "medium" | "long";
initiative: "low" | "medium" | "high";
}
export interface ChatContext {
emotion: { dominant: EmotionTag; style: ChatResponseStyle };
relationship: { intimacy: number; trust: number; stage: RelationshipStage };
}
export type ChatAvailability = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
export interface ChatResult {
text: string;
actions: string[];
isFastChannel: boolean;
availability: ChatAvailability;
exceptionType: string | null;
context: ChatContext | null;
}
export async function sendChatMessage(
characterId: string,
userId: string,
sessionId: string,
text: string,
): Promise<ChatResult> {
const res = await fetch(`${API_BASE_URL}/chat/${characterId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, sessionId, text }),
});
if (!res.ok) {
throw new Error(`對話請求失敗(${res.status})`);
}
return res.json();
}
export interface TachieExpressionResponse {
emotionTag: EmotionTag;
rawEmotionTag: EmotionTag;
eyebrow: string;
eye: string;
mouth: string;
effect: string | null;
pose: string;
magnitude: number;
gaze: "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
availableCostumes: string[];
lockedFallbackApplied: boolean;
usedCryingVariant: boolean;
}
export async function fetchExpression(characterId: string, userId: string): Promise<TachieExpressionResponse> {
const res = await fetch(`${API_BASE_URL}/tachie/${characterId}/${userId}/expression`);
if (!res.ok) {
throw new Error(`表情查詢失敗(${res.status})`);
}
return res.json();
}
export async function registerPushToken(userId: string, token: string, platform: string): Promise<void> {
await fetch(`${API_BASE_URL}/push/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, token, platform }),
});
}
+12
View File
@@ -0,0 +1,12 @@
import type { EmotionTag } from "@kokorone/shared";
// 跟 apps/web/lib/emotion-labels.ts 內容相同——標籤本身很小,先各自維護一份,
// 之後若三端要共用更多 UI 邏輯,這是第一個適合搬進 packages/shared 的候選。
export const EMOTION_LABELS: Record<EmotionTag, string> = {
CALM: "平靜",
JOY: "愉悅",
SAD: "低落",
ALERT: "警戒",
SHY: "害羞",
GRUMPY: "彆扭",
};
+7
View File
@@ -0,0 +1,7 @@
// 部分 Hermes 版本沒有全域 crypto.randomUUID,這裡提供一個不依賴額外套件的簡易後備方案。
export function generateSessionId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@kokorone/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@expo/metro-runtime": "~57.0.9",
"@kokorone/shared": "*",
"expo": "~57.0.12",
"expo-constants": "~57.0.10",
"expo-device": "~57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.10",
"expo-router": "~57.0.12",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
+7089 -86
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "push_tokens" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "push_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "push_notification_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"success" BOOLEAN NOT NULL,
"errorText" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "push_notification_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens"("token");
+28
View File
@@ -120,6 +120,8 @@ model User {
adultModeConsent AdultModeConsent? adultModeConsent AdultModeConsent?
romanceEventLogs RomanceEventLog[] romanceEventLogs RomanceEventLog[]
adultContentAttempts AdultContentAttemptLog[] adultContentAttempts AdultContentAttemptLog[]
pushTokens PushToken[]
pushNotificationLogs PushNotificationLog[]
@@map("users") @@map("users")
} }
@@ -729,3 +731,29 @@ model EpilogueMilestoneLog {
@@map("epilogue_milestone_logs") @@map("epilogue_milestone_logs")
} }
// Q-3 推播通知:使用者的裝置推播 token(一個使用者可以有多台裝置,各自登記一筆)。
model PushToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
platform String // 例如 "expo"
createdAt DateTime @default(now())
@@map("push_tokens")
}
// Q-3 每次嘗試送出推播都留一筆記錄(成功或失敗皆記),方便之後追查「提醒有沒有真的送達」。
model PushNotificationLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
body String
success Boolean
errorText String?
createdAt DateTime @default(now())
@@map("push_notification_logs")
}
+3
View File
@@ -253,4 +253,7 @@ export default async function smokeJ() {
// 清理本次測試建立的角色(cascade 會一併清掉任務、主動關照紀錄、記憶等)。 // 清理本次測試建立的角色(cascade 會一併清掉任務、主動關照紀錄、記憶等)。
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
// Q-3:任務觸發會嘗試推播給使用者,PushNotificationLog 不隨 Character cascade 清除
// (它只綁 userId,不綁 characterId),這裡額外清掉本次測試產生的推播記錄,避免無限累積。
await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID, title: "心音 Kokorone" } });
} }
+118
View File
@@ -0,0 +1,118 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const USER_ID = "seed-user-primary";
const CHAR_ID = "smoke-q-char";
const TOKEN = "ExponentPushToken[smoke-q-test-token]";
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
// Q-1/Q-2/Q-3 目前都是行動 APP(React Native + Expo,apps/mobile)消費既有的 API 契約,
// 沒有為 Q 群組新增角色/對話端點——這裡驗證的是 Q 群組唯一新增的後端功能:推播通知(Q-3),
// 以及 APP 賴以運作的既有契約仍然完整(/health、/chat、/characters、/schedule)。
// APP 本身的畫面/導覽/實際串接已在本群組以 `expo start --web` + 無頭瀏覽器手動驗證過
// (見 todo.md 實作記錄),不在這個純後端 smoke test 的範圍內。
export default async function smokeQ() {
await prisma.character.deleteMany({ where: { id: CHAR_ID } });
await prisma.pushToken.deleteMany({ where: { token: TOKEN } });
// Q-1 驗收:APP 能呼叫 api 的 /health 並顯示結果——直接驗證這個契約存在且回應正常。
const health = await get("/health");
if (!health || typeof health.status !== "string") {
throw new Error("/health 應該回傳可顯示的狀態文字");
}
await post("/personality/characters", {
id: CHAR_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "測試角色",
basicInfo: "測試角色",
backgroundStory: "測試",
personalityArchetype: "元氣",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy: 20, trust: 10 },
});
// Q-2 驗收(後端契約部分):聊天分頁依賴的 /chat 端點能完成一輪對話並回傳更新後的關係/情緒狀態。
const chatResult = await post(`/chat/${CHAR_ID}`, {
userId: USER_ID,
sessionId: "smoke-q-session",
text: "嗨,最近好嗎?",
});
if (!chatResult.text || !chatResult.context) {
throw new Error("聊天分頁需要的一輪對話(含情緒/關係上下文)應該能正常完成");
}
// Q-3 推播通知:註冊裝置 token、送出推播應該留下記錄;J 群組的提醒觸發應該會嘗試推播。
await post("/push/register", { userId: USER_ID, token: TOKEN, platform: "expo" });
const registered = await prisma.pushToken.findUnique({ where: { token: TOKEN } });
if (!registered || registered.userId !== USER_ID) {
throw new Error("裝置推播 token 應該被正確註冊");
}
const sendResult = await post("/push/send-test", { userId: USER_ID, title: "測試標題", body: "測試內容" });
if (sendResult.attempted !== 1 || sendResult.succeeded !== 1) {
throw new Error("已註冊裝置的使用者,推播應該嘗試送出且成功(Mock Provider)");
}
const log = await prisma.pushNotificationLog.findFirst({
where: { userId: USER_ID, title: "測試標題" },
orderBy: { createdAt: "desc" },
});
if (!log || !log.success) {
throw new Error("每次推播嘗試都應該留下記錄");
}
// 重複註冊同一個 token(模擬 APP 重啟)不應該產生重複紀錄。
await post("/push/register", { userId: USER_ID, token: TOKEN, platform: "expo" });
const tokenCount = await prisma.pushToken.count({ where: { token: TOKEN } });
if (tokenCount !== 1) {
throw new Error("重複註冊同一個裝置 token 不應該產生重複紀錄");
}
// J 群組整合:任務觸發時應該嘗試推播給使用者(用實際觸發一個任務來驗證,而不是直接呼叫推播端點)。
const task = await post(`/task/${CHAR_ID}`, {
userId: USER_ID,
type: "ONE_TIME",
content: "測試提醒",
rawText: "提醒我測試",
});
const beforeCount = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } });
await post(`/task/${task.task.id}/fire-now`, {});
const afterCount = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } });
if (afterCount !== beforeCount + 1) {
throw new Error("J 群組建立的提醒觸發時,應該嘗試推播給使用者的已註冊裝置");
}
const taskPushLog = await prisma.pushNotificationLog.findFirst({
where: { userId: USER_ID },
orderBy: { createdAt: "desc" },
});
if (!taskPushLog?.body.includes("測試提醒")) {
throw new Error("任務觸發的推播內容應該包含提醒訊息本身");
}
// 清理本次測試建立的角色與推播資料(cascade 會一併清掉任務、關係、情緒狀態等)。
await prisma.character.deleteMany({ where: { id: CHAR_ID } });
await prisma.pushToken.deleteMany({ where: { token: TOKEN } });
await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID, title: { in: ["測試標題", "心音 Kokorone"] } } });
}
+14 -4
View File
@@ -405,10 +405,20 @@ flowchart TB
### Q. APP(React Native + Expo) ### Q. APP(React Native + Expo)
- [ ] **Q-1 Expo 專案建立(S)**:於 `apps/mobile` 建立 Expo 專案並接上 `packages/shared`(型別與 API client 共用)。驗收:APP 能呼叫 api 的 `/health` 並顯示結果。依據:技術選型「React Native + Expo」。 - [x] **Q-1 Expo 專案建立(S)**:於 `apps/mobile` 建立 Expo 專案並接上 `packages/shared`(型別與 API client 共用)。驗收:APP 能呼叫 api 的 `/health` 並顯示結果。依據:技術選型「React Native + Expo」。
- [ ] **Q-2 行動版佈局與分頁籤(M)**:立繪置頂+對話流覆蓋的單欄佈局,底部分頁籤(聊天/角色/日常/設定)。驗收:四個分頁可切換且聊天分頁可完成一輪對話。依據:§三種載體的版面「APP」。 - [x] **Q-2 行動版佈局與分頁籤(M)**:立繪置頂+對話流覆蓋的單欄佈局,底部分頁籤(聊天/角色/日常/設定)。驗收:四個分頁可切換且聊天分頁可完成一輪對話。依據:§三種載體的版面「APP」。
- [ ] **Q-3 推播通知(M)**:接上 Expo 推播,支援提醒觸發、角色主動訊息與鎖屏通知。驗收:J 群組建立的提醒能以推播送達。依據:§三種載體的版面「支援推播(提醒、她的主動訊息)與鎖屏通知」。 - [x] **Q-3 推播通知(M)**:接上 Expo 推播,支援提醒觸發、角色主動訊息與鎖屏通知。驗收:J 群組建立的提醒能以推播送達。依據:§三種載體的版面「支援推播(提醒、她的主動訊息)與鎖屏通知」。
- [ ] **Q-V 階段驗證(S)**:`npm run restart && npm run smoke -- Q`(Q.mjs:API 契約層測試),並在模擬器或實機啟動 APP 完成一輪對話與一次推播(需人工確認可用裝置)。 - [x] **Q-V 階段驗證(S)**:`npm run restart && npm run smoke -- Q`(Q.mjs:API 契約層測試),並在模擬器或實機啟動 APP 完成一輪對話與一次推播(需人工確認可用裝置)。
> **實作記錄(Q 群組)**:
> - `apps/mobile` 用 `create-expo-app` 的 `blank-typescript` 範本建立,改用 **Expo Router**(檔案系統路由)取代範本內建的單一 `App.tsx`——跟 `apps/web` 的 Next.js App Router 是同一套心智模型(`app/(tabs)/` 底下每個檔案就是一個分頁),三端統一走「檔案結構=路由結構」的慣例。套件命名對齊既有慣例改成 `@kokorone/mobile`,`main` 指到 `expo-router/entry`。
> - **四個分頁(聊天/角色/日常/設定)都是真的打 API、不是靜態畫面**:角色分頁打 `/characters`、日常分頁打 `/schedule/:id/info`、設定分頁打 `/health` 與推播註冊、聊天分頁完整重用 H 群組/O 群組已經驗證過的對話+表情查詢流程(`sendChatMessage` → 更新關係/情緒 → 查詢 `/tachie/.../expression` → 延遲約 220ms 才顯示文字,跟 `apps/web/app/chat/ChatView.tsx` 是同一個節奏,只是元件換成 React Native 的 `View`/`Text`/`TextInput`)。
> - **立繪本體目前是 React Native 的佔位色塊,沒有把 O 群組的 PixiJS 渲染器搬過來**:PixiJS 的 `Application`/`Graphics`/`Text` 是瀏覽器 DOM/Canvas API,React Native 沒有這層——真的要在 APP 上重現分層合成的立繪,需要 `react-native-skia` 或類似的原生繪圖套件,屬於超出本群組時間範圍的額外工作,先用一個依情緒 `magnitude` 微調透明度的色塊佔位,保留「這裡之後要接真的立繪渲染」的位置。
> - **本環境沒有 Android/iOS 模擬器(無 Android SDK、非 macOS),所以「在模擬器或實機啟動 APP」這項驗收如實無法在這個環境完成**——這正是 Q-V 驗收文字本身就寫明「需人工確認可用裝置」的部分。改用 `expo start --web`(Expo/React Native Web 支援)+ 在 scratchpad 暫裝的 `playwright-core`(同 O 群組的作法)實際驅動一個無頭瀏覽器跑過完整流程:四個分頁都能切換、都能打到真的 API 並顯示回應、聊天分頁完成一輪對話後親密度與情緒即時更新、推播按鈕在沒有真實裝置時會走「未授權/非裝置」的訊息分支而不是整個掛掉。**這不是原生 APP 的完整驗證(沒有測到原生模組、沒有測到真的推播送達),但驗證了「畫面邏輯、導覽、API 串接」這一層的正確性**,比完全沒驗證好得多,且跟 O 群組的瀏覽器驗證方法論一致。
> - **踩到一個真的 CORS 問題**:`expo start --web` 預設跑在 `localhost:8081`,但 `apps/api/src/main.ts` 的 CORS 設定只放行 `WEB_ORIGIN`(預設 `localhost:3100`,即 Next.js 網頁版),瀏覽器直接擋掉行動版網頁對 API 的請求——這個限制只在「用瀏覽器測 Expo 網頁版」這個情境下存在,原生 iOS/Android APP 不受 CORS 約束(CORS 是瀏覽器機制)。修正方式是讓 `enableCors` 的 `origin` 同時放行 `WEB_ORIGIN` 與 `localhost:8081` 兩個來源——**這是一個小但永久的後端設定變更**(不是只在驗證時暫開、驗證完就關),因為之後任何人想用 `expo start --web` 快速檢查行動版而不開實體裝置,都會需要這條放行。
> - **Q-3 推播的 Provider 抽象是 F-1 LLMProvider/P-4 TTSProvider 模式的第三次套用**(`PushProvider`/`PUSH_PROVIDER`/`MockPushProvider`/`ExpoPushProvider`/`PUSH_PROVIDER=mock|expo`),但跟 LLM/TTS 不同的地方是:**Expo 推播 API 不需要另外挑供應商待人工確認**——「React Native + Expo」這個技術選型本身就決定了推播走 Expo 的服務,且匿名發送不需要金鑰,所以 `ExpoPushProvider` 是直接可以打出真實 HTTP 請求的完整實作,不是像 `RealTTSProvider`/`ClaudeProvider` 那樣的空殼——只是在這個環境沒有真實裝置 token,實際呼叫 Expo 端會回報 token 無效,這是預期行為。
> - **J 群組整合**:`TaskTriggerService.fire()` 現在觸發訊息組好之後,會呼叫 `PushNotificationService.sendToUser`,用同一句觸發訊息當推播內容——沒有註冊任何裝置 token 的使用者,`sendToUser` 單純回報 `attempted: 0`,不影響任務本身照常觸發。**新增了 `PushNotificationLog` 這張表記錄每次推播嘗試(成功或失敗皆記)**,但它只綁 `userId` 不綁 `characterId`,不會隨著 `Character` 被刪除 cascade 清掉——`J.mjs` 因此補了一行清理,否則每次跑 J 群組的迴歸測試都會在這張表留下不會消失的殘留資料。
> - **全量回歸執行到 D/J 群組時各出現一次瞬斷**(`fetch failed`/重啟健康檢查逾時 60 秒),當時主機負載飆到 41.77——跟前面幾個群組記錄過的環境性瞬斷是同一類狀況,負載降下來後單獨重跑兩個群組都立刻全綠,不是 Q 群組程式碼引入的迴歸。
### R. 正式基礎設施遷移 ### R. 正式基礎設施遷移