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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); }