관리자 설정 로딩과 부트 진단 복구

This commit is contained in:
Yun Chan 2026-09-22 23:06:54 +09:00
parent d58eb67764
commit fb640a75ac
7 changed files with 767 additions and 100 deletions

View file

@ -920,6 +920,10 @@ test.describe("admin route guards", () => {
await expect(page.getByTestId("auth-restore-failed")).toBeVisible(); await expect(page.getByTestId("auth-restore-failed")).toBeVisible();
await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible(); await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible();
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
// 관리자 auth 복구 UI(.vg-route-error)는 3.5초/8초 watchdog 뒤에도
// 정상적인 오류 복구 화면으로 남고 부트 진단과 겹치지 않는다.
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await page.getByTestId("auth-restore-retry").click(); await page.getByTestId("auth-restore-retry").click();
@ -1073,7 +1077,22 @@ test.describe("admin route guards", () => {
await expect(page.locator(".aic-chart__day").last()).toContainText("≤$3.56"); await expect(page.locator(".aic-chart__day").last()).toContainText("≤$3.56");
await expect(page.locator(".aic-cache-score")).toContainText("80%"); await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); expect(
await page
.getByLabel("AI 엔진 공급자")
.locator("option")
.evaluateAll((options) => options.map((option) => option.value)),
).toEqual([
"claude_cli",
"claude_api",
"codex_cli",
"codex_api",
"agy_cli",
"antigravity_api",
"openai",
"openrouter",
"solar",
]);
const usageRequest = page.waitForRequest((request) => const usageRequest = page.waitForRequest((request) =>
request.url().includes("/api/admin/usage?window_days=7"), request.url().includes("/api/admin/usage?window_days=7"),

View file

@ -86,6 +86,24 @@ interface AdminEngineConfigResponse {
updated_at: number | null; updated_at: number | null;
} }
interface EngineCapabilitiesResponse {
provider: string;
available: boolean;
source: string;
models: Array<{
id: string;
label: string;
description: string;
reasoning_efforts: string[];
default_reasoning_effort: string | null;
is_default: boolean;
}>;
default_model: string;
default_reasoning_effort: string | null;
detail: string;
fetched_at: number;
}
function usageFixture( function usageFixture(
days: number, days: number,
overrides: Partial<AdminUsageResponse> = {}, overrides: Partial<AdminUsageResponse> = {},
@ -154,9 +172,14 @@ interface AdminAiMockState {
usageForWindow: (days: number) => AdminUsageResponse; usageForWindow: (days: number) => AdminUsageResponse;
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */ /** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
usageGate?: (days: number) => Promise<void> | undefined; usageGate?: (days: number) => Promise<void> | undefined;
usageStatus?: (days: number) => number | undefined;
engineConfig: AdminEngineConfigResponse; engineConfig: AdminEngineConfigResponse;
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */ /** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
engineConfigStatus?: number; engineConfigStatus?: number;
engineConfigGate?: () => Promise<void> | undefined;
capabilityGate?: (provider: string, engineUrl: string) => Promise<void> | undefined;
capabilityStatus?: (provider: string, engineUrl: string) => number | undefined;
capabilityFor?: (provider: string, engineUrl: string) => EngineCapabilitiesResponse;
} }
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState { function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
@ -211,6 +234,11 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
const days = Number(url.searchParams.get("window_days") ?? 30); const days = Number(url.searchParams.get("window_days") ?? 30);
const gate = state.usageGate?.(days); const gate = state.usageGate?.(days);
if (gate) await gate; if (gate) await gate;
const status = state.usageStatus?.(days);
if (status) {
await fulfillJson({ detail: `usage ${days} unavailable` }, status);
return;
}
await fulfillJson(state.usageForWindow(days)); await fulfillJson(state.usageForWindow(days));
return; return;
} }
@ -238,7 +266,15 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
state.counts.capabilities += 1; state.counts.capabilities += 1;
const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode; const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode;
await fulfillJson({ const engineUrl = url.searchParams.get("engine_url") ?? state.engineConfig.engine_url;
const gate = state.capabilityGate?.(provider, engineUrl);
if (gate) await gate;
const status = state.capabilityStatus?.(provider, engineUrl);
if (status) {
await fulfillJson({ detail: `capability ${provider} unavailable` }, status);
return;
}
await fulfillJson(state.capabilityFor?.(provider, engineUrl) ?? {
provider, provider,
available: true, available: true,
source: "live_cli", source: "live_cli",
@ -262,6 +298,8 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
if (method === "GET" && path.endsWith("/admin/engine-config")) { if (method === "GET" && path.endsWith("/admin/engine-config")) {
state.counts.engine += 1; state.counts.engine += 1;
const gate = state.engineConfigGate?.();
if (gate) await gate;
if (state.engineConfigStatus) { if (state.engineConfigStatus) {
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus); await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
return; return;
@ -374,7 +412,7 @@ test.describe("full sweep: admin ai operations", () => {
}); });
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty // checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
test("shows a role=alert banner with the engine panel placeholder on load failure and clears it on refresh", async ({ test("shows an explicit engine state after an engine-config failure and recovers on refresh", async ({
page, page,
}) => { }) => {
const state = createMockState({ engineConfigStatus: 500 }); const state = createMockState({ engineConfigStatus: 500 });
@ -382,29 +420,151 @@ test.describe("full sweep: admin ai operations", () => {
await page.goto("/admin/ai"); await page.goto("/admin/ai");
// engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출. const enginePanel = page.locator(".aic-engine");
const alert = page.locator(".aic-alert[role='alert']"); await expect(enginePanel.getByRole("alert")).toContainText("engine config unavailable");
await expect(alert).toBeVisible(); await expect(
await expect(alert).toContainText("engine config unavailable"); enginePanel.getByText("현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."),
// 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다. ).toBeVisible();
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible();
await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0); await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0);
// 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침.
state.engineConfigStatus = undefined; state.engineConfigStatus = undefined;
let gate: ReturnType<typeof deferred> | null = deferred(); let gate: ReturnType<typeof deferred> | null = deferred();
state.usageGate = () => gate?.promise; state.usageGate = () => gate?.promise;
await page.getByRole("button", { name: "새로고침", exact: true }).click(); await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(alert).toHaveCount(0); await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(enginePanel.getByRole("alert")).toHaveCount(0);
gate.resolve(); gate.resolve();
gate = null; gate = null;
// 성공 응답 이후에도 배너는 없고 엔진 설정 폼이 렌더된다.
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(alert).toHaveCount(0);
}); });
test("shows engine config before a slow usage response completes", async ({ page }) => {
const usageGate = deferred();
const state = createMockState({ usageGate: () => usageGate.promise });
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "true");
usageGate.resolve();
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false");
});
test("shows a retryable Korean error when engine-config read times out", async ({ page }) => {
test.setTimeout(30_000);
const engineGate = deferred();
const state = createMockState({ engineConfigGate: () => engineGate.promise });
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(
page.locator(".aic-engine").getByText(
"AI 엔진 설정 조회 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
),
).toBeVisible({ timeout: 20_000 });
engineGate.resolve();
});
test("keeps the latest provider selection when an earlier capability request finishes late", async ({
page,
}) => {
const claudeGate = deferred();
const state = createMockState({
capabilityGate: (provider) => (provider === "claude_api" ? claudeGate.promise : undefined),
capabilityFor: (provider) => ({
provider,
available: true,
source: "live_cli",
models: [
{
id: `${provider}-model`,
label: `${provider} 모델`,
description: "테스트 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: `${provider}-model`,
default_reasoning_effort: "medium",
detail: "테스트 모델 목록",
fetched_at: 1_785_142_800,
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const provider = page.getByLabel("AI 엔진 공급자");
const model = page.getByLabel("AI 기본 모델");
await expect(model.getByRole("option", { name: "openai 모델" })).toBeAttached();
const lateCapabilityResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
url.pathname.endsWith("/admin/engine-capabilities") &&
url.searchParams.get("engine_mode") === "claude_api"
);
});
await provider.selectOption("claude_api");
await provider.selectOption("openrouter");
await expect(model).toHaveValue("openrouter-model");
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(provider).toHaveValue("openrouter");
await expect(model).toHaveValue("openrouter-model");
claudeGate.resolve();
await lateCapabilityResponse;
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
await expect(model).toHaveValue("openrouter-model");
});
test("clears prior capability models when refresh changes the engine URL and lookup fails", async ({
page,
}) => {
const state = createMockState({
capabilityFor: (provider, engineUrl) => ({
provider,
available: true,
source: "live_cli",
models: [
{
id: `model-${engineUrl}`,
label: `모델 ${engineUrl}`,
description: "테스트 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: `model-${engineUrl}`,
default_reasoning_effort: "medium",
detail: "테스트 모델 목록",
fetched_at: 1_785_142_800,
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const model = page.getByLabel("AI 기본 모델");
await expect(model.getByRole("option", { name: "모델 http://127.0.0.1:9099" })).toBeAttached();
state.engineConfig = engineConfigFixture({
engine_url: "http://127.0.0.1:9100",
model: "restored-model",
});
state.capabilityStatus = () => 500;
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9100");
await expect(model).toHaveValue("restored-model");
await expect(model.getByRole("option", { name: "모델 http://127.0.0.1:9099" })).toHaveCount(0);
await expect(page.locator(".aic-engine").getByRole("alert")).toContainText(
"capability openai unavailable",
);
});
// checklist: admin-ai-window-toggle // checklist: admin-ai-window-toggle
test("refetches only usage on window change and ignores a stale late response", async ({ test("refetches only usage on window change and ignores a stale late response", async ({
page, page,
@ -457,6 +617,30 @@ test.describe("full sweep: admin ai operations", () => {
expect(state.counts.capabilities).toBe(1); expect(state.counts.capabilities).toBe(1);
}); });
test("does not let a superseded usage response clear a newer usage error", async ({ page }) => {
const slowWindow = deferred();
const state = createMockState({
usageGate: (days) => (days === 90 ? slowWindow.promise : undefined),
usageStatus: (days) => (days === 7 ? 500 : undefined),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const staleResponse = page.waitForResponse((response) =>
response.url().includes("/api/admin/usage?window_days=90"),
);
await page.getByRole("button", { name: "90일" }).click();
await page.getByRole("button", { name: "7일" }).click();
const alert = page.getByRole("alert").filter({ hasText: "usage 7 unavailable" });
await expect(alert).toContainText("usage 7 unavailable");
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false");
slowWindow.resolve();
await staleResponse;
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
await expect(alert).toContainText("usage 7 unavailable");
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false");
});
// checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel // checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel
test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({ test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({
page, page,

View file

@ -232,9 +232,15 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await page.goto("/learn"); await page.goto("/learn");
await expect(page.getByText(BOOT_SCREEN_TEXT)).toBeVisible(); const bootScreen = page.getByText(BOOT_SCREEN_TEXT);
await expect(bootScreen).toBeVisible();
await expect(bootScreen).toHaveAttribute("data-vignette-boot-loading", "true");
// 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다 // 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다
await expect(page).toHaveURL(/\/learn$/); await expect(page).toHaveURL(/\/learn$/);
// 3.5초와 8초 watchdog은 화면에 실제로 보이는 bounded BootScreen을
// 빈 React root로 오인하지 않는다.
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
releaseMe(); releaseMe();
@ -242,6 +248,154 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
}); });
test("removes a watchdog panel when the delayed boot screen becomes visible", async ({ page }) => {
test.setTimeout(15_000);
let releaseMain!: () => void;
const mainGate = new Promise<void>((resolve) => {
releaseMain = resolve;
});
let releaseMe!: () => void;
const meGate = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await page.route("**/src/main.tsx*", async (route) => {
await mainGate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await meGate;
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.goto("/learn", { waitUntil: "commit" });
await expect(page.locator("#root")).toBeEmpty();
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 4_500 });
releaseMain();
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveAttribute(
"data-vignette-boot-loading",
"true",
);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, { timeout: 1_500 });
releaseMe();
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
test("reports a boot screen that remains visible through the 20-second watchdog", async ({ page }) => {
test.setTimeout(35_000);
let releaseChunk!: () => void;
const gate = new Promise<void>((resolve) => {
releaseChunk = resolve;
});
await page.route("**/src/pages/Login.tsx*", async (route) => {
await gate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
}),
});
});
const meResolved = page.waitForResponse((response) => response.url().includes("/api/auth/me"));
await page.goto("/login", { waitUntil: "domcontentloaded" });
await meResolved;
await expect(page.getByText(BOOT_SCREEN_TEXT).filter({ visible: true })).toHaveAttribute(
"data-vignette-boot-loading",
"true",
);
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 14_000 });
await expect(page.locator("#vignette-boot-diagnostic")).toHaveAttribute(
"data-vignette-boot-reason",
"watchdog",
);
releaseChunk();
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
test("keeps manual and runtime diagnostics when a delayed boot screen appears after watchdog", async ({ page }) => {
test.setTimeout(15_000);
let releaseMain!: () => void;
const mainGate = new Promise<void>((resolve) => {
releaseMain = resolve;
});
let releaseMe!: () => void;
const meGate = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await page.route("**/src/main.tsx*", async (route) => {
await mainGate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await meGate;
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.goto("/learn", { waitUntil: "commit" });
const diagnostic = page.locator("#vignette-boot-diagnostic");
await expect(diagnostic).toBeVisible({ timeout: 4_500 });
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "watchdog");
await page.evaluate(() => {
(window as Window & { __VIGNETTE_BOOT_DIAG__?: { check(): void } }).__VIGNETTE_BOOT_DIAG__?.check();
});
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "manual");
await page.evaluate(() => {
window.dispatchEvent(new ErrorEvent("error", { message: "boot diagnostic runtime fixture" }));
});
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "runtime-error");
releaseMain();
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveAttribute(
"data-vignette-boot-loading",
"true",
);
await page.waitForTimeout(250);
await expect(diagnostic).toBeVisible();
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "runtime-error");
await expect(diagnostic.locator("dd").first()).toHaveText("runtime-error");
releaseMe();
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
// checklist: shell-suspense-fallback // checklist: shell-suspense-fallback
// 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안 // 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안
// Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현. // Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현.
@ -254,7 +408,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await gate; await gate;
await route.continue(); await route.continue();
}); });
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
}),
});
});
const meResolved = page.waitForResponse( const meResolved = page.waitForResponse(
(res) => res.url().includes("/api/auth/me"), (res) => res.url().includes("/api/auth/me"),
{ timeout: 15_000 }, { timeout: 15_000 },
@ -285,7 +467,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
page.on("request", (request) => { page.on("request", (request) => {
if (request.isNavigationRequest()) documentRequests += 1; if (request.isNavigationRequest()) documentRequests += 1;
}); });
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
}),
});
});
await page.goto("/login"); await page.goto("/login");
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
expect(documentRequests).toBe(1); expect(documentRequests).toBe(1);

