운영 화면과 회기 리뷰 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;
}
interface AdminUsageDailyCost {
day: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
@ -60,6 +68,17 @@ interface AdminUsageBudget {
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 {
source: "database" | "server_session_registry";
durable: boolean;
@ -70,7 +89,9 @@ interface AdminUsageResponse {
tokens_out: number;
cost_usd: number;
budget: AdminUsageBudget;
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[];
daily_cost?: AdminUsageDailyCost[];
}
interface AdminUptimeResponse {
@ -99,6 +120,10 @@ interface AdminSupportTicket {
subject: string;
body: 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;
updated_at: number;
}
@ -262,6 +287,30 @@ function costLabel(value: number) {
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) {
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.getByText("샘플 정상률")).toBeVisible();
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 }) => {
await signInAsLearner(page);

View file

@ -1,7 +1,13 @@
import { promises as fs } from "node:fs";
import path from "node:path";
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 {
completeOnboarding,
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.configure({ mode: "serial" });
@ -257,10 +338,25 @@ test.describe("layout visual gate @single-run", () => {
test("session review stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
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 clientReply = "괜찮아요. 천천히 말해볼게요.";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
async function routeMvpApi(page: Page) {
const sessionStartRequests: unknown[] = [];
const streamSeen = deferred();
const streamGate = deferred();
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
@ -49,6 +61,7 @@ async function routeMvpApi(page: Page) {
await route.fallback();
return;
}
sessionStartRequests.push(route.request().postDataJSON());
await route.fulfill({
status: 201,
contentType: "application/json",
@ -76,7 +89,7 @@ async function routeMvpApi(page: Page) {
session_no: 1,
status: "active",
stage: "라포",
theory_mode: "humanistic",
theory_mode: "cbt",
effective_openness: 0.21,
started_at: new Date().toISOString(),
ended_at: null,
@ -87,6 +100,8 @@ async function routeMvpApi(page: Page) {
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
streamSeen.resolve();
await streamGate.promise;
await route.fulfill({
status: 200,
contentType: "text/event-stream",
@ -176,6 +191,8 @@ async function routeMvpApi(page: Page) {
}),
});
});
return { sessionStartRequests, streamGate, streamSeen };
}
test.describe("P1 MVP core loop", () => {
@ -187,21 +204,32 @@ test.describe("P1 MVP core loop", () => {
page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
});
await routeMvpApi(page);
const api = await routeMvpApi(page);
await page.goto("/learn/session/P1");
await expect(
page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.getByRole("button", { name: /CBT/ }).click();
await page.getByRole("button", { name: "회기 시작" }).click();
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.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
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.getByLabel("학습자 발화 입력")).toBeEnabled();
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 { 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 EMPTY_REVIEW_SESSION_ID = "empty-review-visual";
export function filledReviewResponse(
sessionId: string = FILLED_REVIEW_SESSION_ID,
@ -67,7 +72,10 @@ export function filledReviewResponse(
{ kind: "reflect", label: "감정 반영" },
{ kind: "explore", label: "개방 질문" },
],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "안정" }],
nonverbal: [
{ kind: "pace", label: "말 속도", detail: "안정" },
{ kind: "paralinguistic", label: "음성 단서", detail: "한숨 감지 · 신뢰도 82%" },
],
note: {
author: "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 { 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";
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--rubric")).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 style = window.getComputedStyle(el);
@ -64,19 +69,34 @@ async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => {
const root = gridEl.closest(".sr-root");
const overview = gridEl.querySelector(".sr-overview")?.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
.getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
return {
isEmpty: root?.classList.contains("sr-root--empty") ?? false,
columns,
overviewLeft: overview ? Math.floor(overview.left) : 0,
overviewRight: overview ? Math.ceil(overview.right) : 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);
}
}
@ -84,6 +104,7 @@ async function expectReviewRegionsReachable(page: Page) {
test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = await createEndedSession(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
@ -124,6 +145,7 @@ test.describe("session review", () => {
test("renders filled review evidence state without clipping", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
await routePrepostMeasures(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID));
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("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("감정 반영").first()).toBeVisible();
await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
@ -154,4 +194,27 @@ test.describe("session review", () => {
await expectReviewRegionsReachable(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 {
engine_mode: string;
engine_url: string;
@ -214,6 +225,9 @@ async function expectNoSettingsControlClipping(page: Page) {
".vg-set__field",
".vg-set__control-block",
".vg-set__opt",
".vg-set__tickets",
".vg-set__ticket",
".vg-set__ticket-note",
".vg-set__voice",
".vg-set__range-row",
".vg-set__seg",
@ -405,27 +419,39 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) {
const voicePresetsPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/voice-presets"),
);
const supportTicketsPromise = page.waitForResponse(
isApiResponse("GET", "/users/support-tickets"),
);
const engineConfigPromise = options.admin
? page.waitForResponse(isApiResponse("GET", "/admin/engine-config"))
: null;
await page.goto("/settings");
const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] =
const [
profileResponse,
preferencesResponse,
voicePresetsResponse,
supportTicketsResponse,
engineConfigResponse,
] =
await Promise.all([
profilePromise,
preferencesPromise,
voicePresetsPromise,
supportTicketsPromise,
engineConfigPromise,
]);
await expectResponseOk(profileResponse);
await expectResponseOk(preferencesResponse);
await expectResponseOk(voicePresetsResponse);
await expectResponseOk(supportTicketsResponse);
if (engineConfigResponse) await expectResponseOk(engineConfigResponse);
const profile = (await profileResponse.json()) as UserProfileResponse;
const preferences = (await preferencesResponse.json()) as UserPreferencesResponse;
const supportTickets = (await supportTicketsResponse.json()) as UserSupportTicketsResponse;
const engineConfig = engineConfigResponse
? ((await engineConfigResponse.json()) as AdminEngineConfigResponse)
: null;
@ -433,7 +459,7 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible();
return { profile, preferences, engineConfig };
return { profile, preferences, supportTickets, engineConfig };
}
test.describe("settings page", () => {
@ -580,6 +606,55 @@ test.describe("settings page", () => {
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) => {
const displayName = `Settings Admin ${testInfo.project.name}`;
const email = await signInAs(page, "admin", testInfo, displayName);

View file

@ -411,7 +411,11 @@ const AVATAR_CSS = `
.vg-avatar__stage{
position:relative;width:100%;border-radius:50%;
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{
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"];
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": {
parameters: {
query?: never;
@ -1212,7 +1230,8 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/** List My Support Tickets */
get: operations["list_my_support_tickets_users_support_tickets_get"];
put?: never;
/** Create Support Ticket */
post: operations["create_support_ticket_users_support_tickets_post"];
@ -1349,15 +1368,31 @@ export interface components {
* @enum {string}
*/
category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other";
/**
* Child Ticket Count
* @default 0
*/
child_ticket_count: number;
/** Created At */
created_at: number;
/**
* Duplicate Count
* @default 0
*/
duplicate_count: number;
/** Duplicate Parent Candidate Id */
duplicate_parent_candidate_id?: string | null;
/**
* Event Count
* @default 0
*/
event_count: number;
/** Fingerprint */
fingerprint: string;
/** Last Event At */
last_event_at?: number | null;
/** Parent Ticket Id */
parent_ticket_id?: string | null;
/**
* Priority
* @enum {string}
@ -1386,6 +1421,8 @@ export interface components {
AdminTicketPatch: {
/** Assigned Group */
assigned_group?: string | null;
/** Parent Ticket Id */
parent_ticket_id?: string | null;
/** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Resolution Note */
@ -1521,6 +1558,38 @@ export interface components {
/** Used Ratio */
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: {
budget: components["schemas"]["AdminUsageBudget"];
@ -1528,8 +1597,11 @@ export interface components {
by_provider: components["schemas"]["AdminUsageBreakdown"][];
/** Cost Usd */
cost_usd: number;
/** Daily Cost */
daily_cost?: components["schemas"]["AdminUsageDailyCost"][];
/** Durable */
durable: boolean;
evaluator_cache: components["schemas"]["AdminUsageEvaluatorCache"];
/** Generated At */
generated_at: number;
/** Metered Turns */
@ -2904,7 +2976,7 @@ export interface components {
* Kind
* @enum {string}
*/
kind: "audio" | "silence" | "pace" | "barge_in";
kind: "audio" | "silence" | "pace" | "barge_in" | "paralinguistic" | "prosody" | "audio_quality";
/** Label */
label: string;
};
@ -3649,6 +3721,102 @@ export interface components {
*/
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: {
/** Affiliation */
@ -3751,6 +3919,40 @@ export interface components {
/** User Id */
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: {
/** Body */
@ -3800,6 +4002,21 @@ export interface components {
/** Ticket Id */
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: {
/** 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: {
parameters: {
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: {
parameters: {
query?: never;

View file

@ -562,6 +562,11 @@ export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
export type OnboardingRequest = ApiSchema<"OnboardingRequest">;
export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
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;
@ -588,6 +593,13 @@ export const userApi = {
updatePreferences: (body: UserPreferencesPatchRequest) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
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">;

View file

@ -221,6 +221,48 @@ function usageBudgetDetail(data: AdminUsageResponse): string {
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 {
if (!data) return "-";
if (!data.durable || data.sample_count === 0) return "샘플 없음";
@ -268,6 +310,10 @@ function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" |
return "accent";
}
function shortTicketId(ticketId: string): string {
return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase();
}
function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email;
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 users = usersData?.users ?? [];
const counts = useMemo(
@ -848,6 +922,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
{usage ? <small>{usageBudgetDetail(usage)}</small> : null}
</div>
{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 className="ad-panel">
@ -1658,44 +1754,84 @@ export default function Admin({ section = "overview" }: AdminProps) {
{activeTickets.length > 0 ? (
<div className="ad-ticket-list" aria-label="미해결 운영 티켓 큐">
{activeTickets.map((ticket) => (
<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>
{activeTickets.map((ticket) => {
const duplicateParentId = ticket.duplicate_parent_candidate_id;
const canLinkDuplicate =
ticket.duplicate_count > 0 &&
Boolean(duplicateParentId) &&
duplicateParentId !== ticket.ticket_id &&
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>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
<div className="ad-ticket__actions">
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketStatus(ticket, "in_progress")}
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
>
</Button>
<Button
size="sm"
variant="primary"
onClick={() => void updateTicketStatus(ticket, "resolved")}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
</div>
</article>
))}
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
<div className="ad-ticket__actions">
{canLinkDuplicate ? (
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketParent(ticket, duplicateParentId ?? null)}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
) : null}
{ticket.parent_ticket_id ? (
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketParent(ticket, null)}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketStatus(ticket, "in_progress")}
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
>
</Button>
<Button
size="sm"
variant="primary"
onClick={() => void updateTicketStatus(ticket, "resolved")}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
</div>
</article>
);
})}
</div>
) : null}
</section>

View file

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

View file

@ -13,6 +13,7 @@ import {
import {
sessionApi,
teacherApi,
userApi,
type ReviewCaseWorksheet,
type ReviewNonverbalEvent,
type ReviewNote,
@ -21,11 +22,69 @@ import {
type ReviewTurn,
type ReviewWorksheetSection,
type SessionReviewResponse,
type UserPrepostMeasureItem,
type UserPrepostMeasuresResponse,
} from "../lib/api";
import { ValenceChart } from "./session-review/ValenceChart";
import "./session-review/session-review.css";
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) {
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() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
@ -653,7 +887,7 @@ export default function SessionReview() {
</div>
</Card>
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : ""}`}>
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : "sr-cols--learner"}`}>
<div className="sr-left">
<Card className="sr-overview">
<Kicker> </Kicker>
@ -1018,6 +1252,8 @@ export default function SessionReview() {
) : null}
</Card>
{!isSupervisorView ? <PrepostMeasureCard /> : null}
<CaseWorksheetCard
sessionId={data.session_id}
worksheet={data.caseWorksheet}

View file

@ -20,6 +20,8 @@ import {
type NotificationPreferences,
type UserPreferencesResponse,
type UserProfileResponse,
type UserSupportTicketListItem,
type UserSupportTicketsResponse,
type VoicePresetResponse,
} from "../lib/api";
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 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 }) {
return (
<svg
@ -65,6 +91,22 @@ function healthStatusLabel(status: string | undefined): string {
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({
children,
testId,
@ -155,11 +197,14 @@ export default function Settings() {
const [displayName, setDisplayName] = useState(user?.name ?? "");
const [affiliation, setAffiliation] = useState("");
const [preferences, setPreferences] = useState<UserPreferencesResponse | null>(null);
const [supportTickets, setSupportTickets] = useState<UserSupportTicketsResponse | null>(null);
const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]);
const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null);
const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null);
const [profileReady, setProfileReady] = 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 [error, setError] = useState<string | null>(null);
const [savedKey, setSavedKey] = useState<string | null>(null);
@ -192,14 +237,21 @@ export default function Settings() {
setError(null);
setProfileReady(false);
setPreferencesReady(false);
setSupportTicketsReady(false);
setPreferences(null);
setSupportTickets(null);
setSupportTicketsError(null);
setVoicePresets([]);
setEngineConfig(null);
engineConfigRef.current = null;
try {
const [nextProfile, nextPrefs, presets, nextEngine] = await Promise.all([
const [nextProfile, nextPrefs, nextTickets, presets, nextEngine] = await Promise.all([
userApi.me(),
userApi.preferences(),
userApi.supportTickets().catch((err) => {
setSupportTicketsError(err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다.");
return null;
}),
userApi.voicePresets(),
isAdmin ? adminEngineApi.get() : Promise.resolve(null),
]);
@ -215,6 +267,8 @@ export default function Settings() {
}
setPreferences(nextPrefs);
setPreferencesReady(true);
setSupportTickets(nextTickets);
setSupportTicketsReady(true);
setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark"));
setVoicePresets(presets);
if (nextEngine) {
@ -240,6 +294,7 @@ export default function Settings() {
const navItems: NavItem[] = useMemo(() => {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
{ id: "support", label: "지원 요청", icon: "review" },
{ id: "appearance", label: "테마", icon: "settings" },
{ id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" },
@ -550,6 +605,73 @@ export default function Settings() {
</div>
</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 ? (
<Panel
id="set-engine"

View file

@ -272,6 +272,64 @@
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 {
display: grid;
gap: 10px;
@ -1113,6 +1171,24 @@
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 time {
color: var(--text-muted);
@ -1143,8 +1219,10 @@
.ad-ticket__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
max-width: 190px;
}
.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__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__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__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);}

View file

@ -120,6 +120,7 @@
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
@ -133,10 +134,59 @@
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet"
"overview transcript feedback"
"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-right {
display: contents;
@ -175,6 +225,9 @@
.sr-card--growth {
grid-area: growth;
}
.sr-card--prepost {
grid-area: prepost;
}
.sr-card--worksheet {
grid-area: worksheet;
}
@ -353,6 +406,146 @@
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 {
position: relative;
width: 100%;
@ -692,6 +885,15 @@
.sr-nonverbal--audio .sr-nonverbal__dot {
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 {
max-width: 68ch;
@ -1187,6 +1389,7 @@
[data-theme="dark"] .sr-empty,
[data-theme="dark"] .sr-chip-toggle,
[data-theme="dark"] .sr-ws-item,
[data-theme="dark"] .sr-prepost__field input,
[data-theme="dark"] .sr-feedback,
[data-theme="dark"] .sr-nextline {
background: rgba(255, 255, 255, 0.055);
@ -1196,6 +1399,7 @@
[data-theme="dark"] .sr-turn + .sr-turn,
[data-theme="dark"] .sr-readiness,
[data-theme="dark"] .sr-ws-limitations,
[data-theme="dark"] .sr-prepost__actions,
[data-theme="dark"] .sr-feedback__src {
border-color: rgba(203, 227, 220, 0.1);
}
@ -1225,6 +1429,20 @@
"rubric rubric"
"good growth"
"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"
"worksheet worksheet"
"session session";
@ -1237,6 +1455,20 @@
"rubric rubric"
"good growth"
"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"
"worksheet worksheet"
"session session";
@ -1283,12 +1515,28 @@
"rubric"
"good"
"growth"
"prepost"
"transcript"
"feedback"
"worksheet"
"session";
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 {
grid-template-areas:
"overview"
@ -1298,11 +1546,27 @@
"rubric"
"good"
"growth"
"prepost"
"transcript"
"feedback"
"worksheet"
"session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-summary {
font-size: 20px;
line-height: 1.45;

View file

@ -544,8 +544,8 @@
}
.sx-page--active .sx-stage {
background:
radial-gradient(circle at 28% 50%, rgba(145, 200, 189, 0.18), transparent 42%),
linear-gradient(135deg, rgba(11, 22, 20, 0.96), rgba(31, 43, 39, 0.93)),
radial-gradient(circle at 28% 50%, rgba(197, 222, 214, 0.26), transparent 42%),
linear-gradient(135deg, rgba(24, 42, 38, 0.94), rgba(48, 65, 59, 0.9)),
var(--asset-warm-elements) center / cover no-repeat;
border-color: rgba(255, 255, 255, 0.11);
box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28);
@ -949,6 +949,33 @@
.sx-utt.is-partial .sx-utt__line {
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 {
display: inline-block;
width: 2px;
@ -961,6 +988,10 @@
@keyframes sxCaret {
50% { opacity: 0; }
}
@keyframes sxThinkingDot {
0%, 100% { opacity: 0.25; transform: translateY(0); }
50% { opacity: 0.9; transform: translateY(-2px); }
}
/* "최신으로" 칩 (위로 스크롤 시) */
.sx-transcript__jump {
@ -1642,6 +1673,10 @@
background: rgba(204, 143, 119, 0.12);
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 {
background: rgba(111, 179, 164, 0.13);
color: #edf5f2;
@ -1731,6 +1766,36 @@
overflow: hidden;
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 {
width: 1px;
@ -2336,6 +2401,72 @@
background: rgba(204, 143, 119, 0.17);
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 {
max-width: 460px;
font-size: 12.5px;
@ -2431,16 +2562,21 @@
.sx-stage.is-paused::after {
content: "일시정지";
position: absolute;
inset: 0;
inset: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
align-items: flex-start;
justify-content: flex-end;
padding: 12px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-on-accent);
background: color-mix(in srgb, var(--bg-stage) 62%, transparent);
backdrop-filter: blur(1px);
color: #f4faf7;
border: 1px solid rgba(203, 227, 220, 0.14);
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 {
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 {
display: none;
}
@ -2971,6 +3124,14 @@
font-size: 13px;
line-height: 1.4;
}
.sx-voice-skip span {
display: none;
}
.sx-voice-skip {
width: 34px;
padding: 0;
justify-content: center;
}
}
@media (max-width: 420px) {
@ -3246,6 +3407,7 @@
.sx-orb,
.sx-mic.is-on::after,
.sx-utt__caret,
.sx-utt__dots i,
.sx-transcript__live-dot.is-live {
animation: none !important;
}

View file

@ -1334,18 +1334,21 @@ body[data-page="settings"] .vg-set__forms {
body[data-page="settings"] #set-account {
order: 1;
}
body[data-page="settings"] #set-engine {
body[data-page="settings"] #set-support {
order: 2;
}
body[data-page="settings"] #set-appearance {
body[data-page="settings"] #set-engine {
order: 3;
}
body[data-page="settings"] #set-voice {
body[data-page="settings"] #set-appearance {
order: 4;
}
body[data-page="settings"] #set-notify {
body[data-page="settings"] #set-voice {
order: 5;
}
body[data-page="settings"] #set-notify {
order: 6;
}
body[data-page="settings"] .vg-set__group {
padding: 18px;
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 {
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 {
margin-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__pill,
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 {
border-color: rgba(203, 227, 220, 0.11);
background: rgba(255, 255, 255, 0.055);