Files
Kokorone/apps/web/lib/api.ts
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

988 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EmotionTag, RelationshipStage } from "@kokorone/shared";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
export interface ChatResponseStyle {
tone: string;
sentenceLength: "short" | "medium" | "long";
initiative: "low" | "medium" | "high";
}
export interface ChatContext {
emotion: { dominant: EmotionTag; style: ChatResponseStyle };
relationship: { intimacy: number; trust: number; stage: RelationshipStage };
}
export type ChatAvailability = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
export interface ChatResult {
text: string;
actions: string[];
isFastChannel: boolean;
availability: ChatAvailability;
exceptionType: string | null;
// I-2:作息為睡眠且未破例時不會組裝上下文,此時為 null。
context: ChatContext | null;
}
// 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, 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})`);
}
return res.json();
}
export async function endSoloRoom(roomId: string): Promise<void> {
await fetch(`${API_BASE_URL}/room/${roomId}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
}
export interface TachieExpressionResponse {
emotionTag: EmotionTag;
rawEmotionTag: EmotionTag;
eyebrow: string;
eye: string;
mouth: string;
effect: string | null;
pose: string;
magnitude: number;
gaze: "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
availableCostumes: string[];
lockedFallbackApplied: boolean;
usedCryingVariant: boolean;
}
// O-2 表情演出:情緒引擎已經在對話文字生成之前更新完畢,這裡獨立查詢目前的表情狀態,
// 讓前端可以在文字訊息出現前先套用新的表情差分。
export async function fetchExpression(characterId: string, userId: string): Promise<TachieExpressionResponse> {
const res = await fetch(`${API_BASE_URL}/tachie/${characterId}/${userId}/expression`);
if (!res.ok) {
throw new Error(`表情查詢失敗(${res.status})`);
}
return res.json();
}
export 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();
}