Skip to content

Commit 8a35dd3

Browse files
committed
feat(role-play): add character/mod system, memory, session management and UI improvements
- Add character manager with multi-character support and emotion system - Add mod manager for configurable role-play scenarios (prologue, system prompt, tools) - Add hybrid memory system (SP injection + save_memory tool) for cross-session persistence - Add session reset API to clear all session data and restore initial state - Migrate storage from IndexedDB to disk-based persistence (~/.openroom/sessions/) - Decouple image generation tool from storage (generate only, save via file tools) - Persist suggested replies in chat history to survive page refresh - Redesign floating action buttons as horizontal bottom toolbar
1 parent dd748a3 commit 8a35dd3

21 files changed

Lines changed: 3865 additions & 732 deletions
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
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+
...character.character_meta_info?.emotion_images,
168+
}));
169+
const [newEmotion, setNewEmotion] = useState('');
170+
171+
const handleAddEmotion = () => {
172+
const e = newEmotion.trim().toLowerCase();
173+
if (e && !emotions.includes(e)) {
174+
setEmotions([...emotions, e]);
175+
setNewEmotion('');
176+
}
177+
};
178+
179+
const handleRemoveEmotion = (emotion: string) => {
180+
setEmotions(emotions.filter((e) => e !== emotion));
181+
const updated = { ...emotionImages };
182+
delete updated[emotion];
183+
setEmotionImages(updated);
184+
};
185+
186+
const handleResetEmotions = () => {
187+
setEmotions([...CHARACTER_EMOTION_LIST]);
188+
};
189+
190+
const updateEmotionImage = (emotion: string, url: string) => {
191+
setEmotionImages({ ...emotionImages, [emotion]: url });
192+
};
193+
194+
const handleSave = () => {
195+
const cleanImages: Record<string, string> = {};
196+
for (const [k, v] of Object.entries(emotionImages)) {
197+
if (v?.trim()) cleanImages[k] = v.trim();
198+
}
199+
200+
onSave({
201+
id: character.id,
202+
character_name: name.trim() || 'Unnamed',
203+
character_gender_desc: gender.trim(),
204+
character_desc: desc.trim(),
205+
character_emotion_list: emotions,
206+
character_meta_info: {
207+
base_image_url: imageUrl.trim() || undefined,
208+
emotion_images: Object.keys(cleanImages).length > 0 ? cleanImages : undefined,
209+
},
210+
});
211+
};
212+
213+
return (
214+
<div className={styles.overlay} onClick={onClose}>
215+
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
216+
<div className={styles.panelHeader}>
217+
<span className={styles.panelTitle}>Edit Character</span>
218+
<button className={styles.closeBtn} onClick={onClose}>
219+
<X size={18} />
220+
</button>
221+
</div>
222+
223+
<div className={styles.panelBody}>
224+
{imageUrl && (
225+
<div className={styles.avatarPreview}>
226+
<img src={imageUrl} alt={name} className={styles.avatarImg} />
227+
</div>
228+
)}
229+
230+
<div className={styles.field}>
231+
<label className={styles.label}>Name</label>
232+
<input
233+
className={styles.input}
234+
value={name}
235+
onChange={(e) => setName(e.target.value)}
236+
placeholder="Character name"
237+
/>
238+
</div>
239+
240+
<div className={styles.field}>
241+
<label className={styles.label}>Gender</label>
242+
<input
243+
className={styles.input}
244+
value={gender}
245+
onChange={(e) => setGender(e.target.value)}
246+
placeholder="female / male / non-binary / ..."
247+
/>
248+
</div>
249+
250+
<div className={styles.field}>
251+
<label className={styles.label}>Persona Description</label>
252+
<textarea
253+
className={styles.textarea}
254+
value={desc}
255+
onChange={(e) => setDesc(e.target.value)}
256+
rows={6}
257+
placeholder="Describe the character's personality, background, speaking style..."
258+
/>
259+
</div>
260+
261+
<div className={styles.field}>
262+
<label className={styles.label}>Default Avatar (base image)</label>
263+
<input
264+
className={styles.input}
265+
value={imageUrl}
266+
onChange={(e) => setImageUrl(e.target.value)}
267+
placeholder="https://..."
268+
/>
269+
</div>
270+
271+
<div className={styles.field}>
272+
<label className={styles.label}>
273+
Emotions & Expression Images
274+
<button className={styles.resetLink} onClick={handleResetEmotions}>
275+
Reset to defaults
276+
</button>
277+
</label>
278+
<div className={styles.emotionImageList}>
279+
{emotions.map((e) => (
280+
<div key={e} className={styles.emotionImageRow}>
281+
<div className={styles.emotionImageHeader}>
282+
<span className={styles.emotionTag}>
283+
{e}
284+
<button
285+
className={styles.emotionRemove}
286+
onClick={() => handleRemoveEmotion(e)}
287+
>
288+
<Trash2 size={10} />
289+
</button>
290+
</span>
291+
{emotionImages[e] && (
292+
<img src={emotionImages[e]} alt={e} className={styles.emotionThumb} />
293+
)}
294+
</div>
295+
<input
296+
className={styles.input}
297+
value={emotionImages[e] || ''}
298+
onChange={(ev) => updateEmotionImage(e, ev.target.value)}
299+
placeholder={`Image URL for "${e}" (optional, falls back to default)`}
300+
/>
301+
</div>
302+
))}
303+
</div>
304+
<div className={styles.emotionAdd}>
305+
<input
306+
className={styles.input}
307+
value={newEmotion}
308+
onChange={(e) => setNewEmotion(e.target.value)}
309+
onKeyDown={(e) => e.key === 'Enter' && handleAddEmotion()}
310+
placeholder="Add emotion..."
311+
/>
312+
<button className={styles.addBtn} onClick={handleAddEmotion}>
313+
<Plus size={14} />
314+
</button>
315+
</div>
316+
</div>
317+
</div>
318+
319+
<div className={styles.panelFooter}>
320+
<button className={styles.cancelBtn} onClick={onClose}>
321+
Back
322+
</button>
323+
<button className={styles.saveBtn} onClick={handleSave}>
324+
Done
325+
</button>
326+
</div>
327+
</div>
328+
</div>
329+
);
330+
};
331+
332+
export default CharacterPanel;

0 commit comments

Comments
 (0)