diff --git a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx index 2f59773..8abc015 100644 --- a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx +++ b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx @@ -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 { - 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 {name.charAt(0)}; @@ -53,9 +44,66 @@ interface CharacterPanelProps { onClose: () => void; } +interface CharacterAssetLifecycle { + createdAssets: string[]; + deleteOnCommit: string[]; +} + +function collectCharacterLocalAssetPaths(character?: CharacterConfig): Set { + const paths = new Set(); + 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 { + const paths = new Set(); + 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): Promise { + 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 = ({ collection, onSave, onClose }) => { const [col, setCol] = useState(() => ({ ...collection })); const [editingId, setEditingId] = useState(null); + const [sessionCreatedAssets, setSessionCreatedAssets] = useState>(() => new Set()); + const [pendingDeleteAssets, setPendingDeleteAssets] = useState>(() => new Set()); const characters = getCharacterList(col); const activeId = col.activeId; @@ -67,10 +115,18 @@ const CharacterPanel: React.FC = ({ 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); }; @@ -90,14 +146,34 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC const handleSave = () => { onSave(col); + const referencedPaths = collectCollectionLocalAssetPaths(col); + const stagedDeletes = new Set(); + 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 ( { + 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)} @@ -106,11 +182,11 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC } return ( -
+
e.stopPropagation()}>
Characters -
@@ -174,7 +250,7 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC New Character
-
@@ -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} />
@@ -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)} /> ))}
@@ -470,7 +590,7 @@ const CharacterEditor: React.FC<{
-
@@ -128,7 +174,10 @@ const ImageUploader: React.FC = ({ }} onDragLeave={() => setDragover(false)} onDrop={handleDrop} - onClick={() => inputRef.current?.click()} + aria-busy={uploading} + onClick={() => { + if (!uploading) inputRef.current?.click(); + }} > {uploading ? ( Uploading... @@ -143,7 +192,9 @@ const ImageUploader: React.FC = ({ type="file" accept={accept} className={styles.assetHiddenInput} + disabled={uploading} onChange={(e) => { + if (uploading) return; const file = e.target.files?.[0]; if (file) handleFile(file); }} diff --git a/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts b/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts index ed6a4fd..b74c99d 100644 --- a/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts +++ b/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts @@ -118,7 +118,7 @@ describe('characterAssetUpload', () => { expect(isVideoAssetUrl('https://cdn.example.com/avatar.png?format=webp')).toBe(false); }); - it('detects image asset URLs with query strings and hashes', () => { +it('detects image asset URLs with query strings and hashes', () => { expect(isImageAssetUrl('https://cdn.example.com/avatar.jpg?version=1')).toBe(true); expect(isImageAssetUrl('https://cdn.example.com/avatar.png#preview')).toBe(true); expect(isImageAssetUrl('https://cdn.example.com/avatar.jpeg?format=webp')).toBe(true); diff --git a/apps/webuiapps/src/lib/characterAssetUpload.ts b/apps/webuiapps/src/lib/characterAssetUpload.ts index 9586fb8..6303303 100644 --- a/apps/webuiapps/src/lib/characterAssetUpload.ts +++ b/apps/webuiapps/src/lib/characterAssetUpload.ts @@ -7,7 +7,7 @@ import { putBinaryFile, getBinaryFile, deleteFilesByPaths } from './diskStorage' const CHARACTER_ASSETS_PATH = '/characters'; export const MAX_CHARACTER_IMAGE_BYTES = 10 * 1024 * 1024; -export const MAX_CHARACTER_VIDEO_BYTES = 50 * 1024 * 1024; +export const MAX_CHARACTER_VIDEO_BYTES = 20 * 1024 * 1024; export const CHARACTER_IMAGE_MIME_TO_EXT = { 'image/jpeg': 'jpg', @@ -109,7 +109,6 @@ function createUniqueAssetFilename(safeEmotion: string, ext: string): string { } else { random = `${Date.now()}-${fallbackUniqueAssetId++}`; } - return `${safeEmotion}-${Date.now()}-${random}.${ext}`; }