From 2fca08fbf8069476db68a58f08b12b4d161e5af2 Mon Sep 17 00:00:00 2001 From: SweetSophia Date: Wed, 13 May 2026 00:04:36 +0200 Subject: [PATCH 1/5] fix: harden character asset storage paths --- apps/webuiapps/src/lib/characterAssetUpload.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webuiapps/src/lib/characterAssetUpload.ts b/apps/webuiapps/src/lib/characterAssetUpload.ts index 9586fb8..7877afa 100644 --- a/apps/webuiapps/src/lib/characterAssetUpload.ts +++ b/apps/webuiapps/src/lib/characterAssetUpload.ts @@ -109,7 +109,6 @@ function createUniqueAssetFilename(safeEmotion: string, ext: string): string { } else { random = `${Date.now()}-${fallbackUniqueAssetId++}`; } - return `${safeEmotion}-${Date.now()}-${random}.${ext}`; } From 29158d47937d66ed7d16ebcfbf02603433ca33d3 Mon Sep 17 00:00:00 2001 From: SweetSophia Date: Wed, 13 May 2026 08:40:36 +0200 Subject: [PATCH 2/5] fix: address asset storage review comments --- .../src/components/ChatPanel/CharacterPanel.tsx | 13 +++++++++++++ .../src/lib/__tests__/characterAssetUpload.test.ts | 1 + apps/webuiapps/src/lib/characterAssetUpload.ts | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx index 2f59773..939e6b8 100644 --- a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx +++ b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx @@ -25,6 +25,19 @@ async function deleteReplacedCharacterAsset( } } +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)}; diff --git a/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts b/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts index ed6a4fd..ba42604 100644 --- a/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts +++ b/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts @@ -109,6 +109,7 @@ describe('characterAssetUpload', () => { expect(getCharacterAssetKind(path)).toBe(type); }); +<<<<<<< HEAD it('detects video asset URLs with query strings and hashes', () => { expect(isVideoAssetUrl('https://cdn.example.com/avatar.mp4?version=1')).toBe(true); expect(isVideoAssetUrl('https://cdn.example.com/avatar.webm#preview')).toBe(true); diff --git a/apps/webuiapps/src/lib/characterAssetUpload.ts b/apps/webuiapps/src/lib/characterAssetUpload.ts index 7877afa..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', From ae7d696ebc405ad950ec41abf0278a6bb29336e3 Mon Sep 17 00:00:00 2001 From: SweetSophia Date: Wed, 13 May 2026 00:09:11 +0200 Subject: [PATCH 3/5] fix: centralize character media classification --- .../src/components/ChatPanel/CharacterPanel.tsx | 13 ------------- .../src/components/ChatPanel/ImageUploader.tsx | 2 +- .../src/lib/__tests__/characterAssetUpload.test.ts | 3 +-- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx index 939e6b8..2f59773 100644 --- a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx +++ b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx @@ -25,19 +25,6 @@ async function deleteReplacedCharacterAsset( } } -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)}; diff --git a/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx b/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx index 761f028..0df4aa1 100644 --- a/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx +++ b/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx @@ -69,7 +69,7 @@ const ImageUploader: React.FC = ({ const type = isVid ? 'video' : 'image'; const path = await uploadCharacterAsset(characterId, emotion, file, type); if (expectedUrlRef.current === path || !expectedUrlRef.current) { - const isVidLocal = isVid; +const isVidLocal = isVid; setIsVideo(isVidLocal); if (isExternalOrDataUrl(path)) { setPreviewUrl(path); diff --git a/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts b/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts index ba42604..b74c99d 100644 --- a/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts +++ b/apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts @@ -109,7 +109,6 @@ describe('characterAssetUpload', () => { expect(getCharacterAssetKind(path)).toBe(type); }); -<<<<<<< HEAD it('detects video asset URLs with query strings and hashes', () => { expect(isVideoAssetUrl('https://cdn.example.com/avatar.mp4?version=1')).toBe(true); expect(isVideoAssetUrl('https://cdn.example.com/avatar.webm#preview')).toBe(true); @@ -119,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); From 2e62c8c21468927779d89bde72e5bae9b0cf7d0d Mon Sep 17 00:00:00 2001 From: SweetSophia Date: Wed, 13 May 2026 00:14:17 +0200 Subject: [PATCH 4/5] fix: stage character asset mutations --- .../components/ChatPanel/CharacterPanel.tsx | 182 ++++++++++++++---- .../components/ChatPanel/ImageUploader.tsx | 53 ++++- 2 files changed, 195 insertions(+), 40 deletions(-) diff --git a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx index 2f59773..52edf35 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,63 @@ 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 { + for (const path of new Set(paths)) { + 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 +112,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 +143,33 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC const handleSave = () => { onSave(col); + const referencedPaths = collectCollectionLocalAssetPaths(col); + const stagedDeletes = new Set( + [...pendingDeleteAssets].filter((path) => !referencedPaths.has(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 +178,11 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC } return ( -
+
e.stopPropagation()}>
Characters -
@@ -174,7 +246,7 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC New Character
-
@@ -388,15 +504,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 +570,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 +580,7 @@ const CharacterEditor: React.FC<{
-
@@ -128,7 +168,10 @@ const isVidLocal = isVid; }} onDragLeave={() => setDragover(false)} onDrop={handleDrop} - onClick={() => inputRef.current?.click()} + aria-busy={uploading} + onClick={() => { + if (!uploading) inputRef.current?.click(); + }} > {uploading ? ( Uploading... @@ -143,7 +186,9 @@ const isVidLocal = isVid; type="file" accept={accept} className={styles.assetHiddenInput} + disabled={uploading} onChange={(e) => { + if (uploading) return; const file = e.target.files?.[0]; if (file) handleFile(file); }} From 167b00ca9c631258118dea9f4d6d67ef0ba9aebb Mon Sep 17 00:00:00 2001 From: SweetSophia Date: Wed, 13 May 2026 17:55:37 +0200 Subject: [PATCH 5/5] fix: address PR#6 review comments - batch deletes, fix error-path leak, avoid set churn --- .../components/ChatPanel/CharacterPanel.tsx | 34 ++++++++++++------- .../components/ChatPanel/ImageUploader.tsx | 8 ++++- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx index 52edf35..8abc015 100644 --- a/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx +++ b/apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx @@ -87,13 +87,16 @@ function diffLocalAssetPaths( } async function deleteAssetPaths(paths: Iterable): Promise { - for (const path of new Set(paths)) { - try { - await deleteCharacterAsset(path); - } catch (err) { - console.warn('Failed to delete character asset:', err); - } - } + 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 }) => { @@ -144,9 +147,10 @@ const CharacterPanel: React.FC = ({ collection, onSave, onC const handleSave = () => { onSave(col); const referencedPaths = collectCollectionLocalAssetPaths(col); - const stagedDeletes = new Set( - [...pendingDeleteAssets].filter((path) => !referencedPaths.has(path)), - ); + 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); } @@ -285,13 +289,19 @@ const CharacterEditor: React.FC<{ const markUploadedAsset = (url: string) => { if (!isLocalCharacterAssetPath(url)) return; - setCreatedAssets((current) => new Set(current).add(url)); + 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) => new Set(current).add(trimmedUrl)); + setDeleteOnCommit((current) => { + if (current.has(trimmedUrl)) return current; + return new Set(current).add(trimmedUrl); + }); }; const handleAddEmotion = () => { diff --git a/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx b/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx index 3807edc..d6c9a83 100644 --- a/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx +++ b/apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx @@ -97,7 +97,13 @@ const isVidLocal = isVid; if (isExternalOrDataUrl(path)) { setPreviewUrl(path); } else { - const url = await getCharacterAssetUrl(path); + let url: string | null | undefined; + try { + url = await getCharacterAssetUrl(path); + } catch { + await cleanupStaleUpload(path); + throw new Error('Failed to retrieve asset URL'); + } if (!isCurrentUpload()) { await cleanupStaleUpload(path); return;