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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:10:26 +08:00
co-authored by Claude Sonnet 5
parent 127a59dafd
commit 1bea06e3cd
195 changed files with 17219 additions and 738 deletions
@@ -0,0 +1,296 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
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: "敘述",
};
function emptyLine(): ImportSceneLineInput {
return { lineType: "NARRATION", rawText: "" };
}
// T19-P4 匯入章節:場景層級的摘要/原文+逐句台詞。多人在場且台詞沒有明示說話者時,M-3/M-11
// 既有的發言歸屬會把信心不足的句子隔離(QUARANTINED),提交後直接依回應導向待裁決頁或顯示已投影。
export function CanonImportView() {
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);
const presetWorkId = new URLSearchParams(window.location.search).get("workId");
if (presetWorkId && all.some((w) => w.id === presetWorkId)) {
setWorkId(presetWorkId);
}
});
}, []);
useEffect(() => {
const selected = works.find((w) => w.id === workId);
setStoryOrder((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,
chapterLabel: chapterLabel.trim(),
storyOrder,
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 (
<div className="flex flex-col gap-4">
<select
value={workId}
onChange={(event) => setWorkId(event.target.value)}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇作品……</option>
{works.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
{result && (
<div className="flex flex-col gap-1 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
{result.pendingAdjudication ? (
<>
<p style={{ color: "var(--ink)" }}>匯入成功,但有台詞或矛盾待人工確認,暫不投影。</p>
<Link href="/canon-review" className="font-bold" style={{ color: "var(--accent)" }}>
前往待裁決頁
</Link>
</>
) : (
<p style={{ color: "var(--ink)" }}>匯入成功,已完成投影{result.projected ? "" : "(場景超前目前錨定點,先留存等待錨定點推進)"}。</p>
)}
</div>
)}
{workId && (
<>
<div className="flex flex-wrap gap-2">
<input
value={chapterLabel}
onChange={(event) => setChapterLabel(event.target.value)}
placeholder="章節標題(例如:第十章)"
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<input
type="number"
value={volume}
onChange={(event) => setVolume(Number(event.target.value))}
placeholder="卷數"
className="w-20 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<input
type="number"
value={storyOrder}
onChange={(event) => setStoryOrder(Number(event.target.value))}
placeholder="storyOrder"
className="w-28 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
</div>
<textarea
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="場景摘要(客觀記錄,供角色記憶與情緒基線使用)"
className="rounded-2xl border bg-transparent p-3 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
rows={3}
/>
<textarea
value={rawText}
onChange={(event) => setRawText(event.target.value)}
placeholder="原文(完整章節文本)"
className="rounded-2xl border bg-transparent p-3 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
rows={5}
/>
<div className="flex flex-col gap-2">
<p className="text-xs font-bold" style={{ color: "var(--ink)" }}>
在場角色
</p>
<div className="flex flex-wrap gap-2 text-xs">
{characters.map((character) => (
<button
key={character.id}
type="button"
onClick={() => toggleParticipant(character.id)}
className="rounded-full border px-3 py-1"
style={{
borderColor: "var(--line)",
background: participantCharacterIds.includes(character.id) ? "var(--chip-bg)" : "transparent",
color: "var(--ink)",
}}
>
{character.aliases[0]?.formalName ?? character.id}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-xs font-bold" style={{ color: "var(--ink)" }}>
逐句台詞/敘述
</p>
<button
type="button"
onClick={() => setLines((prev) => [...prev, emptyLine()])}
className="rounded-full border px-3 py-1 text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
新增一行
</button>
</div>
{lines.map((line, index) => (
<div key={index} className="flex flex-wrap items-center gap-2 text-xs">
<select
value={line.lineType}
onChange={(event) => updateLine(index, { lineType: event.target.value as SceneLineType })}
className="rounded-full border bg-transparent px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
{(Object.keys(LINE_TYPE_LABELS) as SceneLineType[]).map((type) => (
<option key={type} value={type}>
{LINE_TYPE_LABELS[type]}
</option>
))}
</select>
<input
value={line.rawText}
onChange={(event) => updateLine(index, { rawText: event.target.value })}
placeholder="這一句的內容"
className="flex-1 rounded-full border bg-transparent px-3 py-1 outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
{line.lineType === "DIALOGUE" && (
<select
value={line.markerCharacterId ?? ""}
onChange={(event) =>
updateLine(index, {
markerCharacterId: event.target.value || undefined,
hasExplicitMarker: Boolean(event.target.value),
})
}
className="rounded-full border bg-transparent px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">(無明示說話者)</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
)}
<button
type="button"
onClick={() => removeLine(index)}
className="rounded-full border px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
刪除
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={pending || !chapterLabel.trim() || !summary.trim() || !rawText.trim()}
className="self-start rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
匯入這一章
</button>
</>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { CanonImportView } from "./CanonImportView";
export default function CanonImportPage() {
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
匯入章節
</h1>
<Link href="/lobby" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到大廳
</Link>
</div>
<CanonImportView />
</main>
);
}
@@ -0,0 +1,280 @@
"use client";
import { useEffect, useState } from "react";
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 待裁決畫面:列出隔離區台詞與待人工確認的矛盾,提供逐句裁決/歸屬更正/指紋精煉後重試。
export function CanonReviewView() {
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState<string>("");
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 {
const result = await reattemptQuarantined(sceneId);
setError(null);
await refreshWork(workId);
// eslint-disable-next-line no-console
console.info(`指紋精煉後重試釋放了 ${result.released} 句`);
} catch {
setError("重試失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
const sceneIdsWithQuarantine = Array.from(new Set(quarantined.map((line) => line.sceneId)));
return (
<div className="flex flex-col gap-4">
<select
value={workId}
onChange={(event) => setWorkId(event.target.value)}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇作品……</option>
{works.map((work) => (
<option key={work.id} value={work.id}>
{work.title}
</option>
))}
</select>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
{workId && (
<>
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
隔離區台詞(QUARANTINED)
</h2>
{sceneIdsWithQuarantine.map((sceneId) => (
<button
key={sceneId}
type="button"
onClick={() => void handleReattempt(sceneId)}
disabled={pending}
className="rounded-full border px-3 py-1 text-xs disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
指紋精煉後重試此場景
</button>
))}
</div>
{quarantined.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有隔離中的台詞。
</p>
)}
{quarantined.map((line) => (
<div key={line.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>{line.rawText}</p>
<p style={{ color: "var(--ink-soft)" }}>
信心分數:{(line.confidence * 100).toFixed(0)}%.證據來源:
{line.hasExplicitMarker ? `明示標記→${characterLabel(characters, line.markerCharacterId)}` : "推論(無明示標記)"}
</p>
<div className="flex items-center gap-2">
<select
value={selection[line.id] ?? ""}
onChange={(event) => setSelection({ ...selection, [line.id]: event.target.value })}
className="rounded-full border bg-transparent px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇說話者……</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleAdjudicate(line.id)}
disabled={pending || !selection[line.id]}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
裁決
</button>
</div>
</div>
))}
</section>
<section className="flex flex-col gap-2">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
已歸屬台詞(可更正)
</h2>
{confirmed.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有已歸屬的台詞。
</p>
)}
{confirmed.map((line) => (
<div key={line.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>{line.rawText}</p>
<p style={{ color: "var(--ink-soft)" }}>目前歸屬:{characterLabel(characters, line.speakerCharacterId)}</p>
<div className="flex items-center gap-2">
<select
value={selection[line.id] ?? ""}
onChange={(event) => setSelection({ ...selection, [line.id]: event.target.value })}
className="rounded-full border bg-transparent px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">改為……</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleCorrect(line.id)}
disabled={pending || !selection[line.id]}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
更正
</button>
</div>
</div>
))}
</section>
<section className="flex flex-col gap-2">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
待人工確認的矛盾(PENDING_HUMAN_REVIEW)
</h2>
{contradictions.filter((c) => c.status === "PENDING_HUMAN_REVIEW").length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有待確認的矛盾。
</p>
)}
{contradictions
.filter((c) => c.status === "PENDING_HUMAN_REVIEW")
.map((contradiction) => (
<div key={contradiction.id} className="rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>
{contradiction.eventLabel}.{contradiction.factKey}
</p>
<p style={{ color: "var(--ink-soft)" }}>{contradiction.description}</p>
</div>
))}
</section>
</>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { CanonReviewView } from "./CanonReviewView";
export default function CanonReviewPage() {
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
待裁決
</h1>
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到聊天
</Link>
</div>
<CanonReviewView />
</main>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { ChatView } from "./ChatView";
export default function ChatPage() {
return <ChatView />;
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useState } from "react";
import {
fetchPersonalityDrift,
listCanonWorks,
listCharacters,
proposeEpilogueMilestone,
setTimeFlow,
type CanonWork,
type CharacterSummary,
type DriftedPersonality,
type MilestoneProposalResult,
type TimeFlowMode,
} from "../../lib/api";
const TIME_FLOW_LABELS: Record<TimeFlowMode, string> = {
FAST: "快轉",
REALTIME: "現實同步(1:1)",
SLOW: "放慢",
FROZEN: "凍結",
};
// N-6 後日談畫面:時間流速切換、人格漂移檢視、里程碑提案——三者都依「推演深淺」(storyOrder)運作,
// 預設值抓作品目前的 anchorStoryOrder,使用者可自行調整。
export function EpilogueView() {
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState("");
const [work, setWork] = useState<CanonWork | null>(null);
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [characterId, setCharacterId] = useState("");
const [storyOrder, setStoryOrder] = useState(0);
const [timeFlowError, setTimeFlowError] = useState<string | null>(null);
const [drift, setDrift] = useState<DriftedPersonality | null>(null);
const [milestoneResult, setMilestoneResult] = useState<MilestoneProposalResult | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
void listCanonWorks().then(setWorks);
}, []);
useEffect(() => {
const selected = works.find((w) => w.id === workId) ?? null;
setWork(selected);
setStoryOrder(selected?.anchorStoryOrder ?? 0);
setDrift(null);
setMilestoneResult(null);
setTimeFlowError(null);
if (workId) {
void listCharacters().then((all) => setCharacters(all.filter((c) => c.workId === workId)));
} else {
setCharacters([]);
}
}, [workId, works]);
async function handleSetTimeFlow(mode: TimeFlowMode) {
if (!workId || pending) return;
setPending(true);
setTimeFlowError(null);
try {
const updated = await setTimeFlow(workId, mode);
setWork(updated);
setWorks((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
} catch (err) {
// N-6 驗收:受限情境要顯示被拒原因,這裡把 setTimeFlow() 丟出的後端 message 原樣呈現。
setTimeFlowError(err instanceof Error ? err.message : "時間流速設定失敗");
} finally {
setPending(false);
}
}
async function handleViewDrift() {
if (!characterId || pending) return;
setPending(true);
try {
setDrift(await fetchPersonalityDrift(characterId, storyOrder));
} catch {
setDrift(null);
} finally {
setPending(false);
}
}
async function handleProposeMilestone() {
if (!characterId || pending) return;
setPending(true);
try {
setMilestoneResult(await proposeEpilogueMilestone(characterId, storyOrder));
} catch {
setMilestoneResult(null);
} finally {
setPending(false);
}
}
return (
<div className="flex flex-col gap-4">
<select
value={workId}
onChange={(event) => setWorkId(event.target.value)}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇作品……</option>
{works.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{work && (
<>
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
時間流速
</h2>
<p style={{ color: "var(--ink-soft)" }}>
目前:{TIME_FLOW_LABELS[work.timeFlowMode]}
{work.inEpilogue ? ".已進入後日談" : ".尚未進入後日談"}
</p>
<div className="flex flex-wrap gap-2">
{(Object.keys(TIME_FLOW_LABELS) as TimeFlowMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => void handleSetTimeFlow(mode)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{
borderColor: "var(--line)",
background: work.timeFlowMode === mode ? "var(--chip-bg)" : "transparent",
color: "var(--ink)",
}}
>
{TIME_FLOW_LABELS[mode]}
</button>
))}
</div>
{timeFlowError && <p style={{ color: "var(--accent)" }}>{timeFlowError}</p>}
</section>
<select
value={characterId}
onChange={(event) => {
setCharacterId(event.target.value);
setDrift(null);
setMilestoneResult(null);
}}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇角色……</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
{characterId && (
<>
<div className="flex items-center gap-2 text-xs">
<label style={{ color: "var(--ink-soft)" }}>推演深淺(storyOrder,預設抓作品目前的錨定點)</label>
<input
type="number"
value={storyOrder}
onChange={(event) => setStoryOrder(Number(event.target.value))}
className="w-24 rounded-full border bg-transparent px-3 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
</div>
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
人格漂移
</h2>
<button
type="button"
onClick={() => void handleViewDrift()}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
查看
</button>
</div>
{drift && (
<p style={{ color: "var(--ink-soft)" }}>
性格核心:{drift.archetype}(不變).外顯度:{drift.expressiveness.toFixed(2)}(位移
{drift.driftAmount >= 0 ? "+" : ""}
{drift.driftAmount.toFixed(2)}).情緒衰減倍率:{drift.emotionHalfLifeMultiplier.toFixed(2)}
</p>
)}
</section>
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
人生里程碑
</h2>
<button
type="button"
onClick={() => void handleProposeMilestone()}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
提案
</button>
</div>
{milestoneResult && (
<p style={{ color: milestoneResult.label ? "var(--accent)" : "var(--ink-soft)" }}>
{milestoneResult.label
? `提案成功:${milestoneResult.label}`
: "此時沒有里程碑可提案(可能離上次太近,或角色目標裡沒有對應的正史伏筆)"}
</p>
)}
</section>
</>
)}
</>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { EpilogueView } from "./EpilogueView";
export default function EpiloguePage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
後日談
</h1>
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到聊天
</Link>
</div>
<EpilogueView />
</main>
);
}
+565
View File
@@ -0,0 +1,565 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
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";
const USER_ID = "seed-user-primary";
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
BUILDING: "建置中",
SLEEPING: "睡眠中",
BUSY: "忙碌中",
SEMI_BUSY: "略忙",
IDLE: "空閒",
IN_SOLO_ROOM: "單獨聊天中",
IN_GROUP_ROOM: "多人房中",
};
const UNAVAILABLE_STATES: LobbyPresenceState[] = ["BUILDING", "SLEEPING", "IN_SOLO_ROOM", "IN_GROUP_ROOM"];
// T19-P2:大廳頁完整版——取代 T19-P1 的最小快照(在場名單+房間清單),改用 T4 正式聚合端點
// 一次取得作品分組、角色狀態、各進行中房間未讀數,並提供大廳對話式呼叫(T5)輸入框:答應者開房
// 後直接導向該房,全部婉拒或找代答時把角色台詞就地顯示在輸入框上方(不跳轉)。
export function LobbyView() {
const router = useRouter();
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);
} catch {
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
void refresh();
}, []);
// T19-P5:套用中時查詢待決邀請與待決大廳呼叫——沿用她的身分決定接受/婉拒,不讓人格系統代答。
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);
const invitations = await listPendingInvitations(USER_ID);
setPendingInvitations(invitations);
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);
const calls = await listPendingLobbyCalls(USER_ID);
setPendingCalls(calls);
if (decision.roomId) {
router.push(`/room/${decision.roomId}`);
return;
}
} catch (err) {
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
// T19-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單——依目前大廳snapshot 內的
// 作品清單批次查詢,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 (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
);
}
if (!snapshot) {
return (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
載入中……
</p>
);
}
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 (
<div className="flex flex-col gap-4">
{possessedCharacterId && (
<section
className="flex flex-col gap-2 rounded-2xl border-2 p-3"
style={{ borderColor: "var(--accent-bright)", background: "var(--chip-bg)" }}
>
<div className="flex items-center justify-between">
<p className="text-sm font-bold" style={{ color: "var(--accent)" }}>
你現在是「{possessedCharacterName}」
</p>
<button
type="button"
onClick={() => void handleExitPossession()}
disabled={pending}
className="rounded-full border px-3 py-1 text-xs font-bold disabled:opacity-50"
style={{ borderColor: "var(--accent)", color: "var(--accent)" }}
>
退出套用
</button>
</div>
<p className="text-[11px]" style={{ color: "var(--ink-soft)" }}>
她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定她收到的邀請與呼叫。
</p>
{pendingCalls.map((call) => (
<div key={call.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>有人喊了你:「{call.text}」</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleDecideCall(call.id, true)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
接受
</button>
<button
type="button"
onClick={() => void handleDecideCall(call.id, false)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
婉拒
</button>
</div>
</div>
))}
{pendingInvitations.map((invitation) => (
<div key={invitation.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>
{(invitation.invitedByCharacterId &&
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
"有人"}
邀請你到房間裡聊聊
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleDecideInvitation(invitation.id, true)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
接受
</button>
<button
type="button"
onClick={() => void handleDecideInvitation(invitation.id, false)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
婉拒
</button>
</div>
</div>
))}
</section>
)}
<section className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
進行中房間({snapshot.activeRooms.length})
</h2>
{snapshot.activeRooms.length === 0 ? (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有進行中的房間。
</p>
) : (
<ul className="flex flex-col gap-2">
{snapshot.activeRooms.map((room) => (
<li key={room.id}>
<button
type="button"
onClick={() => router.push(`/room/${room.id}`)}
className="flex w-full flex-col gap-1 rounded-xl border px-3 py-2 text-left text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<span className="flex items-center justify-between">
<span style={{ color: "var(--ink-soft)" }}>{room.mode}</span>
{room.unreadCount > 0 && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-bold text-white"
style={{ background: "var(--accent-bright)" }}
>
{room.unreadCount}
</span>
)}
</span>
<span>
{room.memberCharacterIds
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
.join("、") || "(無人在場)"}
</span>
{room.topicSeed && <span style={{ color: "var(--ink-soft)" }}>話題:{room.topicSeed}</span>}
</button>
</li>
))}
</ul>
)}
</section>
{workGroups.map(({ work, characters }) => {
if (characters.length === 0) return null;
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
const endings = endingDetectionsByWork[work.id] ?? [];
return (
<section key={work.id} className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-bold" style={{ color: "var(--ink)" }}>
{work.title}
{work.inEpilogue && (
<span className="rounded-full px-2 py-0.5 text-[10px]" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
後日談
</span>
)}
</h2>
<div className="flex items-center gap-2 text-[10px]">
{pendingCount > 0 && (
<Link
href="/canon-review"
className="rounded-full px-2 py-0.5 font-bold text-white"
style={{ background: "var(--accent)" }}
>
有 {pendingCount} 項待裁決
</Link>
)}
<Link
href={`/canon-import?workId=${work.id}`}
className="rounded-full border px-2 py-0.5"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
匯入章節
</Link>
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{characters.map((character) => (
<span
key={character.id}
className="flex items-center gap-1 rounded-full border px-2 py-1"
style={{
borderColor: "var(--line)",
color: UNAVAILABLE_STATES.includes(character.presenceState) ? "var(--ink-soft)" : "var(--ink)",
}}
>
{character.formalName}
<span style={{ color: "var(--ink-soft)" }}>/{PRESENCE_LABELS[character.presenceState]}</span>
{character.id === possessedCharacterId ? (
<span className="rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white" style={{ background: "var(--accent)" }}>
套用中
</span>
) : (
!possessedCharacterId && (
<button
type="button"
onClick={() => void handleEnterPossession(character.id)}
disabled={pending}
className="rounded-full border px-1.5 py-0.5 text-[9px] disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
套用
</button>
)
)}
</span>
))}
</div>
{endings.length > 0 && (
<div className="flex flex-col gap-2">
{endings.map((ending) => (
<div key={ending.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>偵測到可能的結局章節,是否切入後日談?</p>
<p style={{ color: "var(--ink-soft)" }}>判定理由:{ending.evidence}</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleConfirmEnding(ending.id)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
確認.切入後日談
</button>
<button
type="button"
onClick={() => void handleRejectEnding(ending.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
拒絕
</button>
</div>
</div>
))}
</div>
)}
{work.milestones.length > 0 && (
<div className="flex flex-col gap-1 text-[11px]" style={{ color: "var(--ink-soft)" }}>
{work.milestones.map((milestone, index) => (
<p key={`${milestone.characterId}-${index}`}>
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}
:{milestone.label}
</p>
))}
</div>
)}
</section>
);
})}
{unassigned.length > 0 && (
<section className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
未分組角色
</h2>
<div className="flex flex-wrap gap-2 text-xs">
{unassigned.map((character) => (
<span
key={character.id}
className="flex items-center gap-1 rounded-full border px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
{character.formalName}
<span style={{ color: "var(--ink-soft)" }}>/{PRESENCE_LABELS[character.presenceState]}</span>
{character.id === possessedCharacterId ? (
<span className="rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white" style={{ background: "var(--accent)" }}>
套用中
</span>
) : (
!possessedCharacterId && (
<button
type="button"
onClick={() => void handleEnterPossession(character.id)}
disabled={pending}
className="rounded-full border px-1.5 py-0.5 text-[9px] disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
套用
</button>
)
)}
</span>
))}
</div>
</section>
)}
{lobbyMessage && (
<p className="rounded-xl border px-3 py-2 text-xs" style={{ borderColor: "var(--line)", color: "var(--ink)" }}>
{lobbyMessage}
</p>
)}
{possessedCharacterId ? (
<p className="rounded-full border px-3 py-2 text-center text-xs" style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}>
套用中無法以自己的身分喊人,請先退出套用。
</p>
) : (
<div className="flex items-center gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSay();
}}
placeholder="喊一下誰的名字……"
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
disabled={pending}
/>
<button
type="button"
onClick={() => void handleSay()}
disabled={pending || !text.trim()}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from "next/link";
import { LobbyView } from "./LobbyView";
export default function LobbyPage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
大廳
</h1>
<div className="flex gap-3">
<Link href="/tasks" className="text-xs" style={{ color: "var(--ink-soft)" }}>
委託
</Link>
<Link href="/settings" className="text-xs" style={{ color: "var(--ink-soft)" }}>
設定
</Link>
<Link href="/canon-review" className="text-xs" style={{ color: "var(--ink-soft)" }}>
待裁決
</Link>
<Link href="/epilogue" className="text-xs" style={{ color: "var(--ink-soft)" }}>
後日談
</Link>
<Link href="/room" className="text-xs" style={{ color: "var(--ink-soft)" }}>
建立房間
</Link>
</div>
</div>
<LobbyView />
</main>
);
}
+3 -17
View File
@@ -1,20 +1,6 @@
import Link from "next/link";
import { HeartbeatWave } from "../components/HeartbeatWave";
import { redirect } from "next/navigation";
// T19-P2:首頁改為導向大廳——大廳是遊戲化改版後的常駐入口,不再停在單一角色的固定 1:1 聊天。
export default function Home() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
<h1 className="text-4xl font-bold tracking-wide">
心音 <span className="text-[var(--color-heartbeat-pink)]">Kokorone</span>
</h1>
<HeartbeatWave amplitude={60} className="w-full max-w-md text-[var(--color-heartbeat-pink)]" />
<Link
href="/chat"
className="rounded-full px-6 py-2 text-sm font-bold text-white"
style={{ background: "var(--accent-bright)" }}
>
開始聊天
</Link>
</main>
);
redirect("/lobby");
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createRoom, listCharacters, type CharacterSummary, type RoomMode } from "../../lib/api";
const USER_ID = "seed-user-primary";
// K-7 建房介面:建立後導向 /room/[roomId](T19-P2 起的通用房間畫面,含 SSE/立繪舞台/語音/靜音,
// 見 T19-P3),不再自己維護一份較舊、功能較少的房間顯示邏輯。
export function RoomView() {
const router = useRouter();
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [selectedCharacterIds, setSelectedCharacterIds] = useState<string[]>([]);
const [mode, setMode] = useState<RoomMode>("GROUP");
const [topicSeed, setTopicSeed] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void listCharacters().then(setCharacters);
}, []);
async function handleCreateRoom() {
if (selectedCharacterIds.length === 0 || pending) return;
setPending(true);
setError(null);
try {
const created = await createRoom(USER_ID, selectedCharacterIds, mode, topicSeed || undefined);
router.push(`/room/${created.id}`);
} catch {
setError("房間建立失敗,請確認 api 服務是否已啟動");
setPending(false);
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
建立房間
</h2>
<div className="flex flex-wrap gap-2 text-xs">
{characters.map((character) => {
const checked = selectedCharacterIds.includes(character.id);
return (
<label
key={character.id}
className="flex items-center gap-1 rounded-full border px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setSelectedCharacterIds((prev) => (checked ? prev.filter((id) => id !== character.id) : [...prev, character.id]))
}
/>
{character.aliases[0]?.formalName ?? character.id}
</label>
);
})}
</div>
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-1" style={{ color: "var(--ink)" }}>
<input type="radio" checked={mode === "GROUP"} onChange={() => setMode("GROUP")} />
群聊
</label>
<label className="flex items-center gap-1" style={{ color: "var(--ink)" }}>
<input type="radio" checked={mode === "SELF_CHAT"} onChange={() => setMode("SELF_CHAT")} />
角色自聊(旁觀)
</label>
</div>
<input
value={topicSeed}
onChange={(event) => setTopicSeed(event.target.value)}
placeholder="話題種子(選填)"
className="rounded-full border bg-transparent px-3 py-1.5 text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<button
type="button"
onClick={() => void handleCreateRoom()}
disabled={pending || selectedCharacterIds.length === 0}
className="self-start rounded-full px-4 py-1.5 text-xs font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
建立房間
</button>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
</div>
);
}
+477
View File
@@ -0,0 +1,477 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ROOM_USER_SPEAKER_ID,
endRoom,
fetchExpression,
getPossessionStatus,
getRoom,
getRoomUsage,
listCharacters,
reportRoomRead,
roomStreamUrl,
sendRoomMessage,
setRoomMuted,
speakAsPossessed,
synthesizeVoice,
type CharacterSummary,
type PossessionStatus,
type RoomDetail,
type RoomTurnKind,
type RoomUsageSummary,
type TachieExpressionResponse,
} from "../../../lib/api";
import { ChatBubble } from "../../../components/ChatBubble";
import { RoomStage, type StageParticipant } from "../../../components/RoomStage";
import { isVoiceAutoplayEnabled } from "../../../lib/voice-autoplay-settings";
// T19-P3:TALK 是一般台詞(既有 ChatBubble 樣式),ACTION 沿用 H-3 的*…*敘述樣式,其餘三種另外標籤。
const TURN_KIND_LABEL: Partial<Record<RoomTurnKind, string>> = {
DELEGATE: "委託",
LEAVE: "離開",
SYSTEM: "系統",
};
const USER_ID = "seed-user-primary";
const USER_LABEL = "你";
// T26:佇列積壓過長(使用者離開視窗一段時間後回來,或連續好幾則自走發言)時,只留最近幾則待播,
// 較舊的直接跳過——語音是「聽個氣氛」,不需要也不該補播全部積壓內容。
const VOICE_QUEUE_MAX_BACKLOG = 3;
// 首次解鎖語音時,在使用者手勢內播放一次極短靜音音檔,換取瀏覽器日後允許非手勢觸發的自動播放。
const SILENT_AUDIO_DATA_URI =
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==";
interface VoiceQueueItem {
characterId: string;
text: string;
}
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 };
interface RoomChatViewProps {
roomId: string;
}
// T19-P2:通用房間畫面——取代舊版 RoomView/SoloChatView 只能各自對應建房表單或單一固定角色的
// 限制,改為接受大廳呼叫(T5)或既有房間清單開出的任意 roomId,統一走 SSE(T10)即時接收新訊息、
// 並在第一次載入時依 unread(T11)畫出未讀分隔線;分隔線位置在整個瀏覽階段內固定,不隨後續已讀
// 狀態更新而移動(符合一般聊天 App「離開再進來才會重新定位」的直覺)。
export function RoomChatView({ roomId }: RoomChatViewProps) {
const [room, setRoom] = useState<RoomDetail | null>(null);
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
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 feedEndRef = useRef<HTMLDivElement | null>(null);
// T19-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。
const [possession, setPossession] = useState<PossessionStatus | null>(null);
useEffect(() => {
void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined);
}, [roomId]);
// T19-P3:立繪舞台(目前發話者高亮)與用量顯示。
const [expressions, setExpressions] = useState<Record<string, TachieExpressionResponse | null>>({});
const [speakingCharacterId, setSpeakingCharacterId] = useState<string | null>(null);
const [usage, setUsage] = useState<RoomUsageSummary | null>(null);
function refreshExpression(characterId: string) {
fetchExpression(characterId, USER_ID)
.then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression })))
.catch(() => undefined);
}
// T26:自走語音自動播放——佇列、播放中旗標、解鎖狀態一律用 ref,避免 SSE handler(只在 roomId
// 變化時重新註冊)讀到註冊當下就固定住的舊 state(stale closure)。
const [autoplayEnabled, setAutoplayEnabled] = useState(true);
const autoplayEnabledRef = useRef(true);
const [unlocked, setUnlocked] = useState(false);
const unlockedRef = useRef(false);
const voiceQueueRef = useRef<VoiceQueueItem[]>([]);
const voicePlayingRef = useRef(false);
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const voiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const unmountedRef = useRef(false);
// T31:靜音時不推播、不自動播語音,但自走照常進行——同樣用 ref 供 SSE handler 讀最新值。
const mutedRef = useRef(false);
useEffect(() => {
const sync = () => {
const value = isVoiceAutoplayEnabled();
autoplayEnabledRef.current = value;
setAutoplayEnabled(value);
};
sync();
window.addEventListener("storage", sync);
return () => window.removeEventListener("storage", sync);
}, []);
useEffect(() => {
return () => {
unmountedRef.current = true;
currentAudioRef.current?.pause();
if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current);
voiceQueueRef.current = [];
};
}, []);
function unlockVoiceAutoplay() {
const unlockAudio = new Audio(SILENT_AUDIO_DATA_URI);
void unlockAudio.play().catch(() => undefined);
unlockedRef.current = true;
setUnlocked(true);
void processVoiceQueue();
}
function enqueueVoice(item: VoiceQueueItem) {
if (!autoplayEnabledRef.current || !unlockedRef.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));
});
} else {
await new Promise<void>((resolve) => {
const audio = new Audio(result.audioRef);
currentAudioRef.current = audio;
audio.addEventListener("ended", () => resolve());
audio.addEventListener("error", () => resolve());
void audio.play().catch(() => resolve());
});
}
} catch {
// 合成失敗:安靜跳過這一則,不阻斷佇列後續播放、不影響訊息本身顯示。
} finally {
currentAudioRef.current = null;
voicePlayingRef.current = false;
if (!unmountedRef.current) void processVoiceQueue();
}
}
function characterLabel(characterId: string): string {
if (characterId === ROOM_USER_SPEAKER_ID) return USER_LABEL;
return characters.find((c) => c.id === characterId)?.aliases[0]?.formalName ?? characterId;
}
useEffect(() => {
void listCharacters().then(setCharacters);
}, []);
// 初次載入:抓歷程+unread,畫面就緒後才開 SSE(避免歷程還沒到就先收到即時事件而重複或錯序)。
useEffect(() => {
let cancelled = false;
let source: EventSource | 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;
source = new EventSource(roomStreamUrl(roomId, lastSeq));
source.addEventListener("turn", (event) => {
const messageEvent = event as MessageEvent<string>;
const data = JSON.parse(messageEvent.data) as {
speakerId: string;
speakerLabel: string;
content: string;
kind: RoomTurnKind;
};
const { kind: turnKind, ...rest } = data;
const seq = Number(messageEvent.lastEventId);
setFeed((prev) =>
prev.some((item) => item.kind === "turn" && item.seq === seq) ? prev : [...prev, { kind: "turn", seq, turnKind, ...rest }],
);
void reportRoomRead(roomId, USER_ID, seq).catch(() => undefined);
// T26:只有角色的新發言才自動播放,使用者自己的發言不需要念給自己聽。
if (data.speakerId !== ROOM_USER_SPEAKER_ID) {
enqueueVoice({ characterId: data.speakerId, text: data.content });
// T19-P3:新發言者上場——更新立繪舞台的高亮對象與表情,並同步刷新用量顯示。
setSpeakingCharacterId(data.speakerId);
refreshExpression(data.speakerId);
void getRoomUsage(roomId).then(setUsage).catch(() => undefined);
}
});
source.addEventListener("member", (event) => {
const messageEvent = event as MessageEvent<string>;
const data = JSON.parse(messageEvent.data) as { characterId: string; action: "joined" | "left" };
const seq = Number(messageEvent.lastEventId);
setFeed((prev) => [...prev, { kind: "member", seq, ...data }]);
});
source.addEventListener("closed", (event) => {
const messageEvent = event as MessageEvent<string>;
const data = JSON.parse(messageEvent.data) as { reason: string };
const seq = Number(messageEvent.lastEventId);
setFeed((prev) => [...prev, { kind: "closed", seq, reason: data.reason }]);
setEnded(true);
});
} catch {
if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動");
}
}
void init();
return () => {
cancelled = true;
source?.close();
};
}, [roomId]);
useEffect(() => {
feedEndRef.current?.scrollIntoView({ block: "end" });
}, [feed.length]);
async function handleSend() {
const trimmed = text.trim();
if (!trimmed || pending || ended) return;
setPending(true);
setError(null);
try {
if (possessedCharacterId) {
await speakAsPossessed(roomId, USER_ID, trimmed);
} else {
await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed);
}
setText(""); // 新訊息透過 SSE 推送進 feed,這裡不需要也不應該自己 append
} catch (err) {
setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleToggleMute() {
if (!room || pending) return;
const next = !room.muted;
try {
await setRoomMuted(roomId, next);
mutedRef.current = next;
setRoom((prev) => (prev ? { ...prev, muted: next } : prev));
} catch {
setError("靜音設定失敗,請確認 api 服務是否已啟動");
}
}
async function handleEnd() {
if (pending || ended) return;
setPending(true);
setError(null);
try {
await endRoom(roomId);
setEnded(true);
} catch {
setError("結束房間失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
if (error && !room) {
return (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
);
}
if (!room) {
return (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
載入中……
</p>
);
}
// T19-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話;套用中但她不在這裡時,
// 一般發話入口一律不提供(後端本來就會擋,這裡先不讓使用者白打一輪)。
const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room.participants.includes(possession!.possessingCharacterId!);
const possessedCharacterId =
possession?.possessingCharacterId && room.participants.includes(possession.possessingCharacterId)
? possession.possessingCharacterId
: null;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-xs" style={{ color: "var(--ink-soft)" }}>
<span>
{room.mode}.{ended ? "已結束" : "進行中"}.參與者:{room.participants.map((id) => characterLabel(id)).join("、") || "(無)"}
{usage && usage.generationCount > 0 && (
<>
.用量 {usage.totalPromptTokens + usage.totalCompletionTokens} tokens({usage.generationCount} 次生成)
</>
)}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleToggleMute()}
disabled={ended}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
{room.muted ? "🔇 已靜音" : "🔊 靜音"}
</button>
<button
type="button"
onClick={() => void handleEnd()}
disabled={pending || ended}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
結束房間
</button>
</div>
</div>
<RoomStage
participants={room.participants.map<StageParticipant>((characterId) => ({
characterId,
label: characterLabel(characterId),
expression: expressions[characterId] ?? null,
}))}
speakingCharacterId={speakingCharacterId}
/>
{autoplayEnabled && !unlocked && !room.muted && (
<button
type="button"
onClick={unlockVoiceAutoplay}
className="rounded-2xl border px-3 py-2 text-left text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
🔊 點擊開啟自走語音自動播放(瀏覽器需要先有一次互動才能自動播放聲音)
</button>
)}
<div className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
{feed.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
還沒有人說話。
</p>
)}
{feed.map((item) => (
<div key={`${item.kind}-${item.seq}`} className="flex flex-col gap-2">
{firstUnreadSeq !== null && item.seq === firstUnreadSeq && (
<div className="flex items-center gap-2 text-[10px]" style={{ color: "var(--accent)" }}>
<span className="h-px flex-1" style={{ background: "var(--accent)" }} />
未讀訊息
<span className="h-px flex-1" style={{ background: "var(--accent)" }} />
</div>
)}
{item.kind === "turn" && (
<div className="flex flex-col" style={{ alignItems: item.speakerId === ROOM_USER_SPEAKER_ID ? "flex-end" : "flex-start" }}>
{TURN_KIND_LABEL[item.turnKind] && (
<span
className="mb-0.5 rounded-full px-2 py-0.5 text-[10px]"
style={{ background: "var(--chip-bg)", color: "var(--accent)" }}
>
{TURN_KIND_LABEL[item.turnKind]}
</span>
)}
<ChatBubble
role={item.speakerId === ROOM_USER_SPEAKER_ID ? "user" : "character"}
text={item.content}
characterId={item.speakerId !== ROOM_USER_SPEAKER_ID ? item.speakerId : undefined}
userId={item.speakerId !== ROOM_USER_SPEAKER_ID ? USER_ID : undefined}
/>
</div>
)}
{item.kind === "member" && (
<p className="self-center text-[10px]" style={{ color: "var(--ink-soft)" }}>
{characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"}
</p>
)}
{item.kind === "closed" && (
<p className="self-center text-[10px]" style={{ color: "var(--ink-soft)" }}>
房間已關閉
</p>
)}
</div>
))}
<div ref={feedEndRef} />
</div>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
{!ended && possessedCharacterId && (
<p className="rounded-full px-3 py-1 text-center text-xs font-bold" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
你正以「{characterLabel(possessedCharacterId)}」的身分發話
</p>
)}
{!ended && possessedElsewhere && (
<p className="rounded-full border px-3 py-2 text-center text-xs" style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}>
套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。
</p>
)}
{!ended && !possessedElsewhere && (
<div className="flex items-center gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSend();
}}
placeholder={possessedCharacterId ? `以「${characterLabel(possessedCharacterId)}」的身分說點什麼……` : "說點什麼……"}
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
disabled={pending}
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={pending || !text.trim()}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link";
import { RoomChatView } from "./RoomChatView";
export default async function RoomByIdPage({ params }: { params: Promise<{ roomId: string }> }) {
const { roomId } = await params;
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
房間
</h1>
<Link href="/lobby" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到大廳
</Link>
</div>
<RoomChatView roomId={roomId} />
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { RoomView } from "./RoomView";
export default function RoomPage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
多人房間
</h1>
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到聊天
</Link>
</div>
<RoomView />
</main>
);
}
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api";
import { EMOTION_LABELS } from "../../lib/emotion-labels";
import { ChatBubble } from "../../components/ChatBubble";
import { HeartbeatWave } from "../../components/HeartbeatWave";
import { TachieStage } from "../../components/TachieStage";
import { useEffect, useState } from "react";
import { getOrCreateSoloRoom, sendSoloMessage, fetchExpression, type TachieExpressionResponse } from "../../../lib/api";
import { EMOTION_LABELS } from "../../../lib/emotion-labels";
import { ChatBubble } from "../../../components/ChatBubble";
import { HeartbeatWave } from "../../../components/HeartbeatWave";
import { TachieStage } from "../../../components/TachieStage";
import { RomanceSection } from "../../../components/RomanceSection";
import type { EmotionTag } from "@kokorone/shared";
// O-2 表情演出時機:表情差分要在文字回覆出現前先變化——順序要對才有真實感。
@@ -19,8 +20,10 @@ interface Message {
text: string;
}
export function ChatView() {
const [sessionId] = useState(() => crypto.randomUUID());
// T38:既有 1:1 聊天併入 SOLO 房——原 apps/web/app/chat/ChatView.tsx 的內容搬移至此,
// 呼叫端改用房間端點(getOrCreateSoloRoom/sendSoloMessage)取代舊版 sessionId 自行管理。
export function SoloChatView() {
const [roomId, setRoomId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
@@ -28,10 +31,17 @@ export function ChatView() {
const [intimacy, setIntimacy] = useState(0);
const [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [romanceRefreshKey, setRomanceRefreshKey] = useState(0);
useEffect(() => {
getOrCreateSoloRoom(USER_ID, CHARACTER_ID)
.then((room) => setRoomId(room.id))
.catch(() => setError("房間建立失敗,請確認 api 服務是否已啟動"));
}, []);
async function handleSend() {
const text = input.trim();
if (!text || sending) {
if (!text || sending || !roomId) {
return;
}
setSending(true);
@@ -40,7 +50,7 @@ export function ChatView() {
setMessages((prev) => [...prev, { role: "user", text }]);
try {
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
const result = await sendSoloMessage(roomId, USER_ID, text);
if (result.availability === "NO_REPLY") {
// I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。
return;
@@ -57,6 +67,8 @@ export function ChatView() {
}
await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS));
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
// L-9:心動值與戀愛階段隨事件即時更新——每輪對話後都重新查詢一次。
setRomanceRefreshKey((key) => key + 1);
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
@@ -105,7 +117,13 @@ export function ChatView() {
</p>
)}
{messages.map((message, index) => (
<ChatBubble key={index} role={message.role} text={message.text} />
<ChatBubble
key={index}
role={message.role}
text={message.text}
characterId={CHARACTER_ID}
userId={USER_ID}
/>
))}
</div>
@@ -115,6 +133,8 @@ export function ChatView() {
</p>
)}
<RomanceSection characterId={CHARACTER_ID} userId={USER_ID} refreshKey={romanceRefreshKey} />
<div
className="mt-2 flex items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm"
style={{ borderColor: "var(--line)" }}
+5
View File
@@ -0,0 +1,5 @@
import { SoloChatView } from "./SoloChatView";
export default function SoloChatPage() {
return <SoloChatView />;
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import {
checkAdultContent,
fetchRomanceEligibility,
setAdultModeConsent,
type AdultContentCheckResult,
type RomanceEligibility,
} from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
// L-8 成人模式 opt-in 介面:只做開關與既有 API 串接。
// 「成年使用者驗證」的實際機制計畫未指定(需人工確認),這裡先讓 verifiedAdult 等同使用者自我宣告,
// 待正式驗證流程拍板後再另行補項——不在這裡臆測或假造一套驗證邏輯。
export function AdultModeSection() {
const [optedIn, setOptedIn] = useState(false);
const [eligibility, setEligibility] = useState<RomanceEligibility | null>(null);
const [checkResult, setCheckResult] = useState<AdultContentCheckResult | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function refresh() {
setError(null);
try {
const [eligibilityResult, checkResultValue] = await Promise.all([
fetchRomanceEligibility(CHARACTER_ID),
checkAdultContent(CHARACTER_ID, USER_ID),
]);
setEligibility(eligibilityResult);
setCheckResult(checkResultValue);
} catch {
setError("無法連線到 api 服務,成人模式狀態暫時無法顯示");
}
}
useEffect(() => {
void refresh();
}, []);
async function handleToggle(next: boolean) {
setPending(true);
setError(null);
try {
await setAdultModeConsent(USER_ID, next, next);
setOptedIn(next);
await refresh();
} catch {
setError("成人模式設定失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
return (
<section className="flex flex-col gap-3 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
成人模式
</h2>
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
系統預設為全年齡純愛尺度;開啟後也只有正史成年角色能解鎖,正史未成年、後日談推演才成年的角色永久限純愛尺度。
</p>
<label className="flex items-center gap-2 text-sm" style={{ color: "var(--ink)" }}>
<input
type="checkbox"
checked={optedIn}
disabled={pending}
onChange={(event) => void handleToggle(event.target.checked)}
/>
主動開啟成人模式(正式的成年使用者驗證機制尚待人工確認,目前僅為自我宣告已成年)
</label>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
{eligibility && (
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--ink-soft)" }}>
<span>戀愛軸資格:{eligibility.romanceAxisEligible ? "可用" : "尚未解鎖"}</span>
<span>成人模式角色資格:{eligibility.adultModeEligible ? "符合" : "不符合"}</span>
{eligibility.isEpilogueAdult && <span>此角色為後日談推演成年,永久限純愛尺度。</span>}
</div>
)}
{checkResult && (
<p
className="rounded-lg px-3 py-2 text-xs"
style={{
background: checkResult.allowed ? "var(--chip-bg)" : "var(--bg-soft)",
color: checkResult.allowed ? "var(--accent)" : "var(--ink-soft)",
}}
>
{checkResult.allowed ? "目前可使用成人內容。" : `目前無法使用成人內容:${checkResult.reason}`}
</p>
)}
</section>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { isVoiceAutoplayEnabled, setVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings";
// T26:多人房內角色新發言的自動語音播放開關;關閉後回到只能逐則點擊播放(見 VoicePlaybackButton)。
export function VoiceAutoplaySection() {
const [enabled, setEnabled] = useState(true);
useEffect(() => {
setEnabled(isVoiceAutoplayEnabled());
}, []);
function handleToggle(next: boolean) {
setVoiceAutoplayEnabled(next);
setEnabled(next);
}
return (
<section className="flex flex-col gap-3 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
自走語音自動播放
</h2>
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
開啟後,在房間畫面時角色的新發言會自動依序念出來;關閉則回到逐則點擊才播放。
</p>
<label className="flex items-center gap-2 text-sm" style={{ color: "var(--ink)" }}>
<input type="checkbox" checked={enabled} onChange={(event) => handleToggle(event.target.checked)} />
在房間時自動播放角色語音
</label>
</section>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { AdultModeSection } from "./AdultModeSection";
import { VoiceAutoplaySection } from "./VoiceAutoplaySection";
export default function SettingsPage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
設定
</h1>
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到聊天
</Link>
</div>
<AdultModeSection />
<VoiceAutoplaySection />
</main>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useEffect, useState } from "react";
import {
completeTask,
createTask,
failTask,
fireTaskNow,
listTasks,
parseTask,
type ParseTaskResult,
type Task,
type TaskType,
} from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const TASK_TYPE_LABELS: Record<TaskType, string> = {
ONE_TIME: "單次提醒",
RECURRING: "週期提醒",
CONDITIONAL: "條件提醒",
QUERY_ORGANIZE: "查資料/整理",
TODO_TRACKING: "代辦追蹤",
};
const STATUS_LABELS: Record<Task["status"], string> = {
PENDING: "尚未觸發",
TRIGGERED: "已觸發,等候回應",
COMPLETED: "已完成",
FAILED: "失敗",
CANCELLED: "已取消",
};
function isOverdue(task: Task): boolean {
return task.status === "TRIGGERED" && task.overdueNudgeCount > 0;
}
// J-8 委託介面:自然語言輸入→(辨識成功即直接建立/辨識失敗則手動表單建立)→列表→觸發→完成或失敗,
// 對應計畫§委託子系統「角色不只陪聊,還能接受委託」。
export function TasksView() {
const [text, setText] = useState("");
const [tasks, setTasks] = useState<Task[]>([]);
const [parseResult, setParseResult] = useState<ParseTaskResult | null>(null);
const [manualDraft, setManualDraft] = useState<{ content: string; triggerAt: string } | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
setTasks(await listTasks(CHARACTER_ID, USER_ID));
} catch {
setError("無法連線到 api 服務,委託清單暫時無法顯示");
}
}
useEffect(() => {
void refresh();
}, []);
async function handleParse() {
const trimmed = text.trim();
if (!trimmed || pending) {
return;
}
setPending(true);
setError(null);
setParseResult(null);
setManualDraft(null);
try {
const result = await parseTask(CHARACTER_ID, USER_ID, trimmed);
setParseResult(result);
if (result.created) {
setText("");
await refresh();
} else {
// 辨識失敗:退回手動表單,讓使用者自己補齊內容後透過 createTask() 建立。
setManualDraft({ content: trimmed, triggerAt: "" });
}
} catch {
setError("委託解析失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleManualCreate() {
if (!manualDraft || !manualDraft.content.trim() || pending) {
return;
}
setPending(true);
setError(null);
try {
await createTask(CHARACTER_ID, USER_ID, {
type: "TODO_TRACKING",
content: manualDraft.content.trim(),
rawText: text.trim() || manualDraft.content.trim(),
triggerAt: manualDraft.triggerAt ? new Date(manualDraft.triggerAt).toISOString() : undefined,
});
setText("");
setManualDraft(null);
setParseResult(null);
await refresh();
} catch {
setError("委託建立失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleAction(action: (taskId: string) => Promise<Task>, taskId: string) {
setPending(true);
setError(null);
try {
await action(taskId);
await refresh();
} catch {
setError("操作失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<label className="text-xs" style={{ color: "var(--ink-soft)" }}>
跟她說一句話,讓她幫你記著(例如「三點提醒我開會」)
</label>
<div className="flex gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleParse();
}
}}
placeholder="三點提醒我開會"
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
disabled={pending}
/>
<button
type="button"
onClick={() => void handleParse()}
disabled={pending}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
{parseResult?.created && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
已建立委託:{parseResult.task?.content}
{parseResult.performanceLine ? `——${parseResult.performanceLine}` : ""}
</p>
)}
{manualDraft && (
<div className="flex flex-col gap-2 rounded-xl p-3 text-xs" style={{ background: "var(--bg-soft)" }}>
<p style={{ color: "var(--ink-soft)" }}>沒辨識出明確時間,改用手動表單建立(分類為「代辦追蹤」):</p>
<input
value={manualDraft.content}
onChange={(event) => setManualDraft({ ...manualDraft, content: event.target.value })}
placeholder="委託內容"
className="rounded-full border bg-transparent px-3 py-1.5"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<input
type="datetime-local"
value={manualDraft.triggerAt}
onChange={(event) => setManualDraft({ ...manualDraft, triggerAt: event.target.value })}
className="rounded-full border bg-transparent px-3 py-1.5"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<button
type="button"
onClick={() => void handleManualCreate()}
disabled={pending}
className="self-start rounded-full px-4 py-1.5 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
確認建立
</button>
</div>
)}
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
</div>
<div className="flex flex-col gap-2">
{tasks.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有委託。
</p>
)}
{tasks.map((task) => (
<div key={task.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<span className="font-bold" style={{ color: "var(--ink)" }}>
{task.content}
</span>
<span
className="rounded-full px-2 py-0.5"
style={{
background: isOverdue(task) ? "var(--accent-bright)" : "var(--chip-bg)",
color: isOverdue(task) ? "#fff" : "var(--accent)",
}}
>
{isOverdue(task) ? "逾期未回應" : STATUS_LABELS[task.status]}
</span>
</div>
<span style={{ color: "var(--ink-soft)" }}>
{TASK_TYPE_LABELS[task.type]}
{task.triggerAt ? `.${new Date(task.triggerAt).toLocaleString("zh-TW")}` : ""}
</span>
{task.triggerMessage && <span style={{ color: "var(--ink-soft)" }}>她說:{task.triggerMessage}</span>}
{(task.status === "PENDING" || task.status === "TRIGGERED") && (
<div className="flex gap-2 pt-1">
{task.status === "PENDING" && (
<button
type="button"
onClick={() => void handleAction((id) => fireTaskNow(id).then(() => listTasks(CHARACTER_ID, USER_ID).then((all) => all.find((t) => t.id === id)!)), task.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
立即觸發(測試用)
</button>
)}
<button
type="button"
onClick={() => void handleAction(completeTask, task.id)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
完成
</button>
<button
type="button"
onClick={() => void handleAction(failTask, task.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
失敗
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { TasksView } from "./TasksView";
export default function TasksPage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
委託
</h1>
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到聊天
</Link>
</div>
<TasksView />
</main>
);
}