diff --git a/apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx b/apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx new file mode 100644 index 0000000..3a36836 --- /dev/null +++ b/apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx @@ -0,0 +1,204 @@ +/** + * Helper sub-components extracted from ChatPanel + * + * StageIndicator, ActionsTaken, CharacterAvatar, renderMessageContent + */ + +import React, { useState, useEffect, useCallback, memo, useRef } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import type { CharacterConfig } from '@/lib/characterManager'; +import { resolveEmotionMedia } from '@/lib/characterManager'; +import type { ModManager } from '@/lib/modManager'; +import styles from './index.module.scss'; + +// --------------------------------------------------------------------------- +// Render message content — formats (action text) as styled spans +// --------------------------------------------------------------------------- + +export function renderMessageContent(content: string): React.ReactNode { + const parts = content.split(/(\([^)]+\))/g); + return parts.map((part, i) => { + if (/^\([^)]+\)$/.test(part)) { + return ( + + {part} + + ); + } + return part; + }); +} + +// --------------------------------------------------------------------------- +// Stage Indicator +// --------------------------------------------------------------------------- + +export const StageIndicator: React.FC<{ modManager: ModManager | null }> = ({ modManager }) => { + if (!modManager) return null; + + const total = modManager.stageCount; + const current = modManager.currentStageIndex; + const finished = modManager.isFinished; + + return ( +
+ + Stage {finished ? total : current + 1}/{total} + +
+ {Array.from({ length: total }, (_, i) => ( +
+ ))} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Actions Taken (collapsible) +// --------------------------------------------------------------------------- + +export const ActionsTaken: React.FC<{ calls: string[] }> = ({ calls }) => { + const [open, setOpen] = useState(false); + if (calls.length === 0) return null; + + return ( +
+ + {open && ( +
+ {calls.map((c, i) => ( +
{c}
+ ))} +
+ )} +
+ ); +}; + +// --------------------------------------------------------------------------- +// CharacterAvatar – crossfade between emotion media without flashing +// --------------------------------------------------------------------------- + +interface AvatarLayer { + url: string; + type: 'video' | 'image'; + active: boolean; +} + +export const CharacterAvatar: React.FC<{ + character: CharacterConfig; + emotion?: string; + onEmotionEnd: () => void; +}> = memo(({ character, emotion, onEmotionEnd }) => { + const isIdle = !emotion; + const media = resolveEmotionMedia(character, emotion || 'default'); + + const [layers, setLayers] = useState(() => + media ? [{ url: media.url, type: media.type, active: true }] : [], + ); + const activeUrl = layers.find((l) => l.active)?.url; + const cleanupRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (cleanupRef.current) clearTimeout(cleanupRef.current); + }; + }, []); + + useEffect(() => { + if (!media) { + setLayers([]); + return; + } + if (media.url === activeUrl) return; + setLayers((prev) => { + // If the URL already exists (possibly inactive), reactivate it + const existing = prev.find((l) => l.url === media.url); + if (existing) { + // Cancel any pending cleanup that might remove this layer + if (cleanupRef.current) { + clearTimeout(cleanupRef.current); + cleanupRef.current = null; + } + return prev.map((l) => ({ + ...l, + active: l.url === media.url, + })); + } + return [...prev, { url: media.url, type: media.type, active: false }]; + }); + }, [media?.url, activeUrl]); + + const desiredUrlRef = useRef(media?.url); + desiredUrlRef.current = media?.url; + + const handleMediaReady = useCallback((readyUrl: string) => { + // Ignore stale loads — if the user has since switched to a different emotion, + // don't activate the old layer + if (desiredUrlRef.current && readyUrl !== desiredUrlRef.current) return; + setLayers((prev) => { + const staleUrls = prev.filter((l) => l.url !== readyUrl).map((l) => l.url); + if (cleanupRef.current) clearTimeout(cleanupRef.current); + cleanupRef.current = setTimeout(() => { + setLayers((curr) => curr.filter((l) => !staleUrls.includes(l.url))); + }, 300); + return prev.map((l) => ({ ...l, active: l.url === readyUrl })); + }); + }, []); + + if (layers.length === 0) { + return
{character.character_name.charAt(0)}
; + } + + return ( + <> + {layers.map((layer) => { + const layerStyle: React.CSSProperties = { + position: 'absolute', + inset: 0, + opacity: layer.active ? 1 : 0, + transition: 'opacity 0.25s ease-out', + }; + if (layer.type === 'video') { + return ( +