View file

@ -127,6 +127,13 @@
); );
return nodes.filter(isVisiblyRendered).length; return nodes.filter(isVisiblyRendered).length;
} }
function hasVisibleBootLoading(root) {
if (!root) return false;
var nodes = Array.prototype.slice.call(
root.querySelectorAll("[data-vignette-boot-loading]"),
);
return nodes.some(isVisiblyRendered);
}
function appHealth() { function appHealth() {
var root = document.getElementById("root"); var root = document.getElementById("root");
var path = window.location.pathname; var path = window.location.pathname;
@ -138,7 +145,13 @@
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']", "[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
) )
: null; : null;
var hasAdminContent = !isAdmin || isVisiblyRendered(adminContent); var adminRecovery = root
? root.querySelector(
".vg-route-error[data-testid='auth-restore-retrying'],.vg-route-error[data-testid='auth-restore-failed']",
)
: null;
var hasAdminContent =
!isAdmin || isVisiblyRendered(adminContent) || isVisiblyRendered(adminRecovery);
return { return {
healthy: Boolean( healthy: Boolean(
root && root &&
@ -148,13 +161,26 @@
hasAdminContent, hasAdminContent,
), ),
visible: visible, visible: visible,
bootLoading: hasVisibleBootLoading(root),
}; };
} }
function dismissDiagnosticIfHealthy() { function dismissDiagnosticIfHealthy() {
if (!appHealth().healthy) return false;
var panel = document.getElementById("vignette-boot-diagnostic"); var panel = document.getElementById("vignette-boot-diagnostic");
if (panel) panel.remove(); var health = appHealth();
return true; if (health.healthy) {
if (panel) panel.remove();
return true;
}
if (
panel &&
panel.getAttribute("data-vignette-boot-reason") === "watchdog" &&
health.bootLoading &&
Date.now() - loadedAt < 20000
) {
panel.remove();
return true;
}
return false;
} }
function scheduleHealthyCheck() { function scheduleHealthyCheck() {
if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer); if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer);
@ -241,6 +267,13 @@
var path = window.location.pathname; var path = window.location.pathname;
var text = (document.body && document.body.innerText ? document.body.innerText : "").trim(); var text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
var health = appHealth(); var health = appHealth();
if (
reason === "watchdog" &&
health.bootLoading &&
Date.now() - loadedAt < 20000
) {
return;
}
if (health.healthy && reason !== "manual") { if (health.healthy && reason !== "manual") {
if (reason !== "watchdog") { if (reason !== "watchdog") {
sendDiagnostic(reason, text.length, health.visible); sendDiagnostic(reason, text.length, health.visible);
@ -265,12 +298,22 @@
"position:fixed;z-index:2147483647;inset:24px auto auto 24px;max-width:min(760px,calc(100vw - 48px));padding:18px;border:1px solid #d59b47;border-radius:8px;background:#fffaf0;color:#21312e;box-shadow:0 18px 48px rgba(10,20,18,.18);font:13px/1.5 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;white-space:normal;"; "position:fixed;z-index:2147483647;inset:24px auto auto 24px;max-width:min(760px,calc(100vw - 48px));padding:18px;border:1px solid #d59b47;border-radius:8px;background:#fffaf0;color:#21312e;box-shadow:0 18px 48px rgba(10,20,18,.18);font:13px/1.5 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;white-space:normal;";
document.body.appendChild(panel); document.body.appendChild(panel);
} }
var currentReason = panel.getAttribute("data-vignette-boot-reason");
var effectiveReason = reason;
if (
reason !== "watchdog" ||
(currentReason !== "runtime-error" && currentReason !== "manual")
) {
panel.setAttribute("data-vignette-boot-reason", reason);
} else {
effectiveReason = currentReason;
}
var lines = [ var lines = [
"<b style=\"display:block;font-size:18px;margin-bottom:6px\">Vignette 화면 진단</b>", "<b style=\"display:block;font-size:18px;margin-bottom:6px\">Vignette 화면 진단</b>",
"<div>React 화면이 정상 콘텐츠를 표시하지 못해서 HTML 부트스트랩 진단을 띄웠습니다.</div>", "<div>React 화면이 정상 콘텐츠를 표시하지 못해서 HTML 부트스트랩 진단을 띄웠습니다.</div>",
"<dl style=\"display:grid;grid-template-columns:132px minmax(0,1fr);gap:0;margin:12px 0 0;border:1px solid rgba(0,0,0,.12);border-radius:8px;overflow:hidden\">", "<dl style=\"display:grid;grid-template-columns:132px minmax(0,1fr);gap:0;margin:12px 0 0;border:1px solid rgba(0,0,0,.12);border-radius:8px;overflow:hidden\">",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">reason</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" + "<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">reason</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
reason + effectiveReason +
"</dd>", "</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">path</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" + "<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">path</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
path + path +
@ -320,6 +363,9 @@
window.setTimeout(function () { window.setTimeout(function () {
render("watchdog"); render("watchdog");
}, 8000); }, 8000);
window.setTimeout(function () {
render("watchdog");
}, 20000);
})(); })();
</script> </script>
</head> </head>

