feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -4,6 +4,26 @@
*/
export interface paths {
"/admin/engine-capabilities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Engine Capabilities
* @description Return gateway-discovered models and reasoning levels for one provider.
*/
get: operations["get_engine_capabilities_admin_engine_capabilities_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/engine-config": {
parameters: {
query?: never;
@ -1420,6 +1440,8 @@ export interface components {
engine_url?: string | null;
/** Model */
model?: string | null;
/** Reasoning Effort */
reasoning_effort?: string | null;
};
/** AdminEngineConfigResponse */
AdminEngineConfigResponse: {
@ -1428,12 +1450,17 @@ export interface components {
* @default false
*/
durable: boolean;
/** Engine Mode */
engine_mode: string;
/**
* Engine Mode
* @enum {string}
*/
engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "solar";
/** Engine Url */
engine_url: string;
/** Model */
model: string;
/** Reasoning Effort */
reasoning_effort?: ("low" | "medium" | "high" | "xhigh" | "max" | "ultra") | null;
/**
* Source
* @default runtime_default
@ -2151,6 +2178,55 @@ export interface components {
*/
role: "learner" | "teacher" | "admin";
};
/** EngineCapabilitiesResponse */
EngineCapabilitiesResponse: {
/** Available */
available: boolean;
/** Default Model */
default_model?: string | null;
/** Default Reasoning Effort */
default_reasoning_effort?: ("low" | "medium" | "high" | "xhigh" | "max" | "ultra") | null;
/**
* Detail
* @default
*/
detail: string;
/** Fetched At */
fetched_at: number;
/** Models */
models?: components["schemas"]["EngineModelOption"][];
/**
* Provider
* @enum {string}
*/
provider: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "solar";
/**
* Source
* @enum {string}
*/
source: "live_cli" | "live_api" | "static_cli" | "unavailable";
};
/** EngineModelOption */
EngineModelOption: {
/** Default Reasoning Effort */
default_reasoning_effort?: ("low" | "medium" | "high" | "xhigh" | "max" | "ultra") | null;
/**
* Description
* @default
*/
description: string;
/** Id */
id: string;
/**
* Is Default
* @default false
*/
is_default: boolean;
/** Label */
label: string;
/** Reasoning Efforts */
reasoning_efforts?: ("low" | "medium" | "high" | "xhigh" | "max" | "ultra")[];
};
/**
* EvaluationSummary
* @description ( + deep ).
@ -3631,6 +3707,8 @@ export interface components {
supervisor_rationale?: string | null;
/** Theory Mode */
theory_mode?: string | null;
/** Turn Valence */
turn_valence?: components["schemas"]["TurnValencePoint"][];
/**
* Turns Evaluated
* @default 0
@ -4267,6 +4345,16 @@ export interface components {
/** Turn Seq */
turn_seq: number;
};
/**
* TurnValencePoint
* @description deep-loop seq(1-based) + v(1~+1).
*/
TurnValencePoint: {
/** Seq */
seq: number;
/** V */
v: number;
};
/** UserPreferencesPatch */
UserPreferencesPatch: {
notifications?: components["schemas"]["NotificationPreferences"] | null;
@ -4633,6 +4721,42 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
get_engine_capabilities_admin_engine_capabilities_get: {
parameters: {
query?: {
engine_mode?: string | null;
engine_url?: string | null;
force?: boolean;
};
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EngineCapabilitiesResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_engine_config_admin_engine_config_get: {
parameters: {
query?: never;

View file

@ -472,12 +472,17 @@ export const sessionApi = {
return { available: false, reason: "voice health check failed" };
}
},
speakClientTurn: async (sessionId: string, turnSeq: number): Promise<Blob> => {
speakClientTurn: async (
sessionId: string,
turnSeq: number,
signal?: AbortSignal,
): Promise<Blob> => {
const r = await fetch(apiUrl("/voice/speech"), {
method: "POST",
credentials: "include",
signal,
headers: {
Accept: "audio/mpeg",
Accept: "audio/*",
"Content-Type": "application/json",
},
body: JSON.stringify({ session_id: sessionId, turn_seq: turnSeq }),
@ -683,9 +688,19 @@ export const userApi = {
export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;
export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">;
export type EngineCapabilitiesResponse = ApiSchema<"EngineCapabilitiesResponse">;
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
const query = new URLSearchParams({ engine_mode: engineMode, force: String(force) });
if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim());
return (
api.get<EngineCapabilitiesResponse>(
`/admin/engine-capabilities?${query.toString()}`,
)
);
},
update: (body: AdminEngineConfigPatchRequest) =>
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
};

View file

@ -4,14 +4,23 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { AUTH_EXPIRED_EVENT, api, apiUrl, authApi, type MeResponse } from "./api";
import {
AUTH_EXPIRED_EVENT,
ApiError,
api,
apiUrl,
authApi,
type MeResponse,
} from "./api";
export type Role = "learner" | "teacher" | "admin";
export type AccountStatus = "pending" | "approved" | "suspended";
export type DesignRole = "learner" | "instructor" | "admin";
export type AuthRestoreState = "loading" | "retrying" | "ready" | "failed";
export interface AuthUser {
userId: string;
@ -34,6 +43,8 @@ export interface AuthContextValue {
user: AuthUser | null;
role: Role | null;
loading: boolean;
restoreState: AuthRestoreState;
retryRestore: () => Promise<void>;
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
refresh: () => Promise<AuthUser | null>;
@ -102,6 +113,47 @@ const DEV_NAME_BY_ROLE: Record<Role, string> = {
const AuthContext = createContext<AuthContextValue | null>(null);
const AUTH_RESTORE_TIMEOUT_MS = 5_000;
const AUTH_RESTORE_RETRY_DELAYS_MS = [0, 400, 1_000] as const;
function wait(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
}
function isSignedOut(error: unknown) {
return error instanceof ApiError && error.status === 401;
}
/**
* API가 .
* ,
* .
*/
async function restoreCurrentUser(onRetry: () => void): Promise<MeResponse> {
let lastError: unknown = new Error("auth restore failed");
for (let attempt = 0; attempt < AUTH_RESTORE_RETRY_DELAYS_MS.length; attempt += 1) {
const delayMs = AUTH_RESTORE_RETRY_DELAYS_MS[attempt];
if (delayMs > 0) {
onRetry();
await wait(delayMs);
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), AUTH_RESTORE_TIMEOUT_MS);
try {
return await api.get<MeResponse>("/auth/me", { signal: controller.signal });
} catch (error) {
lastError = error;
if (isSignedOut(error)) throw error;
} finally {
window.clearTimeout(timeout);
}
}
throw lastError;
}
function userFromMe(me: MeResponse): AuthUser {
const onboarding = me as MeResponse & { onboarding_completed_at?: number | null };
const nickname = (me.nickname ?? "").trim();
@ -127,11 +179,14 @@ function userFromMe(me: MeResponse): AuthUser {
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [restoreState, setRestoreState] = useState<AuthRestoreState>("loading");
const initialRestoreStarted = useRef(false);
useEffect(() => {
const handleAuthExpired = () => {
setUser(null);
setLoading(false);
setRestoreState("ready");
};
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
return () => {
@ -139,23 +194,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
if (alive) setUser(userFromMe(me));
} catch {
if (alive) setUser(null);
} finally {
if (alive) setLoading(false);
const retryRestore = useCallback(async () => {
setLoading(true);
setRestoreState("loading");
try {
const me = await restoreCurrentUser(() => setRestoreState("retrying"));
setUser(userFromMe(me));
setRestoreState("ready");
} catch (error) {
if (isSignedOut(error)) {
setUser(null);
setRestoreState("ready");
} else {
setRestoreState("failed");
}
})();
return () => {
alive = false;
};
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (initialRestoreStarted.current) return;
initialRestoreStarted.current = true;
void retryRestore();
}, [retryRestore]);
useEffect(() => {
const body = document.body;
if (user) body.setAttribute("data-role", designRoleOf(user.role));
@ -210,13 +273,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user,
role: user?.role ?? null,
loading,
restoreState,
retryRestore,
login,
logout,
refresh,
acceptConsent,
withdrawConsent,
}),
[user, loading, login, logout, refresh, acceptConsent, withdrawConsent],
[
user,
loading,
restoreState,
retryRestore,
login,
logout,
refresh,
acceptConsent,
withdrawConsent,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -0,0 +1,42 @@
const RECOVERY_KEY_PREFIX = "vignette:chunk-recovery:";
type ChunkRecoveryWindow = Window & {
__vignetteChunkRecoveryInstalled?: boolean;
};
function recoveryKey(pathname: string) {
return `${RECOVERY_KEY_PREFIX}${pathname}`;
}
/**
* Cloudflare Pages Vite
* . ErrorBoundary로 .
*/
export function installChunkRecovery() {
const recoveryWindow = window as ChunkRecoveryWindow;
if (recoveryWindow.__vignetteChunkRecoveryInstalled) return;
recoveryWindow.__vignetteChunkRecoveryInstalled = true;
window.addEventListener("vite:preloadError", (event) => {
const key = recoveryKey(window.location.pathname);
try {
if (window.sessionStorage.getItem(key)) return;
window.sessionStorage.setItem(key, new Date().toISOString());
} catch {
// 재시도 상태를 보존할 수 없으면 무한 reload 대신 원래 오류 경계로 보낸다.
return;
}
event.preventDefault();
window.location.reload();
});
}
/** 라우트 청크가 정상 렌더된 뒤에만 같은 경로의 재시도 잠금을 해제한다. */
export function clearChunkRecoveryMarker(pathname: string) {
try {
window.sessionStorage.removeItem(recoveryKey(pathname));
} catch {
// storage 접근 불가 환경에서도 정상 렌더는 유지한다.
}
}

View file

@ -4,13 +4,23 @@ export const THEME_KEY = "vignette.theme";
const themeListeners = new Set<() => void>();
export function readInitialTheme(): AppTheme {
// 1순위: 사용자가 토글로 직접 고른 저장값. 한 번이라도 바꿨으면 항상 이 값이 이긴다.
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "light" || saved === "dark") return saved;
} catch {
/* localStorage 접근 불가 환경에서는 기본값 사용 */
/* localStorage 접근 불가 환경에서는 아래 OS 설정 판단으로 넘어간다 */
}
// 이번 시각 검증은 어두운 훈련 화면을 기본 표면으로 삼는다. 사용자가 바꾸면 저장값을 우선한다.
// 2순위: 저장값이 없는 첫 진입이면 OS(브라우저) 색 구성 설정을 따른다.
// 앱이 임의로 다크를 강요하지 않고 사용자가 이미 표명한 선호를 존중하기 위함이다.
try {
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
} catch {
/* matchMedia 미지원/예외 환경은 아래 폴백 사용 */
}
// 3순위: OS 설정을 알 수 없을 때의 기존 폴백. 어두운 훈련 화면을 기본 표면으로 삼는다.
return "dark";
}