新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
988 lines
33 KiB
TypeScript
988 lines
33 KiB
TypeScript
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();
|
||
}
|