View file

@ -66,6 +66,8 @@ const Settings = lazy(() => import("./pages/Settings"));
function BootScreen() { function BootScreen() {
return ( return (
<div <div
role="status"
data-vignette-boot-loading="true"
style={{ style={{
minHeight: "100dvh", minHeight: "100dvh",
display: "grid", display: "grid",

View file

@ -616,6 +616,7 @@ export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">; export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">; export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">; export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
type AdminReadOptions = Pick<RequestOptions, "signal">;
export type AdminTicketFilters = { export type AdminTicketFilters = {
status?: AdminSupportTicket["status"] | ""; status?: AdminSupportTicket["status"] | "";
category?: AdminSupportTicket["category"] | ""; category?: AdminSupportTicket["category"] | "";
@ -628,8 +629,9 @@ export type AdminTicketFilters = {
}; };
export const adminApi = { export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"), health: (options?: AdminReadOptions) => api.get<AdminHealthResponse>("/admin/health", options),
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`), usage: (windowDays = 7, options?: AdminReadOptions) =>
api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`, options),
uptime: (windowHours = 24) => uptime: (windowHours = 24) =>
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`), api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
tickets: (filters: AdminTicketFilters = {}) => { tickets: (filters: AdminTicketFilters = {}) => {
@ -828,14 +830,19 @@ export type AdminProviderOAuthStartResponse =
ApiSchema<"AdminProviderOAuthStartResponse">; ApiSchema<"AdminProviderOAuthStartResponse">;
export const adminEngineApi = { export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"), get: (options?: AdminReadOptions) =>
capabilities: (engineMode: string, force = false, engineUrl?: string) => { api.get<AdminEngineConfigResponse>("/admin/engine-config", options),
capabilities: (
engineMode: string,
force = false,
engineUrl?: string,
options?: AdminReadOptions,
) => {
const query = new URLSearchParams({ engine_mode: engineMode, force: String(force) }); const query = new URLSearchParams({ engine_mode: engineMode, force: String(force) });
if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim()); if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim());
return ( return api.get<EngineCapabilitiesResponse>(
api.get<EngineCapabilitiesResponse>( `/admin/engine-capabilities?${query.toString()}`,
`/admin/engine-capabilities?${query.toString()}`, options,
)
); );
}, },
update: (body: AdminEngineConfigPatchRequest) => update: (body: AdminEngineConfigPatchRequest) =>

View file

@ -188,11 +188,33 @@ function healthStatusLabel(status?: string): string {
return "확인 불가"; return "확인 불가";
} }
const ADMIN_READ_TIMEOUT_MS = 15_000;
const CAPABILITIES_READ_TIMEOUT_MS = 25_000;
class AdminReadTimeoutError extends Error {
constructor(label: string) {
super(`${label} 조회 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.`);
this.name = "AdminReadTimeoutError";
}
}
function adminReadError(cause: unknown, label: string): string {
if (cause instanceof AdminReadTimeoutError) return cause.message;
if (cause instanceof Error) return cause.message;
return `${label}을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.`;
}
function capabilitiesScope(engineMode: string, engineUrl: string): string {
return `${engineMode}\u0000${engineUrl.trim()}`;
}
export default function AdminAi() { export default function AdminAi() {
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30); const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
const [usage, setUsage] = useState<AdminUsageResponse | null>(null); const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [health, setHealth] = useState<AdminHealthResponse | null>(null); const [health, setHealth] = useState<AdminHealthResponse | null>(null);
const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null); const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null);
const [engineLoading, setEngineLoading] = useState(true);
const [engineError, setEngineError] = useState<string | null>(null);
const [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null); const [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null); const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
@ -200,100 +222,257 @@ export default function AdminAi() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const initialLoad = useRef(true); const mounted = useRef(false);
const loadGeneration = useRef(0);
const displayRequestId = useRef(0);
const usageRequestId = useRef(0);
const healthRequestId = useRef(0);
const capabilityRequestId = useRef(0); const capabilityRequestId = useRef(0);
const engineDraftRevision = useRef(0);
const loadedEngineRevision = useRef(0);
const loadedCapabilitiesScope = useRef<string | null>(null);
const activeControllers = useRef(new Set<AbortController>());
const loadRef = useRef<() => Promise<void>>(async () => undefined);
const loadedWindowDays = useRef(windowDays);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
loadGeneration.current += 1;
capabilityRequestId.current += 1;
for (const controller of activeControllers.current) controller.abort();
activeControllers.current.clear();
};
}, []);
const runTimedRead = useCallback(
async <T,>(
timeoutMs: number,
label: string,
request: (signal: AbortSignal) => Promise<T>,
): Promise<T> => {
const controller = new AbortController();
activeControllers.current.add(controller);
let timedOut = false;
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await request(controller.signal);
} catch (cause) {
if (timedOut) throw new AdminReadTimeoutError(label);
throw cause;
} finally {
window.clearTimeout(timeout);
activeControllers.current.delete(controller);
}
},
[],
);
const updateEngineDraft = useCallback(
(updater: (current: AdminEngineConfigResponse) => AdminEngineConfigResponse): number => {
const revision = ++engineDraftRevision.current;
setEngine((current) => (current ? updater(current) : current));
return revision;
},
[],
);
const loadCapabilities = useCallback( const loadCapabilities = useCallback(
async ( async (
engineMode: string, engineMode: string,
engineUrl: string, engineUrl: string,
options: { force?: boolean; applyDefault?: boolean } = {}, options: { force?: boolean; applyDefault?: boolean; draftRevision?: number } = {},
) => { ) => {
const requestId = ++capabilityRequestId.current; const requestId = ++capabilityRequestId.current;
const draftRevision = options.draftRevision ?? engineDraftRevision.current;
const scope = capabilitiesScope(engineMode, engineUrl);
setCapabilitiesLoading(true); setCapabilitiesLoading(true);
setCapabilitiesError(null); setCapabilitiesError(null);
try { try {
const next = await adminEngineApi.capabilities( const next = await runTimedRead(
engineMode, CAPABILITIES_READ_TIMEOUT_MS,
options.force ?? false, "모델 목록",
engineUrl, (signal) =>
adminEngineApi.capabilities(engineMode, options.force ?? false, engineUrl, { signal }),
); );
if (requestId !== capabilityRequestId.current) return; if (
!mounted.current ||
requestId !== capabilityRequestId.current ||
draftRevision !== engineDraftRevision.current
) {
return;
}
setCapabilities(next); setCapabilities(next);
loadedCapabilitiesScope.current = scope;
if (options.applyDefault && next.available && next.default_model) { if (options.applyDefault && next.available && next.default_model) {
setEngine((current) => { setEngine((current) => {
if (!current || current.engine_mode !== engineMode) return current; if (!current || current.engine_mode !== engineMode) return current;
return { return {
...current, ...current,
model: next.default_model ?? "", model: next.default_model ?? "",
reasoning_effort: next.default_reasoning_effort, reasoning_effort: next.default_reasoning_effort ?? null,
}; };
}); });
} }
} catch (cause) { } catch (cause) {
if (requestId !== capabilityRequestId.current) return; if (
setCapabilities(null); !mounted.current ||
setCapabilitiesError( requestId !== capabilityRequestId.current ||
cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.", draftRevision !== engineDraftRevision.current
); ) {
return;
}
setCapabilitiesError(adminReadError(cause, "모델 목록"));
} finally { } finally {
if (requestId === capabilityRequestId.current) setCapabilitiesLoading(false); if (mounted.current && requestId === capabilityRequestId.current) {
setCapabilitiesLoading(false);
}
} }
}, },
[], [runTimedRead],
);
const loadUsage = useCallback(
async (days: number): Promise<{ requestId: number; error: string | null }> => {
const requestId = ++usageRequestId.current;
try {
const next = await runTimedRead(
ADMIN_READ_TIMEOUT_MS,
"AI 사용량",
(signal) => adminApi.usage(days, { signal }),
);
if (!mounted.current || requestId !== usageRequestId.current) {
return { requestId, error: null };
}
setUsage(next);
return { requestId, error: null };
} catch (cause) {
if (!mounted.current || requestId !== usageRequestId.current) {
return { requestId, error: null };
}
return { requestId, error: adminReadError(cause, "AI 사용량") };
}
},
[runTimedRead],
);
const loadHealth = useCallback(
async (): Promise<{ requestId: number; error: string | null }> => {
const requestId = ++healthRequestId.current;
try {
const next = await runTimedRead(
ADMIN_READ_TIMEOUT_MS,
"엔진 상태",
(signal) => adminApi.health({ signal }),
);
if (!mounted.current || requestId !== healthRequestId.current) {
return { requestId, error: null };
}
setHealth(next);
return { requestId, error: null };
} catch (cause) {
if (!mounted.current || requestId !== healthRequestId.current) {
return { requestId, error: null };
}
return { requestId, error: adminReadError(cause, "엔진 상태") };
}
},
[runTimedRead],
); );
const load = useCallback(async () => { const load = useCallback(async () => {
const generation = ++loadGeneration.current;
const displayRequest = ++displayRequestId.current;
const draftRevision = engineDraftRevision.current;
capabilityRequestId.current += 1;
setCapabilitiesLoading(false);
setLoading(true); setLoading(true);
setError(null); setError(null);
try { setEngineLoading(true);
const [usageResult, healthResult, engineResult] = await Promise.allSettled([ setEngineError(null);
adminApi.usage(windowDays),
adminApi.health(),
adminEngineApi.get(),
]);
if (usageResult.status === "fulfilled") setUsage(usageResult.value);
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
if (engineResult.status === "fulfilled") {
setEngine(engineResult.value);
await loadCapabilities(engineResult.value.engine_mode, engineResult.value.engine_url);
}
const failures = [usageResult, healthResult, engineResult]
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) =>
result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.",
);
if (failures.length > 0) setError([...new Set(failures)].join(" · "));
} finally {
setLoading(false);
}
}, [loadCapabilities, windowDays]);
useEffect(() => { const usagePromise = loadUsage(windowDays);
if (initialLoad.current) { const healthPromise = loadHealth();
initialLoad.current = false; void runTimedRead(
void load(); ADMIN_READ_TIMEOUT_MS,
return; "AI 엔진 설정",
} (signal) => adminEngineApi.get({ signal }),
let active = true; )
setLoading(true); .then((next) => {
setError(null); if (
void adminApi.usage(windowDays) !mounted.current ||
.then((nextUsage) => { generation !== loadGeneration.current ||
if (active) setUsage(nextUsage); draftRevision !== engineDraftRevision.current ||
draftRevision !== loadedEngineRevision.current
) {
return;
}
const scope = capabilitiesScope(next.engine_mode, next.engine_url);
if (loadedCapabilitiesScope.current !== scope) {
setCapabilities(null);
setCapabilitiesError(null);
}
setEngine(next);
loadedEngineRevision.current = draftRevision;
setEngineError(null);
void loadCapabilities(next.engine_mode, next.engine_url, { draftRevision });
}) })
.catch((cause) => { .catch((cause) => {
if (active) { if (!mounted.current || generation !== loadGeneration.current) return;
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다."); setEngineError(adminReadError(cause, "AI 엔진 설정"));
}
}) })
.finally(() => { .finally(() => {
if (active) setLoading(false); if (mounted.current && generation === loadGeneration.current) setEngineLoading(false);
}); });
return () => {
active = false; const [usageResult, healthResult] = await Promise.all([usagePromise, healthPromise]);
}; if (
}, [windowDays]); !mounted.current ||
generation !== loadGeneration.current ||
displayRequest !== displayRequestId.current ||
usageResult.requestId !== usageRequestId.current ||
healthResult.requestId !== healthRequestId.current
) {
return;
}
const errors = [usageResult.error, healthResult.error].filter((item): item is string => Boolean(item));
setError(errors.length ? [...new Set(errors)].join(" · ") : null);
setLoading(false);
}, [loadCapabilities, loadHealth, loadUsage, runTimedRead, windowDays]);
useEffect(() => {
loadRef.current = load;
}, [load]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadRef.current();
}, 0);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
if (windowDays === loadedWindowDays.current) return;
loadedWindowDays.current = windowDays;
const displayRequest = ++displayRequestId.current;
setLoading(true);
setError(null);
void loadUsage(windowDays).then((usageResult) => {
if (
!mounted.current ||
displayRequest !== displayRequestId.current ||
usageResult.requestId !== usageRequestId.current
) {
return;
}
setError(usageResult.error);
setLoading(false);
});
}, [loadUsage, windowDays]);
const saveEngine = async () => { const saveEngine = async () => {
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return; if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
@ -307,9 +486,11 @@ export default function AdminAi() {
model: engine.model.trim(), model: engine.model.trim(),
reasoning_effort: engine.reasoning_effort, reasoning_effort: engine.reasoning_effort,
}); });
setEngine(next); const savedRevision = updateEngineDraft(() => next);
loadedEngineRevision.current = savedRevision;
setSaved(true); setSaved(true);
setHealth(await adminApi.health().catch(() => health)); const healthResult = await loadHealth();
if (healthResult.error) setError(healthResult.error);
window.setTimeout(() => setSaved(false), 2400); window.setTimeout(() => setSaved(false), 2400);
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다."); setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
@ -317,7 +498,6 @@ export default function AdminAi() {
setSaving(false); setSaving(false);
} }
}; };
const usageCostBasis = readCostBasis(usage); const usageCostBasis = readCostBasis(usage);
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis); const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
const daily = usage?.daily_cost ?? []; const daily = usage?.daily_cost ?? [];
@ -710,6 +890,14 @@ export default function AdminAi() {
{healthStatusLabel(engineService?.status)} {healthStatusLabel(engineService?.status)}
</span> </span>
</div> </div>
{engineError ? (
<div className="aic-capability is-unavailable" role="alert">
<div>
<b>엔진 설정을 확인할 수 없음</b>
<span>{engineError}</span>
</div>
</div>
) : null}
{engine ? ( {engine ? (
<> <>
<Field <Field
@ -726,15 +914,17 @@ export default function AdminAi() {
const engineMode = event.target const engineMode = event.target
.value as AdminEngineConfigResponse["engine_mode"]; .value as AdminEngineConfigResponse["engine_mode"];
setCapabilities(null); setCapabilities(null);
setEngine({ loadedCapabilitiesScope.current = null;
...engine, const draftRevision = updateEngineDraft((current) => ({
...current,
engine_mode: engineMode, engine_mode: engineMode,
model: "", model: "",
reasoning_effort: null, reasoning_effort: null,
}); }));
void loadCapabilities(engineMode, engine.engine_url, { void loadCapabilities(engineMode, engine.engine_url, {
force: true, force: true,
applyDefault: true, applyDefault: true,
draftRevision,
}); });
}} }}
> >
@ -750,8 +940,12 @@ export default function AdminAi() {
id="aic-engine-url" id="aic-engine-url"
value={engine.engine_url} value={engine.engine_url}
onChange={(event) => { onChange={(event) => {
setEngine({ ...engine, engine_url: event.target.value }); updateEngineDraft((current) => ({
...current,
engine_url: event.target.value,
}));
setCapabilities(null); setCapabilities(null);
loadedCapabilitiesScope.current = null;
setCapabilitiesError( setCapabilitiesError(
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.", "연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
); );
@ -771,11 +965,11 @@ export default function AdminAi() {
onChange={(event) => { onChange={(event) => {
const model = event.target.value; const model = event.target.value;
const option = capabilityModels.find((item) => item.id === model); const option = capabilityModels.find((item) => item.id === model);
setEngine({ updateEngineDraft((current) => ({
...engine, ...current,
model, model,
reasoning_effort: option?.default_reasoning_effort ?? null, reasoning_effort: option?.default_reasoning_effort ?? null,
}); }));
}} }}
aria-label="AI 기본 모델" aria-label="AI 기본 모델"
disabled={capabilitiesLoading || !capabilities?.available} disabled={capabilitiesLoading || !capabilities?.available}
@ -805,11 +999,11 @@ export default function AdminAi() {
aria-label="AI 추론 강도" aria-label="AI 추론 강도"
disabled={capabilitiesLoading || !selectedModel} disabled={capabilitiesLoading || !selectedModel}
onChange={(event) => onChange={(event) =>
setEngine({ updateEngineDraft((current) => ({
...engine, ...current,
reasoning_effort: (event.target.value || reasoning_effort: (event.target.value ||
null) as AdminEngineConfigResponse["reasoning_effort"], null) as AdminEngineConfigResponse["reasoning_effort"],
}) }))
} }
> >
{selectedEfforts.length ? null : ( {selectedEfforts.length ? null : (
@ -858,6 +1052,7 @@ export default function AdminAi() {
void loadCapabilities(engine.engine_mode, engine.engine_url, { void loadCapabilities(engine.engine_mode, engine.engine_url, {
force: true, force: true,
applyDefault: !selectedModel, applyDefault: !selectedModel,
draftRevision: engineDraftRevision.current,
}) })
} }
disabled={capabilitiesLoading} disabled={capabilitiesLoading}
@ -889,7 +1084,11 @@ export default function AdminAi() {
</div> </div>
</> </>
) : ( ) : (
<div className="aic-empty">현재 AI 엔진 설정을 불러오는 중입니다.</div> <div className="aic-empty">
{engineLoading
? "현재 AI 엔진 설정을 불러오는 중입니다."
: "현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."}
</div>
)} )}
</section> </section>