feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ChatView } from "./ChatView";
|
||||
|
||||
export default function ChatPage() {
|
||||
return <ChatView />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)" }}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SoloChatView } from "./SoloChatView";
|
||||
|
||||
export default function SoloChatPage() {
|
||||
return <SoloChatView />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { VoicePlaybackButton } from "./VoicePlaybackButton";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
role: "user" | "character";
|
||||
text: string;
|
||||
characterId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// H-3 動作描寫標記渲染:把 *…* 標記以暮空紫小字呈現,與台詞區分。
|
||||
@@ -19,28 +23,30 @@ function renderContent(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatBubble({ role, text }: ChatBubbleProps) {
|
||||
export function ChatBubble({ role, text, characterId, userId }: ChatBubbleProps) {
|
||||
const isUser = role === "user";
|
||||
return (
|
||||
<div
|
||||
className="max-w-[78%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed"
|
||||
style={
|
||||
isUser
|
||||
? {
|
||||
alignSelf: "flex-end",
|
||||
background: "var(--accent-bright)",
|
||||
color: "#fff",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
alignSelf: "flex-start",
|
||||
background: "var(--bg-soft)",
|
||||
border: "1px solid var(--line)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{renderContent(text)}
|
||||
<div className="flex max-w-[78%] flex-col" style={{ alignSelf: isUser ? "flex-end" : "flex-start", alignItems: isUser ? "flex-end" : "flex-start" }}>
|
||||
<div
|
||||
className="rounded-2xl px-3.5 py-2 text-sm leading-relaxed"
|
||||
style={
|
||||
isUser
|
||||
? {
|
||||
background: "var(--accent-bright)",
|
||||
color: "#fff",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
background: "var(--bg-soft)",
|
||||
border: "1px solid var(--line)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{renderContent(text)}
|
||||
</div>
|
||||
{/* P-6:語音播放介面只在角色訊息旁提供,使用者自己的訊息不需要播放。 */}
|
||||
{!isUser && characterId && userId && <VoicePlaybackButton characterId={characterId} userId={userId} text={text} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
attemptRomanceMilestone,
|
||||
confessRomance,
|
||||
fetchRomanceState,
|
||||
type ConfessionOutcome,
|
||||
type MilestoneResult,
|
||||
type MilestoneType,
|
||||
type RomanceStage,
|
||||
type RomanceState,
|
||||
} from "../lib/api";
|
||||
|
||||
const ROMANCE_STAGE_LABELS: Record<RomanceStage, string> = {
|
||||
NONE: "朋友(戀愛軸未啟動)",
|
||||
INTERESTED: "在意",
|
||||
AMBIGUOUS: "曖昧",
|
||||
PARTNER: "戀人",
|
||||
DEEP_PARTNER: "深度伴侶",
|
||||
CRISIS: "關係危機",
|
||||
};
|
||||
|
||||
const CONFESSION_OUTCOME_LABELS: Record<ConfessionOutcome, string> = {
|
||||
INELIGIBLE: "目前角色不具戀愛軸資格,無法告白。",
|
||||
REJECTED_TOO_SOON: "現在提還太尷尬了,她需要一點時間緩一緩。",
|
||||
REJECTED: "她婉拒了……心動值受到影響。",
|
||||
ACCEPTED: "她答應了!你們正式成為戀人。",
|
||||
};
|
||||
|
||||
const MILESTONE_LABELS: Record<MilestoneType, string> = {
|
||||
DISTANCE_SHORTENED: "拉近距離",
|
||||
OCCASIONAL_CONTACT: "偶爾的肢體接觸",
|
||||
LATE_NIGHT_TALK: "深夜談心",
|
||||
HAND_HOLDING: "牽手",
|
||||
HUG: "擁抱",
|
||||
FOREHEAD_KISS: "額頭吻",
|
||||
CASUAL_INTIMACY: "日常親密互動",
|
||||
};
|
||||
|
||||
interface RomanceSectionProps {
|
||||
characterId: string;
|
||||
userId: string;
|
||||
refreshKey: number; // 每完成一輪對話就遞增,讓戀愛狀態隨事件即時更新
|
||||
}
|
||||
|
||||
// L-9:對話介面顯示心動值與戀愛階段,並提供告白/里程碑操作。
|
||||
// 「不得把失敗畫成成功」:confess/milestone 的結果一律照 outcome/allowed 如實呈現,不做樂觀 UI。
|
||||
export function RomanceSection({ characterId, userId, refreshKey }: RomanceSectionProps) {
|
||||
const [state, setState] = useState<RomanceState | null>(null);
|
||||
const [confessionMessage, setConfessionMessage] = useState<{ outcome: ConfessionOutcome } | null>(null);
|
||||
const [milestoneType, setMilestoneType] = useState<MilestoneType>("DISTANCE_SHORTENED");
|
||||
const [milestoneResult, setMilestoneResult] = useState<MilestoneResult | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
setState(await fetchRomanceState(characterId, userId));
|
||||
} catch {
|
||||
// 戀愛狀態查詢失敗不影響對話本身,維持上一次已知狀態。
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [characterId, userId, refreshKey]);
|
||||
|
||||
async function handleConfess() {
|
||||
setPending(true);
|
||||
setConfessionMessage(null);
|
||||
try {
|
||||
const result = await confessRomance(characterId, userId);
|
||||
setConfessionMessage({ outcome: result.outcome });
|
||||
await refresh();
|
||||
} catch {
|
||||
setConfessionMessage(null);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMilestone() {
|
||||
setPending(true);
|
||||
setMilestoneResult(null);
|
||||
try {
|
||||
const result = await attemptRomanceMilestone(characterId, userId, milestoneType);
|
||||
setMilestoneResult(result);
|
||||
await refresh();
|
||||
} catch {
|
||||
setMilestoneResult(null);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ color: "var(--ink-soft)" }}>
|
||||
心動值 <b style={{ color: "var(--accent)" }}>{state.romanceMeter}</b>
|
||||
</span>
|
||||
<span className="rounded-full px-2 py-0.5 font-bold" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
|
||||
{ROMANCE_STAGE_LABELS[state.romanceStage]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfess()}
|
||||
disabled={pending}
|
||||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
告白
|
||||
</button>
|
||||
<select
|
||||
value={milestoneType}
|
||||
onChange={(event) => setMilestoneType(event.target.value as MilestoneType)}
|
||||
disabled={pending}
|
||||
className="rounded-full border bg-transparent px-2 py-1"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
{Object.entries(MILESTONE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleMilestone()}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
嘗試
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{confessionMessage && (
|
||||
<p style={{ color: confessionMessage.outcome === "ACCEPTED" ? "var(--accent)" : "var(--ink-soft)" }}>
|
||||
{CONFESSION_OUTCOME_LABELS[confessionMessage.outcome]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{milestoneResult && (
|
||||
<p style={{ color: milestoneResult.allowed ? "var(--accent)" : "var(--ink-soft)" }}>
|
||||
{milestoneResult.allowed
|
||||
? `${milestoneResult.isFirstTime ? "(第一次)" : ""}她接受了這個舉動。`
|
||||
: milestoneResult.reactionLine ?? "現在還不是時候。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { TachieStage, type TachieExpression } from "./TachieStage";
|
||||
|
||||
export interface StageParticipant {
|
||||
characterId: string;
|
||||
label: string;
|
||||
expression: TachieExpression | null;
|
||||
}
|
||||
|
||||
interface RoomStageProps {
|
||||
participants: StageParticipant[];
|
||||
speakingCharacterId: string | null;
|
||||
}
|
||||
|
||||
// T19-P3:多人房立繪舞台——目前發話者放大高亮,人多時整體縮小排列,避免超出可視範圍。
|
||||
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 (
|
||||
<div
|
||||
className="flex items-end justify-center gap-2 overflow-x-auto rounded-2xl border p-2"
|
||||
style={{ borderColor: "var(--line)", background: "linear-gradient(180deg, var(--bg-soft), var(--card))" }}
|
||||
>
|
||||
{participants.map((participant) => {
|
||||
const speaking = participant.characterId === speakingCharacterId;
|
||||
return (
|
||||
<div
|
||||
key={participant.characterId}
|
||||
className="flex shrink-0 flex-col items-center gap-1"
|
||||
style={{
|
||||
transform: `scale(${speaking ? scale * 1.08 : scale})`,
|
||||
opacity: speakingCharacterId && !speaking ? 0.55 : 1,
|
||||
transition: "transform 200ms ease, opacity 200ms ease",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-xl" style={speaking ? { boxShadow: "0 0 0 2px var(--accent-bright)" } : undefined}>
|
||||
<TachieStage expression={participant.expression} />
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: speaking ? "var(--accent)" : "var(--ink-soft)" }}>
|
||||
{participant.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { synthesizeVoice } from "../lib/api";
|
||||
|
||||
type PlaybackState = "idle" | "loading" | "playing" | "unavailable";
|
||||
|
||||
interface VoicePlaybackButtonProps {
|
||||
characterId: string;
|
||||
userId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// P-6:語音播放介面——呼叫 /voice/:characterId/:userId/synthesize 取得合成結果後播放。
|
||||
// TTS_PROVIDER=mock 回傳的 audioRef 是 `mock://` 佔位字串(非真實音檔),改用計時器模擬播放時長;
|
||||
// 之後接上真實供應商、audioRef 變成可播放網址時走真正的 <audio> 播放,呼叫端不用改一行。
|
||||
// 合成失敗(網路或後端錯誤)時直接隱藏這顆按鈕,退回純文字顯示,不影響訊息本身、不阻斷對話。
|
||||
export function VoicePlaybackButton({ characterId, userId, text }: VoicePlaybackButtonProps) {
|
||||
const [state, setState] = useState<PlaybackState>("idle");
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function stopPlayback() {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
async function startPlayback() {
|
||||
setState("loading");
|
||||
try {
|
||||
const result = await synthesizeVoice(characterId, userId, text);
|
||||
if (result.audioRef.startsWith("mock://")) {
|
||||
setState("playing");
|
||||
timerRef.current = setTimeout(() => setState("idle"), Math.max(result.durationEstimateMs, 300));
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(result.audioRef);
|
||||
audio.addEventListener("ended", () => setState("idle"));
|
||||
audio.addEventListener("error", () => setState("unavailable"));
|
||||
audioRef.current = audio;
|
||||
await audio.play();
|
||||
setState("playing");
|
||||
} catch {
|
||||
setState("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "unavailable") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (state === "playing" ? stopPlayback() : void startPlayback())}
|
||||
disabled={state === "loading"}
|
||||
aria-label={state === "playing" ? "停止播放語音" : "播放語音"}
|
||||
className="mt-1 inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px] disabled:opacity-50"
|
||||
style={{ color: "var(--accent)", background: "var(--chip-bg)" }}
|
||||
>
|
||||
{state === "loading" && "合成中…"}
|
||||
{state === "playing" && "■ 停止"}
|
||||
{state === "idle" && "▶ 播放語音"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+922
-11
@@ -25,16 +25,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 房,取代舊版 sendChatMessage/endChatSession(sessionId 由呼叫端自行
|
||||
// 產生)。伺服器端會為同一使用者+角色配對沿用同一間進行中的 SOLO 房,呼叫端不需要自己管理 session。
|
||||
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})`);
|
||||
@@ -42,11 +57,11 @@ export async function sendChatMessage(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function endChatSession(sessionId: string, characterId: string): Promise<void> {
|
||||
await fetch(`${API_BASE_URL}/session/${sessionId}/end`, {
|
||||
export async function endSoloRoom(roomId: string): Promise<void> {
|
||||
await fetch(`${API_BASE_URL}/room/${roomId}/end`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ characterId }),
|
||||
body: "{}",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,3 +89,899 @@ 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;
|
||||
}
|
||||
|
||||
// P-6 語音播放:呼叫語音子系統取得合成結果,播放邏輯見 components/VoicePlaybackButton.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 interface RomanceEligibility {
|
||||
romanceAxisEligible: boolean;
|
||||
adultModeEligible: boolean;
|
||||
isEpilogueAdult: boolean;
|
||||
}
|
||||
|
||||
export async function fetchRomanceEligibility(characterId: string): Promise<RomanceEligibility> {
|
||||
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/eligibility`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`戀愛資格查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface AdultContentCheckResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// L-8:每次呼叫都會在後端留下一筆嘗試紀錄(見 RomanceEligibilityService.checkAdultContentAllowed),
|
||||
// 因此只在使用者實際切換 opt-in 開關、或載入設定頁當下呼叫,不要輪詢。
|
||||
export async function checkAdultContent(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/adult-content-check`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`成人內容資格檢查失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface AdultModeConsent {
|
||||
userId: string;
|
||||
verifiedAdult: boolean;
|
||||
optedIn: boolean;
|
||||
}
|
||||
|
||||
// L-8:「成年使用者驗證」機制計畫未指定(需人工確認),這裡只做 opt-in 開關與既有 API 串接——
|
||||
// verifiedAdult 目前等同使用者自我宣告(與 optedIn 同一個開關),不是真正的身分驗證。
|
||||
export async function setAdultModeConsent(userId: string, verifiedAdult: boolean, optedIn: boolean): Promise<AdultModeConsent> {
|
||||
const res = await fetch(`${API_BASE_URL}/romance/adult-mode/consent`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, verifiedAdult, optedIn }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`成人模式設定失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type RomanceStage = "NONE" | "INTERESTED" | "AMBIGUOUS" | "PARTNER" | "DEEP_PARTNER" | "CRISIS";
|
||||
|
||||
export interface RomanceState {
|
||||
intimacy: number;
|
||||
trust: number;
|
||||
stage: RelationshipStage;
|
||||
romanceMeter: number;
|
||||
romanceStage: RomanceStage;
|
||||
}
|
||||
|
||||
// L-9:心動值與戀愛階段顯示,直接讀 Relationship 資料列(後端未另外包裝)。
|
||||
export async function fetchRomanceState(characterId: string, userId: string): Promise<RomanceState> {
|
||||
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/${userId}/state`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`戀愛狀態查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type ConfessionOutcome = "INELIGIBLE" | "REJECTED_TOO_SOON" | "REJECTED" | "ACCEPTED";
|
||||
|
||||
export interface ConfessionResult {
|
||||
outcome: ConfessionOutcome;
|
||||
romanceMeter: number;
|
||||
consecutiveRejections: number;
|
||||
}
|
||||
|
||||
// L-9:告白可能被拒絕——UI 必須如實呈現 outcome,不得把非 ACCEPTED 的結果畫成成功。
|
||||
export async function confessRomance(characterId: string, userId: string): Promise<ConfessionResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/confess`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`告白請求失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type MilestoneType =
|
||||
| "DISTANCE_SHORTENED"
|
||||
| "OCCASIONAL_CONTACT"
|
||||
| "LATE_NIGHT_TALK"
|
||||
| "HAND_HOLDING"
|
||||
| "HUG"
|
||||
| "FOREHEAD_KISS"
|
||||
| "CASUAL_INTIMACY";
|
||||
|
||||
export interface MilestoneResult {
|
||||
allowed: boolean;
|
||||
isFirstTime: boolean;
|
||||
reactionLine?: string;
|
||||
}
|
||||
|
||||
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 interface SceneLine {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
lineIndex: number;
|
||||
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();
|
||||
}
|
||||
|
||||
// T19-P4:匯入章節——場景層級(人類可讀摘要+原文)+逐句台詞/敘述/動作/內心獨白。
|
||||
export type SceneLineType = "DIALOGUE" | "ACTION" | "MONOLOGUE" | "NARRATION";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// T19-P4:待裁決作品卡片提示——沿用 M-11 待裁決畫面同一個判斷(場景自身有隔離台詞或所屬作品有
|
||||
// 待確認矛盾),這裡只取數量供大廳卡片顯示提示用。
|
||||
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;
|
||||
}
|
||||
|
||||
// T13/T19-P4:每章結局偵測待確認清單——後端已經只回傳 resolution=PENDING 的紀錄。
|
||||
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();
|
||||
}
|
||||
|
||||
// T14/T19-P4:確認結局偵測——切入後日談;拒絕則維持原狀,且該場景不再重複提示。
|
||||
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 type TimeFlowMode = "FAST" | "REALTIME" | "SLOW" | "FROZEN";
|
||||
|
||||
// N-6:時間流速上限(後日談推演成年角色不可快轉)在引擎層以 400 擋下,
|
||||
// 這裡把後端回傳的 message 原樣往上丟,讓畫面能顯示真正的限制原因,而不是通用錯誤文字。
|
||||
export async function setTimeFlow(workId: string, mode: TimeFlowMode): Promise<CanonWork> {
|
||||
const res = await fetch(`${API_BASE_URL}/epilogue/works/${workId}/time-flow`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
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 interface DriftedPersonality {
|
||||
archetype: string;
|
||||
expressiveness: number;
|
||||
driftAmount: number;
|
||||
emotionHalfLifeMultiplier: number;
|
||||
}
|
||||
|
||||
export async function fetchPersonalityDrift(characterId: string, atStoryOrder: number): Promise<DriftedPersonality> {
|
||||
const res = await fetch(`${API_BASE_URL}/epilogue/characters/${characterId}/drift?atStoryOrder=${atStoryOrder}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`人格漂移查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface MilestoneProposalResult {
|
||||
scene: { id: string } | null;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export async function proposeEpilogueMilestone(
|
||||
characterId: string,
|
||||
storyOrder: number,
|
||||
attendeeCharacterIds: string[] = [],
|
||||
): Promise<MilestoneProposalResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/epilogue/characters/${characterId}/milestone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ storyOrder, attendeeCharacterIds }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`里程碑提案失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const ROOM_USER_SPEAKER_ID = "USER";
|
||||
|
||||
export type RoomMode = "LOBBY" | "SOLO" | "GROUP" | "SELF_CHAT";
|
||||
export type RoomStatus = "ACTIVE" | "ENDED";
|
||||
|
||||
export interface RoomSummary {
|
||||
id: string;
|
||||
mode: RoomMode;
|
||||
status: RoomStatus;
|
||||
topicSeed: string | null;
|
||||
}
|
||||
|
||||
export async function createRoom(
|
||||
userId: string,
|
||||
characterIds: string[],
|
||||
mode: RoomMode,
|
||||
topicSeed?: string,
|
||||
): Promise<RoomSummary> {
|
||||
const res = await fetch(`${API_BASE_URL}/room`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, characterIds, mode, topicSeed }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`房間建立失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// T19-P3:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,對應後端 RoomTurnKind(見 T6)。
|
||||
export type RoomTurnKind = "TALK" | "ACTION" | "DELEGATE" | "LEAVE" | "SYSTEM";
|
||||
|
||||
export interface RoomTurn {
|
||||
seq: number;
|
||||
speakerId: string; // 使用者固定為 ROOM_USER_SPEAKER_ID,其餘為 characterId
|
||||
speakerLabel: string;
|
||||
content: string;
|
||||
kind: RoomTurnKind;
|
||||
}
|
||||
|
||||
export interface RoomUsageSummary {
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
generationCount: number;
|
||||
}
|
||||
|
||||
export interface RoomUnreadInfo {
|
||||
lastReadSeq: number;
|
||||
firstUnreadSeq: number | null;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface RoomDetail extends RoomSummary {
|
||||
participants: string[]; // characterId 陣列,顯示名稱需另外查 listCharacters()
|
||||
turnCount: number;
|
||||
turns: RoomTurn[];
|
||||
hasMore: boolean; // T12:還有更舊(或更新)的歷程可以翻頁
|
||||
nextCursor: number | null; // T12:接續翻頁用的游標,配合 afterSeq/beforeSeq 使用
|
||||
unread: RoomUnreadInfo; // T11:定位第一則未讀分隔線用
|
||||
muted: boolean; // T31:靜音——不推播、不自動播放語音,自走仍照常進行
|
||||
usage: RoomUsageSummary; // T9/T19-P3:本房累計用量
|
||||
}
|
||||
|
||||
export interface RoomTurnsPageOptions {
|
||||
afterSeq?: number; // 往後翻(取更新的一段)
|
||||
beforeSeq?: number; // 往前翻(取更舊的一段)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// T12:重新進入未關閉的房間預設看到最新一頁;帶 beforeSeq/afterSeq 可接續往前或往後翻頁。
|
||||
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();
|
||||
}
|
||||
|
||||
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 LobbySnapshot {
|
||||
lobbyRoomId: string;
|
||||
roster: string[]; // 目前在大廳在場的 characterId
|
||||
activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[];
|
||||
}
|
||||
|
||||
// T19-P1/T20-P1:大廳頁最小可用所需的在場名單與進行中房間清單(正式聚合端點見 T4,P2)。
|
||||
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/T19-P2:正式大廳聚合端點——單一請求取得作品分組、角色狀態與各進行中房間的未讀數,取代
|
||||
// getLobby() 舊版最小快照作為大廳頁的主要資料來源。
|
||||
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/T19-P2:大廳對話式呼叫——在大廳打字喊角色的名字,答應者會開一間 SOLO/GROUP 房。
|
||||
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();
|
||||
}
|
||||
|
||||
// T11/T19-P2:回報已讀游標(單調遞增、不可回退),回傳更新後的未讀狀態。
|
||||
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();
|
||||
}
|
||||
|
||||
// T10/T19-P2:房間 SSE 推送端點的完整 URL,供 EventSource 使用;afterSeq 帶初次載入時已看到的最新
|
||||
// seq,避免重連時重複收到已顯示過的 turn(瀏覽器原生重連改走 Last-Event-ID,這裡只用於首次連線)。
|
||||
export function roomStreamUrl(roomId: string, afterSeq?: number): string {
|
||||
const params = afterSeq !== undefined ? `?afterSeq=${afterSeq}` : "";
|
||||
return `${API_BASE_URL}/room/${roomId}/stream${params}`;
|
||||
}
|
||||
|
||||
export interface RoomMessageResult {
|
||||
speakers: { characterId: string; text: string; score: number }[];
|
||||
}
|
||||
|
||||
export async function sendRoomMessage(roomId: string, userId: string, userLabel: string, text: string): Promise<RoomMessageResult> {
|
||||
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();
|
||||
}
|
||||
|
||||
// K-7:插話後即切換為群聊——這是後端 SelfChatService.interject() 的既定行為,前端不需要另外處理模式切換。
|
||||
export async function interjectRoom(roomId: string, userId: string, userLabel: string, text: string): Promise<RoomMessageResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/interject`, {
|
||||
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 interface SelfChatAdvanceResult {
|
||||
ended: boolean;
|
||||
turn?: { characterId: string; content: string };
|
||||
reason?: "all-left"; // T6:唯一的收斂路徑——所有角色都已各自 LEAVE 離場
|
||||
}
|
||||
|
||||
export async function advanceSelfChat(roomId: string): Promise<SelfChatAdvanceResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/room/${roomId}/self-chat/advance`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 type TaskType = "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING";
|
||||
export type TaskStatus = "PENDING" | "TRIGGERED" | "COMPLETED" | "FAILED" | "CANCELLED";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
characterId: string;
|
||||
userId: string;
|
||||
type: TaskType;
|
||||
content: string;
|
||||
rawText: string;
|
||||
triggerAt: string | null;
|
||||
cronExpression: string | null;
|
||||
condition: string | null;
|
||||
status: TaskStatus;
|
||||
triggeredAt: string | null;
|
||||
triggerMessage: string | null;
|
||||
overdueNudgeCount: number;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ParseTaskResult {
|
||||
created: boolean;
|
||||
task?: Task;
|
||||
performanceLine?: string;
|
||||
rapport?: boolean;
|
||||
}
|
||||
|
||||
// J-8:自然語句解析——辨識成功時後端已直接建立委託(非純預覽),辨識失敗則回傳 created:false,
|
||||
// 由畫面提供 createTask() 手動表單作為退路,兩個端點各自對應不同情境,不是「先預覽再重複建立」。
|
||||
export async function parseTask(characterId: string, userId: string, text: string): Promise<ParseTaskResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/task/${characterId}/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`委託解析失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
type: TaskType;
|
||||
content: string;
|
||||
rawText: string;
|
||||
triggerAt?: string;
|
||||
}
|
||||
|
||||
export async function createTask(characterId: string, userId: string, input: CreateTaskInput): Promise<ParseTaskResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/task/${characterId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, ...input }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`委託建立失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listTasks(characterId: string, userId: string): Promise<Task[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/task?characterId=${encodeURIComponent(characterId)}&userId=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`委託清單查詢失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// J-8:後端註記這是「測試用途:直接觸發,略過真實排程等待」,畫面上維持同樣的用途標示,
|
||||
// 不假裝這是正式的「提早提醒」功能——單純是驗收流程要求的「觸發」這一步在畫面上走得完。
|
||||
export async function fireTaskNow(taskId: string): Promise<{ message: string | null }> {
|
||||
const res = await fetch(`${API_BASE_URL}/task/${taskId}/fire-now`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`委託觸發失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function completeTask(taskId: string): Promise<Task> {
|
||||
const res = await fetch(`${API_BASE_URL}/task/${taskId}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`委託標記完成失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function failTask(taskId: string): Promise<Task> {
|
||||
const res = await fetch(`${API_BASE_URL}/task/${taskId}/fail`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`委託標記失敗失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function attemptRomanceMilestone(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
milestoneType: MilestoneType,
|
||||
): Promise<MilestoneResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/milestone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, milestoneType }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`里程碑請求失敗(${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// T19-P5 人格套用:契約與 apps/api/src/room/possession.controller.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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const STORAGE_KEY = "kokorone:voice-autoplay-enabled";
|
||||
|
||||
// T26:自走語音在場自動播放的開關,純前端偏好(不需要跨裝置同步),預設開啟。
|
||||
export function isVoiceAutoplayEnabled(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored !== "false";
|
||||
}
|
||||
|
||||
export function setVoiceAutoplayEnabled(enabled: boolean): void {
|
||||
window.localStorage.setItem(STORAGE_KEY, String(enabled));
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY }));
|
||||
}
|
||||
Reference in New Issue
Block a user