diff --git a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx new file mode 100644 index 0000000..81552ed --- /dev/null +++ b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx @@ -0,0 +1,365 @@ +import React, { useState } from 'react'; +import { X, Plus, Trash2, Check } from 'lucide-react'; +import { + type CharacterConfig, + type CharacterCollection, + CHARACTER_EMOTION_LIST, + generateCharacterId, + getCharacterList, +} from '@/lib/characterManager'; +import styles from './panel.module.scss'; + +interface CharacterPanelProps { + collection: CharacterCollection; + onSave: (collection: CharacterCollection) => void; + onClose: () => void; +} + +const CharacterPanel: React.FC = ({ collection, onSave, onClose }) => { + const [col, setCol] = useState(() => ({ ...collection })); + const [editingId, setEditingId] = useState(null); + + const characters = getCharacterList(col); + const activeId = col.activeId; + const editing = editingId ? col.items[editingId] : null; + + const handleSelect = (id: string) => { + setCol({ ...col, activeId: id }); + }; + + const handleDelete = (id: string) => { + if (characters.length <= 1) return; + const items = { ...col.items }; + delete items[id]; + const newActiveId = col.activeId === id ? Object.keys(items)[0] : col.activeId; + setCol({ activeId: newActiveId, items }); + if (editingId === id) setEditingId(null); + }; + + const handleAdd = () => { + const id = generateCharacterId(); + const newChar: CharacterConfig = { + id, + character_name: 'New Character', + character_gender_desc: '', + character_desc: '', + character_emotion_list: [...CHARACTER_EMOTION_LIST], + character_meta_info: { base_image_url: '' }, + }; + setCol({ ...col, items: { ...col.items, [id]: newChar } }); + setEditingId(id); + }; + + const handleSave = () => { + onSave(col); + }; + + if (editing) { + return ( + { + setCol({ ...col, items: { ...col.items, [updated.id]: updated } }); + setEditingId(null); + }} + onClose={() => setEditingId(null)} + /> + ); + } + + return ( +
+
e.stopPropagation()}> +
+ Characters + +
+ +
+
+ {characters.map((char) => ( +
handleSelect(char.id)} + > +
+ {char.character_meta_info?.base_image_url ? ( + {char.character_name} + ) : ( + {char.character_name.charAt(0)} + )} +
+
+
{char.character_name}
+
+ {char.character_gender_desc || 'No gender set'} +
+
+
+ {char.id === activeId && ( + + + + )} + + {characters.length > 1 && ( + + )} +
+
+ ))} +
+
+ +
+ +
+ + +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Character Editor (single character editing form) +// --------------------------------------------------------------------------- + +const CharacterEditor: React.FC<{ + character: CharacterConfig; + onSave: (config: CharacterConfig) => void; + onClose: () => void; +}> = ({ character, onSave, onClose }) => { + const [name, setName] = useState(character.character_name); + const [gender, setGender] = useState(character.character_gender_desc); + const [desc, setDesc] = useState(character.character_desc); + const [imageUrl, setImageUrl] = useState(character.character_meta_info?.base_image_url || ''); + const [emotions, setEmotions] = useState([...character.character_emotion_list]); + const [emotionImages, setEmotionImages] = useState>(() => { + const images: Record = { ...character.character_meta_info?.emotion_images }; + // Populate from emotion_videos (use first video URL) if emotion_images is missing + const videos = character.character_meta_info?.emotion_videos; + if (videos) { + for (const [emotion, urls] of Object.entries(videos)) { + if (!images[emotion] && urls?.length) { + images[emotion] = urls[0]; + } + } + } + return images; + }); + const [emotionVideos, setEmotionVideos] = useState>(() => ({ + ...character.character_meta_info?.emotion_videos, + })); + const [newEmotion, setNewEmotion] = useState(''); + + const handleAddEmotion = () => { + const e = newEmotion.trim().toLowerCase(); + if (e && !emotions.includes(e)) { + setEmotions([...emotions, e]); + setNewEmotion(''); + } + }; + + const handleRemoveEmotion = (emotion: string) => { + setEmotions(emotions.filter((e) => e !== emotion)); + const updatedImages = { ...emotionImages }; + delete updatedImages[emotion]; + setEmotionImages(updatedImages); + const updatedVideos = { ...emotionVideos }; + delete updatedVideos[emotion]; + setEmotionVideos(updatedVideos); + }; + + const handleResetEmotions = () => { + setEmotions([...CHARACTER_EMOTION_LIST]); + }; + + const updateEmotionImage = (emotion: string, url: string) => { + setEmotionImages({ ...emotionImages, [emotion]: url }); + }; + + const handleSave = () => { + const cleanImages: Record = {}; + for (const [k, v] of Object.entries(emotionImages)) { + if (v?.trim()) cleanImages[k] = v.trim(); + } + + const cleanVideos: Record = {}; + for (const [k, v] of Object.entries(emotionVideos)) { + if (v?.length) cleanVideos[k] = v; + } + + onSave({ + id: character.id, + character_name: name.trim() || 'Unnamed', + character_gender_desc: gender.trim(), + character_desc: desc.trim(), + character_emotion_list: emotions, + character_meta_info: { + ...character.character_meta_info, + base_image_url: imageUrl.trim() || undefined, + emotion_images: Object.keys(cleanImages).length > 0 ? cleanImages : undefined, + emotion_videos: Object.keys(cleanVideos).length > 0 ? cleanVideos : undefined, + }, + }); + }; + + return ( +
+
e.stopPropagation()}> +
+ Edit Character + +
+ +
+ {imageUrl && ( +
+ {name} +
+ )} + +
+ + setName(e.target.value)} + placeholder="Character name" + /> +
+ +
+ + setGender(e.target.value)} + placeholder="female / male / non-binary / ..." + /> +
+ +
+ +