feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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: "設定" }} />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useReduceMotion } from "../lib/reduce-motion";
|
||||
|
||||
export interface HeartbeatDotProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
// Q-4:未讀/推播提示紅點=小心跳,對應 apps/web 的 `.heartbeat-dot`(1.2s 循環的 opacity+scale 脈動)。
|
||||
export function HeartbeatDot({ size = 10, color = "#FF7E9D", style }: HeartbeatDotProps) {
|
||||
const reduceMotion = useReduceMotion();
|
||||
const t = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
t.setValue(0);
|
||||
return;
|
||||
}
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(t, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
Animated.timing(t, { toValue: 0, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [reduceMotion, t]);
|
||||
|
||||
const scale = t.interpolate({ inputRange: [0, 1], outputRange: [1, 1.3] });
|
||||
const opacity = t.interpolate({ inputRange: [0, 1], outputRange: [1, 0.7] });
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ width: size, height: size, borderRadius: size / 2, backgroundColor: color, opacity, transform: [{ scale }] },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
|
||||
import Svg, { Polyline } from "react-native-svg";
|
||||
import { useReduceMotion } from "../lib/reduce-motion";
|
||||
|
||||
const WIDTH = 400;
|
||||
const MID_Y = 50;
|
||||
const DASH_LENGTH = 600;
|
||||
|
||||
// 與 apps/web/components/HeartbeatWave.tsx 相同的波形算法,維持三端視覺一致;
|
||||
// 親密度越高,波峰振幅越大。
|
||||
function buildPoints(amplitude: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, amplitude));
|
||||
const scale = 0.4 + (clamped / 100) * 1.1;
|
||||
const up1 = MID_Y - 15 * scale;
|
||||
const down = MID_Y + 26 * scale;
|
||||
const up2 = MID_Y - 22 * scale;
|
||||
|
||||
const points: Array<[number, number]> = [
|
||||
[0, MID_Y],
|
||||
[150, MID_Y],
|
||||
[168, MID_Y],
|
||||
[178, up1],
|
||||
[188, down],
|
||||
[198, up2],
|
||||
[208, MID_Y],
|
||||
[WIDTH, MID_Y],
|
||||
];
|
||||
|
||||
return points.map(([x, y]) => `${x},${y.toFixed(1)}`).join(" ");
|
||||
}
|
||||
|
||||
const AnimatedPolyline = Animated.createAnimatedComponent(Polyline);
|
||||
|
||||
export interface HeartbeatWaveProps {
|
||||
amplitude?: number; // 0~100,對應親密度
|
||||
animateOnMount?: boolean; // 是否播放「由左至右畫出」的載入動畫
|
||||
color?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function HeartbeatWave({ amplitude = 50, animateOnMount = true, color = "#FF7E9D", style }: HeartbeatWaveProps) {
|
||||
const reduceMotion = useReduceMotion();
|
||||
const shouldAnimate = animateOnMount && !reduceMotion;
|
||||
const dashOffset = useRef(new Animated.Value(shouldAnimate ? DASH_LENGTH : 0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
dashOffset.setValue(0);
|
||||
return;
|
||||
}
|
||||
dashOffset.setValue(DASH_LENGTH);
|
||||
Animated.timing(dashOffset, {
|
||||
toValue: 0,
|
||||
duration: 1600,
|
||||
easing: Easing.out(Easing.ease),
|
||||
// strokeDashoffset 不在原生驅動支援的屬性清單內,只能走 JS 驅動。
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [shouldAnimate, dashOffset]);
|
||||
|
||||
const points = useMemo(() => buildPoints(amplitude), [amplitude]);
|
||||
|
||||
return (
|
||||
<Svg viewBox={`0 0 ${WIDTH} 100`} style={style}>
|
||||
<AnimatedPolyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={DASH_LENGTH}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
confirmEndingDetection,
|
||||
decideInvitation,
|
||||
decideLobbyCall,
|
||||
enterPossession,
|
||||
exitPossession,
|
||||
getFullLobby,
|
||||
getPossessionStatus,
|
||||
listEndingDetections,
|
||||
listPendingAdjudication,
|
||||
listPendingInvitations,
|
||||
listPendingLobbyCalls,
|
||||
rejectEndingDetection,
|
||||
sayToLobby,
|
||||
type EndingDetectionLog,
|
||||
type LobbyFullSnapshot,
|
||||
type LobbyPresenceState,
|
||||
type PendingInvitation,
|
||||
type PendingLobbyCall,
|
||||
type PossessionStatus,
|
||||
} from "../lib/api";
|
||||
import { useUnread } from "../lib/unread-context";
|
||||
|
||||
const USER_ID = "seed-user-primary";
|
||||
|
||||
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
|
||||
BUILDING: "建置中",
|
||||
SLEEPING: "睡眠中",
|
||||
BUSY: "忙碌中",
|
||||
SEMI_BUSY: "略忙",
|
||||
IDLE: "空閒",
|
||||
IN_SOLO_ROOM: "單獨聊天中",
|
||||
IN_GROUP_ROOM: "多人房中",
|
||||
};
|
||||
|
||||
// T20-P2:大廳頁完整版,取代 T20-P1 最小快照(在場名單+房間清單),改用 T4 正式聚合端點,
|
||||
// 與 apps/web 的 LobbyView 對應同一版面:進行中房間(含未讀)→ 作品分組角色卡片 → 大廳對話輸入。
|
||||
export function LobbyPreview() {
|
||||
const router = useRouter();
|
||||
const { setRoomUnreadCounts } = useUnread();
|
||||
const [snapshot, setSnapshot] = useState<LobbyFullSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [lobbyMessage, setLobbyMessage] = useState<string | null>(null);
|
||||
const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState<Record<string, number>>({});
|
||||
const [endingDetectionsByWork, setEndingDetectionsByWork] = useState<Record<string, EndingDetectionLog[]>>({});
|
||||
const [possession, setPossession] = useState<PossessionStatus | null>(null);
|
||||
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
|
||||
const [pendingCalls, setPendingCalls] = useState<PendingLobbyCall[]>([]);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]);
|
||||
setSnapshot(lobby);
|
||||
setPossession(possessionStatus);
|
||||
setError(null);
|
||||
// T20-P2:把各房未讀數餵給 UnreadContext,聊天分頁籤的紅點才能反映「大廳裡有房間未讀」。
|
||||
setRoomUnreadCounts(Object.fromEntries(lobby.activeRooms.map((room) => [room.id, room.unreadCount])));
|
||||
} catch {
|
||||
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// T20-P5:套用中時查詢待決邀請與待決大廳呼叫,與 apps/web 的 LobbyView 對應同一批次查詢方式。
|
||||
useEffect(() => {
|
||||
if (!possession?.possessingCharacterId) {
|
||||
setPendingInvitations([]);
|
||||
setPendingCalls([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => {
|
||||
if (cancelled) return;
|
||||
setPendingInvitations(invitations);
|
||||
setPendingCalls(calls);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [possession]);
|
||||
|
||||
async function handleEnterPossession(characterId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await enterPossession(USER_ID, characterId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExitPossession() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await exitPossession(USER_ID);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecideInvitation(invitationId: string, accept: boolean) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideInvitation(invitationId, accept);
|
||||
setPendingInvitations(await listPendingInvitations(USER_ID));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecideCall(pendingCallId: string, accept: boolean) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const decision = await decideLobbyCall(pendingCallId, accept);
|
||||
setPendingCalls(await listPendingLobbyCalls(USER_ID));
|
||||
if (decision.roomId) {
|
||||
router.push(`/room/${decision.roomId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// T20-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單,與 apps/web 的
|
||||
// LobbyView 對應同一批次查詢方式,snapshot 每次刷新都會連動重新取得。
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const workIds = snapshot.works.map((w) => w.id);
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
workIds.map(async (workId) => {
|
||||
const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]);
|
||||
return { workId, pendingCount: adjudication.length, endings };
|
||||
}),
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount])));
|
||||
setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings])));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [snapshot]);
|
||||
|
||||
async function handleConfirmEnding(logId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await confirmEndingDetection(logId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectEnding(logId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rejectEndingDetection(logId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSay() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || pending || possession?.possessingCharacterId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setLobbyMessage(null);
|
||||
try {
|
||||
const result = await sayToLobby(USER_ID, trimmed);
|
||||
setText("");
|
||||
if (result.roomId) {
|
||||
router.push(`/room/${result.roomId}`);
|
||||
return;
|
||||
}
|
||||
if (result.message) {
|
||||
setLobbyMessage(result.message);
|
||||
}
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("大廳呼叫失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Text style={styles.error}>{error}</Text>;
|
||||
}
|
||||
if (!snapshot) {
|
||||
return <Text style={styles.hint}>大廳載入中……</Text>;
|
||||
}
|
||||
|
||||
const workGroups = snapshot.works.map((work) => ({
|
||||
work,
|
||||
characters: snapshot.characters.filter((c) => c.workId === work.id),
|
||||
}));
|
||||
const unassigned = snapshot.characters.filter((c) => c.workId === null);
|
||||
const possessedCharacterId = possession?.possessingCharacterId ?? null;
|
||||
const possessedCharacterName = possessedCharacterId
|
||||
? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{possessedCharacterId && (
|
||||
<View style={styles.possessionBanner}>
|
||||
<View style={styles.possessionBannerRow}>
|
||||
<Text style={styles.possessionBannerTitle}>你現在是「{possessedCharacterName}」</Text>
|
||||
<Pressable onPress={() => void handleExitPossession()} disabled={pending}>
|
||||
<Text style={[styles.exitPossessionLink, pending ? styles.sendButtonDisabled : null]}>退出套用</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.possessionBannerHint}>她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定邀請與呼叫。</Text>
|
||||
|
||||
{pendingCalls.map((call) => (
|
||||
<View key={call.id} style={styles.endingCard}>
|
||||
<Text style={styles.endingCardTitle}>有人喊了你:「{call.text}」</Text>
|
||||
<View style={styles.endingCardActions}>
|
||||
<Pressable onPress={() => void handleDecideCall(call.id, true)} disabled={pending}>
|
||||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void handleDecideCall(call.id, false)} disabled={pending}>
|
||||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<View key={invitation.id} style={styles.endingCard}>
|
||||
<Text style={styles.endingCardTitle}>
|
||||
{(invitation.invitedByCharacterId &&
|
||||
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
|
||||
"有人"}
|
||||
邀請你到房間裡聊聊
|
||||
</Text>
|
||||
<View style={styles.endingCardActions}>
|
||||
<Pressable onPress={() => void handleDecideInvitation(invitation.id, true)} disabled={pending}>
|
||||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void handleDecideInvitation(invitation.id, false)} disabled={pending}>
|
||||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionTitle}>進行中房間({snapshot.activeRooms.length})</Text>
|
||||
{snapshot.activeRooms.length === 0 ? (
|
||||
<Text style={styles.hint}>目前沒有進行中的房間。</Text>
|
||||
) : (
|
||||
snapshot.activeRooms.map((room) => (
|
||||
<Pressable key={room.id} style={styles.roomCard} onPress={() => router.push(`/room/${room.id}`)}>
|
||||
<View style={styles.roomCardRow}>
|
||||
<Text style={styles.roomCardText}>
|
||||
{room.memberCharacterIds
|
||||
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
|
||||
.join("、") || room.mode}
|
||||
</Text>
|
||||
{room.unreadCount > 0 && (
|
||||
<View style={styles.unreadBadge}>
|
||||
<Text style={styles.unreadBadgeText}>{room.unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
|
||||
{workGroups.map(({ work, characters }) => {
|
||||
if (characters.length === 0) return null;
|
||||
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
|
||||
const endings = endingDetectionsByWork[work.id] ?? [];
|
||||
return (
|
||||
<View key={work.id} style={styles.workSection}>
|
||||
<View style={styles.workHeaderRow}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{work.title}
|
||||
{work.inEpilogue ? "(後日談)" : ""}
|
||||
</Text>
|
||||
<View style={styles.workHeaderActions}>
|
||||
{pendingCount > 0 && (
|
||||
<Pressable style={styles.pendingBadge} onPress={() => router.push("/canon-review")}>
|
||||
<Text style={styles.pendingBadgeText}>有 {pendingCount} 項待裁決</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.importChip} onPress={() => router.push(`/canon-import?workId=${work.id}`)}>
|
||||
<Text style={styles.importChipText}>匯入章節</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rosterRow}>
|
||||
{characters.map((character) => (
|
||||
<View key={character.id} style={styles.chip}>
|
||||
<Text style={styles.chipText}>
|
||||
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
|
||||
</Text>
|
||||
{character.id === possessedCharacterId ? (
|
||||
<View style={styles.possessedBadge}>
|
||||
<Text style={styles.possessedBadgeText}>套用中</Text>
|
||||
</View>
|
||||
) : (
|
||||
!possessedCharacterId && (
|
||||
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
|
||||
<Text style={styles.possessButtonText}>套用</Text>
|
||||
</Pressable>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{endings.map((ending) => (
|
||||
<View key={ending.id} style={styles.endingCard}>
|
||||
<Text style={styles.endingCardTitle}>偵測到可能的結局章節,是否切入後日談?</Text>
|
||||
<Text style={styles.endingCardEvidence}>判定理由:{ending.evidence}</Text>
|
||||
<View style={styles.endingCardActions}>
|
||||
<Pressable onPress={() => void handleConfirmEnding(ending.id)} disabled={pending}>
|
||||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>確認.切入後日談</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void handleRejectEnding(ending.id)} disabled={pending}>
|
||||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>拒絕</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{work.milestones.length > 0 && (
|
||||
<View style={styles.milestoneList}>
|
||||
{work.milestones.map((milestone, index) => (
|
||||
<Text key={`${milestone.characterId}-${index}`} style={styles.milestoneText}>
|
||||
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}:
|
||||
{milestone.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<View style={styles.workSection}>
|
||||
<Text style={styles.sectionTitle}>未分組角色</Text>
|
||||
<View style={styles.rosterRow}>
|
||||
{unassigned.map((character) => (
|
||||
<View key={character.id} style={styles.chip}>
|
||||
<Text style={styles.chipText}>
|
||||
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
|
||||
</Text>
|
||||
{character.id === possessedCharacterId ? (
|
||||
<View style={styles.possessedBadge}>
|
||||
<Text style={styles.possessedBadgeText}>套用中</Text>
|
||||
</View>
|
||||
) : (
|
||||
!possessedCharacterId && (
|
||||
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
|
||||
<Text style={styles.possessButtonText}>套用</Text>
|
||||
</Pressable>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{lobbyMessage && (
|
||||
<View style={styles.messageBox}>
|
||||
<Text style={styles.messageBoxText}>{lobbyMessage}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{possessedCharacterId ? (
|
||||
<Text style={styles.hint}>套用中無法以自己的身分喊人,請先退出套用。</Text>
|
||||
) : (
|
||||
<View style={styles.sayRow}>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="喊一下誰的名字……"
|
||||
style={styles.sayInput}
|
||||
editable={!pending}
|
||||
onSubmitEditing={handleSay}
|
||||
/>
|
||||
<Pressable onPress={handleSay} disabled={pending || !text.trim()}>
|
||||
<Text style={[styles.sendButton, pending || !text.trim() ? styles.sendButtonDisabled : null]}>送出</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, gap: 6 },
|
||||
sectionTitle: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f", marginTop: 6 },
|
||||
rosterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||||
workSection: { gap: 6 },
|
||||
workHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 6 },
|
||||
workHeaderActions: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
pendingBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 8, paddingVertical: 3 },
|
||||
pendingBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
|
||||
importChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 3 },
|
||||
importChipText: { fontSize: 10, color: "#8a6b6f" },
|
||||
endingCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 4 },
|
||||
endingCardTitle: { fontSize: 11, color: "#222" },
|
||||
endingCardEvidence: { fontSize: 11, color: "#8a6b6f" },
|
||||
endingCardActions: { flexDirection: "row", gap: 12 },
|
||||
confirmButton: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D" },
|
||||
rejectButton: { fontSize: 11, color: "#8a6b6f" },
|
||||
milestoneList: { gap: 2 },
|
||||
milestoneText: { fontSize: 11, color: "#8a6b6f" },
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
possessedBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 6, paddingVertical: 1 },
|
||||
possessedBadgeText: { fontSize: 9, fontWeight: "bold", color: "#fff" },
|
||||
possessButton: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 6, paddingVertical: 1 },
|
||||
possessButtonText: { fontSize: 9, color: "#8a6b6f" },
|
||||
possessionBanner: { borderRadius: 12, borderWidth: 2, borderColor: "#FF7E9D", backgroundColor: "#FFE1E9", padding: 10, gap: 6 },
|
||||
possessionBannerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
possessionBannerTitle: { fontSize: 13, fontWeight: "bold", color: "#FF7E9D" },
|
||||
possessionBannerHint: { fontSize: 11, color: "#8a6b6f" },
|
||||
exitPossessionLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" },
|
||||
chipText: { fontSize: 12, color: "#222" },
|
||||
roomCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
roomCardRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
roomCardText: { fontSize: 12, color: "#222" },
|
||||
unreadBadge: {
|
||||
backgroundColor: "#FF7E9D",
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
minWidth: 18,
|
||||
alignItems: "center",
|
||||
},
|
||||
unreadBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
|
||||
messageBox: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
messageBoxText: { fontSize: 12, color: "#222" },
|
||||
sayRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
},
|
||||
sayInput: { flex: 1, fontSize: 14 },
|
||||
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
|
||||
sendButtonDisabled: { opacity: 0.5 },
|
||||
hint: { fontSize: 12, color: "#8a6b6f", paddingHorizontal: 16 },
|
||||
error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 },
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { TachieStage, type TachieExpression } from "./TachieStage";
|
||||
import { HeartbeatDot } from "./HeartbeatDot";
|
||||
|
||||
export interface StageParticipant {
|
||||
characterId: string;
|
||||
label: string;
|
||||
expression: TachieExpression | null;
|
||||
}
|
||||
|
||||
interface RoomStageProps {
|
||||
participants: StageParticipant[];
|
||||
speakingCharacterId: string | null;
|
||||
}
|
||||
|
||||
// T20-P3:多人房立繪舞台——與 apps/web 的 components/RoomStage.tsx 呈現同等資訊,目前發話者
|
||||
// 用 HeartbeatDot(Q-4 既有的心跳提示元件)標示,人多時整體縮小排列。
|
||||
export function RoomStage({ participants, speakingCharacterId }: RoomStageProps) {
|
||||
if (participants.length === 0) return null;
|
||||
const scale = participants.length <= 3 ? 1 : participants.length <= 6 ? 0.7 : 0.5;
|
||||
|
||||
return (
|
||||
<ScrollView horizontal contentContainerStyle={styles.row} showsHorizontalScrollIndicator={false}>
|
||||
{participants.map((participant) => {
|
||||
const speaking = participant.characterId === speakingCharacterId;
|
||||
return (
|
||||
<View key={participant.characterId} style={[styles.item, { transform: [{ scale }], opacity: speakingCharacterId && !speaking ? 0.55 : 1 }]}>
|
||||
{speaking && <HeartbeatDot size={8} style={styles.dot} />}
|
||||
<TachieStage expression={participant.expression} />
|
||||
<Text style={[styles.label, speaking ? styles.labelSpeaking : null]}>{participant.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: "row", alignItems: "flex-end", gap: 8, paddingHorizontal: 12, paddingVertical: 4 },
|
||||
item: { alignItems: "center", gap: 2 },
|
||||
dot: { marginBottom: 2 },
|
||||
label: { fontSize: 10, color: "#8a6b6f" },
|
||||
labelSpeaking: { color: "#FF7E9D", fontWeight: "bold" },
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { Canvas, Group, Oval, Path, Rect, RoundedRect } from "@shopify/react-native-skia";
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
|
||||
export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
|
||||
|
||||
export interface TachieExpression {
|
||||
emotionTag: EmotionTag;
|
||||
eyebrow: string;
|
||||
eye: string;
|
||||
mouth: string;
|
||||
effect: string | null;
|
||||
pose: string;
|
||||
magnitude: number;
|
||||
gaze: GazeDirection;
|
||||
}
|
||||
|
||||
const EFFECT_GLYPH: Record<string, string> = {
|
||||
臉紅: "◕‸◕",
|
||||
汗: "💧",
|
||||
淚: "😢",
|
||||
青筋: "💢",
|
||||
音符: "♪",
|
||||
};
|
||||
|
||||
const WIDTH = 200;
|
||||
const HEIGHT = 260;
|
||||
const BODY_X = 100;
|
||||
const BODY_Y = 40;
|
||||
|
||||
// Q-5:以 react-native-skia 重現 apps/web/components/TachieStage.tsx(PixiJS)的分層合成與微動態,
|
||||
// 沿用同一套佔位色塊幾何與 magnitude 縮放公式,維持三端視覺一致。差分選用規則仍在後端
|
||||
// (/tachie/:characterId/:userId/expression),這裡只負責依 expression 的欄位畫出對應的差分。
|
||||
// 沒有安裝 react-native-reanimated(Skia 此版本已列為選用 peer dependency),改用一般 React state
|
||||
// 搭配 requestAnimationFrame 驅動眨眼/呼吸這種低頻率的常駐動態,避免多引入一個原生依賴與 babel 設定。
|
||||
export function TachieStage({ expression }: { expression: TachieExpression | null }) {
|
||||
const [breathPhase, setBreathPhase] = useState(0);
|
||||
const [blinking, setBlinking] = useState(false);
|
||||
const blinkClockRef = useRef(0);
|
||||
const lastTickRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let raf: number;
|
||||
function tick(now: number) {
|
||||
const last = lastTickRef.current ?? now;
|
||||
const deltaTime = ((now - last) / 1000) * 60; // 對齊 web 版以「每 frame(60fps 基準)」為單位的節奏
|
||||
lastTickRef.current = now;
|
||||
|
||||
setBreathPhase((phase) => phase + deltaTime * 0.03);
|
||||
blinkClockRef.current += deltaTime;
|
||||
const cycle = blinkClockRef.current % 180; // 約每 3 秒眨一次眼,與 web 版一致
|
||||
setBlinking(cycle > 172);
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
// O-4 外顯度縮放:magnitude 越小,差分動作幅度越小;佔位資產仍保留最低幅度,確保肉眼看得出「有變化」。
|
||||
const magnitude = Math.max(0.12, expression?.magnitude ?? 0.12);
|
||||
const bodyScaleY = 1 + Math.sin(breathPhase) * 0.015;
|
||||
const eyeScaleY = blinking ? 0.1 : 1;
|
||||
|
||||
const eyebrowY = -26 - 6 * magnitude;
|
||||
const gazeDx = expression?.gaze === "AWAY" ? -8 : 0;
|
||||
const gazeDy = expression?.gaze === "UP" ? -5 : 0;
|
||||
const eyeHeight = 6 + 6 * magnitude;
|
||||
const isFrown = expression?.emotionTag === "SAD" || expression?.emotionTag === "GRUMPY";
|
||||
const curve = 10 * magnitude * (isFrown ? 1 : -1);
|
||||
const mouthPath = `M -12 6 Q 0 ${6 + curve} 12 6`;
|
||||
|
||||
const glyph = expression?.effect ? (EFFECT_GLYPH[expression.effect] ?? expression.effect) : null;
|
||||
|
||||
return (
|
||||
<View style={{ width: WIDTH, height: HEIGHT }}>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
{/* O-1/O-4 分層合成(由下而上):身體姿勢 → 服裝層 → 表情層(眉/眼/口)→ 動態層(眨眼/呼吸,常駐)。 */}
|
||||
<Group transform={[{ translateX: BODY_X }, { translateY: BODY_Y }, { scaleY: bodyScaleY }]}>
|
||||
<RoundedRect x={-55} y={0} width={110} height={180} r={24} color="#FFB3C6" />
|
||||
<Rect x={-55} y={95} width={110} height={85} color="#9B8CFF" />
|
||||
|
||||
<Group transform={[{ translateY: -8 }]}>
|
||||
<Rect x={-40} y={eyebrowY} width={40} height={4} color="#4A3B3B" />
|
||||
<Rect x={0} y={eyebrowY} width={40} height={4} color="#4A3B3B" />
|
||||
|
||||
<Group transform={[{ translateX: -20 + gazeDx }, { translateY: -10 + gazeDy }, { scaleY: eyeScaleY }]}>
|
||||
<Oval x={-6} y={-eyeHeight} width={12} height={eyeHeight * 2} color="#2F2F2F" />
|
||||
</Group>
|
||||
<Group transform={[{ translateX: 20 + gazeDx }, { translateY: -10 + gazeDy }, { scaleY: eyeScaleY }]}>
|
||||
<Oval x={-6} y={-eyeHeight} width={12} height={eyeHeight * 2} color="#2F2F2F" />
|
||||
</Group>
|
||||
|
||||
<Path path={mouthPath} style="stroke" strokeWidth={3} strokeCap="round" strokeJoin="round" color="#8A4A4A" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Canvas>
|
||||
{glyph && <Text style={styles.effectGlyph}>{glyph}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
effectGlyph: { position: "absolute", left: 140, top: 30, fontSize: 20 },
|
||||
});
|
||||
|
||||
// TachieStage.web.tsx 透過 WithSkiaWeb 動態載入這個模組時要求 default export;
|
||||
// 原生端(TachieStage.tsx)用回具名匯出,兩邊都指向同一份實作,不重複維護兩套繪製邏輯。
|
||||
export default TachieStage;
|
||||
@@ -0,0 +1,3 @@
|
||||
// 原生端(iOS/Android/Expo Go)直接用 react-native-skia 的原生繪圖引擎,不需要 Web 專用的
|
||||
// CanvasKit WASM 載入流程——見 TachieStage.web.tsx 才是那條路徑要走的地方。
|
||||
export { TachieStage, type TachieExpression, type GazeDirection } from "./TachieStage.impl";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Text } from "react-native";
|
||||
// eslint-disable-next-line import/no-unresolved -- 深層路徑是 react-native-skia 官方文件指定的 Web 專用匯出位置。
|
||||
import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web";
|
||||
import type { GazeDirection, TachieExpression } from "./TachieStage.impl";
|
||||
|
||||
export type { GazeDirection, TachieExpression };
|
||||
|
||||
// Expo web 走瀏覽器 CanvasKit(WASM),需要先非同步載入才能畫圖——原生端沒有這個限制,
|
||||
// 所以原生/網頁各自一份極薄的進入點,實際繪製邏輯全部共用 TachieStage.impl.tsx。
|
||||
// canvaskit.wasm 已由 `npx setup-skia-web` 放在 apps/mobile/public/,走 Metro web 的靜態根目錄慣例。
|
||||
export function TachieStage(props: { expression: TachieExpression | null }) {
|
||||
return (
|
||||
<WithSkiaWeb
|
||||
getComponent={() => import("./TachieStage.impl")}
|
||||
componentProps={props}
|
||||
fallback={<Text>立繪載入中…</Text>}
|
||||
opts={{ locateFile: () => "/canvaskit.wasm" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+657
-8
@@ -38,16 +38,31 @@ export interface ChatResult {
|
||||
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}`, {
|
||||
// T38:既有 1:1 聊天併入 SOLO 房,取代舊版由呼叫端自行產生 sessionId 的 sendChatMessage。
|
||||
// 伺服器端為同一使用者+角色配對沿用同一間進行中的 SOLO 房,契約與 apps/web/lib/api.ts 一致。
|
||||
export interface SoloRoomSummary {
|
||||
id: string;
|
||||
mode: "SOLO";
|
||||
status: "ACTIVE" | "CLOSED";
|
||||
}
|
||||
|
||||
export async function getOrCreateSoloRoom(userId: string, characterId: string): Promise<SoloRoomSummary> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/solo`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, sessionId, text }),
|
||||
body: JSON.stringify({ userId, characterId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`房間建立失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendSoloMessage(roomId: string, userId: string, text: string): Promise<ChatResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`對話請求失敗(${res.status})`);
|
||||
@@ -78,6 +93,25 @@ export async function fetchExpression(characterId: string, userId: string): Prom
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SynthesizedVoice {
|
||||
audioRef: string; // TTS_PROVIDER=mock 時是 `mock://` 佔位字串,非真實音檔
|
||||
prosodyMarkup: string;
|
||||
durationEstimateMs: number;
|
||||
}
|
||||
|
||||
// T20-P3:語音合成——與 apps/web 呼叫同一端點,播放邏輯見 app/room/[roomId].tsx。
|
||||
export async function synthesizeVoice(characterId: string, userId: string, text: string): Promise<SynthesizedVoice> {
|
||||
const res = await fetch(`${API_BASE_URL}/voice/${characterId}/${userId}/synthesize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
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",
|
||||
@@ -85,3 +119,618 @@ export async function registerPushToken(userId: string, token: string, platform:
|
||||
body: JSON.stringify({ userId, token, platform }),
|
||||
});
|
||||
}
|
||||
|
||||
// T20-P1:大廳頁最小可用(在場名單+進行中房間清單)與房間歷程分頁,契約與 apps/web/lib/api.ts 一致
|
||||
// (見該檔 T19-P1 段落),此處為 React Native 端各自實作的對應封裝。
|
||||
export interface LobbySnapshot {
|
||||
lobbyRoomId: string;
|
||||
roster: string[];
|
||||
activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[];
|
||||
}
|
||||
|
||||
export async function getLobby(userId: string): Promise<LobbySnapshot> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/lobby?userId=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`大廳查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type LobbyPresenceState = "BUILDING" | "SLEEPING" | "BUSY" | "SEMI_BUSY" | "IDLE" | "IN_SOLO_ROOM" | "IN_GROUP_ROOM";
|
||||
|
||||
export interface LobbyMilestoneView {
|
||||
characterId: string;
|
||||
label: string;
|
||||
storyOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LobbyWorkView {
|
||||
id: string;
|
||||
title: string;
|
||||
inEpilogue: boolean;
|
||||
milestones: LobbyMilestoneView[]; // T34:自走一段時間後自主成立的人生變化
|
||||
}
|
||||
|
||||
export interface LobbyCharacterView {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
formalName: string;
|
||||
buildStatus: string;
|
||||
presenceState: LobbyPresenceState;
|
||||
roomId: string | null;
|
||||
}
|
||||
|
||||
export interface LobbyActiveRoomView {
|
||||
id: string;
|
||||
mode: string;
|
||||
topicSeed: string | null;
|
||||
memberCharacterIds: string[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface LobbyFullSnapshot {
|
||||
works: LobbyWorkView[];
|
||||
characters: LobbyCharacterView[];
|
||||
activeRooms: LobbyActiveRoomView[];
|
||||
}
|
||||
|
||||
// T4/T20-P2:正式大廳聚合端點,契約與 apps/web/lib/api.ts 一致(見該檔 T19-P2 段落)。
|
||||
export async function getFullLobby(userId: string): Promise<LobbyFullSnapshot> {
|
||||
const res = await fetch(`${API_BASE_URL}/lobby?userId=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`大廳查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface LobbySayResult {
|
||||
calledCharacterIds: string[];
|
||||
accepted: string[];
|
||||
declined: string[];
|
||||
roomId: string | null;
|
||||
mode: "SOLO" | "GROUP" | null;
|
||||
message: string | null;
|
||||
delegatedBy: string | null;
|
||||
pendingPossessedCharacterIds: string[]; // T43:喊到但目前套用中,改記待決給套用者
|
||||
}
|
||||
|
||||
// T5/T20-P2:大廳對話式呼叫,契約與 apps/web/lib/api.ts 一致。
|
||||
export async function sayToLobby(userId: string, text: string): Promise<LobbySayResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/lobby/say`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`大廳呼叫失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// T20-P4:匯入章節、待裁決、結局確認——契約與 apps/web/lib/api.ts 的 T19-P4 段落一致,mobile 補上
|
||||
// 對等的封裝與畫面(`app/canon-import.tsx`/`app/canon-review.tsx`)。
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
aliases: { formalName: string }[];
|
||||
}
|
||||
|
||||
export async function listCharacters(): Promise<CharacterSummary[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/characters`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`角色清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CanonWork {
|
||||
id: string;
|
||||
title: string;
|
||||
timeFlowMode: "FAST" | "REALTIME" | "SLOW" | "FROZEN";
|
||||
anchorStoryOrder: number;
|
||||
inEpilogue: boolean;
|
||||
}
|
||||
|
||||
export async function listCanonWorks(): Promise<CanonWork[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/works`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`作品清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CanonScene {
|
||||
id: string;
|
||||
workId: string;
|
||||
storyOrder: number;
|
||||
projected: boolean;
|
||||
}
|
||||
|
||||
export async function listCanonScenes(workId: string): Promise<CanonScene[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/scenes`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`場景清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type SceneLineStatus = "PENDING" | "CONFIRMED" | "QUARANTINED";
|
||||
export type SceneLineType = "DIALOGUE" | "ACTION" | "MONOLOGUE" | "NARRATION";
|
||||
|
||||
export interface SceneLine {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
lineIndex: number;
|
||||
lineType: SceneLineType;
|
||||
rawText: string;
|
||||
hasExplicitMarker: boolean;
|
||||
markerCharacterId: string | null;
|
||||
speakerCharacterId: string | null;
|
||||
confidence: number;
|
||||
status: SceneLineStatus;
|
||||
}
|
||||
|
||||
export interface CanonSceneDetail extends CanonScene {
|
||||
lines: SceneLine[];
|
||||
}
|
||||
|
||||
export async function getCanonScene(sceneId: string): Promise<CanonSceneDetail> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/scenes/${sceneId}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`場景詳情查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type ContradictionStatus = "PENDING_HUMAN_REVIEW" | "RESOLVED";
|
||||
|
||||
export interface SceneContradiction {
|
||||
id: string;
|
||||
workId: string;
|
||||
eventLabel: string;
|
||||
factKey: string;
|
||||
description: string;
|
||||
status: ContradictionStatus;
|
||||
}
|
||||
|
||||
export async function listContradictions(workId: string): Promise<SceneContradiction[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/contradictions`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`矛盾清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function adjudicateLine(lineId: string, speakerCharacterId: string): Promise<SceneLine> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/scene-lines/${lineId}/adjudicate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ speakerCharacterId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`裁決失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function correctLineAttribution(lineId: string, newSpeakerCharacterId: string): Promise<SceneLine> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/scene-lines/${lineId}/correct`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newSpeakerCharacterId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`歸屬更正失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function reattemptQuarantined(sceneId: string): Promise<{ released: number }> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/scenes/${sceneId}/reattempt-quarantined`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`重試失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ImportSceneLineInput {
|
||||
lineType: SceneLineType;
|
||||
rawText: string;
|
||||
hasExplicitMarker?: boolean;
|
||||
markerCharacterId?: string;
|
||||
}
|
||||
|
||||
export interface ImportSceneInput {
|
||||
workId: string;
|
||||
volume: number;
|
||||
chapterLabel: string;
|
||||
storyOrder: number;
|
||||
summary: string;
|
||||
rawText: string;
|
||||
participantCharacterIds: string[];
|
||||
lines: ImportSceneLineInput[];
|
||||
}
|
||||
|
||||
export interface ImportedSceneResult extends CanonSceneDetail {
|
||||
pendingAdjudication: boolean;
|
||||
}
|
||||
|
||||
export async function importCanonScene(input: ImportSceneInput): Promise<ImportedSceneResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/scenes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(typeof body?.message === "string" ? body.message : `章節匯入失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listPendingAdjudication(workId: string): Promise<ImportedSceneResult[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/pending-adjudication`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`待裁決清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type EndingDetectionResolution = "PENDING" | "CONFIRMED" | "REJECTED";
|
||||
|
||||
export interface EndingDetectionLog {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
evidence: string;
|
||||
resolution: EndingDetectionResolution;
|
||||
createdAt: string;
|
||||
respondedAt: string | null;
|
||||
}
|
||||
|
||||
export async function listEndingDetections(workId: string): Promise<EndingDetectionLog[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/ending-detections`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`結局偵測清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function confirmEndingDetection(logId: string): Promise<CanonWork> {
|
||||
const res = await fetch(`${API_BASE_URL}/epilogue/ending-detections/${logId}/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(typeof body?.message === "string" ? body.message : `結局確認失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function rejectEndingDetection(logId: string): Promise<EndingDetectionLog> {
|
||||
const res = await fetch(`${API_BASE_URL}/epilogue/ending-detections/${logId}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(typeof body?.message === "string" ? body.message : `結局拒絕失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const ROOM_USER_SPEAKER_ID = "USER";
|
||||
|
||||
// T20-P3:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,對應後端 RoomTurnKind(見 T6/T19-P3)。
|
||||
export type RoomTurnKind = "TALK" | "ACTION" | "DELEGATE" | "LEAVE" | "SYSTEM";
|
||||
|
||||
export interface RoomTurn {
|
||||
seq: number;
|
||||
speakerId: string;
|
||||
speakerLabel: string;
|
||||
content: string;
|
||||
kind: RoomTurnKind;
|
||||
}
|
||||
|
||||
export interface RoomUnreadInfo {
|
||||
lastReadSeq: number;
|
||||
firstUnreadSeq: number | null;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface RoomUsageSummary {
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
generationCount: number;
|
||||
}
|
||||
|
||||
export interface RoomDetail {
|
||||
id: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
topicSeed: string | null;
|
||||
participants: string[];
|
||||
turnCount: number;
|
||||
turns: RoomTurn[];
|
||||
hasMore: boolean;
|
||||
nextCursor: number | null;
|
||||
unread: RoomUnreadInfo;
|
||||
muted: boolean; // T31:靜音——不推播、不自動播放語音,自走仍照常進行
|
||||
usage: RoomUsageSummary;
|
||||
}
|
||||
|
||||
export async function getRoomUsage(roomId: string): Promise<RoomUsageSummary> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/usage`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`用量查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// T31:靜音只影響推播與自動語音播放,自走排程與生成照常進行。
|
||||
export async function setRoomMuted(roomId: string, muted: boolean): Promise<{ id: string; muted: boolean }> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/mute`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ muted }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`靜音設定失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface RoomTurnsPageOptions {
|
||||
afterSeq?: number;
|
||||
beforeSeq?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getRoom(roomId: string, page: RoomTurnsPageOptions = {}): Promise<RoomDetail> {
|
||||
const params = new URLSearchParams();
|
||||
if (page.afterSeq !== undefined) params.set("afterSeq", String(page.afterSeq));
|
||||
if (page.beforeSeq !== undefined) params.set("beforeSeq", String(page.beforeSeq));
|
||||
if (page.limit !== undefined) params.set("limit", String(page.limit));
|
||||
const query = params.toString();
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}${query ? `?${query}` : ""}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`房間查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface RoomMessageResult {
|
||||
speakers: { characterId: string; text: string; score: number }[];
|
||||
}
|
||||
|
||||
// T20-P2:通用房間發言——後端依 room.mode 內部分流 SOLO/GROUP,回應形狀不同,但本畫面一律透過
|
||||
// SSE/串流收新訊息,不依賴這裡的回傳值本身,故用寬鬆型別接收即可。
|
||||
export async function sendRoomMessage(roomId: string, userId: string, userLabel: string, text: string): Promise<unknown> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, userLabel, text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`發言失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function endRoom(roomId: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/end`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`結束房間失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// T11/T20-P2:回報已讀游標,契約與 apps/web/lib/api.ts 一致。
|
||||
export async function reportRoomRead(roomId: string, userId: string, lastReadSeq: number): Promise<RoomUnreadInfo> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/read`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, lastReadSeq }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`已讀回報失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type RoomStreamEvent =
|
||||
| { type: "turn"; seq: number; data: { speakerId: string; speakerLabel: string; content: string; kind: RoomTurnKind } }
|
||||
| { type: "member"; seq: number; data: { characterId: string; action: "joined" | "left" } }
|
||||
| { type: "closed"; seq: number; data: { reason: string } };
|
||||
|
||||
// T10/T20-P2:房間即時推送——React Native 沒有跨平台一致的 EventSource(見 AGENTS.md
|
||||
// 「Expo HAS CHANGED」提醒,已查證 Expo SDK 57 官方文件:`expo/fetch` 提供 WinterCG 相容的
|
||||
// streaming response body,`EventSource` 則未在文件中記載跨平台可用),故改用 `expo/fetch` 的
|
||||
// `resp.body.getReader()` 手動解析標準 SSE 分幀(event/id/data,空行斷幀),語意與 apps/web 用
|
||||
// 瀏覽器原生 EventSource 消費的是同一個後端端點、同一套事件格式,只是消費端實作不同。
|
||||
// 回傳一個 cancel 函式,呼叫者卸載時務必呼叫以中止底層串流讀取。
|
||||
export function subscribeRoomStream(
|
||||
roomId: string,
|
||||
afterSeq: number,
|
||||
onEvent: (event: RoomStreamEvent) => void,
|
||||
onError?: (err: unknown) => void,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { fetch: expoFetch } = await import("expo/fetch");
|
||||
const resp = await expoFetch(`${API_BASE_URL}/room/${roomId}/stream?afterSeq=${afterSeq}`, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (!cancelled) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let frameBreak = buffer.indexOf("\n\n");
|
||||
while (frameBreak !== -1) {
|
||||
const frame = buffer.slice(0, frameBreak);
|
||||
buffer = buffer.slice(frameBreak + 2);
|
||||
|
||||
let eventType = "message";
|
||||
let id: string | null = null;
|
||||
const dataLines: string[] = [];
|
||||
for (const line of frame.split("\n")) {
|
||||
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||
else if (line.startsWith("id:")) id = line.slice(3).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (eventType !== "ping" && dataLines.length > 0 && id !== null) {
|
||||
try {
|
||||
const data = JSON.parse(dataLines.join("\n"));
|
||||
onEvent({ type: eventType as RoomStreamEvent["type"], seq: Number(id), data } as RoomStreamEvent);
|
||||
} catch {
|
||||
// 忽略解析失敗的單一分幀,不中斷整條串流。
|
||||
}
|
||||
}
|
||||
frameBreak = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) onError?.(err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}
|
||||
|
||||
// T20-P5 人格套用:契約與 apps/api/src/room/possession.controller.ts、apps/web/lib/api.ts 一致。
|
||||
export interface PossessionStatus {
|
||||
possessingCharacterId: string | null;
|
||||
}
|
||||
|
||||
export async function getPossessionStatus(userId: string): Promise<PossessionStatus> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/status?userId=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`套用狀態查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function enterPossession(userId: string, characterId: string): Promise<PossessionStatus> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/enter`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, characterId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(typeof body?.message === "string" ? body.message : `套用失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function exitPossession(userId: string): Promise<PossessionStatus> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/exit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`退出套用失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function speakAsPossessed(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
text: string,
|
||||
): Promise<{ speakers: { characterId: string; text: string; score: number }[] }> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/${roomId}/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(typeof body?.message === "string" ? body.message : `發話失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type RoomInvitationStatus = "PENDING" | "ACCEPTED" | "DECLINED";
|
||||
|
||||
export interface PendingInvitation {
|
||||
id: string;
|
||||
roomId: string;
|
||||
characterId: string;
|
||||
invitedByCharacterId: string | null;
|
||||
status: RoomInvitationStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function listPendingInvitations(userId: string): Promise<PendingInvitation[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/pending-invitations?userId=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`待決邀請查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function decideInvitation(invitationId: string, accept: boolean): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/invitations/${invitationId}/decide`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accept }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`邀請決定失敗(${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PendingLobbyCall {
|
||||
id: string;
|
||||
userId: string;
|
||||
characterId: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function listPendingLobbyCalls(userId: string): Promise<PendingLobbyCall[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/pending-calls?userId=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`待決呼叫查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function decideLobbyCall(pendingCallId: string, accept: boolean): Promise<{ roomId: string | null }> {
|
||||
const res = await fetch(`${API_BASE_URL}/possession/lobby-calls/${pendingCallId}/decide`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accept }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`呼叫決定失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// 部分 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)}`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AccessibilityInfo } from "react-native";
|
||||
|
||||
// Q-4:對應 apps/web 的 `prefers-reduced-motion`,兩處視覺母題動畫(HeartbeatWave/HeartbeatDot)
|
||||
// 都要在系統開啟「減少動態」時關閉動畫,改為靜態呈現。
|
||||
export function useReduceMotion(): boolean {
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
||||
if (mounted) {
|
||||
setReduceMotion(enabled);
|
||||
}
|
||||
});
|
||||
const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled: boolean) => {
|
||||
setReduceMotion(enabled);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return reduceMotion;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
interface UnreadContextValue {
|
||||
hasUnread: boolean; // 推播觸發的提示 或 任一房間有未讀,兩者只要有一個成立即為 true
|
||||
markUnread: () => void; // Q-4:收到推播時呼叫
|
||||
markRead: () => void; // Q-4:使用者切回聊天分頁時呼叫,只清推播提示,不動各房未讀數
|
||||
roomUnreadCounts: Record<string, number>; // T20-P2:各進行中房間的未讀數(來自大廳聚合查詢)
|
||||
setRoomUnreadCounts: (counts: Record<string, number>) => void;
|
||||
totalRoomUnread: number;
|
||||
}
|
||||
|
||||
const UnreadContext = createContext<UnreadContextValue | null>(null);
|
||||
|
||||
// Q-4:聊天分頁的未讀/推播提示紅點狀態,由根佈局的推播接收監聽器寫入,
|
||||
// 由聊天分頁在取得焦點時清除(見 app/(tabs)/index.tsx 的 useFocusEffect)。
|
||||
// T20-P2:擴充承接大廳多房未讀——分頁籤紅點現在同時反映「有推播尚未看」與「任一房間有未讀訊息」,
|
||||
// 兩個來源各自獨立更新,互不覆蓋(推播清除不會把房間未讀數也清空,反之亦然)。
|
||||
export function UnreadProvider({ children }: { children: ReactNode }) {
|
||||
const [pushUnread, setPushUnread] = useState(false);
|
||||
const [roomUnreadCounts, setRoomUnreadCounts] = useState<Record<string, number>>({});
|
||||
const markUnread = useCallback(() => setPushUnread(true), []);
|
||||
const markRead = useCallback(() => setPushUnread(false), []);
|
||||
const totalRoomUnread = useMemo(
|
||||
() => Object.values(roomUnreadCounts).reduce((sum, count) => sum + count, 0),
|
||||
[roomUnreadCounts],
|
||||
);
|
||||
const hasUnread = pushUnread || totalRoomUnread > 0;
|
||||
|
||||
return (
|
||||
<UnreadContext.Provider
|
||||
value={{ hasUnread, markUnread, markRead, roomUnreadCounts, setRoomUnreadCounts, totalRoomUnread }}
|
||||
>
|
||||
{children}
|
||||
</UnreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUnread(): UnreadContextValue {
|
||||
const ctx = useContext(UnreadContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useUnread 必須在 UnreadProvider 底下使用");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const STORAGE_KEY = "kokorone:voice-autoplay-enabled";
|
||||
|
||||
// T20-P3:自走語音自動播放開關,對應 apps/web 的 lib/voice-autoplay-settings.ts;
|
||||
// RN 沒有同步的 localStorage,AsyncStorage 一律回傳 Promise,呼叫端需 await。預設開啟。
|
||||
export async function isVoiceAutoplayEnabled(): Promise<boolean> {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return stored !== "false";
|
||||
}
|
||||
|
||||
export async function setVoiceAutoplayEnabled(enabled: boolean): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, String(enabled));
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~57.0.9",
|
||||
"@kokorone/shared": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@shopify/react-native-skia": "^2.11.0",
|
||||
"expo": "~57.0.12",
|
||||
"expo-constants": "~57.0.10",
|
||||
"expo-device": "~57.0.1",
|
||||
@@ -17,6 +19,7 @@
|
||||
"react-native": "0.86.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"react-native-web": "^0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user