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,223 @@
/* =====================================================================
useAvatarMotion requestAnimationFrame
출처: docs/DESIGN_CONCEPT.md §4.3() / §4.4( ) / §4.7(rAF 1)
채널: 어깨호흡(translateY) + (scaleY) + (RMSmouth scaleY) + saccade( )
(§4.4): 2. transform/opacity (layout ).
prefers-reduced-motion (ClientAvatar) .
코드: performance.now()/Math.random() OK ( ).
Rive 2
Rive .riv state-machine input(speak amplitude, blink,
gaze, breath) wiring . MotionFrame
ClientAvatar SVG .
===================================================================== */
import { useEffect, useRef, useState } from "react";
import type { AffectParams, AvatarState } from "./persona";
/** 매 프레임 컴포넌트로 내려가는 transform 값 묶음 */
export interface MotionFrame {
/** 어깨/몸 호흡 translateY px (음수=올라감) */
breath: number;
/** 깜빡임 눈 scaleY (1=뜸, 0.08=감김) */
blink: number;
/** 입 열림 0~1 (립싱크) */
mouth: number;
/** saccade 시선 x 미세 오프셋 px */
saccadeX: number;
/** saccade 시선 y 미세 오프셋 px */
saccadeY: number;
}
const IDLE_FRAME: MotionFrame = {
breath: 0,
blink: 1,
mouth: 0,
saccadeX: 0,
saccadeY: 0,
};
/* RMS ( τ180ms) + 0.04 (§4.3)
raw RMS . dt alpha . */
function smoothMouthFromRMS(
analyser: AnalyserNode,
buf: Float32Array<ArrayBuffer>,
prev: number,
dtSec: number,
): number {
analyser.getFloatTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
const rms = Math.sqrt(sum / buf.length);
// 데드존: 무음 구간 입 떨림 제거
const target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
// τ≈180ms 지수평활 (dt 보정 → 프레임레이트 무관)
const tau = 0.18;
const alpha = 1 - Math.exp(-dtSec / tau);
return prev + (target - prev) * alpha;
}
/* analyser (§ : / )
speakingProgress(0~1, ) ,
RMS ( ). */
function fakeMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null) {
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
if (progress >= 1) return 0;
// 음절 리듬 ~6Hz, 미세 변주
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
const base = 0.18 + 0.42 * syl * jitter;
return Math.min(0.85, base);
}
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭)
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
return Math.min(0.8, 0.15 + 0.45 * a * b);
}
export interface AvatarMotionOptions {
state: AvatarState;
params: AffectParams;
analyser: AnalyserNode | null;
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */
speakingProgress?: number | null;
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
enabled: boolean;
}
export function useAvatarMotion({
state,
params,
analyser,
speakingProgress = null,
enabled,
}: AvatarMotionOptions): MotionFrame {
const [frame, setFrame] = useState<MotionFrame>(IDLE_FRAME);
// 최신값을 rAF 클로저가 리렌더 없이 읽도록 ref 미러
const stateRef = useRef(state);
const paramsRef = useRef(params);
const analyserRef = useRef(analyser);
const progressRef = useRef(speakingProgress);
stateRef.current = state;
paramsRef.current = params;
analyserRef.current = analyser;
progressRef.current = speakingProgress;
useEffect(() => {
if (!enabled) {
setFrame(IDLE_FRAME);
return;
}
let raf = 0;
const t0 = performance.now();
let lastT = t0;
// 깜빡임 스케줄 (랜덤 간격, 120ms 감김)
const p0 = paramsRef.current;
let nextBlinkAt = 1200 + Math.random() * (p0.blinkMax - p0.blinkMin) * 1000;
let blinkUntil = 0;
// saccade 스케줄 (6~10s 간격, 0.3s 이동, ±3px)
let nextSaccadeAt = 4000 + Math.random() * 4000;
let saccadeStart = 0;
let saccadeTargetX = 0;
let saccadeTargetY = 0;
let saccadeFromX = 0;
let saccadeFromY = 0;
let saccadeX = 0;
let saccadeY = 0;
let mouthPrev = 0;
const audioBuf = new Float32Array(1024);
const loop = (t: number) => {
const p = paramsRef.current;
const st = stateRef.current;
const elapsed = t - t0;
const dtSec = Math.min(0.05, (t - lastT) / 1000); // 탭 비활성 복귀 시 점프 클램프
lastT = t;
const tSec = elapsed / 1000;
// ── 1) 어깨/몸 호흡 (translateY 1.5~2.5px, 3.5~5.5s) ──
const breath = Math.sin((tSec * 2 * Math.PI) / p.breathPeriod) * p.breathAmp;
// ── 2) 깜빡임 (scaleY 1→0.08→1, 120ms, 간격 4~7s 랜덤) ──
let blink = 1;
if (blinkUntil === 0 && elapsed >= nextBlinkAt) {
blinkUntil = elapsed + 120;
}
if (blinkUntil > 0) {
if (elapsed >= blinkUntil) {
blinkUntil = 0;
nextBlinkAt =
elapsed + p.blinkMin * 1000 + Math.random() * (p.blinkMax - p.blinkMin) * 1000;
blink = 1;
} else {
// 0→감김(0.08)→복귀 삼각 근사
const phase = 1 - Math.abs((blinkUntil - elapsed) / 120 - 0.5) * 2;
blink = 1 - phase * 0.92;
}
}
// ── 3) saccade (시선 미세 이동 ±3px, 0.3s, 간격 6~10s; thinking 빈도↑) ──
const saccadeInterval = st === "thinking" ? 3000 : 7000;
if (saccadeStart === 0 && elapsed >= nextSaccadeAt) {
saccadeStart = elapsed;
saccadeFromX = saccadeX;
saccadeFromY = saccadeY;
// listening 은 학습자 응시 → saccade 거의 없음(중앙 복귀)
const range = st === "listening" ? 1 : 3;
saccadeTargetX = (Math.random() * 2 - 1) * range;
saccadeTargetY = (Math.random() * 2 - 1) * (range * 0.6);
}
if (saccadeStart > 0) {
const k = Math.min(1, (elapsed - saccadeStart) / 300);
// ease-out
const e = 1 - Math.pow(1 - k, 2);
saccadeX = saccadeFromX + (saccadeTargetX - saccadeFromX) * e;
saccadeY = saccadeFromY + (saccadeTargetY - saccadeFromY) * e;
if (k >= 1) {
saccadeStart = 0;
nextSaccadeAt =
elapsed + saccadeInterval * 0.6 + Math.random() * saccadeInterval * 0.6;
}
}
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ──
let mouth = mouthPrev;
if (st === "speaking") {
const a = analyserRef.current;
if (a) {
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
} else {
const target = fakeMouthTarget(tSec, progressRef.current ?? null);
const tau = 0.12;
const alpha = 1 - Math.exp(-dtSec / tau);
mouthPrev = mouthPrev + (target - mouthPrev) * alpha;
}
mouth = mouthPrev;
} else if (mouthPrev > 0.001) {
// 발화 종료 → 부드럽게 닫힘
const alpha = 1 - Math.exp(-dtSec / 0.1);
mouthPrev = mouthPrev * (1 - alpha);
if (mouthPrev < 0.005) mouthPrev = 0;
mouth = mouthPrev;
} else {
mouth = 0;
}
setFrame({ breath, blink, mouth, saccadeX, saccadeY });
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [enabled]);
return frame;
}