vignette/apps/web/src/components/avatar/persona.ts
Yun Chan 24b1b7a6e1 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턴 — 좋은/나쁜 상담에 차등 반응 실증
2026-06-25 23:37:22 +09:00

202 lines
8.1 KiB
TypeScript

/* =====================================================================
ClientAvatar — 페르소나 / 정서 파라미터 타입 + 6파라미터 상태머신 테이블
출처: docs/DESIGN_CONCEPT.md §4.5(6파라미터 정서 상태머신) / §4.6(페르소나 시스템)
── Rive 2차 교체 지점 ─────────────────────────────────────────────
SVG 1차안은 아래 6파라미터를 매 프레임 transform 으로 인코딩한다.
Rive 전환 시: 이 파일의 (state, affect, resistance, rapport) 입력 계약은
그대로 두고, AffectParams → .riv state-machine input 으로만 다시 wiring 한다.
컴포넌트(ClientAvatar/useAvatarMotion)는 AffectParams 만 소비하므로 교체 표면이 좁다.
===================================================================== */
/* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */
export type AvatarState = "idle" | "listening" | "thinking" | "speaking";
/* ── 내담자 정서 4종 (§4.5) ────────────────────────────────────────── */
export type AvatarAffect = "neutral" | "depressed" | "anxious" | "resistant";
/* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */
export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior";
export interface AvatarPersona {
/** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */
label: string;
/** 연령대 (외형 규칙 §4.6) */
ageBand: AvatarAgeBand;
/** 피부톤 hex */
skinTone: string;
/** 헤어 (스타일 + 색) — 1차는 색만 사용 */
hair: { style?: string; color: string };
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
realism: number;
/**
* 저항도 0~1 (§4.6). 라포가 쌓이면 감소한다.
* 미지정 시 affect="resistant" → 0.6, 그 외 0 으로 본다.
*/
resistance?: number;
}
/* ── 6파라미터 상태머신 (§4.5) ──────────────────────────────────────
상태머신이 이 6개 숫자만 바꾸면 정서가 전환된다:
(eyelidDrop, gazeAvert, shoulderTurn, breathRate, auraHue, blinkInterval)
여기서는 모션 루프가 바로 쓰도록 호흡 주기/진폭, 깜빡임 min/max 까지 펼친다. */
export interface AffectParams {
/** 눈꺼풀 하강 px (우울 1~2px) */
eyelidDrop: number;
/** 시선 회피 x 오프셋 px (저항/사고 시) */
gazeAvert: number;
/** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */
shoulderTurn: number;
/** 호흡 주기 초 (= breathRate 역수 표현) */
breathPeriod: number;
/** 호흡 진폭 px (translateY 1.5~2.5) */
breathAmp: number;
/** 광배 색조 (CSS 색/변수) */
auraHue: string;
/** 광배 기본 opacity */
auraOpacity: number;
/** 깜빡임 최소 간격 초 */
blinkMin: number;
/** 깜빡임 최대 간격 초 */
blinkMax: number;
/** 입꼬리 각도 deg (양수=상향, 음수=하향). 미세값만. */
mouthCurve: number;
/** 눈썹 안쪽 끝 기울기 px (우울=안쪽 살짝 올라/처짐) */
browTilt: number;
}
/* ── 정서별 기준 파라미터 표 (§4.5) ──────────────────────────────────
과장 절대 금지: 눈물/홍조/땀 없음. "감지되되 단언되지 않는" 수준. */
const AFFECT_TABLE: Record<AvatarAffect, AffectParams> = {
// 중립/라포 — 세이지 4%, 안정 4s
neutral: {
eyelidDrop: 0,
gazeAvert: 0,
shoulderTurn: 0,
breathPeriod: 4,
breathAmp: 2,
auraHue: "#3E7A6E", // --accent (세이지-틸)
auraOpacity: 0.04,
blinkMin: 4,
blinkMax: 7,
mouthCurve: 1.2, // 미세 상향
browTilt: 0,
},
// 우울(저각성·부정) — 청회색 5%, 느리고 얕게 5.5s
depressed: {
eyelidDrop: 1.5,
gazeAvert: 1.5, // 시선 아래(주로 y, x는 미세)
shoulderTurn: 1,
breathPeriod: 5.5,
breathAmp: 1.5,
auraHue: "#5B6B73", // 청회색
auraOpacity: 0.05,
blinkMin: 5,
blinkMax: 8,
mouthCurve: -0.6, // 수평~미세 하향
browTilt: 1.2, // 안쪽 끝 살짝 처짐
},
// 불안(고각성·부정) — 차가운 청 5% 빠른 펄스, 깜빡임↑, 빠르고 얕게 3s
anxious: {
eyelidDrop: 0,
gazeAvert: 2,
shoulderTurn: 2,
breathPeriod: 3,
breathAmp: 1.5,
auraHue: "#3B6E8F", // 차가운 청
auraOpacity: 0.05,
blinkMin: 2,
blinkMax: 4,
mouthCurve: 0, // 다묾, 긴장
browTilt: 0.4,
},
// 저항/방어 — 중립 회색 무펄스, 반쯤 돌아선 8°, 정지에 가깝게
resistant: {
eyelidDrop: 0.5,
gazeAvert: 4, // 시선 회피(옆/아래 고정)
shoulderTurn: 8, // ★ 교육적 핵심: 라포로 0° 까지 완화
breathPeriod: 5,
breathAmp: 1,
auraHue: "#93A09C", // --ink-3 중립 회색
auraOpacity: 0.03,
blinkMin: 4,
blinkMax: 7,
mouthCurve: -0.2, // 굳게 닫힘
browTilt: -0.4,
},
};
/* ── 연령대 외형 규칙 (§4.6) ────────────────────────────────────────── */
export interface AgeBandLook {
/** 눈 크기 배율 */
eyeSize: number;
/** 턱 폭 배율 (작을수록 좁은 턱) */
jawWidth: number;
/** 광배 채도 가중(생기) */
auraSaturation: number;
}
const AGE_TABLE: Record<AvatarAgeBand, AgeBandLook> = {
teen: { eyeSize: 1.15, jawWidth: 0.92, auraSaturation: 1.1 },
youngAdult: { eyeSize: 1.0, jawWidth: 1.0, auraSaturation: 1.0 },
adult: { eyeSize: 0.95, jawWidth: 1.06, auraSaturation: 0.9 },
senior: { eyeSize: 0.92, jawWidth: 1.0, auraSaturation: 0.85 },
};
export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook {
return AGE_TABLE[ageBand];
}
/* ── 정서 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ──────────────────
rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다.
"라포가 쌓이면 shoulderTurn 8°→0°, gazeAvert→눈맞춤" 이게 이 도구의 교육적 핵심(§4.5). */
export function resolveAffectParams(
affect: AvatarAffect,
state: AvatarState,
rapport: number,
): AffectParams {
// 표를 복사(불변)
const out: AffectParams = { ...AFFECT_TABLE[affect] };
// 라포 완화 (0~1). 저항/회피 성격 파라미터를 비례 감쇠.
const r = Math.min(1, Math.max(0, rapport));
if (r > 0) {
out.shoulderTurn = out.shoulderTurn * (1 - r); // 8° → 0°
out.gazeAvert = out.gazeAvert * (1 - 0.85 * r); // 회피 → 눈맞춤
out.eyelidDrop = out.eyelidDrop * (1 - 0.5 * r);
// 라포가 쌓이면 광배가 중립 회색 → 세이지 쪽으로 살짝 온기
if (affect === "resistant" && r > 0.5) out.auraHue = "#6E8B82";
}
// 상태 보정 (§4.2)
switch (state) {
case "thinking":
// 시선 살짝 아래/옆, 깜빡임 느려짐, 광배 안쪽 수축(채도 -8%)
out.gazeAvert = Math.max(out.gazeAvert, 2);
out.blinkMin = out.blinkMin * 1.3;
out.blinkMax = out.blinkMax * 1.3;
out.auraOpacity *= 0.85;
out.breathAmp *= 0.6; // 정지에 가깝게
break;
case "listening":
// 학습자 쪽 응시(회피 해제), 차분 펄스
out.gazeAvert = out.gazeAvert * (1 - 0.6); // 응시로 당김(완전 0은 아님)
break;
case "speaking":
// 정면 복귀
out.gazeAvert = out.gazeAvert * 0.4;
break;
case "idle":
// 느린 호흡 3.5s 하한
out.breathPeriod = Math.max(out.breathPeriod, 3.5);
break;
}
return out;
}
/** persona.resistance → 초기 저항도. 미지정 시 affect 로 추정. */
export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number {
if (typeof persona.resistance === "number") return persona.resistance;
return affect === "resistant" ? 0.6 : 0;
}