diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index d9a8fb75..a1ab5ab1 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -36,6 +36,7 @@ - "Advance Time: Fixed toolbar time advancement being completely non-functional due to a missing websocket handler. Also fixed invalid ISO 8601 duration strings for the 1 week and 2 weeks options." - "Prompts UI: Fixed the template preview CodeMirror editor being clipped at the bottom across the Active, group, and LLM prompt template tabs, so the bottom scrollbar is fully visible." - "Node Graph: Fixed `data/MakeDict` and `data/MakeList` leaking their initial value as a shared mutable reference across graph executions. Downstream in-place mutations (e.g., writing into a dict fed into SetState) would persist on the node and appear on the next run as stale data. Both nodes now deep-copy their configured default before emitting it." + - "Character Visuals: Fixed an infinite loop in the cover image and portrait tabs that spammed `scene_assets/search` requests at the backend whenever a character had no scene assets to fall back on as references. The reference-asset search now runs at most once per character, breaking the watcher feedback loop that previously kept firing throughout the time the Generate dialog was open." - "macOS Modifiers: Ctrl+click affordances throughout the UI now also accept Cmd (Meta) on macOS, where the OS reserves Ctrl+click for the system context menu. Tooltips, hints, and docs label the modifier as Cmd on Mac and Ctrl elsewhere. Affects regenerate, scene tools (narrator/director/actor/creative), world state manager, contextual generate, message asset image, autocomplete (Ctrl+Enter), and history navigation. (#261)" improvements: - "System Prompt Override Indicators: The system prompt override list now shows a pencil icon next to entries that have an active override, making it easy to see which prompts have been customized." diff --git a/talemate_frontend/src/components/WorldStateManagerCharacterVisualsAvatar.vue b/talemate_frontend/src/components/WorldStateManagerCharacterVisualsAvatar.vue index cc318656..48a44763 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacterVisualsAvatar.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacterVisualsAvatar.vue @@ -532,7 +532,12 @@ export default { }, watch: { character: { - handler(newVal) { + handler(newVal, oldVal) { + // Must precede checkReferenceAssets() — the assets watcher (immediate: true) + // will also call it and relies on the gate already being reset. + if (newVal?.name !== oldVal?.name) { + this.hasCheckedReferences = false; + } // Update local reactive references to default and current avatar this.defaultAvatarId = newVal?.avatar || null; this.currentAvatarId = newVal?.current_avatar || null; @@ -684,50 +689,28 @@ export default { this.userChangedReference = false; this.hasCheckedReferences = true; } else { - // No local assets found, try searching + // No local assets found this.referenceAssetIds = []; this.selectedReferenceAssetId = null; this.referenceSelectionReason = null; this.userChangedReference = false; - - // Search for CHARACTER_PORTRAIT assets that can be used as references - this.getWebsocket().send(JSON.stringify({ - type: 'scene_assets', - action: 'search', - vis_type: targetVisType, - character_name: this.character.name, - reference_vis_types: [targetVisType], - })); + + // Search once per character. Mark as checked BEFORE sending so re-entry + // (e.g. via watchers firing while waiting for the response) doesn't spam + // the backend with identical search requests. + if (!this.hasCheckedReferences) { + this.hasCheckedReferences = true; + this.getWebsocket().send(JSON.stringify({ + type: 'scene_assets', + action: 'search', + vis_type: targetVisType, + character_name: this.character.name, + reference_vis_types: [targetVisType], + })); + } } }, - setReferenceAsset(assetId, reason) { - // Legacy method for backward compatibility with search results - // This sets a single asset, but we'll convert to ordered list - const asset = this.anyCharacterAssets.find(a => a.id === assetId); - if (asset) { - const targetVisType = 'CHARACTER_PORTRAIT'; - const coverImageId = this.character?.cover_image; - const { selectedId, orderedIds } = computeCharacterReferenceOptions( - targetVisType, - this.anyCharacterAssets, - assetId, - this.assets, // same-vis-type assets - coverImageId // fallback - ); - this.referenceAssetIds = orderedIds; - this.selectedReferenceAssetId = selectedId || assetId; - this.referenceSelectionReason = reason ? { reason } : null; - this.userChangedReference = false; - } else { - this.referenceAssetIds = [assetId]; - this.selectedReferenceAssetId = assetId; - this.referenceSelectionReason = reason ? { reason } : null; - this.userChangedReference = false; - } - this.hasCheckedReferences = true; - }, - onReferenceSelectionChange(newId) { // Track that user manually changed the selection if (newId !== this.selectedReferenceAssetId && this.referenceSelectionReason) { @@ -911,18 +894,22 @@ export default { // Handle asset search results if (data.type === 'asset_search_results') { - if (data.character_name === this.character?.name && + if (data.character_name === this.character?.name && data.vis_type === 'CHARACTER_PORTRAIT') { const assetIds = data.asset_ids || []; - - // Use explicit reference assets if found from search + + // Apply results directly. Calling checkReferenceAssets() here would + // re-run the same logic, find no local assets, and fire another + // search — looping until the dialog is closed. Any local-asset + // changes that arrive after this request are picked up by the + // assets / character.cover_image / character.avatar watchers. if (assetIds.length > 0) { - // Recompute with search results included - this.checkReferenceAssets(); - } else { - // Fallback to local assets if search returned no explicit references - this.checkReferenceAssets(); + this.referenceAssetIds = assetIds; + this.selectedReferenceAssetId = assetIds[0]; + this.referenceSelectionReason = { reason: 'Found via asset search' }; + this.userChangedReference = false; } + this.hasCheckedReferences = true; } } diff --git a/talemate_frontend/src/components/WorldStateManagerCharacterVisualsCover.vue b/talemate_frontend/src/components/WorldStateManagerCharacterVisualsCover.vue index 22fffd43..0623613a 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacterVisualsCover.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacterVisualsCover.vue @@ -498,7 +498,12 @@ export default { }, watch: { character: { - handler(newVal) { + handler(newVal, oldVal) { + // Must precede checkReferenceAssets() — the assets watcher (immediate: true) + // will also call it and relies on the gate already being reset. + if (newVal?.name !== oldVal?.name) { + this.hasCheckedReferences = false; + } // Set selection to current cover image when character changes const coverImageId = newVal?.cover_image || null; this.selectedAssetId = coverImageId; @@ -613,50 +618,28 @@ export default { this.userChangedReference = false; this.hasCheckedReferences = true; } else { - // No local assets found, try searching + // No local assets found this.referenceAssetIds = []; this.selectedReferenceAssetId = null; this.referenceSelectionReason = null; this.userChangedReference = false; - - // Search for CHARACTER_CARD assets that can be used as references - this.getWebsocket().send(JSON.stringify({ - type: 'scene_assets', - action: 'search', - vis_type: targetVisType, - character_name: this.character.name, - reference_vis_types: [targetVisType], - })); + + // Search once per character. Mark as checked BEFORE sending so re-entry + // (e.g. via watchers firing while waiting for the response) doesn't spam + // the backend with identical search requests. + if (!this.hasCheckedReferences) { + this.hasCheckedReferences = true; + this.getWebsocket().send(JSON.stringify({ + type: 'scene_assets', + action: 'search', + vis_type: targetVisType, + character_name: this.character.name, + reference_vis_types: [targetVisType], + })); + } } }, - setReferenceAsset(assetId, reason) { - // Legacy method for backward compatibility with search results - // This sets a single asset, but we'll convert to ordered list - const asset = this.anyCharacterAssets.find(a => a.id === assetId); - if (asset) { - const targetVisType = 'CHARACTER_CARD'; - const avatarId = this.character?.avatar; - const { selectedId, orderedIds } = computeCharacterReferenceOptions( - targetVisType, - this.anyCharacterAssets, - assetId, - this.assets, // same-vis-type assets - avatarId // fallback - ); - this.referenceAssetIds = orderedIds; - this.selectedReferenceAssetId = selectedId || assetId; - this.referenceSelectionReason = reason ? { reason } : null; - this.userChangedReference = false; - } else { - this.referenceAssetIds = [assetId]; - this.selectedReferenceAssetId = assetId; - this.referenceSelectionReason = reason ? { reason } : null; - this.userChangedReference = false; - } - this.hasCheckedReferences = true; - }, - onReferenceSelectionChange(newId) { // Track that user manually changed the selection if (newId !== this.selectedReferenceAssetId && this.referenceSelectionReason) { @@ -824,18 +807,22 @@ export default { // Handle asset search results if (data.type === 'asset_search_results') { - if (data.character_name === this.character?.name && + if (data.character_name === this.character?.name && data.vis_type === 'CHARACTER_CARD') { const assetIds = data.asset_ids || []; - - // Use explicit reference assets if found from search + + // Apply results directly. Calling checkReferenceAssets() here would + // re-run the same logic, find no local assets, and fire another + // search — looping until the dialog is closed. Any local-asset + // changes that arrive after this request are picked up by the + // assets / character.cover_image / character.avatar watchers. if (assetIds.length > 0) { - // Recompute with search results included - this.checkReferenceAssets(); - } else { - // Fallback to local assets if search returned no explicit references - this.checkReferenceAssets(); + this.referenceAssetIds = assetIds; + this.selectedReferenceAssetId = assetIds[0]; + this.referenceSelectionReason = { reason: 'Found via asset search' }; + this.userChangedReference = false; } + this.hasCheckedReferences = true; } }