feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

250
apps/web/src/lib/api.ts Normal file
View file

@ -0,0 +1,250 @@
/* =====================================================================
Vignette API (fetch wrapper + SSE )
계약: apps/api/app/routes (auth.py, sessions.py).
- credentials:"include" (BFF __Host-vignette_sid HttpOnly ).
- ApiError .
- SSE: GET /sessions/{id}/stream token/done/ping/error .
===================================================================== */
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
const API_BASE: string =
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
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<string, string>;
/** 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}`;
}
async function parseError(res: Response): Promise<ApiError> {
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<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", body, headers = {}, signal } = options;
const finalHeaders: Record<string, string> = {
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: <T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">) =>
apiFetch<T>(path, { ...opts, method: "GET" }),
post: <T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method">) =>
apiFetch<T>(path, { ...opts, method: "POST", body }),
put: <T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method">) =>
apiFetch<T>(path, { ...opts, method: "PUT", body }),
del: <T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">) =>
apiFetch<T>(path, { ...opts, method: "DELETE" }),
};
/* =====================================================================
(apps/api )
===================================================================== */
/** GET /auth/me — auth.py MeResponse */
export interface MeResponse {
user_id: string;
role: string; // "learner" | "teacher" | "admin"
cohort_ids: string[];
}
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
/** POST /sessions — sessions.py SessionStartResponse */
export interface SessionStartResponse {
session_id: string;
case_id: string;
session_no: number;
stage: SessionStage;
recall_summary: string | null;
}
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export interface TurnResponse {
turn_seq: number;
stage: SessionStage;
effective_openness: number;
client_reply: string | null;
safety_flagged: boolean;
}
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
export interface SessionEndResponse {
session_id: string;
session_no: number;
digest_pending: boolean;
}
/* =====================================================================
SSE GET /sessions/{id}/stream
(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
EventSource same-origin BFF .
===================================================================== */
export interface SessionStreamHandlers {
/** 내담자 AI 토큰 1조각 */
onToken?: (chunk: string) => void;
/** 스트림 정상 종료 */
onDone?: (data: { session_id: string }) => void;
/** 안전(위기) 신호 */
onSafety?: (data: unknown) => void;
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
onError?: (err: { detail: string }) => void;
/** keep-alive ping (Cloudflare 타임아웃 회피용 heartbeat) */
onPing?: () => void;
}
export interface SessionStreamHandle {
/** 스트림 종료(EventSource close) */
close: () => void;
}
function safeParse(data: string): unknown {
try {
return JSON.parse(data);
} catch {
return data;
}
}
/**
* AI SSE .
* @returns close() . close .
*/
export function openSessionStream(
sessionId: string,
handlers: SessionStreamHandlers,
): SessionStreamHandle {
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`);
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요.
const es = new EventSource(url, { withCredentials: true });
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로
// MessageEvent 로 안전하게 좁힌다(.data 접근).
const dataOf = (ev: Event): string | undefined =>
(ev as MessageEvent).data as string | undefined;
es.addEventListener("token", (ev: Event) => {
const data = dataOf(ev);
if (data != null) handlers.onToken?.(data);
});
es.addEventListener("done", (ev: Event) => {
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
es.close();
});
es.addEventListener("safety", (ev: Event) => {
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
});
es.addEventListener("ping", () => {
handlers.onPing?.();
});
es.addEventListener("error", (ev: Event) => {
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
const data = dataOf(ev);
if (data) {
const parsed = safeParse(data) as { detail?: string };
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
} else if (es.readyState === EventSource.CLOSED) {
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
}
});
return {
close: () => es.close(),
};
}
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
export const sessionApi = {
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
end: (sessionId: string) =>
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
stream: openSessionStream,
};