Skip to content

Commit 261426f

Browse files
authored
Merge pull request #8 from MiniMax-AI/feat/role-play-system
feat(role-play): add character/mod system, memory, session management…
2 parents dd748a3 + c7f390c commit 261426f

22 files changed

Lines changed: 4121 additions & 763 deletions
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
import React, { useState } from 'react';
2+
import { X, Plus, Trash2, Check } from 'lucide-react';
3+
import {
4+
type CharacterConfig,
5+
type CharacterCollection,
6+
CHARACTER_EMOTION_LIST,
7+
generateCharacterId,
8+
getCharacterList,
9+
} from '@/lib/characterManager';
10+
import styles from './panel.module.scss';
11+
12+
interface CharacterPanelProps {
13+
collection: CharacterCollection;
14+
onSave: (collection: CharacterCollection) => void;
15+
onClose: () => void;
16+
}
17+
18+
const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onClose }) => {
19+
const [col, setCol] = useState<CharacterCollection>(() => ({ ...collection }));
20+
const [editingId, setEditingId] = useState<string | null>(null);
21+
22+
const characters = getCharacterList(col);
23+
const activeId = col.activeId;
24+
const editing = editingId ? col.items[editingId] : null;
25+
26+
const handleSelect = (id: string) => {
27+
setCol({ ...col, activeId: id });
28+
};
29+
30+
const handleDelete = (id: string) => {
31+
if (characters.length <= 1) return;
32+
const items = { ...col.items };
33+
delete items[id];
34+
const newActiveId = col.activeId === id ? Object.keys(items)[0] : col.activeId;
35+
setCol({ activeId: newActiveId, items });
36+
if (editingId === id) setEditingId(null);
37+
};
38+
39+
const handleAdd = () => {
40+
const id = generateCharacterId();
41+
const newChar: CharacterConfig = {
42+
id,
43+
character_name: 'New Character',
44+
character_gender_desc: '',
45+
character_desc: '',
46+
character_emotion_list: [...CHARACTER_EMOTION_LIST],
47+
character_meta_info: { base_image_url: '' },
48+
};
49+
setCol({ ...col, items: { ...col.items, [id]: newChar } });
50+
setEditingId(id);
51+
};
52+
53+
const handleSave = () => {
54+
onSave(col);
55+
};
56+
57+
if (editing) {
58+
return (
59+
<CharacterEditor
60+
character={editing}
61+
onSave={(updated) => {
62+
setCol({ ...col, items: { ...col.items, [updated.id]: updated } });
63+
setEditingId(null);
64+
}}
65+
onClose={() => setEditingId(null)}
66+
/>
67+
);
68+
}
69+
70+
return (
71+
<div className={styles.overlay} onClick={onClose}>
72+
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
73+
<div className={styles.panelHeader}>
74+
<span className={styles.panelTitle}>Characters</span>
75+
<button className={styles.closeBtn} onClick={onClose}>
76+
<X size={18} />
77+
</button>
78+
</div>
79+
80+
<div className={styles.panelBody}>
81+
<div className={styles.listView}>
82+
{characters.map((char) => (
83+
<div
84+
key={char.id}
85+
className={`${styles.listItem} ${char.id === activeId ? styles.listItemActive : ''}`}
86+
onClick={() => handleSelect(char.id)}
87+
>
88+
<div className={styles.listItemAvatar}>
89+
{char.character_meta_info?.base_image_url ? (
90+
<img src={char.character_meta_info.base_image_url} alt={char.character_name} />
91+
) : (
92+
<span>{char.character_name.charAt(0)}</span>
93+
)}
94+
</div>
95+
<div className={styles.listItemInfo}>
96+
<div className={styles.listItemName}>{char.character_name}</div>
97+
<div className={styles.listItemDesc}>
98+
{char.character_gender_desc || 'No gender set'}
99+
</div>
100+
</div>
101+
<div className={styles.listItemActions}>
102+
{char.id === activeId && (
103+
<span className={styles.activeBadge}>
104+
<Check size={12} />
105+
</span>
106+
)}
107+
<button
108+
className={styles.listItemBtn}
109+
onClick={(e) => {
110+
e.stopPropagation();
111+
setEditingId(char.id);
112+
}}
113+
title="Edit"
114+
>
115+
Edit
116+
</button>
117+
{characters.length > 1 && (
118+
<button
119+
className={styles.listItemBtn}
120+
onClick={(e) => {
121+
e.stopPropagation();
122+
handleDelete(char.id);
123+
}}
124+
title="Delete"
125+
>
126+
<Trash2 size={14} />
127+
</button>
128+
)}
129+
</div>
130+
</div>
131+
))}
132+
</div>
133+
</div>
134+
135+
<div className={styles.panelFooter}>
136+
<button className={styles.addBtn} onClick={handleAdd}>
137+
<Plus size={14} /> New Character
138+
</button>
139+
<div style={{ flex: 1 }} />
140+
<button className={styles.cancelBtn} onClick={onClose}>
141+
Cancel
142+
</button>
143+
<button className={styles.saveBtn} onClick={handleSave}>
144+
Save
145+
</button>
146+
</div>
147+
</div>
148+
</div>
149+
);
150+
};
151+
152+
// ---------------------------------------------------------------------------
153+
// Character Editor (single character editing form)
154+
// ---------------------------------------------------------------------------
155+
156+
const CharacterEditor: React.FC<{
157+
character: CharacterConfig;
158+
onSave: (config: CharacterConfig) => void;
159+
onClose: () => void;
160+
}> = ({ character, onSave, onClose }) => {
161+
const [name, setName] = useState(character.character_name);
162+
const [gender, setGender] = useState(character.character_gender_desc);
163+
const [desc, setDesc] = useState(character.character_desc);
164+
const [imageUrl, setImageUrl] = useState(character.character_meta_info?.base_image_url || '');
165+
const [emotions, setEmotions] = useState<string[]>([...character.character_emotion_list]);
166+
const [emotionImages, setEmotionImages] = useState<Record<string, string>>(() => {
167+
const images: Record<string, string> = { ...character.character_meta_info?.emotion_images };
168+
// Populate from emotion_videos (use first video URL) if emotion_images is missing
169+
const videos = character.character_meta_info?.emotion_videos;
170+
if (videos) {
171+
for (const [emotion, urls] of Object.entries(videos)) {
172+
if (!images[emotion] && urls?.length) {
173+
images[emotion] = urls[0];
174+
}
175+
}
176+
}
177+
return images;
178+
});
179+
const [emotionVideos, setEmotionVideos] = useState<Record<string, string[]>>(() => ({
180+
...character.character_meta_info?.emotion_videos,
181+
}));
182+
const [newEmotion, setNewEmotion] = useState('');
183+
184+
const handleAddEmotion = () => {
185+
const e = newEmotion.trim().toLowerCase();
186+
if (e && !emotions.includes(e)) {
187+
setEmotions([...emotions, e]);
188+
setNewEmotion('');
189+
}
190+
};
191+
192+
const handleRemoveEmotion = (emotion: string) => {
193+
setEmotions(emotions.filter((e) => e !== emotion));
194+
const updatedImages = { ...emotionImages };
195+
delete updatedImages[emotion];
196+
setEmotionImages(updatedImages);
197+
const updatedVideos = { ...emotionVideos };
198+
delete updatedVideos[emotion];
199+
setEmotionVideos(updatedVideos);
200+
};
201+
202+
const handleResetEmotions = () => {
203+
setEmotions([...CHARACTER_EMOTION_LIST]);
204+
};
205+
206+
const updateEmotionImage = (emotion: string, url: string) => {
207+
setEmotionImages({ ...emotionImages, [emotion]: url });
208+
};
209+
210+
const handleSave = () => {
211+
const cleanImages: Record<string, string> = {};
212+
for (const [k, v] of Object.entries(emotionImages)) {
213+
if (v?.trim()) cleanImages[k] = v.trim();
214+
}
215+
216+
const cleanVideos: Record<string, string[]> = {};
217+
for (const [k, v] of Object.entries(emotionVideos)) {
218+
if (v?.length) cleanVideos[k] = v;
219+
}
220+
221+
onSave({
222+
id: character.id,
223+
character_name: name.trim() || 'Unnamed',
224+
character_gender_desc: gender.trim(),
225+
character_desc: desc.trim(),
226+
character_emotion_list: emotions,
227+
character_meta_info: {
228+
...character.character_meta_info,
229+
base_image_url: imageUrl.trim() || undefined,
230+
emotion_images: Object.keys(cleanImages).length > 0 ? cleanImages : undefined,
231+
emotion_videos: Object.keys(cleanVideos).length > 0 ? cleanVideos : undefined,
232+
},
233+
});
234+
};
235+
236+
return (
237+
<div className={styles.overlay} onClick={onClose}>
238+
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
239+
<div className={styles.panelHeader}>
240+
<span className={styles.panelTitle}>Edit Character</span>
241+
<button className={styles.closeBtn} onClick={onClose}>
242+
<X size={18} />
243+
</button>
244+
</div>
245+
246+
<div className={styles.panelBody}>
247+
{imageUrl && (
248+
<div className={styles.avatarPreview}>
249+
<img src={imageUrl} alt={name} className={styles.avatarImg} />
250+
</div>
251+
)}
252+
253+
<div className={styles.field}>
254+
<label className={styles.label}>Name</label>
255+
<input
256+
className={styles.input}
257+
value={name}
258+
onChange={(e) => setName(e.target.value)}
259+
placeholder="Character name"
260+
/>
261+
</div>
262+
263+
<div className={styles.field}>
264+
<label className={styles.label}>Gender</label>
265+
<input
266+
className={styles.input}
267+
value={gender}
268+
onChange={(e) => setGender(e.target.value)}
269+
placeholder="female / male / non-binary / ..."
270+
/>
271+
</div>
272+
273+
<div className={styles.field}>
274+
<label className={styles.label}>Persona Description</label>
275+
<textarea
276+
className={styles.textarea}
277+
value={desc}
278+
onChange={(e) => setDesc(e.target.value)}
279+
rows={6}
280+
placeholder="Describe the character's personality, background, speaking style..."
281+
/>
282+
</div>
283+
284+
<div className={styles.field}>
285+
<label className={styles.label}>Default Avatar (base image)</label>
286+
<input
287+
className={styles.input}
288+
value={imageUrl}
289+
onChange={(e) => setImageUrl(e.target.value)}
290+
placeholder="https://..."
291+
/>
292+
</div>
293+
294+
<div className={styles.field}>
295+
<label className={styles.label}>
296+
Emotions & Expressions
297+
<button className={styles.resetLink} onClick={handleResetEmotions}>
298+
Reset to defaults
299+
</button>
300+
</label>
301+
<div className={styles.emotionImageList}>
302+
{emotions.map((e) => (
303+
<div key={e} className={styles.emotionImageRow}>
304+
<div className={styles.emotionImageHeader}>
305+
<span className={styles.emotionTag}>
306+
{e}
307+
<button
308+
className={styles.emotionRemove}
309+
onClick={() => handleRemoveEmotion(e)}
310+
>
311+
<Trash2 size={10} />
312+
</button>
313+
</span>
314+
{emotionImages[e] &&
315+
(/\.(mp4|webm|mov|ogg)(\?|$)/i.test(emotionImages[e]) ? (
316+
<video
317+
src={emotionImages[e]}
318+
className={styles.emotionThumb}
319+
autoPlay
320+
loop
321+
muted
322+
playsInline
323+
/>
324+
) : (
325+
<img src={emotionImages[e]} alt={e} className={styles.emotionThumb} />
326+
))}
327+
</div>
328+
<input
329+
className={styles.input}
330+
value={emotionImages[e] || ''}
331+
onChange={(ev) => updateEmotionImage(e, ev.target.value)}
332+
placeholder={`Image/Video URL for "${e}" (optional)`}
333+
/>
334+
</div>
335+
))}
336+
</div>
337+
<div className={styles.emotionAdd}>
338+
<input
339+
className={styles.input}
340+
value={newEmotion}
341+
onChange={(e) => setNewEmotion(e.target.value)}
342+
onKeyDown={(e) => e.key === 'Enter' && handleAddEmotion()}
343+
placeholder="Add emotion..."
344+
/>
345+
<button className={styles.addBtn} onClick={handleAddEmotion}>
346+
<Plus size={14} />
347+
</button>
348+
</div>
349+
</div>
350+
</div>
351+
352+
<div className={styles.panelFooter}>
353+
<button className={styles.cancelBtn} onClick={onClose}>
354+
Back
355+
</button>
356+
<button className={styles.saveBtn} onClick={handleSave}>
357+
Done
358+
</button>
359+
</div>
360+
</div>
361+
</div>
362+
);
363+
};
364+
365+
export default CharacterPanel;

0 commit comments

Comments
 (0)