feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能

新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:10:26 +08:00
co-authored by Claude Sonnet 5
parent 127a59dafd
commit 1bea06e3cd
195 changed files with 17219 additions and 738 deletions
+12 -1
View File
@@ -1,7 +1,11 @@
import { Tabs } from "expo-router";
import { HeartbeatDot } from "../../components/HeartbeatDot";
import { useUnread } from "../../lib/unread-context";
// Q-2 底部分頁籤:聊天/角色/日常/設定,同行動網頁版佈局+這一層分頁籤是 APP 相對於行動網頁的唯一差異。
export default function TabsLayout() {
const { hasUnread } = useUnread();
return (
<Tabs
screenOptions={{
@@ -9,7 +13,14 @@ export default function TabsLayout() {
tabBarActiveTintColor: "#FF7E9D",
}}
>
<Tabs.Screen name="index" options={{ title: "聊天" }} />
<Tabs.Screen
name="index"
options={{
title: "大廳",
// Q-4/T20-P2:收到推播、或任一房間有未讀訊息時,籤上顯示心跳紅點;都清了才不佔版面。
tabBarIcon: hasUnread ? () => <HeartbeatDot size={8} /> : undefined,
}}
/>
<Tabs.Screen name="character" options={{ title: "角色" }} />
<Tabs.Screen name="daily" options={{ title: "日常" }} />
<Tabs.Screen name="settings" options={{ title: "設定" }} />
+20 -159
View File
@@ -1,169 +1,30 @@
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";
import { useCallback } from "react";
import { ScrollView, StyleSheet } from "react-native";
import { useFocusEffect } from "expo-router";
import { LobbyPreview } from "../../components/LobbyPreview";
import { useUnread } from "../../lib/unread-context";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const EXPRESSION_LEAD_MS = 220; // O-2/Q-2:表情先變化、文字延後一小段時間出現,跟 apps/web 的作法一致。
// T20-P2:聊天分頁改為大廳優先——取代舊版寫死單一角色的 1:1 聊天畫面(該畫面併入 T38 的 SOLO
// 房後,固定角色的假設已不成立:使用者現在透過大廳喊人,答應者可能是任何角色)。實際對話畫面
// 移到 app/room/[roomId].tsx,由本畫面的房間清單/大廳呼叫導向。
export default function LobbyScreen() {
const { markRead } = useUnread();
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);
}
}
// Q-4:使用者切回本分頁即視為已讀推播提示(各房未讀數則由 LobbyPreview 每次刷新時各自更新)。
useFocusEffect(
useCallback(() => {
markRead();
}, [markRead]),
);
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>
<ScrollView style={styles.flex} contentContainerStyle={styles.content}>
<LobbyPreview />
</ScrollView>
);
}
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 },
content: { paddingBottom: 24 },
});
+24 -2
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useEffect, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from "react-native";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { fetchHealth, registerPushToken } from "../../lib/api";
import { isVoiceAutoplayEnabled, setVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings";
const USER_ID = "seed-user-primary";
@@ -13,6 +14,17 @@ 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);
// T20-P3:與 apps/web 的 VoiceAutoplaySection.tsx 對應,關閉後房間畫面回到逐則點擊才播放。
const [voiceAutoplay, setVoiceAutoplay] = useState(true);
useEffect(() => {
void isVoiceAutoplayEnabled().then(setVoiceAutoplay);
}, []);
async function handleToggleVoiceAutoplay(next: boolean) {
await setVoiceAutoplayEnabled(next);
setVoiceAutoplay(next);
}
async function checkHealth() {
setHealthError(null);
@@ -64,6 +76,15 @@ export default function SettingsScreen() {
</Pressable>
{pushStatus && <Text style={styles.line}>{pushStatus}</Text>}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>自走語音自動播放</Text>
<Text style={styles.line}>開啟後,在房間畫面時角色的新發言會自動依序念出來;關閉則回到逐則點擊才播放。</Text>
<View style={styles.switchRow}>
<Switch value={voiceAutoplay} onValueChange={(next) => void handleToggleVoiceAutoplay(next)} />
<Text style={styles.line}>在房間時自動播放角色語音</Text>
</View>
</View>
</ScrollView>
);
}
@@ -78,4 +99,5 @@ const styles = StyleSheet.create({
buttonText: { color: "#fff", fontWeight: "bold" },
line: { fontSize: 13, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
switchRow: { flexDirection: "row", alignItems: "center", gap: 10 },
});
+21 -1
View File
@@ -1,12 +1,32 @@
import { useEffect } from "react";
import { Stack } from "expo-router";
import * as Notifications from "expo-notifications";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { UnreadProvider, useUnread } from "../lib/unread-context";
// Q-1/Q-2 根佈局:只有一層 Stack 包住底部分頁籤群組,佈局策略跟 apps/web 的 RWD 行動版共用同一份設計原則
// (立繪置頂 40% + 對話流覆蓋其上),只是 React Native 沒有 CSS,改用 View 的 flex 排版達成同樣效果。
function NotificationBadgeBridge() {
const { markUnread } = useUnread();
// Q-4:收到推播(含 App 前景中收到)就點亮聊天分頁的心跳紅點,直到使用者切回聊天分頁才清除。
useEffect(() => {
const subscription = Notifications.addNotificationReceivedListener(() => {
markUnread();
});
return () => subscription.remove();
}, [markUnread]);
return null;
}
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }} />
<UnreadProvider>
<NotificationBadgeBridge />
<Stack screenOptions={{ headerShown: false }} />
</UnreadProvider>
</SafeAreaProvider>
);
}
+279
View File
@@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import {
importCanonScene,
listCanonWorks,
listCharacters,
type CanonWork,
type CharacterSummary,
type ImportSceneLineInput,
type ImportedSceneResult,
type SceneLineType,
} from "../lib/api";
const LINE_TYPE_LABELS: Record<SceneLineType, string> = {
DIALOGUE: "台詞",
ACTION: "動作描寫",
MONOLOGUE: "內心獨白",
NARRATION: "敘述",
};
const LINE_TYPES = Object.keys(LINE_TYPE_LABELS) as SceneLineType[];
function emptyLine(): ImportSceneLineInput {
return { lineType: "NARRATION", rawText: "" };
}
// T20-P4 匯入章節:與 apps/web 的 app/canon-import 對應同一份契約與流程,改用 RN 元件(chip 取代
// <select>)。多人在場且台詞沒有明示說話者時,既有的發言歸屬會把信心不足的句子隔離(QUARANTINED),
// 提交後直接依回應導向待裁決頁或顯示已投影。
export default function CanonImportScreen() {
const router = useRouter();
const { workId: presetWorkId } = useLocalSearchParams<{ workId?: string }>();
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState("");
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [chapterLabel, setChapterLabel] = useState("");
const [volume, setVolume] = useState("1");
const [storyOrder, setStoryOrder] = useState("1");
const [summary, setSummary] = useState("");
const [rawText, setRawText] = useState("");
const [participantCharacterIds, setParticipantCharacterIds] = useState<string[]>([]);
const [lines, setLines] = useState<ImportSceneLineInput[]>([emptyLine()]);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ImportedSceneResult | null>(null);
useEffect(() => {
void listCanonWorks().then((all) => {
setWorks(all);
if (presetWorkId && all.some((w) => w.id === presetWorkId)) {
setWorkId(presetWorkId);
}
});
}, [presetWorkId]);
useEffect(() => {
const selected = works.find((w) => w.id === workId);
setStoryOrder(String((selected?.anchorStoryOrder ?? 0) + 1));
setParticipantCharacterIds([]);
setResult(null);
setError(null);
if (workId) {
void listCharacters().then((all) => setCharacters(all.filter((c) => c.workId === workId)));
} else {
setCharacters([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workId]);
function toggleParticipant(characterId: string) {
setParticipantCharacterIds((prev) =>
prev.includes(characterId) ? prev.filter((id) => id !== characterId) : [...prev, characterId],
);
}
function updateLine(index: number, patch: Partial<ImportSceneLineInput>) {
setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line)));
}
function removeLine(index: number) {
setLines((prev) => prev.filter((_, i) => i !== index));
}
async function handleSubmit() {
if (!workId || !chapterLabel.trim() || !summary.trim() || !rawText.trim() || pending) return;
setPending(true);
setError(null);
setResult(null);
try {
const imported = await importCanonScene({
workId,
volume: Number(volume) || 1,
chapterLabel: chapterLabel.trim(),
storyOrder: Number(storyOrder) || 1,
summary: summary.trim(),
rawText: rawText.trim(),
participantCharacterIds,
lines: lines.filter((line) => line.rawText.trim().length > 0),
});
setResult(imported);
setChapterLabel("");
setSummary("");
setRawText("");
setLines([emptyLine()]);
} catch (err) {
setError(err instanceof Error ? err.message : "章節匯入失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
return (
<ScrollView style={styles.flex} contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<Text style={styles.title}>匯入章節</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.backLink}>回到大廳</Text>
</Pressable>
</View>
<View style={styles.chipRow}>
{works.map((w) => (
<Pressable key={w.id} onPress={() => setWorkId(w.id)} style={[styles.pickChip, workId === w.id ? styles.pickChipActive : null]}>
<Text style={styles.pickChipText}>{w.title}</Text>
</Pressable>
))}
</View>
{error && <Text style={styles.error}>{error}</Text>}
{result && (
<View style={styles.resultBox}>
{result.pendingAdjudication ? (
<>
<Text style={styles.resultText}>匯入成功,但有台詞或矛盾待人工確認,暫不投影。</Text>
<Pressable onPress={() => router.push("/canon-review")}>
<Text style={styles.resultLink}>前往待裁決頁</Text>
</Pressable>
</>
) : (
<Text style={styles.resultText}>
匯入成功,已完成投影{result.projected ? "" : "(場景超前目前錨定點,先留存等待錨定點推進)"}。
</Text>
)}
</View>
)}
{workId && (
<>
<TextInput value={chapterLabel} onChangeText={setChapterLabel} placeholder="章節標題(例如:第十章)" style={styles.input} />
<View style={styles.row}>
<TextInput value={volume} onChangeText={setVolume} placeholder="卷數" keyboardType="numeric" style={[styles.input, styles.rowInput]} />
<TextInput
value={storyOrder}
onChangeText={setStoryOrder}
placeholder="storyOrder"
keyboardType="numeric"
style={[styles.input, styles.rowInput]}
/>
</View>
<TextInput
value={summary}
onChangeText={setSummary}
placeholder="場景摘要(客觀記錄,供角色記憶與情緒基線使用)"
style={[styles.input, styles.textarea]}
multiline
/>
<TextInput value={rawText} onChangeText={setRawText} placeholder="原文(完整章節文本)" style={[styles.input, styles.textarea]} multiline />
<Text style={styles.label}>在場角色</Text>
<View style={styles.chipRow}>
{characters.map((character) => (
<Pressable
key={character.id}
onPress={() => toggleParticipant(character.id)}
style={[styles.pickChip, participantCharacterIds.includes(character.id) ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
</Pressable>
))}
</View>
<View style={styles.headerRow}>
<Text style={styles.label}>逐句台詞/敘述</Text>
<Pressable onPress={() => setLines((prev) => [...prev, emptyLine()])}>
<Text style={styles.addLineLink}>新增一行</Text>
</Pressable>
</View>
{lines.map((line, index) => (
<View key={index} style={styles.lineRow}>
<View style={styles.chipRow}>
{LINE_TYPES.map((type) => (
<Pressable
key={type}
onPress={() => updateLine(index, { lineType: type })}
style={[styles.typeChip, line.lineType === type ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{LINE_TYPE_LABELS[type]}</Text>
</Pressable>
))}
</View>
<TextInput
value={line.rawText}
onChangeText={(value) => updateLine(index, { rawText: value })}
placeholder="這一句的內容"
style={styles.input}
/>
{line.lineType === "DIALOGUE" && (
<View style={styles.chipRow}>
<Pressable
onPress={() => updateLine(index, { markerCharacterId: undefined, hasExplicitMarker: false })}
style={[styles.typeChip, !line.markerCharacterId ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>(無明示說話者)</Text>
</Pressable>
{characters.map((character) => (
<Pressable
key={character.id}
onPress={() => updateLine(index, { markerCharacterId: character.id, hasExplicitMarker: true })}
style={[styles.typeChip, line.markerCharacterId === character.id ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
</Pressable>
))}
</View>
)}
<Pressable onPress={() => removeLine(index)}>
<Text style={styles.removeLineLink}>刪除這一行</Text>
</Pressable>
</View>
))}
<Pressable
onPress={() => void handleSubmit()}
disabled={pending || !chapterLabel.trim() || !summary.trim() || !rawText.trim()}
style={styles.submitButton}
>
<Text style={styles.submitButtonText}>匯入這一章</Text>
</Pressable>
</>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 16, gap: 10, paddingBottom: 40 },
headerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
title: { fontSize: 16, fontWeight: "bold", color: "#FF7E9D" },
backLink: { fontSize: 12, color: "#8a6b6f" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
pickChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 10, paddingVertical: 6 },
pickChipActive: { backgroundColor: "#FFE1E9" },
pickChipText: { fontSize: 12, color: "#222" },
typeChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 4 },
error: { fontSize: 12, color: "#FF7E9D" },
resultBox: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 10, gap: 4 },
resultText: { fontSize: 12, color: "#222" },
resultLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" },
input: {
borderRadius: 12,
borderWidth: 1,
borderColor: "#E5D5D8",
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 13,
color: "#222",
},
row: { flexDirection: "row", gap: 8 },
rowInput: { flex: 1 },
textarea: { minHeight: 70, textAlignVertical: "top" },
label: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f" },
addLineLink: { fontSize: 12, color: "#8a6b6f" },
lineRow: { gap: 6, borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8 },
removeLineLink: { fontSize: 11, color: "#8a6b6f", alignSelf: "flex-start" },
submitButton: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingVertical: 10, alignItems: "center" },
submitButtonText: { fontSize: 14, fontWeight: "bold", color: "#fff" },
});
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import {
adjudicateLine,
correctLineAttribution,
getCanonScene,
listCanonScenes,
listCanonWorks,
listCharacters,
listContradictions,
reattemptQuarantined,
type CanonWork,
type CharacterSummary,
type SceneContradiction,
type SceneLine,
} from "../lib/api";
interface LineRow extends SceneLine {
sceneId: string;
}
function characterLabel(characters: CharacterSummary[], characterId: string | null): string {
if (!characterId) return "(無)";
const found = characters.find((c) => c.id === characterId);
return found?.aliases[0]?.formalName ?? characterId;
}
// M-11/T20-P4 待裁決畫面:與 apps/web 的 CanonReviewView 對應同一份契約,改用 RN 元件(chip 取代
// <select>)。列出隔離區台詞與待人工確認的矛盾,提供逐句裁決/歸屬更正/指紋精煉後重試。
export default function CanonReviewScreen() {
const router = useRouter();
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState("");
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [quarantined, setQuarantined] = useState<LineRow[]>([]);
const [confirmed, setConfirmed] = useState<LineRow[]>([]);
const [contradictions, setContradictions] = useState<SceneContradiction[]>([]);
const [selection, setSelection] = useState<Record<string, string>>({});
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void listCanonWorks()
.then(setWorks)
.catch(() => setError("無法連線到 api 服務,作品清單暫時無法顯示"));
}, []);
async function refreshWork(targetWorkId: string) {
if (!targetWorkId) return;
setError(null);
try {
const [allCharacters, scenes, contradictionList] = await Promise.all([
listCharacters(),
listCanonScenes(targetWorkId),
listContradictions(targetWorkId),
]);
setCharacters(allCharacters.filter((c) => c.workId === targetWorkId));
setContradictions(contradictionList);
const details = await Promise.all(scenes.map((scene) => getCanonScene(scene.id)));
const nextQuarantined: LineRow[] = [];
const nextConfirmed: LineRow[] = [];
for (const scene of details) {
for (const line of scene.lines) {
const row = { ...line, sceneId: scene.id };
if (line.status === "QUARANTINED") {
nextQuarantined.push(row);
} else if (line.status === "CONFIRMED") {
nextConfirmed.push(row);
}
}
}
setQuarantined(nextQuarantined);
setConfirmed(nextConfirmed);
} catch {
setError("待裁決資料查詢失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
void refreshWork(workId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workId]);
async function handleAdjudicate(lineId: string) {
const speakerCharacterId = selection[lineId];
if (!speakerCharacterId || pending) return;
setPending(true);
setError(null);
try {
await adjudicateLine(lineId, speakerCharacterId);
await refreshWork(workId);
} catch {
setError("裁決失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleCorrect(lineId: string) {
const newSpeakerCharacterId = selection[lineId];
if (!newSpeakerCharacterId || pending) return;
setPending(true);
setError(null);
try {
await correctLineAttribution(lineId, newSpeakerCharacterId);
await refreshWork(workId);
} catch {
setError("歸屬更正失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleReattempt(sceneId: string) {
if (pending) return;
setPending(true);
setError(null);
try {
await reattemptQuarantined(sceneId);
await refreshWork(workId);
} catch {
setError("重試失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
const sceneIdsWithQuarantine = Array.from(new Set(quarantined.map((line) => line.sceneId)));
return (
<ScrollView style={styles.flex} contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<Text style={styles.title}>待裁決</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.backLink}>回到大廳</Text>
</Pressable>
</View>
<View style={styles.chipRow}>
{works.map((w) => (
<Pressable key={w.id} onPress={() => setWorkId(w.id)} style={[styles.pickChip, workId === w.id ? styles.pickChipActive : null]}>
<Text style={styles.pickChipText}>{w.title}</Text>
</Pressable>
))}
</View>
{error && <Text style={styles.error}>{error}</Text>}
{workId && (
<>
<View style={styles.section}>
<View style={styles.headerRow}>
<Text style={styles.label}>隔離區台詞(QUARANTINED)</Text>
</View>
{sceneIdsWithQuarantine.map((sceneId) => (
<Pressable key={sceneId} onPress={() => void handleReattempt(sceneId)} style={styles.pickChip}>
<Text style={styles.pickChipText}>指紋精煉後重試此場景</Text>
</Pressable>
))}
{quarantined.length === 0 && <Text style={styles.hint}>目前沒有隔離中的台詞。</Text>}
{quarantined.map((line) => (
<View key={line.id} style={styles.lineCard}>
<Text style={styles.lineText}>{line.rawText}</Text>
<Text style={styles.hint}>
信心分數:{(line.confidence * 100).toFixed(0)}%.證據來源:
{line.hasExplicitMarker ? `明示標記→${characterLabel(characters, line.markerCharacterId)}` : "推論(無明示標記)"}
</Text>
<View style={styles.chipRow}>
{characters.map((character) => (
<Pressable
key={character.id}
onPress={() => setSelection({ ...selection, [line.id]: character.id })}
style={[styles.typeChip, selection[line.id] === character.id ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
</Pressable>
))}
</View>
<Pressable
onPress={() => void handleAdjudicate(line.id)}
disabled={pending || !selection[line.id]}
style={styles.confirmButton}
>
<Text style={styles.confirmButtonText}>裁決</Text>
</Pressable>
</View>
))}
</View>
<View style={styles.section}>
<Text style={styles.label}>已歸屬台詞(可更正)</Text>
{confirmed.length === 0 && <Text style={styles.hint}>目前沒有已歸屬的台詞。</Text>}
{confirmed.map((line) => (
<View key={line.id} style={styles.lineCard}>
<Text style={styles.lineText}>{line.rawText}</Text>
<Text style={styles.hint}>目前歸屬:{characterLabel(characters, line.speakerCharacterId)}</Text>
<View style={styles.chipRow}>
{characters.map((character) => (
<Pressable
key={character.id}
onPress={() => setSelection({ ...selection, [line.id]: character.id })}
style={[styles.typeChip, selection[line.id] === character.id ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
</Pressable>
))}
</View>
<Pressable onPress={() => void handleCorrect(line.id)} disabled={pending || !selection[line.id]} style={styles.pickChip}>
<Text style={styles.pickChipText}>更正</Text>
</Pressable>
</View>
))}
</View>
<View style={styles.section}>
<Text style={styles.label}>待人工確認的矛盾(PENDING_HUMAN_REVIEW)</Text>
{contradictions.filter((c) => c.status === "PENDING_HUMAN_REVIEW").length === 0 && (
<Text style={styles.hint}>目前沒有待確認的矛盾。</Text>
)}
{contradictions
.filter((c) => c.status === "PENDING_HUMAN_REVIEW")
.map((contradiction) => (
<View key={contradiction.id} style={styles.lineCard}>
<Text style={styles.lineText}>
{contradiction.eventLabel}.{contradiction.factKey}
</Text>
<Text style={styles.hint}>{contradiction.description}</Text>
</View>
))}
</View>
</>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 16, gap: 12, paddingBottom: 40 },
headerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
title: { fontSize: 16, fontWeight: "bold", color: "#FF7E9D" },
backLink: { fontSize: 12, color: "#8a6b6f" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
pickChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 10, paddingVertical: 6 },
pickChipActive: { backgroundColor: "#FFE1E9" },
pickChipText: { fontSize: 12, color: "#222" },
typeChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 4 },
error: { fontSize: 12, color: "#FF7E9D" },
section: { gap: 6 },
label: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f" },
hint: { fontSize: 11, color: "#8a6b6f" },
lineCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 6 },
lineText: { fontSize: 12, color: "#222" },
confirmButton: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 12, paddingVertical: 6, alignSelf: "flex-start" },
confirmButtonText: { fontSize: 12, fontWeight: "bold", color: "#fff" },
});
+422
View File
@@ -0,0 +1,422 @@
import { useEffect, useRef, useState } from "react";
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import type { Character } from "@kokorone/shared";
import {
API_BASE_URL,
ROOM_USER_SPEAKER_ID,
endRoom,
fetchExpression,
getPossessionStatus,
getRoom,
getRoomUsage,
reportRoomRead,
sendRoomMessage,
setRoomMuted,
speakAsPossessed,
subscribeRoomStream,
synthesizeVoice,
type PossessionStatus,
type RoomDetail,
type RoomTurnKind,
type RoomUsageSummary,
type TachieExpressionResponse,
} from "../../lib/api";
import { RoomStage, type StageParticipant } from "../../components/RoomStage";
import { isVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings";
const USER_ID = "seed-user-primary";
const USER_LABEL = "你";
// T20-P3:與 apps/web 的 RoomChatView.tsx 對應——佇列積壓過長時只留最近幾則待播。
const VOICE_QUEUE_MAX_BACKLOG = 3;
interface VoiceQueueItem {
characterId: string;
text: string;
}
// T20-P3:與 apps/web 一致,ACTION 沿用既有 ChatBubble 的敘述樣式(此處以斜體呈現),其餘三種標籤。
const TURN_KIND_LABEL: Partial<Record<RoomTurnKind, string>> = {
DELEGATE: "委託",
LEAVE: "離開",
SYSTEM: "系統",
};
type FeedItem =
| { kind: "turn"; seq: number; speakerId: string; speakerLabel: string; content: string; turnKind: RoomTurnKind }
| { kind: "member"; seq: number; characterId: string; action: "joined" | "left" }
| { kind: "closed"; seq: number; reason: string };
// T20-P2:通用房間畫面——取代 T20-P1 唯讀最小可用版,改為可發言、以 fetch streaming(見
// lib/api.ts 的 subscribeRoomStream,非原生 EventSource)即時收新訊息、並依 unread(T11)畫出
// 第一則未讀分隔線,與 apps/web 的 app/room/[roomId] 對應同一版面與行為。
export default function RoomScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const [room, setRoom] = useState<RoomDetail | null>(null);
const [characters, setCharacters] = useState<Character[]>([]);
const [feed, setFeed] = useState<FeedItem[]>([]);
const [firstUnreadSeq, setFirstUnreadSeq] = useState<number | null>(null);
const [ended, setEnded] = useState(false);
const [text, setText] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<FeedItem>>(null);
// T20-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。
const [possession, setPossession] = useState<PossessionStatus | null>(null);
useEffect(() => {
void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined);
}, [roomId]);
// T20-P3:立繪舞台(目前發話者高亮)與用量顯示。
const [expressions, setExpressions] = useState<Record<string, TachieExpressionResponse | null>>({});
const [speakingCharacterId, setSpeakingCharacterId] = useState<string | null>(null);
const [usage, setUsage] = useState<RoomUsageSummary | null>(null);
// T20-P3:自走語音自動播放——與 apps/web 對應,但行動端無瀏覽器自動播放政策,不需要解鎖手勢;
// 佇列、播放中旗標一律用 ref,避免串流事件回呼讀到註冊當下就固定住的舊值(stale closure)。
const [autoplayEnabled, setAutoplayEnabled] = useState(true);
const autoplayEnabledRef = useRef(true);
const mutedRef = useRef(false);
const voiceQueueRef = useRef<VoiceQueueItem[]>([]);
const voicePlayingRef = useRef(false);
const voiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const unmountedRef = useRef(false);
useEffect(() => {
void isVoiceAutoplayEnabled().then((value) => {
autoplayEnabledRef.current = value;
setAutoplayEnabled(value);
});
return () => {
unmountedRef.current = true;
if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current);
voiceQueueRef.current = [];
};
}, []);
function refreshExpression(characterId: string) {
fetchExpression(characterId, USER_ID)
.then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression })))
.catch(() => undefined);
}
function enqueueVoice(item: VoiceQueueItem) {
if (!autoplayEnabledRef.current || mutedRef.current) return;
voiceQueueRef.current.push(item);
if (voiceQueueRef.current.length > VOICE_QUEUE_MAX_BACKLOG) {
voiceQueueRef.current.splice(0, voiceQueueRef.current.length - VOICE_QUEUE_MAX_BACKLOG);
}
void processVoiceQueue();
}
async function processVoiceQueue() {
if (voicePlayingRef.current || unmountedRef.current) return;
const next = voiceQueueRef.current.shift();
if (!next) return;
voicePlayingRef.current = true;
try {
const result = await synthesizeVoice(next.characterId, USER_ID, next.text);
if (unmountedRef.current) return;
if (result.audioRef.startsWith("mock://")) {
await new Promise<void>((resolve) => {
voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300));
});
}
// T20-P3 已知限制:尚未安裝原生音訊播放套件(expo-audio/expo-av),真實音檔(非 mock://)
// 目前僅靜默略過、不會真的播放——見本項 wiki 完成註記;接上真實 TTS Provider 前需另外補上。
} catch {
// 合成失敗:安靜跳過這一則,不阻斷佇列後續播放。
} finally {
voicePlayingRef.current = false;
if (!unmountedRef.current) void processVoiceQueue();
}
}
useEffect(() => {
fetch(`${API_BASE_URL}/characters`)
.then((res) => res.json() as Promise<Character[]>)
.then(setCharacters)
.catch(() => undefined);
}, []);
function characterLabel(characterId: string): string {
return characters.find((c) => c.id === characterId)?.aliases?.[0]?.formalName ?? characterId;
}
async function handleToggleMute() {
if (!room || pending) return;
const next = !room.muted;
try {
await setRoomMuted(room.id, next);
mutedRef.current = next;
setRoom((prev) => (prev ? { ...prev, muted: next } : prev));
} catch {
setError("靜音設定失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
if (!roomId) return;
let cancelled = false;
let unsubscribe: (() => void) | null = null;
async function init() {
try {
const detail = await getRoom(roomId);
if (cancelled) return;
setRoom(detail);
mutedRef.current = detail.muted;
setEnded(detail.status === "ENDED");
setFirstUnreadSeq(detail.unread.firstUnreadSeq);
setUsage(detail.usage);
setFeed(
detail.turns.map((turn) => ({
kind: "turn",
seq: turn.seq,
speakerId: turn.speakerId,
speakerLabel: turn.speakerLabel,
content: turn.content,
turnKind: turn.kind,
})),
);
for (const characterId of detail.participants) refreshExpression(characterId);
const lastTurn = detail.turns.at(-1);
if (lastTurn && lastTurn.speakerId !== ROOM_USER_SPEAKER_ID) setSpeakingCharacterId(lastTurn.speakerId);
const lastSeq = detail.turns.length > 0 ? detail.turns[detail.turns.length - 1].seq : -1;
unsubscribe = subscribeRoomStream(roomId, lastSeq, (event) => {
if (event.type === "turn") {
const { kind: turnKind, ...rest } = event.data;
setFeed((prev) =>
prev.some((item) => item.kind === "turn" && item.seq === event.seq)
? prev
: [...prev, { kind: "turn", seq: event.seq, turnKind, ...rest }],
);
void reportRoomRead(roomId, USER_ID, event.seq).catch(() => undefined);
if (event.data.speakerId !== ROOM_USER_SPEAKER_ID) {
enqueueVoice({ characterId: event.data.speakerId, text: event.data.content });
setSpeakingCharacterId(event.data.speakerId);
refreshExpression(event.data.speakerId);
void getRoomUsage(roomId).then(setUsage).catch(() => undefined);
}
} else if (event.type === "member") {
setFeed((prev) => [...prev, { kind: "member", seq: event.seq, ...event.data }]);
} else if (event.type === "closed") {
setFeed((prev) => [...prev, { kind: "closed", seq: event.seq, reason: event.data.reason }]);
setEnded(true);
}
});
} catch {
if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動");
}
}
void init();
return () => {
cancelled = true;
unsubscribe?.();
};
}, [roomId]);
// T20-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話。
const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room?.participants.includes(possession!.possessingCharacterId!);
const possessedCharacterId =
possession?.possessingCharacterId && room?.participants.includes(possession.possessingCharacterId)
? possession.possessingCharacterId
: null;
async function handleSend() {
const trimmed = text.trim();
if (!trimmed || pending || ended || !roomId) return;
setPending(true);
setError(null);
try {
if (possessedCharacterId) {
await speakAsPossessed(roomId, USER_ID, trimmed);
} else {
await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed);
}
setText(""); // 新訊息透過串流推送進 feed,這裡不自行 append
} catch (err) {
setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleEnd() {
if (pending || ended || !roomId) return;
setPending(true);
setError(null);
try {
await endRoom(roomId);
setEnded(true);
} catch {
setError("結束房間失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
if (error && !room) {
return (
<View style={styles.container}>
<Text style={styles.error}>{error}</Text>
</View>
);
}
if (!room) {
return (
<View style={styles.container}>
<Text style={styles.hint}>載入中……</Text>
</View>
);
}
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<View style={styles.headerRow}>
<Text style={styles.title}>
{room.mode}.{ended ? "已結束" : "進行中"}
{usage && usage.generationCount > 0 ? `.${usage.totalPromptTokens + usage.totalCompletionTokens} tokens` : ""}
</Text>
<View style={styles.headerButtons}>
<Pressable onPress={() => void handleToggleMute()} disabled={ended}>
<Text style={[styles.endButton, ended ? styles.endButtonDisabled : null]}>{room.muted ? "🔇 已靜音" : "🔊 靜音"}</Text>
</Pressable>
<Pressable onPress={handleEnd} disabled={pending || ended}>
<Text style={[styles.endButton, pending || ended ? styles.endButtonDisabled : null]}>結束房間</Text>
</Pressable>
</View>
</View>
<RoomStage
participants={room.participants.map<StageParticipant>((characterId) => ({
characterId,
label: characterLabel(characterId),
expression: expressions[characterId] ?? null,
}))}
speakingCharacterId={speakingCharacterId}
/>
<FlatList
ref={listRef}
style={styles.content}
contentContainerStyle={styles.contentInner}
data={feed}
keyExtractor={(item) => `${item.kind}-${item.seq}`}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })}
ListEmptyComponent={<Text style={styles.hint}>還沒有人說話。</Text>}
renderItem={({ item }) => (
<View style={styles.feedItem}>
{firstUnreadSeq !== null && item.seq === firstUnreadSeq && (
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>未讀訊息</Text>
<View style={styles.dividerLine} />
</View>
)}
{item.kind === "turn" && (
<View style={{ alignSelf: item.speakerId === ROOM_USER_SPEAKER_ID ? "flex-end" : "flex-start" }}>
{TURN_KIND_LABEL[item.turnKind] && <Text style={styles.turnKindBadge}>{TURN_KIND_LABEL[item.turnKind]}</Text>}
<View style={[styles.bubble, item.speakerId === ROOM_USER_SPEAKER_ID ? styles.bubbleUser : styles.bubbleCharacter]}>
<Text style={styles.speakerLabel}>{item.speakerLabel}</Text>
<Text style={[styles.bubbleText, item.turnKind === "ACTION" ? styles.actionText : null]}>{item.content}</Text>
</View>
</View>
)}
{item.kind === "member" && (
<Text style={styles.systemNote}>
{characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"}
</Text>
)}
{item.kind === "closed" && <Text style={styles.systemNote}>房間已關閉</Text>}
</View>
)}
/>
{error && <Text style={styles.error}>{error}</Text>}
{!ended && possessedCharacterId && (
<Text style={styles.possessionNotice}>你正以「{characterLabel(possessedCharacterId)}」的身分發話</Text>
)}
{!ended && possessedElsewhere && (
<Text style={styles.hint}>套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。</Text>
)}
{!ended && !possessedElsewhere && (
<View style={styles.inputRow}>
<TextInput
value={text}
onChangeText={setText}
placeholder={possessedCharacterId ? `以「${characterLabel(possessedCharacterId)}」的身分說點什麼……` : "說點什麼……"}
style={styles.input}
editable={!pending}
onSubmitEditing={handleSend}
/>
<Pressable onPress={handleSend} disabled={pending || !text.trim()}>
<Text style={[styles.sendButton, pending || !text.trim() ? styles.sendButtonDisabled : null]}>送出</Text>
</Pressable>
</View>
)}
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingTop: 12,
},
title: { fontSize: 14, fontWeight: "bold", color: "#FF7E9D", flexShrink: 1 },
headerButtons: { flexDirection: "row", alignItems: "center", gap: 12 },
endButton: { fontSize: 12, color: "#8a6b6f" },
endButtonDisabled: { opacity: 0.5 },
content: { flex: 1 },
contentInner: { padding: 16, gap: 8 },
feedItem: { gap: 6 },
dividerRow: { flexDirection: "row", alignItems: "center", gap: 8 },
dividerLine: { flex: 1, height: 1, backgroundColor: "#FF7E9D" },
dividerText: { fontSize: 10, color: "#FF7E9D" },
hint: { fontSize: 12, color: "#8a6b6f", padding: 16 },
error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 },
possessionNotice: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D", textAlign: "center", paddingVertical: 4 },
bubble: { maxWidth: "80%", borderRadius: 16, padding: 10, gap: 2 },
bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" },
bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" },
speakerLabel: { fontSize: 10, color: "#8a6b6f" },
bubbleText: { color: "#222" },
actionText: { fontStyle: "italic", color: "#9B8CFF" },
turnKindBadge: {
alignSelf: "flex-start",
fontSize: 10,
color: "#FF7E9D",
backgroundColor: "#FFE3EA",
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 1,
marginBottom: 2,
},
systemNote: { alignSelf: "center", fontSize: 10, color: "#8a6b6f" },
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 },
});