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턴 — 좋은/나쁜 상담에 차등 반응 실증
202 lines
8.1 KiB
TypeScript
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;
|
|
}
|