feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,645 @@
/* =====================================================================
Settings (/settings) 3 , .
DEPLOYMENT.md §2 '3번 경로 = 관리자 UI 런타임 엔진 전환' :
· claude_p messages_api ()
· ENGINE_URL / ()
+ / (), /, .
철칙: border-left···· .
weight/tint/kicker/dot. React state .
(mock) .
===================================================================== */
import { useMemo, useRef, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import {
Badge,
Button,
Field,
Icon,
Input,
Kicker,
Panel,
SectionHead,
type IconName,
} from "../components/ui";
import { useAuth, roleLabel, type Role } from "../lib/auth";
import { Toggle } from "./settings/Toggle";
import { useTheme } from "./settings/useTheme";
import "./settings/settings.css";
/* =====================================================================
+ mock ( mock . API )
===================================================================== */
type EngineMode = "claude_p" | "messages_api";
/** 엔진 모델 후보 — 현재 Claude 모델 ID (engine_gateway 기본은 Opus 4.8). */
const ENGINE_MODELS: { id: string; label: string }[] = [
{ id: "claude-opus-4-8", label: "Claude Opus 4.8 · 기본(권장)" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 · 빠름/저비용" },
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5 · 최저비용" },
];
/** 페르소나별 음성 프리셋 (mock). 실제로는 taxonomy/persona API 에서 온다. */
interface VoicePreset {
id: string;
/** voice_id (엔진 TTS 식별자) */
voiceId: string;
/** 사람친화 표시명 */
name: string;
desc: string;
/** 어떤 페르소나 기본 음성인지 (mock 연결) */
personaHint: string;
}
const VOICE_PRESETS: VoicePreset[] = [
{
id: "v-soft",
voiceId: "soft-young-fem",
name: "서린",
desc: "부드럽고 높은 톤 · 청소년 내담자에 적합",
personaHint: "서연 · 17세",
},
{
id: "v-calm",
voiceId: "calm-adult-fem",
name: "하경",
desc: "차분한 여성 · 표준 성인 톤",
personaHint: "기본 성인",
},
{
id: "v-steady",
voiceId: "steady-adult-male",
name: "도현",
desc: "낮고 안정적 · 남성 내담자",
personaHint: "강현우 · 34세",
},
{
id: "v-warm",
voiceId: "warm-senior",
name: "정인",
desc: "느리고 따뜻한 · 노년 내담자",
personaHint: "노년 내담자",
},
];
/** 알림 항목 (mock 기본값). 역할별로 일부만 노출. */
interface NotifyItem {
id: string;
label: string;
hint: string;
/** 이 항목을 보여줄 역할 (없으면 전 역할) */
roles?: Role[];
}
const NOTIFY_ITEMS: NotifyItem[] = [
{
id: "session_done",
label: "세션 완료 알림",
hint: "상담 연습 회기를 마치면 결과 요약을 알립니다.",
},
{
id: "safety_signal",
label: "안전 신호 감지",
hint: "세션 중 위기 신호가 포착되면 즉시 알립니다.",
},
{
id: "learner_progress",
label: "담당 학습자 진척",
hint: "지도 중인 학습자가 회기를 마치면 알립니다.",
roles: ["teacher", "admin"],
},
{
id: "product_news",
label: "제품 소식",
hint: "기능 업데이트·연구 노트.",
},
];
/** 섹션 내비 항목 — 역할별 표시 분기. */
interface NavItem {
id: string;
label: string;
icon: IconName;
/** admin 전용이면 true */
adminOnly?: boolean;
}
/* =====================================================================
(Icon speaker inline SVG, currentColor)
===================================================================== */
function SpeakerIcon({ size = 15 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
aria-hidden="true"
>
<polygon points="6 4 19 12 6 20" />
</svg>
);
}
/* =====================================================================
===================================================================== */
export default function Settings() {
const { user } = useAuth();
const role: Role = user?.role ?? "learner";
const isAdmin = role === "admin";
// 표시 이름·이메일·소속 (mock 초기값 — auth user 가 있으면 일부 사용)
const [displayName, setDisplayName] = useState(user?.name ?? "사용자");
const [email, setEmail] = useState(`${user?.userId ?? "user"}@hanshin.ac.kr`);
const [affiliation, setAffiliation] = useState("한신대학교 심리상담학과");
// 테마 (Topbar 토글과 동일 키/속성 공유 → 충돌 없음)
const [dark, setDark] = useTheme();
// 엔진 (관리자 전용). 기본은 .env 운영값을 미러한 mock.
const [engineMode, setEngineMode] = useState<EngineMode>("claude_p");
const [engineUrl, setEngineUrl] = useState(
"http://host.docker.internal:9099",
);
const [engineModel, setEngineModel] = useState(ENGINE_MODELS[0].id);
// 음성: 선택된 프리셋 + 속도(0.8~1.2, 표준 1.0)
const [voiceId, setVoiceId] = useState(VOICE_PRESETS[0].id);
const [voiceRate, setVoiceRate] = useState(1.0);
// 알림 토글 상태 (mock 기본값)
const [notify, setNotify] = useState<Record<string, boolean>>({
session_done: true,
safety_signal: true,
learner_progress: false,
product_news: false,
});
// 저장 피드백 (어떤 그룹이 방금 저장됐는지)
const [savedKey, setSavedKey] = useState<string | null>(null);
const savedTimer = useRef<number | null>(null);
const flashSaved = (key: string) => {
setSavedKey(key);
if (savedTimer.current) window.clearTimeout(savedTimer.current);
savedTimer.current = window.setTimeout(() => setSavedKey(null), 2400);
};
// 역할별 노출 알림 항목
const visibleNotify = useMemo(
() => NOTIFY_ITEMS.filter((n) => !n.roles || n.roles.includes(role)),
[role],
);
// 섹션 내비 (admin 전용 '엔진' 섹션 분기)
const navItems: NavItem[] = useMemo(() => {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
{ id: "appearance", label: "테마·표시", icon: "settings" },
{ id: "voice", label: "음성", icon: "mic" },
{ id: "notify", label: "알림", icon: "info" },
];
if (isAdmin) {
base.splice(1, 0, { id: "engine", label: "엔진", icon: "shield", adminOnly: true });
}
return base;
}, [isAdmin]);
const goSection = (id: string) => {
document
.getElementById(`set-${id}`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const initials = (user?.name ?? "사용자").trim().slice(0, 1);
return (
<AppShell contextLabel="설정">
<div style={{ marginBottom: "var(--sp-6)" }}>
<SectionHead
kicker="설정"
title="계정과 시뮬레이션 환경을 관리합니다"
desc={
isAdmin
? "프로필, 런타임 엔진, 음성 프리셋, 테마, 알림을 한곳에서 조정하세요."
: "프로필, 음성 프리셋, 테마, 알림을 한곳에서 조정하세요."
}
/>
</div>
<div className="vg-set">
{/* ── 좌측 섹션 내비 ── */}
<nav className="vg-set__nav" aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SETTINGS</div>
{navItems.map((item) => (
<button
key={item.id}
type="button"
className="vg-set__nav-item"
onClick={() => goSection(item.id)}
>
<Icon name={item.icon} size={18} />
{item.label}
</button>
))}
</nav>
{/* ── 우측 폼 ── */}
<div className="vg-set__forms">
{/* (1) 계정 — 전 역할 */}
<Panel id="set-account" className="vg-set__group">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc">
, .
</div>
</div>
<div className="vg-set__profile">
<div className="vg-set__avatar" aria-hidden="true">
{initials}
</div>
<div className="vg-set__profile-meta">
<div className="n">{displayName}</div>
<div className="e">{email}</div>
<div className="badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
<Badge tone="neutral">{affiliation}</Badge>
</div>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h">· .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
aria-label="표시 이름"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-label="이메일"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h">· .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
value={affiliation}
onChange={(e) => setAffiliation(e.target.value)}
aria-label="소속"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h">
. .
</span>
</div>
<div className="vg-set__row-field">
<span style={{ display: "inline-flex" }}>
<Badge tone="accent">{roleLabel(role)}</Badge>
</span>
</div>
</div>
<div className="vg-set__foot">
{savedKey === "account" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button variant="ghost"> </Button>
<Button onClick={() => flashSaved("account")}></Button>
</div>
</Panel>
{/* (2) 엔진 — 관리자 전용 (DEPLOYMENT §2 3번 경로) */}
{isAdmin ? (
<Panel id="set-engine" className="vg-set__group">
<div className="vg-set__group-head">
<Kicker> · </Kicker>
<div
className="vg-set__group-title"
style={{ marginTop: "6px" }}
>
</div>
<div className="vg-set__group-desc">
.
DB에 .
</div>
</div>
<div className="vg-set__callout vg-set__callout--info">
<span className="vg-set__callout-ico">
<Icon name="info" size={18} />
</span>
<span className="vg-set__callout-text">
<strong>claude_p</strong> claude -p
( PC ). {" "}
<strong>messages_api</strong> ENGINE_URL {" "}
ANTHROPIC_API_KEY .
</span>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h">
.
</span>
</div>
<div className="vg-set__row-field">
<div
className="vg-set__seg"
role="radiogroup"
aria-label="엔진 모드"
>
{(
[
{ v: "claude_p", t: "claude_p", sub: "로컬 게이트웨이" },
{
v: "messages_api",
t: "messages_api",
sub: "Anthropic API",
},
] as { v: EngineMode; t: string; sub: string }[]
).map((opt) => (
<button
key={opt.v}
type="button"
role="radio"
aria-checked={engineMode === opt.v}
className={
"vg-set__seg-btn" +
(engineMode === opt.v ? " is-active" : "")
}
onClick={() => setEngineMode(opt.v)}
>
{opt.t}
</button>
))}
</div>
<span className="h" style={{ fontSize: "var(--fs-xs)", color: "var(--text-muted)" }}>
{engineMode === "claude_p"
? "호스트의 claude -p 멀티턴 풀로 라우팅합니다."
: "ANTHROPIC_API_KEY 로 직접 호출합니다 (ENGINE_URL 불필요)."}
</span>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l">ENGINE_URL</span>
<span className="h">
claude_p .
</span>
</div>
<div className="vg-set__row-field">
<Field
hint={
engineMode === "messages_api"
? "messages_api 모드에서는 사용하지 않습니다."
: "예: http://host.docker.internal:9099 또는 http://<엔진PC-IP>:9099"
}
>
<Input
value={engineUrl}
onChange={(e) => setEngineUrl(e.target.value)}
disabled={engineMode === "messages_api"}
placeholder="http://host.docker.internal:9099"
aria-label="ENGINE_URL"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h">
Claude .
</span>
</div>
<div className="vg-set__row-field">
<select
className="vg-set__select"
value={engineModel}
onChange={(e) => setEngineModel(e.target.value)}
aria-label="엔진 모델"
>
{ENGINE_MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
<div className="vg-set__meta">
<span>
: <code>{engineMode}</code>
</span>
<span>
: <code>{engineModel}</code>
</span>
</div>
</div>
</div>
<div className="vg-set__foot">
{savedKey === "engine" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button variant="ghost"> </Button>
<Button onClick={() => flashSaved("engine")}> </Button>
</div>
</Panel>
) : null}
{/* (3) 테마·표시 — 전 역할 */}
<Panel id="set-appearance" className="vg-set__group">
<div className="vg-set__group-head">
<div className="vg-set__group-title">·</div>
<div className="vg-set__group-desc">
. .
</div>
</div>
<div className="vg-set__opt">
<div className="vg-set__opt-text">
<div className="l"> </div>
<div className="h">
. .
</div>
</div>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--sp-3)",
color: "var(--text-muted)",
}}
>
<Icon name={dark ? "moon" : "sun"} size={16} />
<Toggle
checked={dark}
onChange={setDark}
label="다크 모드"
/>
</span>
</div>
</Panel>
{/* (4) 음성 — 전 역할 (페르소나별 프리셋) */}
<Panel id="set-voice" className="vg-set__group">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc">
· .
.
</div>
</div>
<div
className="vg-set__voicelist"
role="radiogroup"
aria-label="음성 프리셋"
>
{VOICE_PRESETS.map((v) => {
const on = voiceId === v.id;
return (
<button
key={v.id}
type="button"
role="radio"
aria-checked={on}
className={"vg-set__voice" + (on ? " is-on" : "")}
onClick={() => setVoiceId(v.id)}
>
<span className="vg-set__voice-rad" aria-hidden="true" />
<span className="vg-set__voice-info">
<span className="vn">
{v.name} · {v.voiceId}
</span>
<span className="vd">
{v.desc} · {v.personaHint}
</span>
</span>
<span
className="vg-set__voice-play"
role="presentation"
aria-hidden="true"
>
<SpeakerIcon />
</span>
</button>
);
})}
</div>
<div
className="vg-set__row"
style={{ marginTop: "var(--sp-5)", borderTop: "1px solid var(--bg-surface-2)" }}
>
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h">
(rate). 1.0 .
</span>
</div>
<div className="vg-set__row-field">
<input
type="range"
min={0.8}
max={1.2}
step={0.02}
value={voiceRate}
onChange={(e) => setVoiceRate(Number(e.target.value))}
aria-label="말하기 속도"
style={{ accentColor: "var(--accent)", width: "100%" }}
/>
<div className="vg-set__meta">
<span>0.8×</span>
<span style={{ color: "var(--accent-deep)", fontWeight: 600 }}>
{voiceRate.toFixed(2)}×
</span>
<span>1.2×</span>
</div>
</div>
</div>
<div className="vg-set__foot">
{savedKey === "voice" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button variant="ghost"></Button>
<Button onClick={() => flashSaved("voice")}></Button>
</div>
</Panel>
{/* (5) 알림 — 전 역할 (일부 항목은 교수/관리자만) */}
<Panel id="set-notify" className="vg-set__group">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc">
, . .
</div>
</div>
{visibleNotify.map((n) => (
<div className="vg-set__opt" key={n.id}>
<div className="vg-set__opt-text">
<div className="l">{n.label}</div>
<div className="h">{n.hint}</div>
</div>
<Toggle
checked={!!notify[n.id]}
onChange={(next) =>
setNotify((prev) => ({ ...prev, [n.id]: next }))
}
label={n.label}
/>
</div>
))}
</Panel>
</div>
</div>
</AppShell>
);
}