import type { EmotionTag, RelationshipStage } from "@kokorone/shared"; // Q-1:與 apps/web/lib/api.ts 共用同一套 @kokorone/shared 型別,API client 本身雖是各端各自實作 // (React Native 沒有 Next.js 的伺服器端環境變數機制),但契約(回應形狀)三端一致。 export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001"; export interface HealthResponse { status: string; } export async function fetchHealth(): Promise { const res = await fetch(`${API_BASE_URL}/health`); if (!res.ok) { throw new Error(`健康檢查失敗(${res.status})`); } return res.json(); } 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; context: ChatContext | null; } // T38:既有 1:1 聊天併入 SOLO 房,取代舊版由呼叫端自行產生 sessionId 的 sendChatMessage。 // 伺服器端為同一使用者+角色配對沿用同一間進行中的 SOLO 房,契約與 apps/web/lib/api.ts 一致。 export interface SoloRoomSummary { id: string; mode: "SOLO"; status: "ACTIVE" | "CLOSED"; } export async function getOrCreateSoloRoom(userId: string, characterId: string): Promise { 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 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; } 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; } // T20-P3:語音合成——與 apps/web 呼叫同一端點,播放邏輯見 app/room/[roomId].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 async function registerPushToken(userId: string, token: string, platform: string): Promise { await fetch(`${API_BASE_URL}/push/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, token, platform }), }); } // T20-P1:大廳頁最小可用(在場名單+進行中房間清單)與房間歷程分頁,契約與 apps/web/lib/api.ts 一致 // (見該檔 T19-P1 段落),此處為 React Native 端各自實作的對應封裝。 export interface LobbySnapshot { lobbyRoomId: string; roster: string[]; activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[]; } export async function getLobby(userId: string): Promise { const res = await fetch(`${API_BASE_URL}/room/lobby?userId=${encodeURIComponent(userId)}`); if (!res.ok) { throw new Error(`大廳查詢失敗(${res.status})`); } return res.json(); } export type LobbyPresenceState = "BUILDING" | "SLEEPING" | "BUSY" | "SEMI_BUSY" | "IDLE" | "IN_SOLO_ROOM" | "IN_GROUP_ROOM"; export interface LobbyMilestoneView { characterId: string; label: string; storyOrder: number; createdAt: string; } export interface LobbyWorkView { id: string; title: string; inEpilogue: boolean; milestones: LobbyMilestoneView[]; // T34:自走一段時間後自主成立的人生變化 } export interface LobbyCharacterView { id: string; workId: string | null; formalName: string; buildStatus: string; presenceState: LobbyPresenceState; roomId: string | null; } export interface LobbyActiveRoomView { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[]; unreadCount: number; } export interface LobbyFullSnapshot { works: LobbyWorkView[]; characters: LobbyCharacterView[]; activeRooms: LobbyActiveRoomView[]; } // T4/T20-P2:正式大廳聚合端點,契約與 apps/web/lib/api.ts 一致(見該檔 T19-P2 段落)。 export async function getFullLobby(userId: string): Promise { const res = await fetch(`${API_BASE_URL}/lobby?userId=${encodeURIComponent(userId)}`); if (!res.ok) { throw new Error(`大廳查詢失敗(${res.status})`); } return res.json(); } export interface LobbySayResult { calledCharacterIds: string[]; accepted: string[]; declined: string[]; roomId: string | null; mode: "SOLO" | "GROUP" | null; message: string | null; delegatedBy: string | null; pendingPossessedCharacterIds: string[]; // T43:喊到但目前套用中,改記待決給套用者 } // T5/T20-P2:大廳對話式呼叫,契約與 apps/web/lib/api.ts 一致。 export async function sayToLobby(userId: string, text: string): Promise { const res = await fetch(`${API_BASE_URL}/lobby/say`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, text }), }); if (!res.ok) { throw new Error(`大廳呼叫失敗(${res.status})`); } return res.json(); } // T20-P4:匯入章節、待裁決、結局確認——契約與 apps/web/lib/api.ts 的 T19-P4 段落一致,mobile 補上 // 對等的封裝與畫面(`app/canon-import.tsx`/`app/canon-review.tsx`)。 export interface CharacterSummary { id: string; workId: string | null; aliases: { formalName: string }[]; } export async function listCharacters(): Promise { 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 type SceneLineType = "DIALOGUE" | "ACTION" | "MONOLOGUE" | "NARRATION"; export interface SceneLine { id: string; sceneId: string; lineIndex: number; lineType: SceneLineType; rawText: string; hasExplicitMarker: boolean; markerCharacterId: string | null; speakerCharacterId: string | null; confidence: number; status: SceneLineStatus; } export interface CanonSceneDetail extends CanonScene { lines: SceneLine[]; } export async function getCanonScene(sceneId: string): Promise { 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(); } 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(); } 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; } 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(); } 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 const ROOM_USER_SPEAKER_ID = "USER"; // T20-P3:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,對應後端 RoomTurnKind(見 T6/T19-P3)。 export type RoomTurnKind = "TALK" | "ACTION" | "DELEGATE" | "LEAVE" | "SYSTEM"; export interface RoomTurn { seq: number; speakerId: string; speakerLabel: string; content: string; kind: RoomTurnKind; } export interface RoomUnreadInfo { lastReadSeq: number; firstUnreadSeq: number | null; unreadCount: number; } export interface RoomUsageSummary { totalPromptTokens: number; totalCompletionTokens: number; generationCount: number; } export interface RoomDetail { id: string; mode: string; status: string; topicSeed: string | null; participants: string[]; turnCount: number; turns: RoomTurn[]; hasMore: boolean; nextCursor: number | null; unread: RoomUnreadInfo; muted: boolean; // T31:靜音——不推播、不自動播放語音,自走仍照常進行 usage: RoomUsageSummary; } export async function getRoomUsage(roomId: string): Promise { const res = await fetch(`${API_BASE_URL}/room/${roomId}/usage`); if (!res.ok) { throw new Error(`用量查詢失敗(${res.status})`); } return res.json(); } // T31:靜音只影響推播與自動語音播放,自走排程與生成照常進行。 export async function setRoomMuted(roomId: string, muted: boolean): Promise<{ id: string; muted: boolean }> { const res = await fetch(`${API_BASE_URL}/room/${roomId}/mute`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ muted }), }); if (!res.ok) { throw new Error(`靜音設定失敗(${res.status})`); } return res.json(); } export interface RoomTurnsPageOptions { afterSeq?: number; beforeSeq?: number; limit?: number; } export async function getRoom(roomId: string, page: RoomTurnsPageOptions = {}): Promise { const params = new URLSearchParams(); if (page.afterSeq !== undefined) params.set("afterSeq", String(page.afterSeq)); if (page.beforeSeq !== undefined) params.set("beforeSeq", String(page.beforeSeq)); if (page.limit !== undefined) params.set("limit", String(page.limit)); const query = params.toString(); const res = await fetch(`${API_BASE_URL}/room/${roomId}${query ? `?${query}` : ""}`); if (!res.ok) { throw new Error(`房間查詢失敗(${res.status})`); } return res.json(); } export interface RoomMessageResult { speakers: { characterId: string; text: string; score: number }[]; } // T20-P2:通用房間發言——後端依 room.mode 內部分流 SOLO/GROUP,回應形狀不同,但本畫面一律透過 // SSE/串流收新訊息,不依賴這裡的回傳值本身,故用寬鬆型別接收即可。 export async function sendRoomMessage(roomId: string, userId: string, userLabel: string, text: string): Promise { const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, userLabel, text }), }); if (!res.ok) { throw new Error(`發言失敗(${res.status})`); } return res.json(); } export async function endRoom(roomId: string): Promise<{ ok: boolean }> { const res = await fetch(`${API_BASE_URL}/room/${roomId}/end`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }); if (!res.ok) { throw new Error(`結束房間失敗(${res.status})`); } return res.json(); } // T11/T20-P2:回報已讀游標,契約與 apps/web/lib/api.ts 一致。 export async function reportRoomRead(roomId: string, userId: string, lastReadSeq: number): Promise { const res = await fetch(`${API_BASE_URL}/room/${roomId}/read`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, lastReadSeq }), }); if (!res.ok) { throw new Error(`已讀回報失敗(${res.status})`); } return res.json(); } export type RoomStreamEvent = | { type: "turn"; seq: number; data: { speakerId: string; speakerLabel: string; content: string; kind: RoomTurnKind } } | { type: "member"; seq: number; data: { characterId: string; action: "joined" | "left" } } | { type: "closed"; seq: number; data: { reason: string } }; // T10/T20-P2:房間即時推送——React Native 沒有跨平台一致的 EventSource(見 AGENTS.md // 「Expo HAS CHANGED」提醒,已查證 Expo SDK 57 官方文件:`expo/fetch` 提供 WinterCG 相容的 // streaming response body,`EventSource` 則未在文件中記載跨平台可用),故改用 `expo/fetch` 的 // `resp.body.getReader()` 手動解析標準 SSE 分幀(event/id/data,空行斷幀),語意與 apps/web 用 // 瀏覽器原生 EventSource 消費的是同一個後端端點、同一套事件格式,只是消費端實作不同。 // 回傳一個 cancel 函式,呼叫者卸載時務必呼叫以中止底層串流讀取。 export function subscribeRoomStream( roomId: string, afterSeq: number, onEvent: (event: RoomStreamEvent) => void, onError?: (err: unknown) => void, ): () => void { let cancelled = false; const controller = new AbortController(); (async () => { try { const { fetch: expoFetch } = await import("expo/fetch"); const resp = await expoFetch(`${API_BASE_URL}/room/${roomId}/stream?afterSeq=${afterSeq}`, { headers: { Accept: "text/event-stream" }, signal: controller.signal, }); const reader = resp.body?.getReader(); if (!reader) return; const decoder = new TextDecoder(); let buffer = ""; while (!cancelled) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let frameBreak = buffer.indexOf("\n\n"); while (frameBreak !== -1) { const frame = buffer.slice(0, frameBreak); buffer = buffer.slice(frameBreak + 2); let eventType = "message"; let id: string | null = null; const dataLines: string[] = []; for (const line of frame.split("\n")) { if (line.startsWith("event:")) eventType = line.slice(6).trim(); else if (line.startsWith("id:")) id = line.slice(3).trim(); else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); } if (eventType !== "ping" && dataLines.length > 0 && id !== null) { try { const data = JSON.parse(dataLines.join("\n")); onEvent({ type: eventType as RoomStreamEvent["type"], seq: Number(id), data } as RoomStreamEvent); } catch { // 忽略解析失敗的單一分幀,不中斷整條串流。 } } frameBreak = buffer.indexOf("\n\n"); } } } catch (err) { if (!cancelled) onError?.(err); } })(); return () => { cancelled = true; controller.abort(); }; } // T20-P5 人格套用:契約與 apps/api/src/room/possession.controller.ts、apps/web/lib/api.ts 一致。 export interface PossessionStatus { possessingCharacterId: string | null; } export async function getPossessionStatus(userId: string): Promise { 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(); }