/* ===================================================================== Vignette — API 클라이언트 (fetch wrapper + SSE 헬퍼) 백엔드 계약: apps/api/app/routes (auth.py, sessions.py). - 모든 요청은 credentials:"include" (BFF __Host-vignette_sid HttpOnly 쿠키). - 에러는 ApiError 로 표준화. - SSE: POST /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달. ===================================================================== */ import type { components } from "./api.gen"; type ApiSchema = components["schemas"][Name]; // Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅). const PUBLIC_API_ORIGIN_BY_HOST: Record = { "vignette.chanpaca.net": "https://api-vignette.chanpaca.net", "vnet.18ka.net": "https://api-vnet.18ka.net", }; function defaultApiBase(): string { if (typeof window !== "undefined") { const host = window.location.hostname; const mapped = PUBLIC_API_ORIGIN_BY_HOST[host]; if (mapped) return mapped; if (host.endsWith(".pages.dev")) { return "https://api-vignette.chanpaca.net"; } } return "/api"; } const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim(); const API_BASE: string = configuredApiBase || defaultApiBase(); export class ApiError extends Error { readonly status: number; readonly detail: string; readonly body: unknown; constructor(status: number, detail: string, body?: unknown) { super(`API ${status}: ${detail}`); this.name = "ApiError"; this.status = status; this.detail = detail; this.body = body; } } type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; interface RequestOptions { method?: HttpMethod; /** JSON 직렬화될 요청 바디 */ body?: unknown; /** 추가 헤더 */ headers?: Record; /** AbortSignal (취소·타임아웃) */ signal?: AbortSignal; } function joinUrl(path: string): string { if (/^https?:\/\//.test(path)) return path; const base = API_BASE.replace(/\/$/, ""); const p = path.startsWith("/") ? path : `/${path}`; return `${base}${p}`; } export function apiUrl(path: string): string { return joinUrl(path); } export function apiWsUrl(path: string): string { const url = new URL(joinUrl(path), window.location.origin); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } async function parseError(res: Response): Promise { let detail = res.statusText || "request failed"; let body: unknown = undefined; try { const ct = res.headers.get("content-type") ?? ""; if (ct.includes("application/json")) { body = await res.json(); // FastAPI 표준 에러는 { detail: ... } const d = (body as { detail?: unknown }).detail; if (typeof d === "string") detail = d; else if (d != null) detail = JSON.stringify(d); } else { const text = await res.text(); if (text) detail = text; body = text; } } catch { /* 본문 파싱 실패는 무시하고 statusText 사용 */ } return new ApiError(res.status, detail, body); } /** * JSON API 호출. 2xx 가 아니면 ApiError throw. * 204/빈 응답은 undefined 반환. */ export async function apiFetch( path: string, options: RequestOptions = {}, ): Promise { const { method = "GET", body, headers = {}, signal } = options; const finalHeaders: Record = { Accept: "application/json", ...headers, }; const init: RequestInit = { method, credentials: "include", // __Host-vignette_sid 쿠키 전송 (BFF) signal, }; if (body !== undefined) { finalHeaders["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } init.headers = finalHeaders; const res = await fetch(joinUrl(path), init); if (!res.ok) { throw await parseError(res); } if (res.status === 204) return undefined as T; const ct = res.headers.get("content-type") ?? ""; if (!ct.includes("application/json")) { return (await res.text()) as unknown as T; } const len = res.headers.get("content-length"); if (len === "0") return undefined as T; return (await res.json()) as T; } export const api = { get: (path: string, opts?: Omit) => apiFetch(path, { ...opts, method: "GET" }), post: (path: string, body?: unknown, opts?: Omit) => apiFetch(path, { ...opts, method: "POST", body }), put: (path: string, body?: unknown, opts?: Omit) => apiFetch(path, { ...opts, method: "PUT", body }), del: (path: string, opts?: Omit) => apiFetch(path, { ...opts, method: "DELETE" }), }; /* ===================================================================== 백엔드 응답 타입 (apps/api 계약 미러) ===================================================================== */ /** GET /auth/me — auth.py MeResponse */ export type MeResponse = ApiSchema<"MeResponse">; export type ConsentResponse = ApiSchema<"ConsentResponse">; export type AuthConfigResponse = ApiSchema<"AuthConfigResponse">; export const authApi = { config: () => api.get("/auth/config"), acceptConsent: () => api.post("/auth/consent", { accepted: true }), withdrawConsent: () => api.del("/auth/consent"), }; export type SessionStage = ApiSchema<"SessionStartResponse">["stage"]; /** GET /personas — personas.py PersonaSummary */ export type PersonaSummary = ApiSchema<"PersonaSummary">; export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived"; export type PersonaReviewAction = ApiSchema<"PersonaReviewDecisionRequest">["action"]; export type PersonaReviewSummary = ApiSchema<"PersonaReviewSummary">; export type PersonaRevisionRequest = ApiSchema<"PersonaRevisionRequest">; export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">; export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">; export type PersonaSourceDocumentRequest = ApiSchema<"PersonaSourceDocumentRequest">; export type PersonaSourceDocumentResponse = ApiSchema<"PersonaSourceDocumentResponse">; export type PersonaGenerationEvidence = ApiSchema<"PersonaGenerationEvidence">; export type PersonaDraftGenerateRequest = ApiSchema<"PersonaDraftGenerateRequest">; export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateResponse">; /** POST /sessions — sessions.py SessionStartResponse */ export type SessionStartResponse = ApiSchema<"SessionStartResponse">; /** POST /sessions/{id}/turn — sessions.py TurnResponse */ export type TurnResponse = ApiSchema<"TurnResponse">; export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">; export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">; export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">; export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">; export type LiveCoachSource = ApiSchema<"LiveCoachSource">; /** POST /sessions/{id}/end — sessions.py SessionEndResponse */ export type SessionEndResponse = ApiSchema<"SessionEndResponse">; export type CrisisResource = ApiSchema<"CrisisResourceResponse">; export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">; export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">; export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">; export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">; export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">; export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">; export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">; export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">; export type SessionDetailResponse = ApiSchema<"SessionDetailResponse">; export type ReviewClient = ApiSchema<"ReviewClient">; export type ReviewTechnique = ApiSchema<"ReviewTechnique">; export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">; export type ReviewNote = ApiSchema<"ReviewNote">; export type ReviewTurn = ApiSchema<"ReviewTurn">; export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">; export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">; export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">; export type ReviewPoint = ApiSchema<"ReviewPoint">; export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">; export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">; export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">; export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">; export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">; export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">; export type SessionShareResponse = ApiSchema<"SessionShareResponse">; export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">; /* ===================================================================== SSE 헬퍼 — POST /sessions/{id}/stream 백엔드 이벤트(sse_starlette): "token" | "done" | "ping" | "safety" | "error" 턴 본문이 필요하므로 EventSource 가 아니라 fetch stream 으로 처리한다. ===================================================================== */ export interface SessionStreamHandlers { /** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */ onOpen?: () => void; /** 내담자 AI 토큰 1조각 */ onToken?: (chunk: string) => void; /** 스트림 정상 종료 */ onDone?: (data: SessionStreamDone) => void; /** 안전(위기) 신호 */ onSafety?: (data: unknown) => void; /** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */ onError?: (err: { detail: string }) => void; /** keep-alive ping (Cloudflare 타임아웃 회피용 heartbeat) */ onPing?: () => void; } export interface SessionStreamDone { session_id: string; stage?: SessionStage; effective_openness?: number; turn_seq?: number; safety_flagged?: boolean; crisis_kind?: string; crisis_resource?: CrisisResource | null; conversation_stopped?: boolean; } function safeParse(data: string): unknown { try { return JSON.parse(data); } catch { return data; } } /** * 내담자 AI 응답 SSE 스트림을 실행한다. * 백엔드는 learner turn 을 저장한 뒤 token/done 이벤트를 흘린다. */ export async function openSessionStream( sessionId: string, text: string, handlers: SessionStreamHandlers, ): Promise { const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), { method: "POST", credentials: "include", headers: { Accept: "text/event-stream", "Content-Type": "application/json", }, body: JSON.stringify({ text }), }); if (!res.ok) { throw await parseError(res); } if (!res.body) { throw new ApiError(res.status, "스트림 응답 본문이 없습니다."); } handlers.onOpen?.(); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let eventName = "message"; let dataLines: string[] = []; let donePayload: SessionStreamDone | null = null; let streamError: ApiError | null = null; const dispatch = () => { if (!eventName && dataLines.length === 0) return; const data = dataLines.join("\n"); const event = eventName || "message"; eventName = "message"; dataLines = []; if (event === "token") { handlers.onToken?.(data); return; } if (event === "done") { const parsed = safeParse(data || "{}") as Partial; donePayload = { session_id: parsed.session_id ?? sessionId, stage: parsed.stage, effective_openness: parsed.effective_openness, turn_seq: parsed.turn_seq, safety_flagged: parsed.safety_flagged, crisis_kind: parsed.crisis_kind, crisis_resource: parsed.crisis_resource, conversation_stopped: parsed.conversation_stopped, }; handlers.onDone?.(donePayload); return; } if (event === "safety") { handlers.onSafety?.(safeParse(data || "null")); return; } if (event === "ping") { handlers.onPing?.(); return; } if (event === "error") { const parsed = safeParse(data || "{}") as { detail?: string }; const detail = parsed.detail ?? "stream error"; handlers.onError?.({ detail }); streamError = new ApiError(503, detail, parsed); } }; const processLine = (line: string) => { if (line === "") { dispatch(); return; } if (line.startsWith(":")) return; const idx = line.indexOf(":"); const field = idx === -1 ? line : line.slice(0, idx); const rawValue = idx === -1 ? "" : line.slice(idx + 1); const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; if (field === "event") eventName = value; else if (field === "data") dataLines.push(value); }; const processBuffer = (final = false) => { const lines = buffer.split(/\r?\n/); buffer = final ? "" : (lines.pop() ?? ""); for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line); if (final && buffer) processLine(buffer); if (final && dataLines.length > 0) dispatch(); }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); processBuffer(); if (streamError) break; } buffer += decoder.decode(); processBuffer(true); if (streamError) throw streamError; return donePayload ?? { session_id: sessionId }; } /* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */ export const personaApi = { list: () => api.get("/personas"), }; export const personaReviewApi = { list: () => api.get("/personas/review"), decide: (personaId: string, action: PersonaReviewAction) => api.post(`/personas/review/${encodeURIComponent(personaId)}`, { action, }), createDraft: (payload: PersonaDraftPayload) => api.post("/personas/drafts", payload), createSource: (payload: PersonaSourceDocumentRequest) => api.post("/personas/sources", payload), generateDraft: (payload: PersonaDraftGenerateRequest) => api.post("/personas/drafts/generate", payload), getDraft: (personaId: string) => api.get(`/personas/drafts/${encodeURIComponent(personaId)}`), updateDraft: (personaId: string, payload: PersonaDraftPayload) => api.put(`/personas/drafts/${encodeURIComponent(personaId)}`, payload), reviseApproved: (personaId: string, payload: PersonaRevisionRequest = { submit_for_review: false }) => api.post(`/personas/${encodeURIComponent(personaId)}/revisions`, payload), archive: (personaId: string) => api.del(`/personas/${encodeURIComponent(personaId)}`), }; export const sessionApi = { list: () => api.get("/sessions"), dashboard: () => api.get("/sessions/dashboard"), /** * 음성 캐스케이드 가용성(STT/TTS provider 키 설정 여부). degraded 시 백엔드가 503을 * 주므로 상태코드와 무관하게 본문을 읽어 available 만 돌려준다(미설정도 정상 응답으로 취급). */ voiceHealth: async (): Promise<{ available: boolean; reason: string | null }> => { try { const r = await fetch(apiUrl("/voice/health"), { credentials: "include" }); const body = (await r.json()) as { available?: boolean; reason?: string | null }; return { available: !!body.available, reason: body.reason ?? null }; } catch { return { available: false, reason: "voice health check failed" }; } }, get: (sessionId: string) => api.get(`/sessions/${encodeURIComponent(sessionId)}`), start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") => api.post("/sessions", { persona_code, theory_mode }), turn: (sessionId: string, text: string) => api.post(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }), liveCoach: (sessionId: string, payload: LiveCoachRequest) => api.post( `/sessions/${encodeURIComponent(sessionId)}/live-coach`, payload, ), liveCoachHistory: (sessionId: string) => api.get(`/sessions/${encodeURIComponent(sessionId)}/live-coach`), end: (sessionId: string) => api.post(`/sessions/${encodeURIComponent(sessionId)}/end`), archive: (sessionId: string) => api.post(`/sessions/${encodeURIComponent(sessionId)}/archive`, {}), restore: (sessionId: string) => api.post(`/sessions/${encodeURIComponent(sessionId)}/restore`, {}), review: (sessionId: string) => api.get(`/sessions/${encodeURIComponent(sessionId)}/review`), saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) => api.put( `/sessions/${encodeURIComponent(sessionId)}/review/worksheet`, payload, ), createShare: (sessionId: string) => api.post(`/sessions/${encodeURIComponent(sessionId)}/share`, {}), revokeShare: (sessionId: string) => api.del(`/sessions/${encodeURIComponent(sessionId)}/share`), stream: openSessionStream, }; export type AdminHealthStatus = ApiSchema<"AdminHealthResponse">["status"]; export type AdminServiceHealth = ApiSchema<"AdminServiceHealth">; export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">; export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">; export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">; export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">; export type AdminHealthEvent = ApiSchema<"AdminHealthEvent">; export type AdminUptimeServiceSummary = ApiSchema<"AdminUptimeServiceSummary">; export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">; export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">; export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">; export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">; export type AdminTicketFilters = { status?: AdminSupportTicket["status"] | ""; category?: AdminSupportTicket["category"] | ""; priority?: AdminSupportTicket["priority"] | ""; assignedGroup?: string; sourcePath?: string; staleOnly?: boolean; search?: string; windowDays?: number; }; export const adminApi = { health: () => api.get("/admin/health"), usage: (windowDays = 7) => api.get(`/admin/usage?window_days=${windowDays}`), uptime: (windowHours = 24) => api.get(`/admin/uptime?window_hours=${windowHours}`), tickets: (filters: AdminTicketFilters = {}) => { const params = new URLSearchParams({ window_days: String(filters.windowDays ?? 30) }); if (filters.status) params.set("status", filters.status); if (filters.category) params.set("category", filters.category); if (filters.priority) params.set("priority", filters.priority); if (filters.assignedGroup?.trim()) params.set("assigned_group", filters.assignedGroup.trim()); if (filters.sourcePath?.trim()) params.set("source_path", filters.sourcePath.trim()); if (filters.staleOnly) params.set("stale_only", "true"); if (filters.search?.trim()) params.set("search", filters.search.trim()); return api.get(`/admin/tickets?${params.toString()}`); }, updateTicket: (ticketId: string, body: AdminTicketPatchRequest) => apiFetch(`/admin/tickets/${encodeURIComponent(ticketId)}`, { method: "PATCH", body, }), }; export type AdminManagedUser = ApiSchema<"AdminUserResponse">; export type AdminUsersResponse = ApiSchema<"AdminUsersResponse">; export type AdminUserCreateRequest = ApiSchema<"AdminUserCreate">; export type AdminUserPatchRequest = ApiSchema<"AdminUserPatch">; export type AdminUserDeleteResponse = ApiSchema<"AdminUserDeleteResponse">; export const adminUsersApi = { list: () => api.get("/admin/users"), create: (body: AdminUserCreateRequest) => apiFetch("/admin/users", { method: "POST", body }), update: ( userId: string, body: AdminUserPatchRequest, ) => apiFetch(`/admin/users/${encodeURIComponent(userId)}`, { method: "PATCH", body, }), deactivate: (userId: string) => apiFetch(`/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE", }), }; export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">; export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">; export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">; export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">; export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">; export type TeacherSessionReviewStatusRequest = ApiSchema<"TeacherSessionReviewStatusRequest">; export type TeacherSessionReviewStatusResponse = ApiSchema<"TeacherSessionReviewStatusResponse">; export const teacherApi = { dashboard: () => api.get("/teacher/dashboard"), updateSessionReviewStatus: ( sessionId: string, body: TeacherSessionReviewStatusRequest, ) => apiFetch( `/teacher/sessions/${encodeURIComponent(sessionId)}/review-status`, { method: "PUT", body }, ), }; export type UserProfileResponse = ApiSchema<"UserProfileResponse">; export type LegalDocumentsResponse = ApiSchema<"LegalDocumentsResponse">; export type NotificationPreferences = ApiSchema<"NotificationPreferences">; export type UserPreferencesResponse = ApiSchema<"UserPreferencesResponse">; export type UserPreferencesPatchRequest = ApiSchema<"UserPreferencesPatch">; export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">; export type OnboardingRequest = ApiSchema<"OnboardingRequest">; export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">; export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">; export type UserSupportTicketListItem = ApiSchema<"UserSupportTicketListItem">; export type UserSupportTicketsResponse = ApiSchema<"UserSupportTicketsResponse">; export type UserPrepostMeasureRequest = ApiSchema<"UserPrepostMeasureRequest">; export type UserPrepostMeasureItem = ApiSchema<"UserPrepostMeasureItem">; export type UserPrepostMeasuresResponse = ApiSchema<"UserPrepostMeasuresResponse">; export type RoleString = "learner" | "teacher" | "admin" | string; export const userApi = { legalDocs: () => api.get("/users/legal-docs"), me: () => api.get("/users/me"), updateMe: (body: UserProfilePatchRequest) => apiFetch("/users/me", { method: "PATCH", body }), uploadAvatar: async (file: File) => { const form = new FormData(); form.append("file", file); const res = await fetch(joinUrl("/users/me/avatar"), { method: "POST", credentials: "include", headers: { Accept: "application/json" }, body: form, }); if (!res.ok) throw await parseError(res); return (await res.json()) as AvatarUploadResponse; }, completeOnboarding: (body: OnboardingRequest) => apiFetch("/users/me/onboarding", { method: "POST", body }), preferences: () => api.get("/users/me/preferences"), updatePreferences: (body: UserPreferencesPatchRequest) => apiFetch("/users/me/preferences", { method: "PATCH", body }), voicePresets: () => api.get("/users/me/voice-presets"), supportTickets: () => api.get("/users/support-tickets"), prepostMeasures: (pilotId = "phase3-pilot-draft") => api.get( `/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`, ), upsertPrepostMeasure: (body: UserPrepostMeasureRequest) => apiFetch("/users/me/prepost-measures", { method: "PUT", body }), }; export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">; export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">; export const adminEngineApi = { get: () => api.get("/admin/engine-config"), update: (body: AdminEngineConfigPatchRequest) => apiFetch("/admin/engine-config", { method: "PATCH", body }), };