feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
42
apps/web/src/lib/chunkRecovery.ts
Normal file
42
apps/web/src/lib/chunkRecovery.ts
Normal 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 접근 불가 환경에서도 정상 렌더는 유지한다.
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue