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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
248
apps/web/src/components/avatar/ClientAvatar.tsx
Normal file
248
apps/web/src/components/avatar/ClientAvatar.tsx
Normal 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;}
|
||||
}
|
||||
`;
|
||||
Loading…
Add table
Add a link
Reference in a new issue