운영 화면과 회기 리뷰 UI 갱신

This commit is contained in:
Yun Chan 2026-06-28 20:13:21 +09:00
parent e7ebb38177
commit 3a9f70a97b
18 changed files with 2210 additions and 137 deletions

View file

@ -53,6 +53,14 @@ interface AdminUsageBreakdown {
cost_usd: number; cost_usd: number;
} }
interface AdminUsageDailyCost {
day: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageBudget { interface AdminUsageBudget {
limit_usd: number; limit_usd: number;
used_ratio: number; used_ratio: number;
@ -60,6 +68,17 @@ interface AdminUsageBudget {
status: "disabled" | "ok" | "warn" | "exceeded"; status: "disabled" | "ok" | "warn" | "exceeded";
} }
interface AdminUsageEvaluatorCache {
enabled: boolean;
entries: number;
hits: number;
misses: number;
stores: number;
evictions: number;
requests: number;
hit_rate: number;
}
interface AdminUsageResponse { interface AdminUsageResponse {
source: "database" | "server_session_registry"; source: "database" | "server_session_registry";
durable: boolean; durable: boolean;
@ -70,7 +89,9 @@ interface AdminUsageResponse {
tokens_out: number; tokens_out: number;
cost_usd: number; cost_usd: number;
budget: AdminUsageBudget; budget: AdminUsageBudget;
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[]; by_provider: AdminUsageBreakdown[];
daily_cost?: AdminUsageDailyCost[];
} }
interface AdminUptimeResponse { interface AdminUptimeResponse {
@ -99,6 +120,10 @@ interface AdminSupportTicket {
subject: string; subject: string;
body: string; body: string;
source_path: string; source_path: string;
parent_ticket_id?: string | null;
duplicate_count?: number;
duplicate_parent_candidate_id?: string | null;
child_ticket_count?: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
} }
@ -262,6 +287,30 @@ function costLabel(value: number) {
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`; return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
} }
function rateLabel(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${Math.round(value * 1000) / 10}%`;
}
function evaluatorCache(response: AdminUsageResponse): AdminUsageEvaluatorCache {
return (
response.evaluator_cache ?? {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
}
);
}
function usageDailyCost(response: AdminUsageResponse): AdminUsageDailyCost[] {
return response.daily_cost ?? [];
}
function isOnline(seconds: number) { function isOnline(seconds: number) {
return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60; return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60;
} }
@ -446,6 +495,20 @@ test.describe("admin route", () => {
? "예산 주의" ? "예산 주의"
: "예산 정상", : "예산 정상",
); );
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"평가 캐시 hit-rate",
);
const cache = evaluatorCache(usage);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
cache.enabled
? `${rateLabel(cache.hit_rate)} hit`
: "캐시 비활성",
);
if (usageDailyCost(usage).length > 0) {
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"일별 비용 추이",
);
}
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible(); await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByText("샘플 정상률")).toBeVisible(); await expect(page.getByText("샘플 정상률")).toBeVisible();
expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0); expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0);
@ -657,6 +720,69 @@ test.describe("admin route", () => {
} }
}); });
test("manually links duplicate operation tickets", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const subject = `[E2E] 중복 운영 티켓 ${slug}`;
const createdTicketIds: string[] = [];
try {
for (let i = 0; i < 2; i += 1) {
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "voice_browser",
priority: "normal",
subject,
body: "E2E 중복 티켓 수동 연결 검증입니다. 실제 음성 장애가 아닙니다.",
source_path: "/__e2e__/learn/session/duplicate",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
createdTicketIds.push(created.ticket_id);
}
const tickets = await openAdminTickets(page);
const createdTickets = tickets.tickets.filter((ticket) => createdTicketIds.includes(ticket.ticket_id));
expect(createdTickets).toHaveLength(2);
expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy();
const linkableCard = page
.locator(".ad-ticket")
.filter({ hasText: subject })
.filter({ has: page.getByRole("button", { name: "연결" }) });
await expect(linkableCard).toBeVisible();
const patchPromise = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" &&
createdTicketIds.some((ticketId) => response.url().endsWith(`/api/admin/tickets/${ticketId}`)),
);
await linkableCard.getByRole("button", { name: "연결" }).click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminSupportTicket;
expect(updated.parent_ticket_id).toBeTruthy();
await expect(page.locator(".ad-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible();
await expectNoHorizontalOverflow(page);
} finally {
for (const ticketId of createdTicketIds) {
const cleanup = await page.request.patch(`/api/admin/tickets/${ticketId}`, {
data: {
status: "resolved",
parent_ticket_id: "",
resolution_note: "E2E cleanup",
},
});
if (!cleanup.ok()) {
console.warn(`E2E duplicate ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`);
}
}
}
});
test("denies learner access to the admin API and UI", async ({ page }) => { test("denies learner access to the admin API and UI", async ({ page }) => {
await signInAsLearner(page); await signInAsLearner(page);

View file

@ -1,7 +1,13 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture"; import {
EMPTY_REVIEW_SESSION_ID,
FILLED_REVIEW_SESSION_ID,
routeEmptySessionReview,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import { import {
completeOnboarding, completeOnboarding,
expectNoHorizontalOverflow, expectNoHorizontalOverflow,
@ -190,6 +196,81 @@ async function gateScreen(
} }
} }
async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
const report = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>(".sr-root--empty");
const cols = document.querySelector<HTMLElement>(".sr-root--empty .sr-cols");
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
if (!root || !cols || !transcript) {
return {
present: false,
columnCount: 0,
};
}
const gridTemplate = getComputedStyle(cols).gridTemplateColumns;
const columnCount = gridTemplate.split(" ").filter(Boolean).length;
const transcriptRect = transcript.getBoundingClientRect();
return {
present: true,
columnCount,
transcriptWidth: Math.round(transcriptRect.width),
mainColumnWidth: Math.round(cols.getBoundingClientRect().width),
};
});
expect(report.present, "empty review layout should be mounted").toBe(true);
expect(
report.columnCount,
"empty review should collapse the desktop masonry layout instead of leaving a sparse third column",
).toBeLessThanOrEqual(2);
}
async function expectFilledReviewLearnerTwoColumn(page: Page) {
const report = await page.evaluate(() => {
const cols = document.querySelector<HTMLElement>(".sr-cols--learner");
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
const overview = document.querySelector<HTMLElement>(".sr-overview");
const rubric = document.querySelector<HTMLElement>(".sr-card--rubric");
const worksheet = document.querySelector<HTMLElement>(".sr-card--worksheet");
if (!cols || !transcript || !overview || !rubric || !worksheet) {
return {
present: false,
viewportWidth: window.innerWidth,
columnCount: 0,
transcriptRight: 0,
overviewLeft: 0,
rubricLeft: 0,
worksheetLeft: 0,
};
}
const columnCount = getComputedStyle(cols).gridTemplateColumns.split(" ").filter(Boolean).length;
const transcriptRect = transcript.getBoundingClientRect();
const overviewRect = overview.getBoundingClientRect();
const rubricRect = rubric.getBoundingClientRect();
const worksheetRect = worksheet.getBoundingClientRect();
return {
present: true,
viewportWidth: window.innerWidth,
columnCount,
transcriptRight: Math.ceil(transcriptRect.right),
overviewLeft: Math.floor(overviewRect.left),
rubricLeft: Math.floor(rubricRect.left),
worksheetLeft: Math.floor(worksheetRect.left),
};
});
expect(report.present, "filled learner review layout should be mounted").toBe(true);
if (report.viewportWidth > 1180) {
expect(report.columnCount, "desktop learner review should use transcript + work column").toBe(2);
expect(report.transcriptRight).toBeLessThanOrEqual(report.overviewLeft);
expect(report.rubricLeft).toBeGreaterThanOrEqual(report.overviewLeft);
expect(report.worksheetLeft).toBeGreaterThanOrEqual(report.overviewLeft);
}
}
test.describe("layout visual gate @single-run", () => { test.describe("layout visual gate @single-run", () => {
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
@ -257,10 +338,25 @@ test.describe("layout visual gate @single-run", () => {
test("session review stays contained across all widths", async ({ page }) => { test("session review stays contained across all widths", async ({ page }) => {
await signInAsLearner(page); await signInAsLearner(page);
await routeFilledSessionReview(page); await routeFilledSessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review", async () => { await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("사례개념화 워크시트")).toBeVisible(); await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expectFilledReviewLearnerTwoColumn(page);
});
});
test("empty session review avoids sparse column gaps across all widths", async ({ page }) => {
await signInAsLearner(page);
await routeEmptySessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${EMPTY_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review-empty", async () => {
await expect(page.locator(".sr-root--empty")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("축어록 저장 후 생성")).toBeVisible();
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await expectEmptyReviewNoDeadThirdColumn(page);
}); });
}); });

View file

@ -4,7 +4,19 @@ const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?"; const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요."; const clientReply = "괜찮아요. 천천히 말해볼게요.";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
async function routeMvpApi(page: Page) { async function routeMvpApi(page: Page) {
const sessionStartRequests: unknown[] = [];
const streamSeen = deferred();
const streamGate = deferred();
await page.route("**/api/auth/me", async (route) => { await page.route("**/api/auth/me", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
@ -49,6 +61,7 @@ async function routeMvpApi(page: Page) {
await route.fallback(); await route.fallback();
return; return;
} }
sessionStartRequests.push(route.request().postDataJSON());
await route.fulfill({ await route.fulfill({
status: 201, status: 201,
contentType: "application/json", contentType: "application/json",
@ -76,7 +89,7 @@ async function routeMvpApi(page: Page) {
session_no: 1, session_no: 1,
status: "active", status: "active",
stage: "라포", stage: "라포",
theory_mode: "humanistic", theory_mode: "cbt",
effective_openness: 0.21, effective_openness: 0.21,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
ended_at: null, ended_at: null,
@ -87,6 +100,8 @@ async function routeMvpApi(page: Page) {
}); });
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => { await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
streamSeen.resolve();
await streamGate.promise;
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "text/event-stream", contentType: "text/event-stream",
@ -176,6 +191,8 @@ async function routeMvpApi(page: Page) {
}), }),
}); });
}); });
return { sessionStartRequests, streamGate, streamSeen };
} }
test.describe("P1 MVP core loop", () => { test.describe("P1 MVP core loop", () => {
@ -187,21 +204,32 @@ test.describe("P1 MVP core loop", () => {
page.on("console", (message) => { page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`); if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
}); });
await routeMvpApi(page); const api = await routeMvpApi(page);
await page.goto("/learn/session/P1"); await page.goto("/learn/session/P1");
await expect( await expect(
page.getByRole("button", { name: "회기 시작" }), page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")), diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible(); ).toBeVisible();
await page.getByRole("button", { name: /CBT/ }).click();
await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
expect(api.sessionStartRequests[0]).toMatchObject({
persona_code: "P1",
theory_mode: "cbt",
});
await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click(); await page.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible(); await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
api.streamGate.resolve();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible(); await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByRole("button", { name: "회기 종료" }).click(); await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();

View file

@ -1,7 +1,12 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import type { SessionReviewResponse } from "../src/lib/api"; import type {
SessionReviewResponse,
UserPrepostMeasureItem,
UserPrepostMeasuresResponse,
} from "../src/lib/api";
export const FILLED_REVIEW_SESSION_ID = "filled-review-visual"; export const FILLED_REVIEW_SESSION_ID = "filled-review-visual";
export const EMPTY_REVIEW_SESSION_ID = "empty-review-visual";
export function filledReviewResponse( export function filledReviewResponse(
sessionId: string = FILLED_REVIEW_SESSION_ID, sessionId: string = FILLED_REVIEW_SESSION_ID,
@ -67,7 +72,10 @@ export function filledReviewResponse(
{ kind: "reflect", label: "감정 반영" }, { kind: "reflect", label: "감정 반영" },
{ kind: "explore", label: "개방 질문" }, { kind: "explore", label: "개방 질문" },
], ],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "안정" }], nonverbal: [
{ kind: "pace", label: "말 속도", detail: "안정" },
{ kind: "paralinguistic", label: "음성 단서", detail: "한숨 감지 · 신뢰도 82%" },
],
note: { note: {
author: "AI 슈퍼바이저", author: "AI 슈퍼바이저",
tone: "ai", tone: "ai",
@ -239,3 +247,176 @@ export async function routeFilledSessionReview(
}); });
}); });
} }
export function prepostMeasuresResponse(
measures: UserPrepostMeasureItem[] = [
{
measure_id: "prepost-self-efficacy-pre",
pilot_id: "phase3-pilot-draft",
measure_name: "self_efficacy",
timepoint: "pre",
raw_score: 3,
min_score: 1,
max_score: 5,
normalized_score: 0.5,
instrument_version: "pilot-prepost-scaffold-2026-06-28",
item_count: 1,
collected_at: 1782662400,
updated_at: 1782662400,
},
{
measure_id: "prepost-self-efficacy-post",
pilot_id: "phase3-pilot-draft",
measure_name: "self_efficacy",
timepoint: "post",
raw_score: 4,
min_score: 1,
max_score: 5,
normalized_score: 0.75,
instrument_version: "pilot-prepost-scaffold-2026-06-28",
item_count: 1,
collected_at: 1782662400,
updated_at: 1782662400,
},
],
): UserPrepostMeasuresResponse {
const completePairs = new Set<string>();
for (const measureName of ["self_efficacy", "skill_proficiency", "training_satisfaction"]) {
const hasPre = measures.some(
(item) => item.measure_name === measureName && item.timepoint === "pre",
);
const hasPost = measures.some(
(item) => item.measure_name === measureName && item.timepoint === "post",
);
if (hasPre && hasPost) completePairs.add(measureName);
}
return {
source: "database",
durable: true,
pilot_id: "phase3-pilot-draft",
required_measure_names: [
"self_efficacy",
"skill_proficiency",
"training_satisfaction",
],
required_timepoints: ["pre", "post"],
complete_measure_pairs: completePairs.size,
measures,
generated_at: 1782662400,
};
}
export async function routePrepostMeasures(page: Page) {
let measures = prepostMeasuresResponse().measures;
await page.route("**/api/users/me/prepost-measures**", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(prepostMeasuresResponse(measures)),
});
return;
}
if (request.method() === "PUT") {
const body = request.postDataJSON() as {
measure_name: UserPrepostMeasureItem["measure_name"];
timepoint: UserPrepostMeasureItem["timepoint"];
raw_score: number;
min_score?: number;
max_score?: number;
instrument_version?: string;
item_count?: number;
pilot_id?: string;
};
const now = 1782666000;
const next: UserPrepostMeasureItem = {
measure_id: `prepost-${body.measure_name}-${body.timepoint}`,
pilot_id: body.pilot_id ?? "phase3-pilot-draft",
measure_name: body.measure_name,
timepoint: body.timepoint,
raw_score: body.raw_score,
min_score: body.min_score ?? 1,
max_score: body.max_score ?? 5,
normalized_score:
(body.raw_score - (body.min_score ?? 1)) / ((body.max_score ?? 5) - (body.min_score ?? 1)),
instrument_version: body.instrument_version ?? "pilot-prepost-scaffold-2026-06-28",
item_count: body.item_count ?? 1,
collected_at: now,
updated_at: now,
};
measures = measures
.filter(
(item) =>
!(
item.measure_name === next.measure_name &&
item.timepoint === next.timepoint &&
item.instrument_version === next.instrument_version
),
)
.concat(next);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
});
return;
}
await route.fallback();
});
}
export function emptyReviewResponse(
sessionId: string = EMPTY_REVIEW_SESSION_ID,
): SessionReviewResponse {
const response = filledReviewResponse(sessionId);
return {
...response,
date: "2026-06-28",
durationLabel: "0분",
durationSeconds: 0,
reachedPhase: "라포",
sessionSignal: "종료됨",
supervisorState: "평가 대기",
summary:
"축어록이 아직 저장되지 않아 평가 근거를 생성하지 않았습니다. 빈 상태에서는 임의의 강점이나 개선점을 표시하지 않습니다.",
phases: [],
phaseAxis: [],
valenceAxis: [],
clientValence: [],
counselorBaseline: [],
turns: [],
rubric: [],
goodMoments: [],
growthPoints: [],
caseWorksheet: {
status: "empty",
generatedBy: "rule-based transcript extractor",
savedAt: null,
sections: [],
limitations: ["축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."],
},
nextLine: null,
clientFeedback: null,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
reviewReady: false,
};
}
export async function routeEmptySessionReview(
page: Page,
sessionId: string = EMPTY_REVIEW_SESSION_ID,
) {
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(emptyReviewResponse(sessionId)),
});
});
}

View file

@ -1,5 +1,9 @@
import { expect, test, type Page, type Response } from "@playwright/test"; import { expect, test, type Page, type Response } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture"; import {
FILLED_REVIEW_SESSION_ID,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support"; import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
interface SessionStartResponse { interface SessionStartResponse {
@ -45,6 +49,7 @@ async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-card--flow")).toBeVisible(); await expect(page.locator(".sr-card--flow")).toBeVisible();
await expect(page.locator(".sr-card--rubric")).toBeVisible(); await expect(page.locator(".sr-card--rubric")).toBeVisible();
await expect(page.locator(".sr-card--transcript")).toBeVisible(); await expect(page.locator(".sr-card--transcript")).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toBeVisible();
const grid = await page.locator(".sr-cols").evaluate((el) => { const grid = await page.locator(".sr-cols").evaluate((el) => {
const style = window.getComputedStyle(el); const style = window.getComputedStyle(el);
@ -64,19 +69,34 @@ async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-card--transcript")).toBeInViewport(); await expect(page.locator(".sr-card--transcript")).toBeInViewport();
const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => { const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => {
const root = gridEl.closest(".sr-root");
const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect(); const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect();
const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect(); const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect();
const rubric = gridEl.querySelector(".sr-card--rubric")?.getBoundingClientRect();
const prepost = gridEl.querySelector(".sr-card--prepost")?.getBoundingClientRect();
const worksheet = gridEl.querySelector(".sr-card--worksheet")?.getBoundingClientRect();
const columns = window const columns = window
.getComputedStyle(gridEl) .getComputedStyle(gridEl)
.gridTemplateColumns.split(" ") .gridTemplateColumns.split(" ")
.filter(Boolean).length; .filter(Boolean).length;
return { return {
isEmpty: root?.classList.contains("sr-root--empty") ?? false,
columns, columns,
overviewLeft: overview ? Math.floor(overview.left) : 0,
overviewRight: overview ? Math.ceil(overview.right) : 0, overviewRight: overview ? Math.ceil(overview.right) : 0,
transcriptLeft: transcript ? Math.floor(transcript.left) : 0, transcriptLeft: transcript ? Math.floor(transcript.left) : 0,
transcriptRight: transcript ? Math.ceil(transcript.right) : 0,
rubricLeft: rubric ? Math.floor(rubric.left) : 0,
prepostLeft: prepost ? Math.floor(prepost.left) : 0,
worksheetLeft: worksheet ? Math.floor(worksheet.left) : 0,
}; };
}); });
if (railGeometry.columns >= 3) { if (!railGeometry.isEmpty && railGeometry.columns === 2) {
expect(railGeometry.transcriptRight).toBeLessThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.rubricLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.prepostLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.worksheetLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
} else if (railGeometry.columns >= 3) {
expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft); expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft);
} }
} }
@ -84,6 +104,7 @@ async function expectReviewRegionsReachable(page: Page) {
test.describe("session review", () => { test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => { test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page); await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = await createEndedSession(page); const sessionId = await createEndedSession(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId)); const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
@ -124,6 +145,7 @@ test.describe("session review", () => {
test("renders filled review evidence state without clipping", async ({ page }) => { test("renders filled review evidence state without clipping", async ({ page }) => {
await signInAsLearner(page); await signInAsLearner(page);
await routeFilledSessionReview(page); await routeFilledSessionReview(page);
await routePrepostMeasures(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID)); const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID));
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
@ -141,6 +163,24 @@ test.describe("session review", () => {
await expect(page.getByText("회기 흐름")).toBeVisible(); await expect(page.getByText("회기 흐름")).toBeVisible();
await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expect(page.getByText("기법 사용 분포")).toBeVisible(); await expect(page.getByText("기법 사용 분포")).toBeVisible();
await expect(page.getByText("파일럿 증거 원장")).toBeVisible();
await expect(page.getByText("1/3 쌍")).toBeVisible();
const prepostInputs = page.locator(".sr-card--prepost input");
await expect(prepostInputs).toHaveCount(6);
await expect(prepostInputs.nth(0)).toHaveValue("3");
await expect(prepostInputs.nth(1)).toHaveValue("4");
const prepostLabels = await prepostInputs.evaluateAll((inputs) =>
inputs.map((input) => input.getAttribute("aria-label")),
);
expect(prepostLabels).toEqual([
"자기효능감 사전 점수",
"자기효능감 사후 점수",
"기술숙련도 사전 점수",
"기술숙련도 사후 점수",
"수련만족도 사전 점수",
"수련만족도 사후 점수",
]);
await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible();
await expect(page.getByText("사례개념화 워크시트")).toBeVisible(); await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expect(page.getByText("감정 반영").first()).toBeVisible(); await expect(page.getByText("감정 반영").first()).toBeVisible();
await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible(); await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
@ -154,4 +194,27 @@ test.describe("session review", () => {
await expectReviewRegionsReachable(page); await expectReviewRegionsReachable(page);
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
const prepostInputs = page.locator(".sr-card--prepost input");
await expect(prepostInputs).toHaveCount(6);
await expect(page.getByText("1/3 쌍")).toBeVisible();
await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible();
await prepostInputs.nth(2).fill("2.5");
await prepostInputs.nth(3).fill("3.5");
await expect(prepostInputs.nth(2)).toHaveValue("2.5");
await expect(prepostInputs.nth(3)).toHaveValue("3.5");
await page.getByRole("button", { name: "점수 저장" }).click();
await expect(page.getByText("2/3 쌍")).toBeVisible();
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
});
}); });

View file

@ -29,6 +29,17 @@ interface UserPreferencesResponse {
}; };
} }
interface UserSupportTicketsResponse {
source: "database";
durable: boolean;
tickets: Array<{
ticket_id: string;
status: string;
subject: string;
resolution_note: string;
}>;
}
interface AdminEngineConfigResponse { interface AdminEngineConfigResponse {
engine_mode: string; engine_mode: string;
engine_url: string; engine_url: string;
@ -214,6 +225,9 @@ async function expectNoSettingsControlClipping(page: Page) {
".vg-set__field", ".vg-set__field",
".vg-set__control-block", ".vg-set__control-block",
".vg-set__opt", ".vg-set__opt",
".vg-set__tickets",
".vg-set__ticket",
".vg-set__ticket-note",
".vg-set__voice", ".vg-set__voice",
".vg-set__range-row", ".vg-set__range-row",
".vg-set__seg", ".vg-set__seg",
@ -405,27 +419,39 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) {
const voicePresetsPromise = page.waitForResponse( const voicePresetsPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/voice-presets"), isApiResponse("GET", "/users/me/voice-presets"),
); );
const supportTicketsPromise = page.waitForResponse(
isApiResponse("GET", "/users/support-tickets"),
);
const engineConfigPromise = options.admin const engineConfigPromise = options.admin
? page.waitForResponse(isApiResponse("GET", "/admin/engine-config")) ? page.waitForResponse(isApiResponse("GET", "/admin/engine-config"))
: null; : null;
await page.goto("/settings"); await page.goto("/settings");
const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] = const [
profileResponse,
preferencesResponse,
voicePresetsResponse,
supportTicketsResponse,
engineConfigResponse,
] =
await Promise.all([ await Promise.all([
profilePromise, profilePromise,
preferencesPromise, preferencesPromise,
voicePresetsPromise, voicePresetsPromise,
supportTicketsPromise,
engineConfigPromise, engineConfigPromise,
]); ]);
await expectResponseOk(profileResponse); await expectResponseOk(profileResponse);
await expectResponseOk(preferencesResponse); await expectResponseOk(preferencesResponse);
await expectResponseOk(voicePresetsResponse); await expectResponseOk(voicePresetsResponse);
await expectResponseOk(supportTicketsResponse);
if (engineConfigResponse) await expectResponseOk(engineConfigResponse); if (engineConfigResponse) await expectResponseOk(engineConfigResponse);
const profile = (await profileResponse.json()) as UserProfileResponse; const profile = (await profileResponse.json()) as UserProfileResponse;
const preferences = (await preferencesResponse.json()) as UserPreferencesResponse; const preferences = (await preferencesResponse.json()) as UserPreferencesResponse;
const supportTickets = (await supportTicketsResponse.json()) as UserSupportTicketsResponse;
const engineConfig = engineConfigResponse const engineConfig = engineConfigResponse
? ((await engineConfigResponse.json()) as AdminEngineConfigResponse) ? ((await engineConfigResponse.json()) as AdminEngineConfigResponse)
: null; : null;
@ -433,7 +459,7 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) {
await expect(page).toHaveURL(/\/settings$/); await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible(); await expect(page.locator(".vg-set")).toBeVisible();
return { profile, preferences, engineConfig }; return { profile, preferences, supportTickets, engineConfig };
} }
test.describe("settings page", () => { test.describe("settings page", () => {
@ -580,6 +606,55 @@ test.describe("settings page", () => {
expect(preferences.notifications.product_news).toBe(nextProductNews); expect(preferences.notifications.product_news).toBe(nextProductNews);
}); });
test("learner sees only own support ticket status and resolution note", async ({
page,
}, testInfo) => {
const learnerName = `Settings Ticket Learner ${testInfo.project.name}`;
const learnerEmail = await signInAs(page, "learner", testInfo, learnerName);
const subject = `설정 티켓 상태 ${slugFor(testInfo)}`;
const hiddenBody = `설정 화면에는 보이면 안 되는 문의 본문 ${slugFor(testInfo)}`;
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "session_review",
priority: "high",
subject,
body: hiddenBody,
source_path: "/settings",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
await signInAs(
page,
"admin",
testInfo,
`Settings Ticket Admin ${testInfo.project.name}`,
`settings.ticket.admin.${slugFor(testInfo)}@twentyoz.kr`,
);
const resolutionNote = `재처리 완료 ${slugFor(testInfo)}`;
const patch = await page.request.patch(`/api/admin/tickets/${created.ticket_id}`, {
data: {
status: "resolved",
assigned_group: "서비스 운영자",
resolution_note: resolutionNote,
},
});
await expectResponseOk(patch);
await signInAs(page, "learner", testInfo, learnerName, learnerEmail);
const { supportTickets } = await openSettings(page);
expect(supportTickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBe(
true,
);
const support = page.locator("#set-support");
await expect(support).toContainText(subject);
await expect(support).toContainText("해결");
await expect(support).toContainText(resolutionNote);
await expect(support).not.toContainText(hiddenBody);
});
test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => { test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => {
const displayName = `Settings Admin ${testInfo.project.name}`; const displayName = `Settings Admin ${testInfo.project.name}`;
const email = await signInAs(page, "admin", testInfo, displayName); const email = await signInAs(page, "admin", testInfo, displayName);

View file

@ -411,7 +411,11 @@ const AVATAR_CSS = `
.vg-avatar__stage{ .vg-avatar__stage{
position:relative;width:100%;border-radius:50%; position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden; display:flex;align-items:center;justify-content:center;overflow:hidden;
background:var(--bg-stage); background:
radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%);
box-shadow:
inset 0 0 0 1px rgba(251,250,248,.28),
inset 0 -24px 40px rgba(28,42,42,.13);
} }
.vg-avatar__aura{ .vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none; position:absolute;inset:-12%;border-radius:50%;pointer-events:none;

View file

@ -1188,6 +1188,24 @@ export interface paths {
patch: operations["patch_preferences_users_me_preferences_patch"]; patch: operations["patch_preferences_users_me_preferences_patch"];
trace?: never; trace?: never;
}; };
"/users/me/prepost-measures": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List My Prepost Measures */
get: operations["list_my_prepost_measures_users_me_prepost_measures_get"];
/** Upsert My Prepost Measure */
put: operations["upsert_my_prepost_measure_users_me_prepost_measures_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/me/voice-presets": { "/users/me/voice-presets": {
parameters: { parameters: {
query?: never; query?: never;
@ -1212,7 +1230,8 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get?: never; /** List My Support Tickets */
get: operations["list_my_support_tickets_users_support_tickets_get"];
put?: never; put?: never;
/** Create Support Ticket */ /** Create Support Ticket */
post: operations["create_support_ticket_users_support_tickets_post"]; post: operations["create_support_ticket_users_support_tickets_post"];
@ -1349,15 +1368,31 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other"; category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other";
/**
* Child Ticket Count
* @default 0
*/
child_ticket_count: number;
/** Created At */ /** Created At */
created_at: number; created_at: number;
/**
* Duplicate Count
* @default 0
*/
duplicate_count: number;
/** Duplicate Parent Candidate Id */
duplicate_parent_candidate_id?: string | null;
/** /**
* Event Count * Event Count
* @default 0 * @default 0
*/ */
event_count: number; event_count: number;
/** Fingerprint */
fingerprint: string;
/** Last Event At */ /** Last Event At */
last_event_at?: number | null; last_event_at?: number | null;
/** Parent Ticket Id */
parent_ticket_id?: string | null;
/** /**
* Priority * Priority
* @enum {string} * @enum {string}
@ -1386,6 +1421,8 @@ export interface components {
AdminTicketPatch: { AdminTicketPatch: {
/** Assigned Group */ /** Assigned Group */
assigned_group?: string | null; assigned_group?: string | null;
/** Parent Ticket Id */
parent_ticket_id?: string | null;
/** Priority */ /** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null; priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Resolution Note */ /** Resolution Note */
@ -1521,6 +1558,38 @@ export interface components {
/** Used Ratio */ /** Used Ratio */
used_ratio: number; used_ratio: number;
}; };
/** AdminUsageDailyCost */
AdminUsageDailyCost: {
/** Cost Usd */
cost_usd: number;
/** Day */
day: string;
/** Tokens In */
tokens_in: number;
/** Tokens Out */
tokens_out: number;
/** Turns */
turns: number;
};
/** AdminUsageEvaluatorCache */
AdminUsageEvaluatorCache: {
/** Enabled */
enabled: boolean;
/** Entries */
entries: number;
/** Evictions */
evictions: number;
/** Hit Rate */
hit_rate: number;
/** Hits */
hits: number;
/** Misses */
misses: number;
/** Requests */
requests: number;
/** Stores */
stores: number;
};
/** AdminUsageResponse */ /** AdminUsageResponse */
AdminUsageResponse: { AdminUsageResponse: {
budget: components["schemas"]["AdminUsageBudget"]; budget: components["schemas"]["AdminUsageBudget"];
@ -1528,8 +1597,11 @@ export interface components {
by_provider: components["schemas"]["AdminUsageBreakdown"][]; by_provider: components["schemas"]["AdminUsageBreakdown"][];
/** Cost Usd */ /** Cost Usd */
cost_usd: number; cost_usd: number;
/** Daily Cost */
daily_cost?: components["schemas"]["AdminUsageDailyCost"][];
/** Durable */ /** Durable */
durable: boolean; durable: boolean;
evaluator_cache: components["schemas"]["AdminUsageEvaluatorCache"];
/** Generated At */ /** Generated At */
generated_at: number; generated_at: number;
/** Metered Turns */ /** Metered Turns */
@ -2904,7 +2976,7 @@ export interface components {
* Kind * Kind
* @enum {string} * @enum {string}
*/ */
kind: "audio" | "silence" | "pace" | "barge_in"; kind: "audio" | "silence" | "pace" | "barge_in" | "paralinguistic" | "prosody" | "audio_quality";
/** Label */ /** Label */
label: string; label: string;
}; };
@ -3649,6 +3721,102 @@ export interface components {
*/ */
voice_rate: number; voice_rate: number;
}; };
/** UserPrepostMeasureItem */
UserPrepostMeasureItem: {
/** Collected At */
collected_at: number;
/** Instrument Version */
instrument_version: string;
/** Item Count */
item_count: number;
/** Max Score */
max_score: number;
/** Measure Id */
measure_id: string;
/**
* Measure Name
* @enum {string}
*/
measure_name: "self_efficacy" | "skill_proficiency" | "training_satisfaction";
/** Min Score */
min_score: number;
/** Normalized Score */
normalized_score: number;
/** Pilot Id */
pilot_id: string;
/** Raw Score */
raw_score: number;
/**
* Timepoint
* @enum {string}
*/
timepoint: "pre" | "post";
/** Updated At */
updated_at: number;
};
/** UserPrepostMeasureRequest */
UserPrepostMeasureRequest: {
/**
* Instrument Version
* @default pilot-prepost-scaffold-2026-06-28
*/
instrument_version: string;
/**
* Item Count
* @default 1
*/
item_count: number;
/**
* Max Score
* @default 5
*/
max_score: number;
/**
* Measure Name
* @enum {string}
*/
measure_name: "self_efficacy" | "skill_proficiency" | "training_satisfaction";
/**
* Min Score
* @default 1
*/
min_score: number;
/**
* Pilot Id
* @default phase3-pilot-draft
*/
pilot_id: string;
/** Raw Score */
raw_score: number;
/**
* Timepoint
* @enum {string}
*/
timepoint: "pre" | "post";
};
/** UserPrepostMeasuresResponse */
UserPrepostMeasuresResponse: {
/** Complete Measure Pairs */
complete_measure_pairs: number;
/** Durable */
durable: boolean;
/** Generated At */
generated_at: number;
/** Measures */
measures: components["schemas"]["UserPrepostMeasureItem"][];
/** Pilot Id */
pilot_id: string;
/** Required Measure Names */
required_measure_names: ("self_efficacy" | "skill_proficiency" | "training_satisfaction")[];
/** Required Timepoints */
required_timepoints: ("pre" | "post")[];
/**
* Source
* @constant
* @enum {string}
*/
source: "database";
};
/** UserProfilePatch */ /** UserProfilePatch */
UserProfilePatch: { UserProfilePatch: {
/** Affiliation */ /** Affiliation */
@ -3751,6 +3919,40 @@ export interface components {
/** User Id */ /** User Id */
user_id: string; user_id: string;
}; };
/** UserSupportTicketListItem */
UserSupportTicketListItem: {
/** Assigned Group */
assigned_group: string;
/**
* Category
* @enum {string}
*/
category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other";
/** Created At */
created_at: number;
/**
* Priority
* @enum {string}
*/
priority: "low" | "normal" | "high" | "urgent";
/** Resolution Note */
resolution_note: string;
/** Resolved At */
resolved_at?: number | null;
/** Source Path */
source_path: string;
/**
* Status
* @enum {string}
*/
status: "open" | "triaged" | "in_progress" | "resolved" | "closed";
/** Subject */
subject: string;
/** Ticket Id */
ticket_id: string;
/** Updated At */
updated_at: number;
};
/** UserSupportTicketRequest */ /** UserSupportTicketRequest */
UserSupportTicketRequest: { UserSupportTicketRequest: {
/** Body */ /** Body */
@ -3800,6 +4002,21 @@ export interface components {
/** Ticket Id */ /** Ticket Id */
ticket_id: string; ticket_id: string;
}; };
/** UserSupportTicketsResponse */
UserSupportTicketsResponse: {
/** Durable */
durable: boolean;
/** Generated At */
generated_at: number;
/**
* Source
* @constant
* @enum {string}
*/
source: "database";
/** Tickets */
tickets: components["schemas"]["UserSupportTicketListItem"][];
};
/** ValidationError */ /** ValidationError */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@ -6083,6 +6300,76 @@ export interface operations {
}; };
}; };
}; };
list_my_prepost_measures_users_me_prepost_measures_get: {
parameters: {
query?: {
pilot_id?: string;
};
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"]["UserPrepostMeasuresResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upsert_my_prepost_measure_users_me_prepost_measures_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["UserPrepostMeasureRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPrepostMeasureItem"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_voice_presets_users_me_voice_presets_get: { get_voice_presets_users_me_voice_presets_get: {
parameters: { parameters: {
query?: never; query?: never;
@ -6115,6 +6402,38 @@ export interface operations {
}; };
}; };
}; };
list_my_support_tickets_users_support_tickets_get: {
parameters: {
query?: never;
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"]["UserSupportTicketsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_support_ticket_users_support_tickets_post: { create_support_ticket_users_support_tickets_post: {
parameters: { parameters: {
query?: never; query?: never;

View file

@ -562,6 +562,11 @@ export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
export type OnboardingRequest = ApiSchema<"OnboardingRequest">; export type OnboardingRequest = ApiSchema<"OnboardingRequest">;
export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">; export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">; 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 type RoleString = "learner" | "teacher" | "admin" | string;
@ -588,6 +593,13 @@ export const userApi = {
updatePreferences: (body: UserPreferencesPatchRequest) => updatePreferences: (body: UserPreferencesPatchRequest) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }), apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"), voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
supportTickets: () => api.get<UserSupportTicketsResponse>("/users/support-tickets"),
prepostMeasures: (pilotId = "phase3-pilot-draft") =>
api.get<UserPrepostMeasuresResponse>(
`/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`,
),
upsertPrepostMeasure: (body: UserPrepostMeasureRequest) =>
apiFetch<UserPrepostMeasureItem>("/users/me/prepost-measures", { method: "PUT", body }),
}; };
export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">; export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;

View file

@ -221,6 +221,48 @@ function usageBudgetDetail(data: AdminUsageResponse): string {
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`; return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
} }
function rateLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${Math.round(value * 1000) / 10}%`;
}
function evaluatorCache(data: AdminUsageResponse): AdminUsageResponse["evaluator_cache"] {
return (
data.evaluator_cache ?? {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
}
);
}
function evaluatorCacheLabel(data: AdminUsageResponse): string {
const cache = evaluatorCache(data);
if (!cache.enabled) return "캐시 비활성";
return `${rateLabel(cache.hit_rate)} hit`;
}
function evaluatorCacheDetail(data: AdminUsageResponse): string {
const cache = evaluatorCache(data);
if (!cache.enabled) return "EVALUATOR_SEMANTIC_CACHE 설정이 꺼져 있습니다.";
return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit · entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}`;
}
function usageDailyCost(data: AdminUsageResponse): NonNullable<AdminUsageResponse["daily_cost"]> {
return data.daily_cost ?? [];
}
function usageDayLabel(day: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
if (!match) return day;
return `${match[2]}.${match[3]}`;
}
function uptimeLabel(data: AdminUptimeResponse | null): string { function uptimeLabel(data: AdminUptimeResponse | null): string {
if (!data) return "-"; if (!data) return "-";
if (!data.durable || data.sample_count === 0) return "샘플 없음"; if (!data.durable || data.sample_count === 0) return "샘플 없음";
@ -268,6 +310,10 @@ function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" |
return "accent"; return "accent";
} }
function shortTicketId(ticketId: string): string {
return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase();
}
function initialOf(user: AdminManagedUser): string { function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email; const label = user.display_name.trim() || user.email;
return Array.from(label)[0]?.toUpperCase() ?? "?"; return Array.from(label)[0]?.toUpperCase() ?? "?";
@ -622,6 +668,34 @@ export default function Admin({ section = "overview" }: AdminProps) {
} }
}; };
const updateTicketParent = async (
ticket: AdminSupportTicket,
parentTicketId: string | null,
) => {
setUpdatingTicketId(ticket.ticket_id);
setTicketsError(null);
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, {
parent_ticket_id: parentTicketId ?? "",
});
setTickets((current) =>
current
? {
...current,
tickets: current.tickets.map((item) =>
item.ticket_id === updated.ticket_id ? updated : item,
),
}
: current,
);
await loadTickets();
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다.");
} finally {
setUpdatingTicketId(null);
}
};
const services = health?.services ?? []; const services = health?.services ?? [];
const users = usersData?.users ?? []; const users = usersData?.users ?? [];
const counts = useMemo( const counts = useMemo(
@ -848,6 +922,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
{usage ? <small>{usageBudgetDetail(usage)}</small> : null} {usage ? <small>{usageBudgetDetail(usage)}</small> : null}
</div> </div>
{usage ? <ProgressBar value={Math.min(100, Math.round(usage.budget.used_ratio * 100))} slim /> : null} {usage ? <ProgressBar value={Math.min(100, Math.round(usage.budget.used_ratio * 100))} slim /> : null}
{usage ? (
<div className="ad-cache">
<span> hit-rate</span>
<b>{evaluatorCacheLabel(usage)}</b>
<small>{evaluatorCacheDetail(usage)}</small>
</div>
) : null}
{usage && usageDailyCost(usage).length > 0 ? (
<div className="ad-cost-trend">
<span> </span>
{usageDailyCost(usage)
.slice(-7)
.map((day) => (
<div key={day.day}>
<b>{usageDayLabel(day.day)}</b>
<span>
{costLabel(day.cost_usd)} · {countLabel(day.turns)}
</span>
</div>
))}
</div>
) : null}
</div> </div>
<div className="ad-panel"> <div className="ad-panel">
@ -1658,44 +1754,84 @@ export default function Admin({ section = "overview" }: AdminProps) {
{activeTickets.length > 0 ? ( {activeTickets.length > 0 ? (
<div className="ad-ticket-list" aria-label="미해결 운영 티켓 큐"> <div className="ad-ticket-list" aria-label="미해결 운영 티켓 큐">
{activeTickets.map((ticket) => ( {activeTickets.map((ticket) => {
<article className="ad-ticket" key={ticket.ticket_id}> const duplicateParentId = ticket.duplicate_parent_candidate_id;
<div> const canLinkDuplicate =
<div className="ad-ticket__meta"> ticket.duplicate_count > 0 &&
<span>{ticketCategoryLabel(ticket.category)}</span> Boolean(duplicateParentId) &&
<span>{ticket.reporter.email}</span> duplicateParentId !== ticket.ticket_id &&
<span> {dateTimeLabel(ticket.updated_at)}</span> duplicateParentId !== ticket.parent_ticket_id;
return (
<article className="ad-ticket" key={ticket.ticket_id}>
<div>
<div className="ad-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span> {dateTimeLabel(ticket.updated_at)}</span>
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
{(ticket.duplicate_count > 0 || ticket.parent_ticket_id || ticket.child_ticket_count > 0) ? (
<div className="ad-ticket__dupes">
{ticket.parent_ticket_id ? (
<span> #{shortTicketId(ticket.parent_ticket_id)}</span>
) : ticket.duplicate_count > 0 ? (
<span> {countLabel(ticket.duplicate_count)}</span>
) : null}
{ticket.child_ticket_count > 0 ? (
<span> {countLabel(ticket.child_ticket_count)}</span>
) : null}
</div>
) : null}
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div> </div>
<h2>{ticket.subject}</h2> <Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
<p>{ticket.body}</p> <Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
<small> <div className="ad-ticket__actions">
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)} {canLinkDuplicate ? (
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""} <Button
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""} size="sm"
</small> variant="secondary"
</div> onClick={() => void updateTicketParent(ticket, duplicateParentId ?? null)}
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge> disabled={updatingTicketId === ticket.ticket_id}
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge> >
<div className="ad-ticket__actions">
<Button </Button>
size="sm" ) : null}
variant="secondary" {ticket.parent_ticket_id ? (
onClick={() => void updateTicketStatus(ticket, "in_progress")} <Button
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"} size="sm"
> variant="secondary"
onClick={() => void updateTicketParent(ticket, null)}
</Button> disabled={updatingTicketId === ticket.ticket_id}
<Button >
size="sm"
variant="primary" </Button>
onClick={() => void updateTicketStatus(ticket, "resolved")} ) : null}
disabled={updatingTicketId === ticket.ticket_id} <Button
> size="sm"
variant="secondary"
</Button> onClick={() => void updateTicketStatus(ticket, "in_progress")}
</div> disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
</article> >
))}
</Button>
<Button
size="sm"
variant="primary"
onClick={() => void updateTicketStatus(ticket, "resolved")}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
</div>
</article>
);
})}
</div> </div>
) : null} ) : null}
</section> </section>

View file

@ -53,6 +53,7 @@ import "./session/session.css";
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */ /* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
type FeedbackMode = "immersive" | "ambient" | "coached"; type FeedbackMode = "immersive" | "ambient" | "coached";
type TheoryMode = "humanistic" | "cbt" | "integrative";
type Speaker = "client" | "learner"; type Speaker = "client" | "learner";
type SignalTone = "pos" | "warn" | "neutral"; type SignalTone = "pos" | "warn" | "neutral";
type VoiceStatus = type VoiceStatus =
@ -115,6 +116,20 @@ const SESSION_PHASES: PhaseInfo[] = [
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" }, { key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
]; ];
const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[] = [
{ value: "humanistic", label: "인간중심", detail: "공감·반영" },
{ value: "cbt", label: "CBT", detail: "생각·행동" },
{ value: "integrative", label: "통합", detail: "혼합 접근" },
];
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
const firstSupported = summary?.theory_target.find(
(target): target is TheoryMode =>
target === "humanistic" || target === "cbt" || target === "integrative",
);
return firstSupported ?? "humanistic";
}
const PERSONA_AVATAR_LOOKS = { const PERSONA_AVATAR_LOOKS = {
P1: { P1: {
skinTone: "#F0DDC4", skinTone: "#F0DDC4",
@ -378,6 +393,7 @@ export default function Session() {
const [startError, setStartError] = useState<string | null>(null); const [startError, setStartError] = useState<string | null>(null);
const [consentChecked, setConsentChecked] = useState(false); const [consentChecked, setConsentChecked] = useState(false);
const [consentBusy, setConsentBusy] = useState(false); const [consentBusy, setConsentBusy] = useState(false);
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
// ── 회기/대화 상태 ── // ── 회기/대화 상태 ──
const [stage, setStage] = useState<SessionStage>("라포"); const [stage, setStage] = useState<SessionStage>("라포");
@ -394,6 +410,7 @@ export default function Session() {
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("ambient"); const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("ambient");
const [composeText, setComposeText] = useState(""); const [composeText, setComposeText] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [clientReplyPending, setClientReplyPending] = useState(false);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>("idle"); const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>("idle");
const [voiceDetail, setVoiceDetail] = useState( const [voiceDetail, setVoiceDetail] = useState(
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.", "마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
@ -483,6 +500,7 @@ export default function Session() {
setSafety(null); setSafety(null);
setCrisisResource(null); setCrisisResource(null);
setResumedSessionLoaded(false); setResumedSessionLoaded(false);
setClientReplyPending(false);
setStartError(null); setStartError(null);
setCoachSuggestion(null); setCoachSuggestion(null);
setCoachError(null); setCoachError(null);
@ -524,6 +542,11 @@ export default function Session() {
}; };
}, [personaCode]); }, [personaCode]);
useEffect(() => {
if (started) return;
setSelectedTheoryMode(preferredTheoryMode(personaSummary));
}, [personaSummary, started]);
// 현재 단계 인덱스 // 현재 단계 인덱스
const stageIdx = useMemo(() => SESSION_PHASES.findIndex((p) => p.key === stage), [stage]); const stageIdx = useMemo(() => SESSION_PHASES.findIndex((p) => p.key === stage), [stage]);
const currentPhase = SESSION_PHASES[Math.max(0, stageIdx)] ?? SESSION_PHASES[0]; const currentPhase = SESSION_PHASES[Math.max(0, stageIdx)] ?? SESSION_PHASES[0];
@ -540,7 +563,7 @@ export default function Session() {
if (!autoScroll) return; if (!autoScroll) return;
const el = scrollRef.current; const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [utterances, autoScroll]); }, [utterances, clientReplyPending, autoScroll]);
// ── 라이브 신호 6초 페이드(§5.5) ── // ── 라이브 신호 6초 페이드(§5.5) ──
useEffect(() => { useEffect(() => {
@ -584,6 +607,7 @@ export default function Session() {
setSafety(next.message); setSafety(next.message);
setPaused(true); setPaused(true);
setMicOn(false); setMicOn(false);
setClientReplyPending(false);
setVoiceStatus("idle"); setVoiceStatus("idle");
setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다."); setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다.");
pushSignal("warn", "위기 안전게이트 작동"); pushSignal("warn", "위기 안전게이트 작동");
@ -698,6 +722,7 @@ export default function Session() {
setCoachEvidenceOpen(false); setCoachEvidenceOpen(false);
setCoachHistoryOpen(false); setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null); setCoachHistoryTurnSeq(null);
setClientReplyPending(false);
pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기"); pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기");
} catch { } catch {
if (!alive) return; if (!alive) return;
@ -725,15 +750,9 @@ export default function Session() {
setStarting(true); setStarting(true);
setStartError(null); setStartError(null);
setResumedSessionLoaded(false); setResumedSessionLoaded(false);
setClientReplyPending(false);
try { try {
// 회기 이론모드 = 페르소나 설계 이론(theory_target) 기준. CBT 페르소나는 cbt로 시작. const res = await sessionApi.start(personaCode, selectedTheoryMode);
// (이전엔 'humanistic' 하드코딩 — 평가 이론부합이 항상 인간중심으로 고정됐다.)
const theoryMode =
personaSummary.theory_target.find(
(t): t is "humanistic" | "cbt" | "integrative" =>
t === "humanistic" || t === "cbt" || t === "integrative",
) ?? "humanistic";
const res = await sessionApi.start(personaCode, theoryMode);
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브 setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
setSessionEnded(false); setSessionEnded(false);
setReviewReady(false); setReviewReady(false);
@ -778,7 +797,7 @@ export default function Session() {
} finally { } finally {
setStarting(false); setStarting(false);
} }
}, [navigate, personaCode, personaSummary, pushSignal]); }, [navigate, personaCode, personaSummary, pushSignal, selectedTheoryMode]);
const handleAcceptConsent = useCallback(async () => { const handleAcceptConsent = useCallback(async () => {
if (!consentChecked) { if (!consentChecked) {
@ -799,6 +818,7 @@ export default function Session() {
const appendServerClientReply = useCallback( const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed) => { (replyText: string | null, at = elapsed) => {
setClientReplyPending(false);
if (!replyText) { if (!replyText) {
setAvatarState("listening"); setAvatarState("listening");
pushSignal("warn", "내담자 응답 없음"); pushSignal("warn", "내담자 응답 없음");
@ -817,7 +837,7 @@ export default function Session() {
/* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */ /* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const text = composeText.trim(); const text = composeText.trim();
if (!text || sending || paused) return; if (!text || sending || paused || voiceStatus === "thinking") return;
if (sessionEnded) { if (sessionEnded) {
setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요."); setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요.");
return; return;
@ -829,8 +849,14 @@ export default function Session() {
} }
const at = elapsed; const at = elapsed;
const learnerId = nextId();
setSending(true); setSending(true);
setComposeText(""); setComposeText("");
setUtterances((prev) => [
...prev,
{ id: learnerId, speaker: "learner", text, at },
]);
setClientReplyPending(true);
setAvatarState("thinking"); setAvatarState("thinking");
setTurnError(null); setTurnError(null);
@ -841,6 +867,7 @@ export default function Session() {
onToken: (chunk) => { onToken: (chunk) => {
if (!chunk) return; if (!chunk) return;
clientReply += chunk; clientReply += chunk;
setClientReplyPending(false);
setAvatarState("speaking"); setAvatarState("speaking");
if (clientId == null) { if (clientId == null) {
clientId = nextId(); clientId = nextId();
@ -878,25 +905,30 @@ export default function Session() {
}, },
}); });
const learnerUtterance: Utterance = {
id: nextId(),
speaker: "learner",
text,
turnSeq: done.turn_seq,
at,
};
if (clientId != null) { if (clientId != null) {
const id = clientId; const id = clientId;
setUtterances((prev) => { setUtterances((prev) => {
const existingIndex = prev.findIndex((u) => u.id === id); const existingIndex = prev.findIndex((u) => u.id === id);
if (existingIndex === -1) return [...prev, learnerUtterance]; if (existingIndex === -1) {
const next = [...prev]; return [
next.splice(existingIndex, 0, learnerUtterance); ...prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
return next.map((u) => (u.id === id ? { ...u, partial: false } : u)); { id, speaker: "client", text: clientReply, at },
];
}
return prev.map((u) =>
u.id === learnerId
? { ...u, turnSeq: done.turn_seq }
: u.id === id
? { ...u, partial: false }
: u,
);
}); });
pushSignal("neutral", "내담자 응답 수신"); pushSignal("neutral", "내담자 응답 수신");
} else { } else {
setUtterances((prev) => [...prev, learnerUtterance]); setClientReplyPending(false);
setUtterances((prev) =>
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
);
pushSignal("warn", "내담자 응답 없음"); pushSignal("warn", "내담자 응답 없음");
} }
setAvatarState("listening"); setAvatarState("listening");
@ -907,9 +939,12 @@ export default function Session() {
}); });
} catch (err) { } catch (err) {
setComposeText(text); setComposeText(text);
setClientReplyPending(false);
if (clientId != null) { if (clientId != null) {
const id = clientId; const id = clientId;
setUtterances((prev) => prev.filter((u) => u.id !== id)); setUtterances((prev) => prev.filter((u) => u.id !== id && u.id !== learnerId));
} else {
setUtterances((prev) => prev.filter((u) => u.id !== learnerId));
} }
const detail = const detail =
err instanceof ApiError err instanceof ApiError
@ -928,7 +963,17 @@ export default function Session() {
} finally { } finally {
setSending(false); setSending(false);
} }
}, [composeText, sending, paused, sessionEnded, liveSessionId, elapsed, pushSignal, requestLiveCoach]); }, [
composeText,
sending,
paused,
voiceStatus,
sessionEnded,
liveSessionId,
elapsed,
pushSignal,
requestLiveCoach,
]);
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => { const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
@ -1156,6 +1201,7 @@ export default function Session() {
return; return;
} }
recorderRef.current = recorder; recorderRef.current = recorder;
setClientReplyPending(false);
recorder.ondataavailable = (event) => { recorder.ondataavailable = (event) => {
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return; if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
@ -1218,6 +1264,7 @@ export default function Session() {
const id = nextId(); const id = nextId();
pendingVoiceLearnerIdRef.current = id; pendingVoiceLearnerIdRef.current = id;
pendingVoiceLearnerTextRef.current = payload.text; pendingVoiceLearnerTextRef.current = payload.text;
setClientReplyPending(true);
setUtterances((prev) => [ setUtterances((prev) => [
...prev, ...prev,
{ id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true }, { id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true },
@ -1226,6 +1273,7 @@ export default function Session() {
} }
if (payload.type === "reply") { if (payload.type === "reply") {
setClientReplyPending(false);
if (payload.stage) setStage(payload.stage); if (payload.stage) setStage(payload.stage);
if (typeof payload.effective_openness === "number") { if (typeof payload.effective_openness === "number") {
setOpenness(payload.effective_openness); setOpenness(payload.effective_openness);
@ -1264,6 +1312,7 @@ export default function Session() {
} }
if (payload.type === "degraded") { if (payload.type === "degraded") {
setClientReplyPending(false);
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다."; const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
const reason = const reason =
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason; payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
@ -1273,6 +1322,7 @@ export default function Session() {
} }
if (payload.type === "error") { if (payload.type === "error") {
setClientReplyPending(false);
if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) { if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) {
const pendingId = pendingVoiceLearnerIdRef.current; const pendingId = pendingVoiceLearnerIdRef.current;
setUtterances((prev) => prev.filter((u) => u.id !== pendingId)); setUtterances((prev) => prev.filter((u) => u.id !== pendingId));
@ -1285,6 +1335,7 @@ export default function Session() {
}; };
ws.onerror = () => { ws.onerror = () => {
setClientReplyPending(false);
shutdownVoice("error", "음성 연결을 확인하지 못했습니다."); shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
pushSignal("warn", "음성 연결 실패"); pushSignal("warn", "음성 연결 실패");
}; };
@ -1322,6 +1373,17 @@ export default function Session() {
stopMicStream, stopMicStream,
]); ]);
const skipVoicePlayback = useCallback(() => {
ttsChunksRef.current = [];
stopTtsPlayback();
closeVoiceSocket();
setVoiceAnalyser(null);
setAvatarState("listening");
setVoiceStatus("idle");
setVoiceDetail("음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.");
pushSignal("neutral", "음성 건너뜀");
}, [closeVoiceSocket, pushSignal, stopTtsPlayback]);
const toggleMic = useCallback(() => { const toggleMic = useCallback(() => {
if (paused || sending || sessionEnded) return; if (paused || sending || sessionEnded) return;
if (voiceAvailable === false) { if (voiceAvailable === false) {
@ -1348,6 +1410,7 @@ export default function Session() {
setPaused((p) => { setPaused((p) => {
const next = !p; const next = !p;
if (next) { if (next) {
if (voiceStatus !== "idle") setClientReplyPending(false);
shutdownVoice("idle", "일시정지 중입니다."); shutdownVoice("idle", "일시정지 중입니다.");
setAvatarState("idle"); setAvatarState("idle");
} else { } else {
@ -1356,7 +1419,7 @@ export default function Session() {
} }
return next; return next;
}); });
}, [sessionEnded, shutdownVoice]); }, [sessionEnded, shutdownVoice, voiceStatus]);
// ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ── // ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ──
useEffect(() => { useEffect(() => {
@ -1546,6 +1609,7 @@ export default function Session() {
voiceStatus === "thinking" || voiceStatus === "thinking" ||
voiceStatus === "speaking" || voiceStatus === "speaking" ||
sending; sending;
const textTurnBlocked = sending || voiceStatus === "thinking";
const elapsedLabel = formatElapsed(elapsed); const elapsedLabel = formatElapsed(elapsed);
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false; const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
const consentRequired = user?.role === "learner" && user.consentAt == null; const consentRequired = user?.role === "learner" && user.consentAt == null;
@ -1748,6 +1812,23 @@ export default function Session() {
</span> </span>
))} ))}
</div> </div>
<fieldset className="sx-theory" aria-label="이론모드 선택">
<legend></legend>
<div className="sx-theory__seg">
{THEORY_MODE_OPTIONS.map((option) => (
<button
type="button"
key={option.value}
className={option.value === selectedTheoryMode ? "is-selected" : ""}
aria-pressed={option.value === selectedTheoryMode}
onClick={() => setSelectedTheoryMode(option.value)}
>
<span>{option.label}</span>
<small>{option.detail}</small>
</button>
))}
</div>
</fieldset>
{personaStatusMessage ? ( {personaStatusMessage ? (
<p className="sx-prestart__note">{personaStatusMessage}</p> <p className="sx-prestart__note">{personaStatusMessage}</p>
) : null} ) : null}
@ -1986,57 +2067,77 @@ export default function Session() {
</div> </div>
<div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}> <div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}>
{utterances.length > 0 ? ( {utterances.length > 0 || clientReplyPending ? (
utterances.map((u) => { <>
const turnCoachEvents = {utterances.map((u) => {
u.speaker === "learner" && u.turnSeq const turnCoachEvents =
? (coachHistoryByTurn.get(u.turnSeq) ?? []) u.speaker === "learner" && u.turnSeq
: []; ? (coachHistoryByTurn.get(u.turnSeq) ?? [])
const latestCoach = turnCoachEvents[turnCoachEvents.length - 1]; : [];
return ( const latestCoach = turnCoachEvents[turnCoachEvents.length - 1];
<div return (
key={u.id} <div
className={ key={u.id}
"sx-utt " + className={
(u.speaker === "client" ? "is-client" : "is-learner") + "sx-utt " +
(u.partial ? " is-partial" : "") (u.speaker === "client" ? "is-client" : "is-learner") +
} (u.partial ? " is-partial" : "")
> }
>
<div className="sx-utt__head">
<span className="sx-utt__spk">
{u.speaker === "client" ? clientName : "나 (학습자)"}
</span>
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
</div>
<div className="sx-utt__body">
<div className="sx-utt__line">
{u.text}
{u.partial ? <span className="sx-utt__caret" /> : null}
</div>
{turnCoachEvents.length && u.turnSeq ? (
<button
type="button"
className={
"sx-utt__coach-mark" +
(latestCoach?.suggestion.tone === "pos"
? " is-pos"
: latestCoach?.suggestion.tone === "warn"
? " is-warn"
: "")
}
title={latestCoach?.suggestion.title ?? "코칭 이력"}
onClick={() => openCoachHistory(u.turnSeq ?? null)}
>
C
{turnCoachEvents.length > 1 ? (
<span>{turnCoachEvents.length}</span>
) : null}
</button>
) : null}
</div>
</div>
);
})}
{clientReplyPending ? (
<div className="sx-utt is-client is-thinking" aria-live="polite">
<div className="sx-utt__head"> <div className="sx-utt__head">
<span className="sx-utt__spk"> <span className="sx-utt__spk">{clientName}</span>
{u.speaker === "client" ? clientName : "나 (학습자)"} <span className="sx-utt__tc">{formatTimecode(elapsed)}</span>
</span>
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
</div> </div>
<div className="sx-utt__body"> <div className="sx-utt__body">
<div className="sx-utt__line"> <div className="sx-utt__line">
{u.text} .
{u.partial ? <span className="sx-utt__caret" /> : null} <span className="sx-utt__dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</div> </div>
{turnCoachEvents.length && u.turnSeq ? (
<button
type="button"
className={
"sx-utt__coach-mark" +
(latestCoach?.suggestion.tone === "pos"
? " is-pos"
: latestCoach?.suggestion.tone === "warn"
? " is-warn"
: "")
}
title={latestCoach?.suggestion.title ?? "코칭 이력"}
onClick={() => openCoachHistory(u.turnSeq ?? null)}
>
C
{turnCoachEvents.length > 1 ? (
<span>{turnCoachEvents.length}</span>
) : null}
</button>
) : null}
</div> </div>
</div> </div>
); ) : null}
}) </>
) : ( ) : (
<div className="sx-transcript__empty"> <div className="sx-transcript__empty">
<span className="sx-transcript__empty-dot" aria-hidden="true" /> <span className="sx-transcript__empty-dot" aria-hidden="true" />
@ -2078,13 +2179,13 @@ export default function Session() {
? "일시정지 중입니다." ? "일시정지 중입니다."
: "학습자 발화를 입력하세요." : "학습자 발화를 입력하세요."
} }
disabled={sessionEnded || paused || sending} disabled={sessionEnded || paused || textTurnBlocked}
aria-label="학습자 발화 입력" aria-label="학습자 발화 입력"
/> />
</div> </div>
<Button <Button
onClick={() => void handleSend()} onClick={() => void handleSend()}
disabled={!composeText.trim() || sending || paused || sessionEnded} disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded}
trailing={<Icon name="arrow-up" size={15} />} trailing={<Icon name="arrow-up" size={15} />}
> >
@ -2306,6 +2407,17 @@ export default function Session() {
<span className="sx-mic-block__l">{micLabel}</span> <span className="sx-mic-block__l">{micLabel}</span>
<span className="sx-mic-block__h">{voiceDetail}</span> <span className="sx-mic-block__h">{voiceDetail}</span>
</span> </span>
{voiceStatus === "speaking" ? (
<button
type="button"
className="sx-voice-skip"
onClick={skipVoicePlayback}
aria-label="음성 건너뛰기"
>
<Icon name="x" size={14} />
<span></span>
</button>
) : null}
</div> </div>
<span className="sx-cb-sep" /> <span className="sx-cb-sep" />

View file

@ -13,6 +13,7 @@ import {
import { import {
sessionApi, sessionApi,
teacherApi, teacherApi,
userApi,
type ReviewCaseWorksheet, type ReviewCaseWorksheet,
type ReviewNonverbalEvent, type ReviewNonverbalEvent,
type ReviewNote, type ReviewNote,
@ -21,11 +22,69 @@ import {
type ReviewTurn, type ReviewTurn,
type ReviewWorksheetSection, type ReviewWorksheetSection,
type SessionReviewResponse, type SessionReviewResponse,
type UserPrepostMeasureItem,
type UserPrepostMeasuresResponse,
} from "../lib/api"; } from "../lib/api";
import { ValenceChart } from "./session-review/ValenceChart"; import { ValenceChart } from "./session-review/ValenceChart";
import "./session-review/session-review.css"; import "./session-review/session-review.css";
type LoadState = "loading" | "ready" | "error"; type LoadState = "loading" | "ready" | "error";
type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
const PREPOST_PILOT_ID = "phase3-pilot-draft";
const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
const PREPOST_SCORE_MIN = 1;
const PREPOST_SCORE_MAX = 5;
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
{ key: "skill_proficiency", label: "기술숙련도", hint: "기법 적용 숙련감" },
{ key: "training_satisfaction", label: "수련만족도", hint: "훈련 경험 만족도" },
];
const PREPOST_TIMEPOINTS: Array<{ key: PrepostTimepoint; label: string }> = [
{ key: "pre", label: "사전" },
{ key: "post", label: "사후" },
];
function prepostDraftKey(measure: PrepostMeasureName, timepoint: PrepostTimepoint) {
return `${measure}:${timepoint}`;
}
function splitPrepostDraftKey(key: string): [PrepostMeasureName, PrepostTimepoint] | null {
const [measure, timepoint] = key.split(":");
if (
PREPOST_MEASURES.some((item) => item.key === measure) &&
PREPOST_TIMEPOINTS.some((item) => item.key === timepoint)
) {
return [measure as PrepostMeasureName, timepoint as PrepostTimepoint];
}
return null;
}
function prepostScoreText(score: number) {
return Number.isInteger(score) ? String(score) : score.toFixed(1).replace(/\.0$/, "");
}
function prepostDraftFromResponse(
response: UserPrepostMeasuresResponse | null,
): Record<string, string> {
const next: Record<string, string> = {};
for (const item of response?.measures ?? []) {
next[prepostDraftKey(item.measure_name, item.timepoint)] = prepostScoreText(item.raw_score);
}
return next;
}
function parsePrepostScore(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
if (parsed < PREPOST_SCORE_MIN || parsed > PREPOST_SCORE_MAX) return null;
return parsed;
}
function renderSummary(summary: string) { function renderSummary(summary: string) {
const parts = summary.split(/(<hl>.*?<\/hl>)/g); const parts = summary.split(/(<hl>.*?<\/hl>)/g);
@ -420,6 +479,181 @@ function CaseWorksheetCard({
); );
} }
function PrepostMeasureCard() {
const [data, setData] = useState<UserPrepostMeasuresResponse | null>(null);
const [draft, setDraft] = useState<Record<string, string>>({});
const [baseline, setBaseline] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
setLoading(true);
setError(null);
try {
const next = await userApi.prepostMeasures(PREPOST_PILOT_ID);
if (!alive) return;
const nextDraft = prepostDraftFromResponse(next);
setData(next);
setDraft(nextDraft);
setBaseline(nextDraft);
} catch {
if (!alive) return;
setError("사전/사후 점수 원장을 불러오지 못했습니다.");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
const dirtyKeys = useMemo(
() =>
Object.keys(draft).filter((key) => {
const split = splitPrepostDraftKey(key);
if (!split) return false;
const value = draft[key]?.trim() ?? "";
return value !== (baseline[key] ?? "") && parsePrepostScore(value) !== null;
}),
[baseline, draft],
);
const invalidKeys = useMemo(
() =>
Object.keys(draft).filter((key) => {
const split = splitPrepostDraftKey(key);
if (!split) return false;
const value = draft[key]?.trim() ?? "";
return value.length > 0 && parsePrepostScore(value) === null;
}),
[draft],
);
const saveDraft = async () => {
if (dirtyKeys.length === 0) return;
setSaving(true);
setError(null);
try {
for (const key of dirtyKeys) {
const split = splitPrepostDraftKey(key);
const score = parsePrepostScore(draft[key] ?? "");
if (!split || score === null) continue;
const [measureName, timepoint] = split;
await userApi.upsertPrepostMeasure({
pilot_id: PREPOST_PILOT_ID,
instrument_version: PREPOST_INSTRUMENT_VERSION,
item_count: 1,
min_score: PREPOST_SCORE_MIN,
max_score: PREPOST_SCORE_MAX,
measure_name: measureName,
timepoint,
raw_score: score,
});
}
const next = await userApi.prepostMeasures(PREPOST_PILOT_ID);
const nextDraft = prepostDraftFromResponse(next);
setData(next);
setDraft(nextDraft);
setBaseline(nextDraft);
} catch {
setError("사전/사후 점수를 저장하지 못했습니다.");
} finally {
setSaving(false);
}
};
const completePairs = data?.complete_measure_pairs ?? 0;
return (
<Card className="sr-card sr-card--side sr-card--prepost">
<div className="sr-prepost__head">
<div>
<Kicker>Phase 3 /</Kicker>
<b> </b>
</div>
<span className="sr-prepost__badge">{completePairs}/3 </span>
</div>
<p className="sr-prepost__note">
1~5 . .
</p>
<div className="sr-prepost__table" aria-busy={loading || saving}>
<div className="sr-prepost__row sr-prepost__row--head" aria-hidden="true">
<span></span>
{PREPOST_TIMEPOINTS.map((timepoint) => (
<span key={timepoint.key}>{timepoint.label}</span>
))}
</div>
{PREPOST_MEASURES.map((measure) => (
<div className="sr-prepost__row" key={measure.key}>
<div className="sr-prepost__measure">
<b>{measure.label}</b>
<small>{measure.hint}</small>
</div>
{PREPOST_TIMEPOINTS.map((timepoint) => {
const key = prepostDraftKey(measure.key, timepoint.key);
const invalid = invalidKeys.includes(key);
return (
<label className="sr-prepost__field" key={key}>
<span>{measure.label} {timepoint.label}</span>
<input
type="number"
min={PREPOST_SCORE_MIN}
max={PREPOST_SCORE_MAX}
step="0.1"
inputMode="decimal"
value={draft[key] ?? ""}
placeholder="-"
aria-invalid={invalid}
aria-label={`${measure.label} ${timepoint.label} 점수`}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setDraft((current) => ({
...current,
[key]: nextValue,
}));
}}
/>
</label>
);
})}
</div>
))}
</div>
{invalidKeys.length > 0 ? (
<p className="sr-prepost__error" role="alert">
1 5 .
</p>
) : error ? (
<p className="sr-prepost__error" role="alert">
{error}
</p>
) : null}
<div className="sr-prepost__actions">
<span>
{loading
? "불러오는 중"
: dirtyKeys.length > 0
? `${dirtyKeys.length}개 변경`
: "저장된 값 기준"}
</span>
<Button
variant="secondary"
size="sm"
disabled={loading || saving || dirtyKeys.length === 0 || invalidKeys.length > 0}
onClick={() => void saveDraft()}
>
{saving ? "저장 중" : "점수 저장"}
</Button>
</div>
</Card>
);
}
export default function SessionReview() { export default function SessionReview() {
const { sessionId } = useParams<{ sessionId: string }>(); const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -653,7 +887,7 @@ export default function SessionReview() {
</div> </div>
</Card> </Card>
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : ""}`}> <div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : "sr-cols--learner"}`}>
<div className="sr-left"> <div className="sr-left">
<Card className="sr-overview"> <Card className="sr-overview">
<Kicker> </Kicker> <Kicker> </Kicker>
@ -1018,6 +1252,8 @@ export default function SessionReview() {
) : null} ) : null}
</Card> </Card>
{!isSupervisorView ? <PrepostMeasureCard /> : null}
<CaseWorksheetCard <CaseWorksheetCard
sessionId={data.session_id} sessionId={data.session_id}
worksheet={data.caseWorksheet} worksheet={data.caseWorksheet}

View file

@ -20,6 +20,8 @@ import {
type NotificationPreferences, type NotificationPreferences,
type UserPreferencesResponse, type UserPreferencesResponse,
type UserProfileResponse, type UserProfileResponse,
type UserSupportTicketListItem,
type UserSupportTicketsResponse,
type VoicePresetResponse, type VoicePresetResponse,
} from "../lib/api"; } from "../lib/api";
import { readInitialTheme } from "../lib/theme"; import { readInitialTheme } from "../lib/theme";
@ -43,6 +45,30 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
}; };
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const; const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
const TICKET_STATUS_LABEL: Record<UserSupportTicketListItem["status"], string> = {
open: "접수",
triaged: "분류됨",
in_progress: "처리 중",
resolved: "해결",
closed: "종료",
};
const TICKET_CATEGORY_LABEL: Record<UserSupportTicketListItem["category"], string> = {
account_access: "계정",
session_review: "회기 리뷰",
voice_browser: "음성·브라우저",
content_scenario: "콘텐츠",
safety: "안전",
other: "기타",
};
const TICKET_PRIORITY_LABEL: Record<UserSupportTicketListItem["priority"], string> = {
low: "낮음",
normal: "보통",
high: "높음",
urgent: "긴급",
};
function SpeakerIcon({ size = 15 }: { size?: number }) { function SpeakerIcon({ size = 15 }: { size?: number }) {
return ( return (
<svg <svg
@ -65,6 +91,22 @@ function healthStatusLabel(status: string | undefined): string {
return "확인 중"; return "확인 중";
} }
function ticketStatusTone(status: UserSupportTicketListItem["status"]) {
if (status === "resolved" || status === "closed") return "pos";
if (status === "in_progress" || status === "triaged") return "info";
return "warn";
}
function formatTicketDate(value?: number | null): string {
if (!value) return "-";
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value * 1000));
}
function SettingsLoadingState({ function SettingsLoadingState({
children, children,
testId, testId,
@ -155,11 +197,14 @@ export default function Settings() {
const [displayName, setDisplayName] = useState(user?.name ?? ""); const [displayName, setDisplayName] = useState(user?.name ?? "");
const [affiliation, setAffiliation] = useState(""); const [affiliation, setAffiliation] = useState("");
const [preferences, setPreferences] = useState<UserPreferencesResponse | null>(null); const [preferences, setPreferences] = useState<UserPreferencesResponse | null>(null);
const [supportTickets, setSupportTickets] = useState<UserSupportTicketsResponse | null>(null);
const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]); const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]);
const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null); const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null);
const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null); const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null);
const [profileReady, setProfileReady] = useState(false); const [profileReady, setProfileReady] = useState(false);
const [preferencesReady, setPreferencesReady] = useState(false); const [preferencesReady, setPreferencesReady] = useState(false);
const [supportTicketsReady, setSupportTicketsReady] = useState(false);
const [supportTicketsError, setSupportTicketsError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [savedKey, setSavedKey] = useState<string | null>(null); const [savedKey, setSavedKey] = useState<string | null>(null);
@ -192,14 +237,21 @@ export default function Settings() {
setError(null); setError(null);
setProfileReady(false); setProfileReady(false);
setPreferencesReady(false); setPreferencesReady(false);
setSupportTicketsReady(false);
setPreferences(null); setPreferences(null);
setSupportTickets(null);
setSupportTicketsError(null);
setVoicePresets([]); setVoicePresets([]);
setEngineConfig(null); setEngineConfig(null);
engineConfigRef.current = null; engineConfigRef.current = null;
try { try {
const [nextProfile, nextPrefs, presets, nextEngine] = await Promise.all([ const [nextProfile, nextPrefs, nextTickets, presets, nextEngine] = await Promise.all([
userApi.me(), userApi.me(),
userApi.preferences(), userApi.preferences(),
userApi.supportTickets().catch((err) => {
setSupportTicketsError(err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다.");
return null;
}),
userApi.voicePresets(), userApi.voicePresets(),
isAdmin ? adminEngineApi.get() : Promise.resolve(null), isAdmin ? adminEngineApi.get() : Promise.resolve(null),
]); ]);
@ -215,6 +267,8 @@ export default function Settings() {
} }
setPreferences(nextPrefs); setPreferences(nextPrefs);
setPreferencesReady(true); setPreferencesReady(true);
setSupportTickets(nextTickets);
setSupportTicketsReady(true);
setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark")); setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark"));
setVoicePresets(presets); setVoicePresets(presets);
if (nextEngine) { if (nextEngine) {
@ -240,6 +294,7 @@ export default function Settings() {
const navItems: NavItem[] = useMemo(() => { const navItems: NavItem[] = useMemo(() => {
const base: NavItem[] = [ const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" }, { id: "account", label: "계정", icon: "users" },
{ id: "support", label: "지원 요청", icon: "review" },
{ id: "appearance", label: "테마", icon: "settings" }, { id: "appearance", label: "테마", icon: "settings" },
{ id: "notify", label: "알림", icon: "info" }, { id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" }, { id: "voice", label: "음성", icon: "mic" },
@ -550,6 +605,73 @@ export default function Settings() {
</div> </div>
</Panel> </Panel>
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"> </div>
<div className="vg-set__group-desc">
.
</div>
</div>
{supportTickets ? (
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
{supportTickets.tickets.length}
</Badge>
) : null}
</div>
{supportTicketsError ? (
<div className="vg-set__state vg-set__state--warn" role="alert">
<span className="vg-set__state-dot" aria-hidden="true" />
<span>{supportTicketsError}</span>
</div>
) : supportTickets ? (
supportTickets.tickets.length > 0 ? (
<div className="vg-set__tickets" aria-label="내 지원 요청 목록">
{supportTickets.tickets.map((ticket) => (
<article className="vg-set__ticket" key={ticket.ticket_id}>
<div className="vg-set__ticket-top">
<Badge tone={ticketStatusTone(ticket.status)}>
{TICKET_STATUS_LABEL[ticket.status]}
</Badge>
<span>{TICKET_CATEGORY_LABEL[ticket.category]}</span>
<span>{TICKET_PRIORITY_LABEL[ticket.priority]}</span>
</div>
<div className="vg-set__ticket-title">{ticket.subject}</div>
<div className="vg-set__ticket-meta">
<span> {formatTicketDate(ticket.created_at)}</span>
<span> {formatTicketDate(ticket.updated_at)}</span>
{ticket.assigned_group ? <span>{ticket.assigned_group}</span> : null}
</div>
{ticket.resolution_note ? (
<div className="vg-set__ticket-note">
<b> </b>
<span>{ticket.resolution_note}</span>
</div>
) : (
<div className="vg-set__ticket-note vg-set__ticket-note--empty">
.
</div>
)}
</article>
))}
</div>
) : (
<SettingsLoadingState testId="settings-support-empty">
.
</SettingsLoadingState>
)
) : supportTicketsReady ? (
<SettingsLoadingState testId="settings-support-empty">
.
</SettingsLoadingState>
) : (
<SettingsSkeleton testId="settings-support-loading" variant="rows">
.
</SettingsSkeleton>
)}
</Panel>
{isAdmin ? ( {isAdmin ? (
<Panel <Panel
id="set-engine" id="set-engine"

View file

@ -272,6 +272,64 @@
font-size: 28px; font-size: 28px;
} }
.ad-cache {
display: grid;
gap: 5px;
padding-top: 10px;
border-top: 1px solid var(--paper-2);
}
.ad-cache span,
.ad-cache small {
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
}
.ad-cache b {
color: var(--text-strong);
font-size: var(--fs-body);
font-weight: 700;
line-height: 1.25;
}
.ad-cost-trend {
display: grid;
gap: 6px;
padding-top: 10px;
border-top: 1px solid var(--paper-2);
}
.ad-cost-trend > span {
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
}
.ad-cost-trend > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.ad-cost-trend b {
flex: 0 0 auto;
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-sm);
line-height: 1.25;
}
.ad-cost-trend div span {
min-width: 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
text-align: right;
}
.ad-ticket-mini { .ad-ticket-mini {
display: grid; display: grid;
gap: 10px; gap: 10px;
@ -1113,6 +1171,24 @@
gap: 4px 10px; gap: 4px 10px;
} }
.ad-ticket__dupes {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 5px 0;
}
.ad-ticket .ad-ticket__dupes span {
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
padding: 3px 6px;
background: var(--bg-app);
color: var(--accent-deep);
font-family: var(--font-num);
font-size: var(--fs-xs);
font-weight: 800;
}
.ad-ticket span, .ad-ticket span,
.ad-ticket time { .ad-ticket time {
color: var(--text-muted); color: var(--text-muted);
@ -1143,8 +1219,10 @@
.ad-ticket__actions { .ad-ticket__actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 6px; gap: 6px;
max-width: 190px;
} }
.ad-ticket__actions .vg-btn { .ad-ticket__actions .vg-btn {

View file

@ -1,7 +1,10 @@
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);} .ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
.ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;} .ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;}
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;} .ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border-radius:20px;background:var(--bg-stage);margin-bottom:32px;} .ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
.ap__hero .vg-avatar__state{color:#5a6663;}
.ap__hero .vg-avatar__state b{color:#1c2a2a;}
.ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;} .ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;}
.ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;} .ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;}
.ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);} .ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);}

View file

@ -120,6 +120,7 @@
"overview flow rubric" "overview flow rubric"
"overview transcript good" "overview transcript good"
"overview transcript growth" "overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet" "overview transcript worksheet"
"overview transcript feedback" "overview transcript feedback"
"session session session"; "session session session";
@ -133,10 +134,59 @@
"overview flow rubric" "overview flow rubric"
"overview transcript good" "overview transcript good"
"overview transcript growth" "overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet" "overview transcript worksheet"
"overview transcript feedback" "overview transcript feedback"
"session session session"; "session session session";
} }
.sr-cols--learner {
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
grid-template-areas:
"transcript overview"
"transcript chart"
"transcript flow"
"transcript rubric"
"transcript good"
"transcript growth"
"transcript prepost"
"transcript worksheet"
"transcript feedback"
"session session";
}
.sr-cols--learner .sr-overview {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
}
.sr-root--empty .sr-cols {
grid-template-columns: minmax(236px, 300px) minmax(0, 1fr);
grid-template-areas:
"overview chart"
"overview flow"
"overview transcript"
"overview rubric"
"overview good"
"overview growth"
"overview prepost"
"overview worksheet"
"overview feedback"
"session session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview chart"
"overview flow"
"overview teacher"
"overview transcript"
"overview rubric"
"overview good"
"overview growth"
"overview prepost"
"overview worksheet"
"overview feedback"
"session session";
}
.sr-left, .sr-left,
.sr-right { .sr-right {
display: contents; display: contents;
@ -175,6 +225,9 @@
.sr-card--growth { .sr-card--growth {
grid-area: growth; grid-area: growth;
} }
.sr-card--prepost {
grid-area: prepost;
}
.sr-card--worksheet { .sr-card--worksheet {
grid-area: worksheet; grid-area: worksheet;
} }
@ -353,6 +406,146 @@
line-height: 1.55; line-height: 1.55;
} }
.sr-prepost__head {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.sr-prepost__head > div {
min-width: 0;
display: grid;
gap: 4px;
}
.sr-prepost__head b {
color: var(--text-strong);
font-size: var(--fs-sm);
line-height: 1.35;
}
.sr-prepost__badge {
flex: none;
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border-subtle));
border-radius: var(--radius-sm);
color: var(--accent-deep);
background: var(--accent-tint);
font-family: var(--font-num);
font-size: 11px;
font-weight: 750;
white-space: nowrap;
}
.sr-prepost__note {
margin: var(--sp-3) 0 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.5;
}
.sr-prepost__table {
min-width: 0;
display: grid;
gap: 7px;
margin-top: var(--sp-4);
}
.sr-prepost__row {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(58px, 68px));
gap: 8px;
align-items: center;
}
.sr-prepost__row--head {
color: var(--text-muted);
font-family: var(--font-num);
font-size: 10.5px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sr-prepost__row--head span:not(:first-child) {
text-align: center;
}
.sr-prepost__measure {
min-width: 0;
display: grid;
gap: 2px;
}
.sr-prepost__measure b {
min-width: 0;
color: var(--text-strong);
font-size: var(--fs-xs);
line-height: 1.35;
overflow-wrap: anywhere;
}
.sr-prepost__measure small {
min-width: 0;
color: var(--text-muted);
font-size: 10.5px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.sr-prepost__field {
min-width: 0;
display: grid;
}
.sr-prepost__field span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.sr-prepost__field input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 7px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-strong);
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 650;
text-align: center;
}
.sr-prepost__field input:focus {
outline: 2px solid var(--accent-tint);
border-color: var(--accent);
}
.sr-prepost__field input[aria-invalid="true"] {
border-color: color-mix(in srgb, var(--crit-text) 44%, var(--border-subtle));
background: var(--crit-tint);
}
.sr-prepost__error {
margin: var(--sp-3) 0 0;
color: var(--crit-text);
font-size: 12px;
line-height: 1.45;
}
.sr-prepost__actions {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-4);
padding-top: var(--sp-3);
border-top: 1px solid var(--hair);
}
.sr-prepost__actions span {
min-width: 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
overflow-wrap: anywhere;
}
.sr-chart { .sr-chart {
position: relative; position: relative;
width: 100%; width: 100%;
@ -692,6 +885,15 @@
.sr-nonverbal--audio .sr-nonverbal__dot { .sr-nonverbal--audio .sr-nonverbal__dot {
background: var(--accent); background: var(--accent);
} }
.sr-nonverbal--paralinguistic .sr-nonverbal__dot {
background: var(--good-solid);
}
.sr-nonverbal--prosody .sr-nonverbal__dot {
background: var(--ink-soft);
}
.sr-nonverbal--audio_quality .sr-nonverbal__dot {
background: var(--text-muted);
}
.sr-note { .sr-note {
max-width: 68ch; max-width: 68ch;
@ -1187,6 +1389,7 @@
[data-theme="dark"] .sr-empty, [data-theme="dark"] .sr-empty,
[data-theme="dark"] .sr-chip-toggle, [data-theme="dark"] .sr-chip-toggle,
[data-theme="dark"] .sr-ws-item, [data-theme="dark"] .sr-ws-item,
[data-theme="dark"] .sr-prepost__field input,
[data-theme="dark"] .sr-feedback, [data-theme="dark"] .sr-feedback,
[data-theme="dark"] .sr-nextline { [data-theme="dark"] .sr-nextline {
background: rgba(255, 255, 255, 0.055); background: rgba(255, 255, 255, 0.055);
@ -1196,6 +1399,7 @@
[data-theme="dark"] .sr-turn + .sr-turn, [data-theme="dark"] .sr-turn + .sr-turn,
[data-theme="dark"] .sr-readiness, [data-theme="dark"] .sr-readiness,
[data-theme="dark"] .sr-ws-limitations, [data-theme="dark"] .sr-ws-limitations,
[data-theme="dark"] .sr-prepost__actions,
[data-theme="dark"] .sr-feedback__src { [data-theme="dark"] .sr-feedback__src {
border-color: rgba(203, 227, 220, 0.1); border-color: rgba(203, 227, 220, 0.1);
} }
@ -1225,6 +1429,20 @@
"rubric rubric" "rubric rubric"
"good growth" "good growth"
"transcript transcript" "transcript transcript"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-root--empty .sr-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"transcript transcript"
"rubric rubric"
"good growth"
"prepost prepost"
"feedback feedback" "feedback feedback"
"worksheet worksheet" "worksheet worksheet"
"session session"; "session session";
@ -1237,6 +1455,20 @@
"rubric rubric" "rubric rubric"
"good growth" "good growth"
"transcript transcript" "transcript transcript"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"transcript transcript"
"rubric rubric"
"good growth"
"prepost prepost"
"feedback feedback" "feedback feedback"
"worksheet worksheet" "worksheet worksheet"
"session session"; "session session";
@ -1283,12 +1515,28 @@
"rubric" "rubric"
"good" "good"
"growth" "growth"
"prepost"
"transcript" "transcript"
"feedback" "feedback"
"worksheet" "worksheet"
"session"; "session";
gap: var(--sp-4); gap: var(--sp-4);
} }
.sr-root--empty .sr-cols {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"overview"
"chart"
"flow"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-cols--supervisor { .sr-cols--supervisor {
grid-template-areas: grid-template-areas:
"overview" "overview"
@ -1298,11 +1546,27 @@
"rubric" "rubric"
"good" "good"
"growth" "growth"
"prepost"
"transcript" "transcript"
"feedback" "feedback"
"worksheet" "worksheet"
"session"; "session";
} }
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-summary { .sr-summary {
font-size: 20px; font-size: 20px;
line-height: 1.45; line-height: 1.45;

View file

@ -544,8 +544,8 @@
} }
.sx-page--active .sx-stage { .sx-page--active .sx-stage {
background: background:
radial-gradient(circle at 28% 50%, rgba(145, 200, 189, 0.18), transparent 42%), radial-gradient(circle at 28% 50%, rgba(197, 222, 214, 0.26), transparent 42%),
linear-gradient(135deg, rgba(11, 22, 20, 0.96), rgba(31, 43, 39, 0.93)), linear-gradient(135deg, rgba(24, 42, 38, 0.94), rgba(48, 65, 59, 0.9)),
var(--asset-warm-elements) center / cover no-repeat; var(--asset-warm-elements) center / cover no-repeat;
border-color: rgba(255, 255, 255, 0.11); border-color: rgba(255, 255, 255, 0.11);
box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28); box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28);
@ -949,6 +949,33 @@
.sx-utt.is-partial .sx-utt__line { .sx-utt.is-partial .sx-utt__line {
color: var(--text-muted); color: var(--text-muted);
} }
.sx-utt.is-thinking .sx-utt__line {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
border: 1px dashed color-mix(in srgb, var(--clay-deep) 24%, transparent);
}
.sx-utt__dots {
display: inline-flex;
align-items: center;
gap: 3px;
flex: none;
}
.sx-utt__dots i {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
animation: sxThinkingDot 900ms var(--ease-in-out) infinite;
}
.sx-utt__dots i:nth-child(2) {
animation-delay: 120ms;
}
.sx-utt__dots i:nth-child(3) {
animation-delay: 240ms;
}
.sx-utt__caret { .sx-utt__caret {
display: inline-block; display: inline-block;
width: 2px; width: 2px;
@ -961,6 +988,10 @@
@keyframes sxCaret { @keyframes sxCaret {
50% { opacity: 0; } 50% { opacity: 0; }
} }
@keyframes sxThinkingDot {
0%, 100% { opacity: 0.25; transform: translateY(0); }
50% { opacity: 0.9; transform: translateY(-2px); }
}
/* "최신으로" 칩 (위로 스크롤 시) */ /* "최신으로" 칩 (위로 스크롤 시) */
.sx-transcript__jump { .sx-transcript__jump {
@ -1642,6 +1673,10 @@
background: rgba(204, 143, 119, 0.12); background: rgba(204, 143, 119, 0.12);
color: #edf5f2; color: #edf5f2;
} }
.sx-page--active .sx-utt.is-thinking .sx-utt__line {
color: rgba(234, 240, 241, 0.62);
border-color: rgba(229, 173, 149, 0.22);
}
.sx-page--active .sx-utt.is-learner .sx-utt__line { .sx-page--active .sx-utt.is-learner .sx-utt__line {
background: rgba(111, 179, 164, 0.13); background: rgba(111, 179, 164, 0.13);
color: #edf5f2; color: #edf5f2;
@ -1731,6 +1766,36 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.sx-voice-skip {
flex: none;
height: 34px;
padding: 0 11px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--bg-surface-2);
color: var(--text-body);
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.sx-voice-skip:hover {
border-color: var(--border-focus);
color: var(--text-strong);
}
.sx-page--active .sx-voice-skip {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.14);
color: rgba(238, 244, 242, 0.86);
}
.sx-page--active .sx-voice-skip:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.sx-cb-sep { .sx-cb-sep {
width: 1px; width: 1px;
@ -2336,6 +2401,72 @@
background: rgba(204, 143, 119, 0.17); background: rgba(204, 143, 119, 0.17);
color: #f0bda9; color: #f0bda9;
} }
.sx-theory {
width: min(100%, 520px);
margin: var(--sp-4) 0 0;
padding: 0;
border: 0;
display: grid;
gap: 8px;
}
.sx-theory legend {
padding: 0;
color: rgba(238, 244, 242, 0.54);
font-size: 11.5px;
font-weight: 760;
}
.sx-theory__seg {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 4px;
border-radius: var(--radius-sm);
background: rgba(6, 15, 14, 0.26);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sx-theory__seg button {
min-width: 0;
min-height: 56px;
display: grid;
align-content: center;
gap: 3px;
padding: 8px 7px;
border: 1px solid transparent;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: rgba(238, 244, 242, 0.72);
text-align: center;
cursor: pointer;
}
.sx-theory__seg button:hover {
border-color: rgba(145, 200, 189, 0.3);
color: #f4f8f7;
}
.sx-theory__seg button:focus-visible {
outline: 2px solid rgba(145, 200, 189, 0.7);
outline-offset: 2px;
}
.sx-theory__seg button.is-selected {
background: rgba(145, 200, 189, 0.18);
border-color: rgba(145, 200, 189, 0.46);
color: #f4f8f7;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.sx-theory__seg span,
.sx-theory__seg small {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.2;
}
.sx-theory__seg span {
font-size: 13px;
font-weight: 760;
}
.sx-theory__seg small {
color: rgba(238, 244, 242, 0.56);
font-size: 11px;
font-weight: 650;
}
.sx-prestart__note { .sx-prestart__note {
max-width: 460px; max-width: 460px;
font-size: 12.5px; font-size: 12.5px;
@ -2431,16 +2562,21 @@
.sx-stage.is-paused::after { .sx-stage.is-paused::after {
content: "일시정지"; content: "일시정지";
position: absolute; position: absolute;
inset: 0; inset: 12px;
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: center; justify-content: flex-end;
font-size: 15px; padding: 12px;
font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--text-on-accent); color: #f4faf7;
background: color-mix(in srgb, var(--bg-stage) 62%, transparent); border: 1px solid rgba(203, 227, 220, 0.14);
backdrop-filter: blur(1px); border-radius: calc(var(--radius-lg) - 2px);
background:
linear-gradient(135deg, rgba(238, 244, 242, 0.08), rgba(145, 200, 189, 0.06));
backdrop-filter: blur(0.5px);
text-shadow: 0 1px 2px rgba(7, 16, 14, 0.5);
} }
/* ── 반응형 ── */ /* ── 반응형 ── */
@ -2918,6 +3054,23 @@
.sx-page--prestart .sx-prestart__chips { .sx-page--prestart .sx-prestart__chips {
display: none; display: none;
} }
.sx-page--prestart .sx-theory {
margin-top: 8px;
}
.sx-page--prestart .sx-theory__seg {
gap: 4px;
padding: 3px;
}
.sx-page--prestart .sx-theory__seg button {
min-height: 50px;
padding: 6px 4px;
}
.sx-page--prestart .sx-theory__seg span {
font-size: 12px;
}
.sx-page--prestart .sx-theory__seg small {
font-size: 10.5px;
}
.sx-page--prestart .sx-prestart__plan { .sx-page--prestart .sx-prestart__plan {
display: none; display: none;
} }
@ -2971,6 +3124,14 @@
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
} }
.sx-voice-skip span {
display: none;
}
.sx-voice-skip {
width: 34px;
padding: 0;
justify-content: center;
}
} }
@media (max-width: 420px) { @media (max-width: 420px) {
@ -3246,6 +3407,7 @@
.sx-orb, .sx-orb,
.sx-mic.is-on::after, .sx-mic.is-on::after,
.sx-utt__caret, .sx-utt__caret,
.sx-utt__dots i,
.sx-transcript__live-dot.is-live { .sx-transcript__live-dot.is-live {
animation: none !important; animation: none !important;
} }

View file

@ -1334,18 +1334,21 @@ body[data-page="settings"] .vg-set__forms {
body[data-page="settings"] #set-account { body[data-page="settings"] #set-account {
order: 1; order: 1;
} }
body[data-page="settings"] #set-engine { body[data-page="settings"] #set-support {
order: 2; order: 2;
} }
body[data-page="settings"] #set-appearance { body[data-page="settings"] #set-engine {
order: 3; order: 3;
} }
body[data-page="settings"] #set-voice { body[data-page="settings"] #set-appearance {
order: 4; order: 4;
} }
body[data-page="settings"] #set-notify { body[data-page="settings"] #set-voice {
order: 5; order: 5;
} }
body[data-page="settings"] #set-notify {
order: 6;
}
body[data-page="settings"] .vg-set__group { body[data-page="settings"] .vg-set__group {
padding: 18px; padding: 18px;
border: 1px solid rgba(37, 45, 42, 0.12); border: 1px solid rgba(37, 45, 42, 0.12);
@ -1409,6 +1412,57 @@ body[data-page="settings"] .vg-set__voicelist {
body[data-page="settings"] .vg-set__voice { body[data-page="settings"] .vg-set__voice {
min-height: 72px; min-height: 72px;
} }
body[data-page="settings"] .vg-set__tickets {
display: grid;
gap: 10px;
}
body[data-page="settings"] .vg-set__ticket {
min-width: 0;
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(37, 45, 42, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.86);
}
body[data-page="settings"] .vg-set__ticket-top,
body[data-page="settings"] .vg-set__ticket-meta {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px 10px;
color: var(--text-muted);
font-size: var(--fs-xs);
}
body[data-page="settings"] .vg-set__ticket-title {
min-width: 0;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
}
body[data-page="settings"] .vg-set__ticket-note {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px 10px;
border-radius: var(--radius-sm);
background: rgba(244, 241, 236, 0.78);
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.55;
overflow-wrap: anywhere;
}
body[data-page="settings"] .vg-set__ticket-note b {
color: var(--text-strong);
font-size: var(--fs-kicker);
letter-spacing: 0.04em;
}
body[data-page="settings"] .vg-set__ticket-note--empty {
color: var(--text-muted);
}
body[data-page="settings"] .vg-set__foot { body[data-page="settings"] .vg-set__foot {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
@ -1594,6 +1648,8 @@ html[data-theme="dark"] body[data-page="settings"] .vg-set__range-row,
html[data-theme="dark"] body[data-page="settings"] .vg-set__seg, html[data-theme="dark"] body[data-page="settings"] .vg-set__seg,
html[data-theme="dark"] body[data-page="settings"] .vg-set__pill, html[data-theme="dark"] body[data-page="settings"] .vg-set__pill,
html[data-theme="dark"] body[data-page="settings"] .vg-set__callout, html[data-theme="dark"] body[data-page="settings"] .vg-set__callout,
html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket,
html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket-note,
html[data-theme="dark"] body[data-page="settings"] .vg-badge { html[data-theme="dark"] body[data-page="settings"] .vg-badge {
border-color: rgba(203, 227, 220, 0.11); border-color: rgba(203, 227, 220, 0.11);
background: rgba(255, 255, 255, 0.055); background: rgba(255, 255, 255, 0.055);