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,248 @@
/* =====================================================================
ClientAvatar ( )
출처: docs/DESIGN_CONCEPT.md §4 (4.1~4.7)
"사실성 35~45% 양식화 반구상 흉상 + 호흡하는 광배." 30~50
( 2),
(·· , ), "감지되되 단언되지 않게".
(§4.7): SVG + CSS/Web Animations + Web Audio (1).
- rAF useAvatarMotion: 호흡 + + (RMS) + saccade
- 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
breathRate/auraHue/blinkInterval. 8°0° ( ).
- transform/opacity (layout ). 2 .
- prefers-reduced-motion: 호흡/ + .
- analyser=null: / .
Rive 2
.riv ,
(persona / state / affect / analyser / rapport)
<svg>+useAvatarMotion <RiveAvatar> .
AffectParams(persona.ts) .riv state-machine input wiring.
===================================================================== */
import { useEffect, useMemo, useState } from "react";
import {
ageLookFor,
baseResistanceOf,
resolveAffectParams,
type AvatarAffect,
type AvatarPersona,
type AvatarState,
} from "./persona";
import { useAvatarMotion } from "./useAvatarMotion";
import { AuraLayer } from "./AuraLayer";
import { BodySilhouette } from "./BodySilhouette";
import { Eyes } from "./Eyes";
import { Brows } from "./Brows";
import { Mouth } from "./Mouth";
/* ( import )
Session.tsx ClientAvatar . */
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
export interface ClientAvatarProps {
persona: AvatarPersona;
state: AvatarState;
affect: AvatarAffect;
/** speaking 립싱크용 Web Audio AnalyserNode (없으면 텍스트/타이핑 폴백) */
analyser?: AnalyserNode | null;
/**
* 0~1 (§4.5 ). .
* shoulderTurn 8°0°, gazeAvert .
*/
rapport?: number;
/**
* analyser 0~1.
* null/ speaking .
*/
speakingProgress?: number | null;
/** px 지름 (기본 220 — §5.2 아바타 220px) */
size?: number;
className?: string;
}
function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
);
}
/** prefers-reduced-motion 을 반응형으로 구독 */
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(prefersReducedMotion);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
const STATE_TEXT: Record<AvatarState, string> = {
idle: "대기 중",
listening: "당신의 말을 듣는 중",
thinking: "잠시 생각하는 중",
speaking: "이야기하는 중",
};
export function ClientAvatar({
persona,
state,
affect,
analyser = null,
rapport = 0,
speakingProgress = null,
size = 220,
className,
}: ClientAvatarProps) {
const reduced = useReducedMotion();
// 외형 안전값
const skin = persona.skinTone;
const hairColor = persona.hair.color;
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
const age = ageLookFor(persona.ageBand);
// 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성)
const baseRes = baseResistanceOf(persona, affect);
// 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과)
const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4;
const params = useMemo(
() => resolveAffectParams(affect, state, effectiveRapport),
[affect, state, effectiveRapport],
);
// 모션 루프 (reduced 면 정지)
const frame = useAvatarMotion({
state,
params,
analyser,
speakingProgress,
enabled: !reduced,
});
// reduced-motion: 정적 프레임(호흡 0, 눈 뜸, 입 닫힘)
const breath = reduced ? 0 : frame.breath;
const blink = reduced ? 1 : frame.blink;
const mouth = reduced ? 0 : frame.mouth;
const saccadeX = reduced ? 0 : frame.saccadeX;
const saccadeY = reduced ? 0 : frame.saccadeY;
// 시선 = 정서 회피(gazeAvert) + saccade 미세 이동
const gazeX = -params.gazeAvert + saccadeX;
const gazeY = saccadeY;
// 홍채 색: 헤어보다 약간 진한 잉크(순흑 금지 → ink 톤)
const irisColor = "#2B2B2B";
// 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위.
const shoulderRotate = params.shoulderTurn * 0.4;
return (
<figure
className={"vg-avatar" + (className ? " " + className : "")}
style={{ width: size }}
data-state={state}
data-affect={affect}
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
>
<style>{AVATAR_CSS}</style>
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
<figcaption className="vg-avatar__label">
<span className="vg-avatar__label-dot" aria-hidden="true" />
</figcaption>
<div className="vg-avatar__stage" style={{ height: size }}>
{/* 호흡하는 광배 */}
<AuraLayer
hue={params.auraHue}
opacity={params.auraOpacity}
saturation={age.auraSaturation}
state={state}
mouth={mouth}
reduced={reduced}
/>
<svg
className="vg-avatar__svg"
viewBox="0 0 200 200"
width={size}
height={size}
role="img"
aria-hidden="true"
>
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
{/* 어깨/목 실루엣 */}
<BodySilhouette color={hairColor} skin={skin} shoulderTurn={params.shoulderTurn} />
{/* 머리 (양식화 — 코·주름·모공 없음) */}
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
{/* 헤어 (단순 캡 형태) */}
<path
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C142 78 128 70 100 70 C72 70 58 78 58 92 Z"
fill={hairColor}
/>
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
<g transform={`translate(${gazeX} ${gazeY})`}>
<Brows browTilt={params.browTilt} color={hairColor} />
<Eyes
eyeSize={age.eyeSize}
blink={blink}
eyelidDrop={params.eyelidDrop}
irisColor={irisColor}
/>
<Mouth open={mouth} curve={params.mouthCurve} color="#9B5B52" />
</g>
</g>
</svg>
</div>
{/* 페르소나 메타 + 상태 텍스트 */}
<div className="vg-avatar__meta">
<span className="vg-avatar__persona">{persona.label}</span>
<span className="vg-avatar__state">
: <b>{STATE_TEXT[state]}</b>
</span>
</div>
</figure>
);
}
const AVATAR_CSS = `
.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;}
.vg-avatar__label{
display:inline-flex;align-items:center;gap:7px;
font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em;
color:var(--text-muted);text-transform:none;
}
.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;}
.vg-avatar__stage{
position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden;
background:var(--bg-stage);
}
.vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
animation:vgAuraBreathe 6s var(--ease-in-out) infinite;
}
.vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}
.vg-avatar__state b{font-weight:600;color:var(--text-body);}
@media (prefers-reduced-motion: reduce){
.vg-avatar__aura{animation:none;}
}
`;