From a1bbb198616593ea245417bc9cb21fc2e55f50e5 Mon Sep 17 00:00:00 2001 From: SweetSophia Date: Wed, 1 Apr 2026 15:35:12 +0200 Subject: [PATCH 01/12] refactor: extract ChatPanel into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose the 1472-line ChatPanel god component into four focused files: - toolDefinitions.ts (133 lines): tool defs, system prompt builder, config guard - ChatSubComponents.tsx (178 lines): CharacterAvatar, StageIndicator, ActionsTaken - SettingsModal.tsx (270 lines): LLM + image generation configuration UI - useConversationEngine.ts (341 lines): conversation loop + tool dispatch hook ChatPanel/index.tsx reduced from 1472 → 632 lines (-57%), now a thin shell that wires state to UI. No behavior changes — pure structural refactor. Benefits: - Conversation engine is testable in isolation - Settings modal can be reasoned about independently - Each file has a single clear responsibility - Future PRs can target specific modules without touching the whole --- .../ChatPanel/ChatSubComponents.tsx | 178 +++ .../components/ChatPanel/SettingsModal.tsx | 270 +++++ .../src/components/ChatPanel/index.tsx | 1018 ++--------------- .../components/ChatPanel/toolDefinitions.ts | 133 +++ .../ChatPanel/useConversationEngine.ts | 341 ++++++ 5 files changed, 1011 insertions(+), 929 deletions(-) create mode 100644 apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx create mode 100644 apps/webuiapps/src/components/ChatPanel/SettingsModal.tsx create mode 100644 apps/webuiapps/src/components/ChatPanel/toolDefinitions.ts create mode 100644 apps/webuiapps/src/components/ChatPanel/useConversationEngine.ts diff --git a/apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx b/apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx new file mode 100644 index 0000000..4ab5c8d --- /dev/null +++ b/apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx @@ -0,0 +1,178 @@ +/** + * Helper sub-components extracted from ChatPanel + * + * StageIndicator, ActionsTaken, CharacterAvatar, renderMessageContent + */ + +import React, { useState, useEffect, useCallback, memo } 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; + + useEffect(() => { + if (!media) { + setLayers([]); + return; + } + if (media.url === activeUrl) return; + setLayers((prev) => { + if (prev.some((l) => l.url === media.url)) return prev; + return [...prev, { url: media.url, type: media.type, active: false }]; + }); + }, [media?.url, activeUrl]); + + const handleMediaReady = useCallback((readyUrl: string) => { + setLayers((prev) => { + const staleUrls = prev.filter((l) => l.url !== readyUrl).map((l) => l.url); + 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 ( +