대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -20,8 +20,10 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import {
|
||||
ageLookFor,
|
||||
baseResistanceOf,
|
||||
expressionLabelFor,
|
||||
resolveAffectParams,
|
||||
type AvatarAffect,
|
||||
type AvatarHairStyle,
|
||||
type AvatarPersona,
|
||||
type AvatarState,
|
||||
} from "./persona";
|
||||
|
|
@ -31,10 +33,21 @@ import { BodySilhouette } from "./BodySilhouette";
|
|||
import { Eyes } from "./Eyes";
|
||||
import { Brows } from "./Brows";
|
||||
import { Mouth } from "./Mouth";
|
||||
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
|
||||
import { useExpressionTransition } from "./useExpressionTransition";
|
||||
import { RasterBust } from "./RasterBust";
|
||||
|
||||
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
|
||||
Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */
|
||||
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
|
||||
export {
|
||||
AVATAR_EXPRESSION_LIBRARY,
|
||||
expressionLabelFor,
|
||||
type AvatarState,
|
||||
type AvatarAffect,
|
||||
type AvatarExpression,
|
||||
type AvatarPersona,
|
||||
} from "./persona";
|
||||
export type { Live2DExpressionMotion, Live2DPersonaModel } from "./live2dModel";
|
||||
|
||||
export interface ClientAvatarProps {
|
||||
persona: AvatarPersona;
|
||||
|
|
@ -55,6 +68,12 @@ export interface ClientAvatarProps {
|
|||
/** px 지름 (기본 220 — §5.2 아바타 220px) */
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Disable rAF-driven breathing/blinking for dense QA grids. */
|
||||
animated?: boolean;
|
||||
/** Hide the top teaching label when the avatar is used as a thumbnail. */
|
||||
showCaption?: boolean;
|
||||
/** Hide persona/state metadata when the avatar is used as a thumbnail. */
|
||||
showMeta?: boolean;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
|
|
@ -84,6 +103,112 @@ const STATE_TEXT: Record<AvatarState, string> = {
|
|||
speaking: "이야기하는 중",
|
||||
};
|
||||
|
||||
function HairBack({
|
||||
style,
|
||||
color,
|
||||
jawWidth,
|
||||
}: {
|
||||
style: AvatarHairStyle;
|
||||
color: string;
|
||||
jawWidth: number;
|
||||
}) {
|
||||
switch (style) {
|
||||
case "long-straight":
|
||||
return (
|
||||
<path
|
||||
d={`M${55 - (1 - jawWidth) * 5} 94 C54 60 75 42 100 42 C125 42 146 60 145 94 L139 146 C128 153 72 153 61 146 Z`}
|
||||
fill={color}
|
||||
opacity={0.96}
|
||||
/>
|
||||
);
|
||||
case "soft-wave":
|
||||
return (
|
||||
<path
|
||||
d="M57 96 C53 65 73 43 100 42 C128 42 146 64 143 98 C146 121 137 145 121 151 C116 140 84 140 78 151 C62 145 54 121 57 96 Z"
|
||||
fill={color}
|
||||
opacity={0.96}
|
||||
/>
|
||||
);
|
||||
case "bob":
|
||||
return (
|
||||
<path
|
||||
d="M58 97 C56 64 76 45 100 45 C124 45 144 64 142 97 C141 121 132 136 118 141 C111 134 88 134 81 141 C67 136 59 121 58 97 Z"
|
||||
fill={color}
|
||||
opacity={0.96}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function HairFront({ style, color }: { style: AvatarHairStyle; color: string }) {
|
||||
switch (style) {
|
||||
case "messy":
|
||||
return (
|
||||
<path
|
||||
d="M58 91 C58 59 78 44 101 44 C123 44 142 59 142 91 C134 78 126 73 116 70 L112 62 L104 72 L94 62 L88 72 L77 70 C68 74 62 81 58 91 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "side-part":
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 59 78 44 101 44 C124 44 143 59 142 92 C134 77 119 69 99 68 C85 70 69 77 58 92 Z M98 46 C94 56 92 64 91 73"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "bob":
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 61 78 47 100 47 C122 47 142 61 142 92 C133 80 124 73 112 71 C103 76 89 74 76 72 C67 77 61 83 58 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "long-straight":
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C135 78 124 71 109 69 C99 75 83 72 70 73 C63 78 59 84 58 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "soft-wave":
|
||||
return (
|
||||
<path
|
||||
d="M57 92 C58 58 78 44 100 44 C122 44 143 59 143 92 C134 78 123 71 111 69 C105 76 94 76 88 70 C74 72 63 79 57 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "short":
|
||||
default:
|
||||
return (
|
||||
<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={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function NeckBridge({ skin }: { skin: string }) {
|
||||
return (
|
||||
<g data-avatar-neck="true">
|
||||
<path
|
||||
d="M88 121 C90 119 110 119 112 121 L114 154 C109 160 91 160 86 154 Z"
|
||||
fill={skin}
|
||||
/>
|
||||
<path
|
||||
d="M89 149 C94 154 106 154 111 149"
|
||||
fill="none"
|
||||
stroke="#8A6B5D"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.18"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientAvatar({
|
||||
persona,
|
||||
state,
|
||||
|
|
@ -93,23 +218,40 @@ export function ClientAvatar({
|
|||
speakingProgress = null,
|
||||
size = 220,
|
||||
className,
|
||||
animated = true,
|
||||
showCaption = true,
|
||||
showMeta = true,
|
||||
}: ClientAvatarProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const motionEnabled = animated && !reduced;
|
||||
|
||||
// 외형 안전값
|
||||
const skin = persona.skinTone;
|
||||
const hairColor = persona.hair.color;
|
||||
const hairStyle = persona.hair.style ?? "short";
|
||||
const outfitColor = persona.outfitColor ?? hairColor;
|
||||
const irisColor = persona.eyeColor ?? "#2B2B2B";
|
||||
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
|
||||
const useRaster = Boolean(persona.rasterArtSet);
|
||||
const age = ageLookFor(persona.ageBand);
|
||||
const expressionLabel = expressionLabelFor(affect);
|
||||
const live2dModel = useMemo(() => live2dModelForPersonaCode(persona.code), [persona.code]);
|
||||
const live2dMotion = useMemo(
|
||||
() => live2dMotionForExpression(live2dModel, affect),
|
||||
[affect, live2dModel],
|
||||
);
|
||||
const live2dModelUrl = useMemo(() => live2dModel3Path(live2dModel), [live2dModel]);
|
||||
|
||||
// 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성)
|
||||
const baseRes = baseResistanceOf(persona, affect);
|
||||
// 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과)
|
||||
const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4;
|
||||
const params = useMemo(
|
||||
const targetParams = useMemo(
|
||||
() => resolveAffectParams(affect, state, effectiveRapport),
|
||||
[affect, state, effectiveRapport],
|
||||
);
|
||||
const transition = useExpressionTransition(targetParams, live2dMotion.fadeInMs, motionEnabled);
|
||||
const params = motionEnabled ? transition.params : targetParams;
|
||||
|
||||
// 모션 루프 (reduced 면 정지)
|
||||
const frame = useAvatarMotion({
|
||||
|
|
@ -117,25 +259,23 @@ export function ClientAvatar({
|
|||
params,
|
||||
analyser,
|
||||
speakingProgress,
|
||||
enabled: !reduced,
|
||||
enabled: motionEnabled,
|
||||
});
|
||||
|
||||
// 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;
|
||||
const breath = motionEnabled ? frame.breath : 0;
|
||||
const blink = motionEnabled ? frame.blink : 1;
|
||||
const mouth = motionEnabled ? frame.mouth : 0;
|
||||
const saccadeX = motionEnabled ? frame.saccadeX : 0;
|
||||
const saccadeY = motionEnabled ? frame.saccadeY : 0;
|
||||
|
||||
// 시선 = 정서 회피(gazeAvert) + saccade 미세 이동
|
||||
const gazeX = -params.gazeAvert + saccadeX;
|
||||
const gazeY = saccadeY;
|
||||
|
||||
// 홍채 색: 헤어보다 약간 진한 잉크(순흑 금지 → ink 톤)
|
||||
const irisColor = "#2B2B2B";
|
||||
|
||||
// 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위.
|
||||
const shoulderRotate = params.shoulderTurn * 0.4;
|
||||
const mouthOpen = Math.max(mouth, params.mouthOpen);
|
||||
|
||||
return (
|
||||
<figure
|
||||
|
|
@ -143,16 +283,31 @@ export function ClientAvatar({
|
|||
style={{ width: size }}
|
||||
data-state={state}
|
||||
data-affect={affect}
|
||||
data-expression-count={live2dModel.expressions.length}
|
||||
data-live2d-schema={live2dModel.schemaVersion}
|
||||
data-live2d-model={live2dModel.modelId}
|
||||
data-live2d-model-url={live2dModelUrl}
|
||||
data-live2d-motion={live2dMotion.name}
|
||||
data-live2d-motion-file={live2dMotion.file}
|
||||
data-live2d-fade-in-ms={live2dMotion.fadeInMs}
|
||||
data-live2d-transition-active={transition.active ? "true" : "false"}
|
||||
data-live2d-transition-progress={transition.progress.toFixed(2)}
|
||||
data-live2d-expression-count={live2dModel.expressions.length}
|
||||
data-persona-code={persona.code ?? ""}
|
||||
data-avatar-animated={motionEnabled ? "true" : "false"}
|
||||
data-render-mode={useRaster ? "raster" : "svg"}
|
||||
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
|
||||
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
|
||||
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`}
|
||||
>
|
||||
<style>{AVATAR_CSS}</style>
|
||||
|
||||
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
|
||||
<figcaption className="vg-avatar__label">
|
||||
<span className="vg-avatar__label-dot" aria-hidden="true" />
|
||||
교육용 가상 내담자
|
||||
</figcaption>
|
||||
{showCaption ? (
|
||||
<figcaption className="vg-avatar__label">
|
||||
<span className="vg-avatar__label-dot" aria-hidden="true" />
|
||||
교육용 가상 내담자
|
||||
</figcaption>
|
||||
) : null}
|
||||
|
||||
<div className="vg-avatar__stage" style={{ height: size }}>
|
||||
{/* 호흡하는 광배 */}
|
||||
|
|
@ -165,51 +320,82 @@ export function ClientAvatar({
|
|||
reduced={reduced}
|
||||
/>
|
||||
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{useRaster ? (
|
||||
<RasterBust
|
||||
artSet={persona.rasterArtSet as string}
|
||||
affect={affect}
|
||||
state={state}
|
||||
breath={breath}
|
||||
blink={blink}
|
||||
mouth={mouthOpen}
|
||||
gazeX={gazeX}
|
||||
gazeY={gazeY}
|
||||
headTilt={params.headTilt}
|
||||
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} />
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
||||
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
||||
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
||||
<NeckBridge skin={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}
|
||||
/>
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
|
||||
{/* 얼굴 그룹: 시선 회피/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" />
|
||||
<HairFront style={hairStyle} color={hairColor} />
|
||||
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows
|
||||
browTilt={params.browTilt}
|
||||
browLift={params.browLift}
|
||||
browPinch={params.browPinch}
|
||||
color={hairColor}
|
||||
/>
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyeOpen={params.eyeOpen}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
pupilScale={params.pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth
|
||||
open={mouthOpen}
|
||||
curve={params.mouthCurve}
|
||||
width={params.mouthWidth}
|
||||
tension={params.mouthTension}
|
||||
color="#9B5B52"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</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>
|
||||
{showMeta ? (
|
||||
<div className="vg-avatar__meta">
|
||||
<span className="vg-avatar__persona">{persona.label}</span>
|
||||
<span className="vg-avatar__state">
|
||||
지금: <b>{STATE_TEXT[state]}</b> · {expressionLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
|
@ -234,6 +420,30 @@ const AVATAR_CSS = `
|
|||
.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;transition:opacity var(--dur-base) var(--ease-out);}
|
||||
/* ── 래스터(Live2D식) 렌더: 레이어 합성 흉상 ── */
|
||||
.vg-raster{position:absolute;inset:0;z-index:1;pointer-events:none;will-change:transform;}
|
||||
.vg-raster__layer{
|
||||
position:absolute;left:50%;top:var(--vg-raster-top,-15%);
|
||||
height:var(--vg-raster-h,136%);width:auto;max-width:none;
|
||||
transform:translateX(-50%);object-fit:contain;
|
||||
user-select:none;-webkit-user-drag:none;opacity:0;
|
||||
}
|
||||
.vg-raster__layer--shoulders{opacity:1;z-index:1;}
|
||||
.vg-raster__layer--neck{opacity:1;z-index:2;}
|
||||
.vg-raster__layer--hairback{opacity:1;z-index:3;}
|
||||
.vg-raster__layer--ear{opacity:1;z-index:4;}
|
||||
.vg-raster__layer--face{opacity:1;z-index:5;}
|
||||
.vg-raster__layer--forehead{opacity:1;z-index:6;}
|
||||
.vg-raster__layer--hair{opacity:1;z-index:7;will-change:transform;}
|
||||
.vg-raster__layer--bangs{opacity:1;z-index:8;will-change:transform;}
|
||||
.vg-raster__layer--brow{z-index:9;will-change:opacity,transform;}
|
||||
.vg-raster__layer--eyes{z-index:10;}
|
||||
.vg-raster__layer--eyelid{z-index:11;}
|
||||
.vg-raster__layer--nose{opacity:1;z-index:12;}
|
||||
.vg-raster__layer--mouth{z-index:13;}
|
||||
.vg-raster__layer--mouthopen{z-index:14;}
|
||||
.vg-raster__layer--base{opacity:1;z-index:1;}
|
||||
.vg-raster__layer--upperface{z-index:2;}
|
||||
.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);}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue