Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 156 additions & 36 deletions apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,15 @@ import {
generateCharacterId,
getCharacterList,
} from '@/lib/characterManager';
import { deleteCharacterAsset, isVideoAssetUrl } from '@/lib/characterAssetUpload';
import {
deleteCharacterAsset,
isLocalCharacterAssetPath,
isVideoAssetUrl,
} from '@/lib/characterAssetUpload';
import { useResolvedAssetUrl } from '@/hooks/useResolvedAssetUrl';
import ImageUploader from './ImageUploader';
import styles from './panel.module.scss';

async function deleteReplacedCharacterAsset(
previousUrl: string | undefined,
nextUrl: string,
): Promise<void> {
if (!previousUrl || previousUrl === nextUrl) return;

try {
await deleteCharacterAsset(previousUrl);
} catch (error) {
console.warn('Failed to delete replaced character asset', error);
}
}

const CharacterAvatarThumb: React.FC<{ url?: string; name: string }> = ({ url, name }) => {
const resolvedUrl = useResolvedAssetUrl(url);
if (!resolvedUrl) return <span>{name.charAt(0)}</span>;
Expand Down Expand Up @@ -53,9 +44,66 @@ interface CharacterPanelProps {
onClose: () => void;
}

interface CharacterAssetLifecycle {
createdAssets: string[];
deleteOnCommit: string[];
}

function collectCharacterLocalAssetPaths(character?: CharacterConfig): Set<string> {
const paths = new Set<string>();
const addPath = (path?: string) => {
const trimmed = path?.trim();
if (trimmed && isLocalCharacterAssetPath(trimmed)) paths.add(trimmed);
};

addPath(character?.character_meta_info?.base_image_url);
for (const path of Object.values(character?.character_meta_info?.emotion_images ?? {})) {
addPath(path);
}
for (const pathsForEmotion of Object.values(
character?.character_meta_info?.emotion_videos ?? {},
)) {
for (const path of pathsForEmotion ?? []) addPath(path);
}

return paths;
}

function collectCollectionLocalAssetPaths(collection: CharacterCollection): Set<string> {
const paths = new Set<string>();
for (const character of Object.values(collection.items)) {
for (const path of collectCharacterLocalAssetPaths(character)) paths.add(path);
}
return paths;
}

function diffLocalAssetPaths(
oldCharacter: CharacterConfig,
newCharacter: CharacterConfig,
): string[] {
const oldPaths = collectCharacterLocalAssetPaths(oldCharacter);
const newPaths = collectCharacterLocalAssetPaths(newCharacter);
return [...oldPaths].filter((path) => !newPaths.has(path));
}

async function deleteAssetPaths(paths: Iterable<string>): Promise<void> {
const uniquePaths = [...new Set(paths)];
await Promise.all(
uniquePaths.map(async (path) => {
try {
await deleteCharacterAsset(path);
} catch (err) {
console.warn('Failed to delete character asset:', err);
}
}),
);
}

const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onClose }) => {
const [col, setCol] = useState<CharacterCollection>(() => ({ ...collection }));
const [editingId, setEditingId] = useState<string | null>(null);
const [sessionCreatedAssets, setSessionCreatedAssets] = useState<Set<string>>(() => new Set());
const [pendingDeleteAssets, setPendingDeleteAssets] = useState<Set<string>>(() => new Set());

const characters = getCharacterList(col);
const activeId = col.activeId;
Expand All @@ -67,10 +115,18 @@ const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onC

const handleDelete = (id: string) => {
if (characters.length <= 1) return;
const deletedCharacter = col.items[id];
const items = { ...col.items };
delete items[id];
const newActiveId = col.activeId === id ? Object.keys(items)[0] : col.activeId;
setCol({ activeId: newActiveId, items });
if (deletedCharacter) {
setPendingDeleteAssets((current) => {
const next = new Set(current);
for (const path of collectCharacterLocalAssetPaths(deletedCharacter)) next.add(path);
return next;
});
}
if (editingId === id) setEditingId(null);
};

Expand All @@ -90,14 +146,34 @@ const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onC

const handleSave = () => {
onSave(col);
const referencedPaths = collectCollectionLocalAssetPaths(col);
const stagedDeletes = new Set<string>();
for (const path of pendingDeleteAssets) {
if (!referencedPaths.has(path)) stagedDeletes.add(path);
}
for (const path of sessionCreatedAssets) {
if (!referencedPaths.has(path)) stagedDeletes.add(path);
}
void deleteAssetPaths(stagedDeletes);
};

const handleCancel = () => {
const originallyReferencedPaths = collectCollectionLocalAssetPaths(collection);
const createdOnlyInSession = [...sessionCreatedAssets].filter(
(path) => !originallyReferencedPaths.has(path),
);
void deleteAssetPaths(createdOnlyInSession);
onClose();
};

if (editing) {
return (
<CharacterEditor
character={editing}
onSave={(updated) => {
onSave={(updated, lifecycle) => {
setCol({ ...col, items: { ...col.items, [updated.id]: updated } });
setSessionCreatedAssets((current) => new Set([...current, ...lifecycle.createdAssets]));
setPendingDeleteAssets((current) => new Set([...current, ...lifecycle.deleteOnCommit]));
setEditingId(null);
}}
onClose={() => setEditingId(null)}
Expand All @@ -106,11 +182,11 @@ const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onC
}

return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.overlay} onClick={handleCancel}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Characters</span>
<button className={styles.closeBtn} onClick={onClose}>
<button className={styles.closeBtn} onClick={handleCancel}>
<X size={18} />
</button>
</div>
Expand Down Expand Up @@ -174,7 +250,7 @@ const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onC
<Plus size={14} /> New Character
</button>
<div style={{ flex: 1 }} />
<button className={styles.cancelBtn} onClick={onClose}>
<button className={styles.cancelBtn} onClick={handleCancel}>
Cancel
</button>
<button className={styles.saveBtn} onClick={handleSave}>
Expand All @@ -192,7 +268,7 @@ const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onC

const CharacterEditor: React.FC<{
character: CharacterConfig;
onSave: (config: CharacterConfig) => void;
onSave: (config: CharacterConfig, lifecycle: CharacterAssetLifecycle) => void;
onClose: () => void;
}> = ({ character, onSave, onClose }) => {
const [activeTab, setActiveTab] = useState<'details' | 'assets'>('details');
Expand All @@ -208,6 +284,25 @@ const CharacterEditor: React.FC<{
...character.character_meta_info?.emotion_videos,
}));
const [newEmotion, setNewEmotion] = useState('');
const [createdAssets, setCreatedAssets] = useState<Set<string>>(() => new Set());
const [deleteOnCommit, setDeleteOnCommit] = useState<Set<string>>(() => new Set());

const markUploadedAsset = (url: string) => {
if (!isLocalCharacterAssetPath(url)) return;
setCreatedAssets((current) => {
if (current.has(url)) return current;
return new Set(current).add(url);
});
};

const markRemovedAsset = (url?: string) => {
const trimmedUrl = url?.trim();
if (!trimmedUrl || !isLocalCharacterAssetPath(trimmedUrl)) return;
setDeleteOnCommit((current) => {
if (current.has(trimmedUrl)) return current;
return new Set(current).add(trimmedUrl);
});
};

const handleAddEmotion = () => {
const e = newEmotion.trim().toLowerCase();
Expand All @@ -221,7 +316,9 @@ const CharacterEditor: React.FC<{
emotionVideos[emotion]?.[0] || emotionImages[emotion];

const handleEmotionAssetUpload = (emotion: string, url: string, type: 'image' | 'video') => {
void deleteReplacedCharacterAsset(getEmotionAssetUrl(emotion), url);
const previousUrl = getEmotionAssetUrl(emotion);
if (previousUrl !== url) markRemovedAsset(previousUrl);
markUploadedAsset(url);

if (type === 'video') {
setEmotionVideos({ ...emotionVideos, [emotion]: [url] });
Expand All @@ -237,8 +334,8 @@ const CharacterEditor: React.FC<{
setEmotionVideos(updatedVideos);
};

const handleEmotionAssetRemove = async (emotion: string) => {
await deleteCharacterAsset(getEmotionAssetUrl(emotion));
const handleEmotionAssetRemove = (emotion: string) => {
markRemovedAsset(getEmotionAssetUrl(emotion));
const updatedImages = { ...emotionImages };
delete updatedImages[emotion];
setEmotionImages(updatedImages);
Expand All @@ -248,7 +345,7 @@ const CharacterEditor: React.FC<{
};

const handleRemoveEmotion = (emotion: string) => {
void deleteCharacterAsset(getEmotionAssetUrl(emotion));
markRemovedAsset(getEmotionAssetUrl(emotion));
setEmotions(emotions.filter((e) => e !== emotion));
const updatedImages = { ...emotionImages };
delete updatedImages[emotion];
Expand Down Expand Up @@ -278,10 +375,24 @@ const CharacterEditor: React.FC<{
}
}

const previousUrl = getEmotionAssetUrl(emotion);
if (previousUrl !== trimmedUrl) markRemovedAsset(previousUrl);

setEmotionImages(updatedImages);
setEmotionVideos(updatedVideos);
};

const handleBaseImageUpload = (url: string) => {
if (imageUrl !== url) markRemovedAsset(imageUrl);
markUploadedAsset(url);
setImageUrl(url);
};

const handleBaseImageRemove = () => {
markRemovedAsset(imageUrl);
setImageUrl('');
};

const handleSave = () => {
const cleanImages: Record<string, string> = {};
for (const [k, v] of Object.entries(emotionImages)) {
Expand All @@ -293,7 +404,7 @@ const CharacterEditor: React.FC<{
if (v?.length) cleanVideos[k] = v;
}

onSave({
const updatedCharacter: CharacterConfig = {
id: character.id,
character_name: name.trim() || 'Unnamed',
character_gender_desc: gender.trim(),
Expand All @@ -305,15 +416,30 @@ const CharacterEditor: React.FC<{
emotion_images: Object.keys(cleanImages).length > 0 ? cleanImages : undefined,
emotion_videos: Object.keys(cleanVideos).length > 0 ? cleanVideos : undefined,
},
};

const referencedPaths = collectCharacterLocalAssetPaths(updatedCharacter);
const staleAssets = [
...new Set([...deleteOnCommit, ...diffLocalAssetPaths(character, updatedCharacter)]),
].filter((path) => !referencedPaths.has(path));

onSave(updatedCharacter, {
createdAssets: [...createdAssets],
deleteOnCommit: staleAssets,
});
};

const handleClose = () => {
void deleteAssetPaths(createdAssets);
onClose();
};

return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.overlay} onClick={handleClose}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Edit Character</span>
<button className={styles.closeBtn} onClick={onClose}>
<button className={styles.closeBtn} onClick={handleClose}>
<X size={18} />
</button>
</div>
Expand Down Expand Up @@ -388,15 +514,9 @@ const CharacterEditor: React.FC<{
currentUrl={imageUrl}
accept="image/*"
onUpload={(url, type) => {
if (type === 'image') {
void deleteReplacedCharacterAsset(imageUrl, url);
setImageUrl(url);
}
}}
onRemove={() => {
void deleteCharacterAsset(imageUrl);
setImageUrl('');
if (type === 'image') handleBaseImageUpload(url);
}}
onRemove={handleBaseImageRemove}
/>
</div>
</div>
Expand Down Expand Up @@ -460,7 +580,7 @@ const CharacterEditor: React.FC<{
emotion={e}
currentUrl={getEmotionAssetUrl(e)}
onUpload={(url, type) => handleEmotionAssetUpload(e, url, type)}
onRemove={() => void handleEmotionAssetRemove(e)}
onRemove={() => handleEmotionAssetRemove(e)}
/>
))}
</div>
Expand All @@ -470,7 +590,7 @@ const CharacterEditor: React.FC<{
</div>

<div className={styles.panelFooter}>
<button className={styles.cancelBtn} onClick={onClose}>
<button className={styles.cancelBtn} onClick={handleClose}>
Back
</button>
<button className={styles.saveBtn} onClick={handleSave}>
Expand Down
Loading