refactor: extract shared asset-grid base for character/scene visual managers (#64)

* refactor: extract shared asset-grid base components for visual managers (#63)
This commit is contained in:
veguAI
2026-07-05 15:26:22 +03:00
committed by GitHub
parent a119dfb027
commit 202e350600
10 changed files with 1489 additions and 1984 deletions

View File

@@ -0,0 +1,156 @@
/**
* Shared reference-asset selection logic for character visual managers
* (cover image, avatar). Complements VisualAssetGenerateMixin.
*
* Requirements:
* - Component must have `character` prop and `imageEditAvailable` prop
* - Component must provide `assets` computed (same-vis-type assets)
* - Component must use VisualAssetsMixin (getCharacterAssets, getWebsocket)
* - Component must provide a `referenceConfig` computed:
* - visType: target VIS_TYPE for generation/search
* - preferredId: asset id to prefer as reference (e.g. current cover image)
* - fallbackId: asset id to fall back to (e.g. the avatar)
* - Component must provide `initialVariationPrompt` / `initialNewPrompt`
* computeds — the prompt prefills used when no same-vis-type asset exists yet
*
* Provides:
* - referenceAssetIds / selection-reason state for VisualAssetGenerateDialog
* - checkReferenceAssets(): recompute reference options, searching the backend
* once per character when nothing is available locally
* - handleAssetSearchResults(data): websocket handler for the search response
* - onReferenceSelectionChange(newId): tracks manual selection changes
* - openGenerateDialog() / openGenerateNewDialog() with initial-prompt prefill
* - anyCharacterAssets / hasAnyCharacterAssets / shouldUseVariationForInitial /
* variationLabel computed
*/
import { computeCharacterReferenceOptions } from '../utils/characterReferenceOptions.js';
export default {
data() {
return {
referenceAssetIds: [],
hasCheckedReferences: false,
referenceSelectionReason: null,
userChangedReference: false,
}
},
computed: {
anyCharacterAssets() {
// Get ALL assets for this character (any vis_type)
if (!this.character?.name) return [];
return this.getCharacterAssets(this.character.name);
},
hasAnyCharacterAssets() {
return this.anyCharacterAssets.length > 0;
},
shouldUseVariationForInitial() {
// Use variation flow for the initial image if:
// 1. No same-vis-type assets exist yet
// 2. Character has ANY assets
// 3. Image editing is available
return this.assets.length === 0 &&
this.hasAnyCharacterAssets &&
this.imageEditAvailable;
},
variationLabel() {
return this.shouldUseVariationForInitial ? 'Generate from Reference' : 'Generate Variation';
},
},
methods: {
checkReferenceAssets() {
if (!this.character?.name) return;
const { visType, preferredId, fallbackId } = this.referenceConfig;
// Use the shared helper to compute ordered options
// Pass same-vis-type assets first (this.assets), then all character assets, preferred ID, and fallback
const { selectedId, orderedIds, reason } = computeCharacterReferenceOptions(
visType,
this.anyCharacterAssets,
preferredId,
this.assets,
fallbackId
);
if (orderedIds.length > 0) {
this.referenceAssetIds = orderedIds;
this.selectedReferenceAssetId = selectedId;
this.referenceSelectionReason = reason || null;
this.userChangedReference = false;
this.hasCheckedReferences = true;
} else {
// No local assets found
this.referenceAssetIds = [];
this.selectedReferenceAssetId = null;
this.referenceSelectionReason = null;
this.userChangedReference = false;
// 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: visType,
character_name: this.character.name,
reference_vis_types: [visType],
}));
}
}
},
handleAssetSearchResults(data) {
if (data.type !== 'asset_search_results') return;
if (data.character_name !== this.character?.name ||
data.vis_type !== this.referenceConfig.visType) return;
const assetIds = data.asset_ids || [];
// 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
// component's watchers.
if (assetIds.length > 0) {
this.referenceAssetIds = assetIds;
this.selectedReferenceAssetId = assetIds[0];
this.referenceSelectionReason = 'Found via asset search';
this.userChangedReference = false;
}
this.hasCheckedReferences = true;
},
onReferenceSelectionChange(newId) {
// Track that user manually changed the selection
if (newId !== this.selectedReferenceAssetId && this.referenceSelectionReason) {
this.userChangedReference = true;
}
},
openGenerateDialog() {
// Ensure reference assets are checked first
if (!this.hasCheckedReferences) {
this.checkReferenceAssets();
}
if (this.shouldUseVariationForInitial && !this.promptInput) {
this.promptInput = this.initialVariationPrompt;
}
this.generateDialogOpen = true;
// Ensure all reference assets are loaded for carousel
if (this.referenceAssetIds.length > 0) {
this.loadAssets(this.referenceAssetIds);
}
},
openGenerateNewDialog() {
this.generateNewDialogOpen = true;
// Prefill with the default prompt if no image of this type exists yet
this.generateNewPromptInput = this.assets.length === 0 ? this.initialNewPrompt : '';
},
},
}

View File

@@ -0,0 +1,65 @@
<template>
<div class="asset-badge" :class="`badge-${position}`" :style="{ background: `rgb(var(--v-theme-${color}))` }">
<v-icon size="x-small" color="white">{{ icon }}</v-icon>
{{ label }}
</div>
</template>
<script>
export default {
name: 'VisualAssetBadge',
props: {
icon: String,
label: String,
color: {
type: String,
default: 'defaultBadge',
},
position: {
type: String,
default: 'bottom-right',
validator: (v) => ['bottom-right', 'bottom-left', 'top-right', 'bottom-overhang'].includes(v),
},
},
}
</script>
<style scoped>
.asset-badge {
position: absolute;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 2px;
}
.badge-bottom-right {
bottom: 4px;
right: 4px;
}
.badge-bottom-left {
bottom: 4px;
left: 4px;
}
.badge-top-right {
top: 4px;
right: 4px;
}
/* Hangs off the bottom edge of the card (rather than the image container),
e.g. the avatar "No Tags" badge. Requires the grid's overflow-visible mode. */
.badge-bottom-overhang {
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
padding: 2px 8px;
z-index: 2;
white-space: nowrap;
min-width: fit-content;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<v-row class="mt-2 generate-cards-row" dense>
<!-- Generate Variation Card -->
<v-col cols="12" md="6" v-if="showVariation" class="pb-8">
<v-card class="generate-card" elevation="7">
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="mr-2" color="secondary">mdi-image</v-icon>
<strong>{{ variationLabel }}</strong>
</div>
<p class="text-caption text-medium-emphasis mb-0">
<slot name="variation-description"></slot>
</p>
<v-alert
v-if="!imageEditAvailable"
icon="mdi-alert-circle-outline"
density="compact"
variant="text"
color="warning"
class="mt-2 mb-0"
>
Image editing backend is not configured. Configure an image editing backend in Visual Agent settings to generate variations.
</v-alert>
</v-card-text>
<v-card-actions>
<v-btn
@click="$emit('generate-variation')"
color="secondary"
variant="tonal"
prepend-icon="mdi-image"
size="small"
:disabled="!imageEditAvailable"
block
>
{{ variationLabel }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<!-- Generate New Card -->
<v-col cols="12" md="6" class="pb-8">
<v-card class="generate-card" elevation="7">
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="mr-2" color="primary">mdi-image-plus</v-icon>
<strong>Generate New</strong>
</div>
<p class="text-caption text-medium-emphasis mb-0">
Create a completely new {{ newLabel }} from scratch using natural language instructions.
The visual agent will generate a prompt and create a new image based on your description.
</p>
<v-alert
v-if="!imageCreateAvailable"
icon="mdi-alert-circle-outline"
density="compact"
variant="text"
color="warning"
class="mt-2 mb-0"
>
Image creation backend is not configured. Configure a text-to-image backend in Visual Agent settings to generate {{ createWarningSubject }}.
</v-alert>
</v-card-text>
<v-card-actions>
<v-btn
@click="$emit('generate-new')"
color="primary"
variant="tonal"
prepend-icon="mdi-image-plus"
size="small"
:disabled="!imageCreateAvailable"
block
>
Generate New
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</template>
<script>
export default {
name: 'VisualAssetGenerateCards',
props: {
showVariation: Boolean,
variationLabel: {
type: String,
default: 'Generate Variation',
},
// Noun for the Generate New description, e.g. 'cover image', 'portrait'.
newLabel: {
type: String,
required: true,
},
// Noun phrase for the missing-backend warning, e.g. 'new cover images'.
createWarningSubject: {
type: String,
default: 'new images',
},
imageEditAvailable: Boolean,
imageCreateAvailable: Boolean,
},
emits: ['generate-variation', 'generate-new'],
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<v-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" max-width="600">
<v-card>
<v-card-title>
<slot name="title"></slot>
</v-card-title>
<v-card-text>
<p class="text-caption mb-4">
<slot name="caption"></slot>
</p>
<VisualReferenceCarousel
v-if="referenceAssetIds.length > 0"
:model-value="selectedReferenceId"
:asset-ids="referenceAssetIds"
:assets-map="assetsMap"
:base64-by-id="base64ById"
:aspect="aspect"
:disabled="isGenerating"
class="mb-4"
@update:model-value="onReferenceUpdate"
/>
<div v-else-if="hasCheckedReferences" class="mb-4">
<v-alert
icon="mdi-information"
density="compact"
variant="text"
color="info"
>
{{ noReferencesText }}
</v-alert>
</div>
<div v-else class="mb-4">
<v-alert
icon="mdi-information"
density="compact"
variant="text"
color="info"
>
Loading reference images...
</v-alert>
</div>
<v-card
v-if="selectionReason && !userChangedReference && selectedReferenceId"
variant="outlined"
color="primary"
class="mb-4"
>
<v-card-text class="pa-3">
<div class="d-flex align-start">
<v-icon class="mr-2 mt-1" color="primary" size="small">mdi-information-outline</v-icon>
<div>
<div class="text-caption font-weight-bold mb-1 text-muted">Why this reference was chosen:</div>
<div class="text-caption text-muted">
{{ selectionReason }}
</div>
</div>
</div>
</v-card-text>
</v-card>
<v-tabs :model-value="mode" @update:model-value="$emit('update:mode', $event)" density="compact" class="mb-2" color="primary">
<v-tab value="single">Single</v-tab>
<v-tab value="batch">Batch</v-tab>
</v-tabs>
<v-window :model-value="mode">
<v-window-item value="single">
<v-textarea
:model-value="prompt"
@update:model-value="$emit('update:prompt', $event)"
label="Prompt"
:hint="promptHint"
rows="3"
auto-grow
:disabled="isGenerating"
></v-textarea>
</v-window-item>
<v-window-item value="batch">
<EditableList
:model-value="batchPrompts"
@update:model-value="$emit('update:batchPrompts', $event)"
label="Add prompt"
hint="Press Ctrl+Enter (Cmd+Enter on Mac) to add."
:disabled="isGenerating"
/>
<v-card
variant="outlined"
color="muted"
class="mt-2"
>
<v-card-text class="pa-3">
<div class="d-flex align-start">
<v-icon class="mr-2 mt-1" color="primary" size="small">mdi-information-outline</v-icon>
<div>
<div class="text-caption text-muted">
{{ batchInfoText }}
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="$emit('close')" :disabled="isGenerating">Cancel</v-btn>
<v-btn
color="primary"
@click="$emit('generate')"
:disabled="!canGenerate || isGenerating"
:loading="isGenerating"
>
{{ mode === 'batch' ? 'Queue Batch' : 'Generate' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import VisualReferenceCarousel from './VisualReferenceCarousel.vue';
import EditableList from './EditableList.vue';
export default {
name: 'VisualAssetGenerateDialog',
components: {
VisualReferenceCarousel,
EditableList,
},
props: {
modelValue: Boolean,
prompt: {
type: String,
default: '',
},
batchPrompts: {
type: Array,
default: () => [],
},
mode: {
type: String,
default: 'single',
},
selectedReferenceId: {
type: String,
default: null,
},
referenceAssetIds: {
type: Array,
default: () => [],
},
assetsMap: Object,
base64ById: Object,
aspect: {
type: String,
required: true,
},
isGenerating: Boolean,
canGenerate: Boolean,
promptHint: String,
noReferencesText: {
type: String,
default: 'No reference images available.',
},
// When false, the empty-reference state renders as "Loading..." instead.
hasCheckedReferences: {
type: Boolean,
default: true,
},
// Optional "Why this reference was chosen" card.
selectionReason: {
type: String,
default: null,
},
userChangedReference: Boolean,
batchInfoText: {
type: String,
default: 'Each prompt will create a separate generation using the same reference image and settings. Generations will be queued in the Visual Library.',
},
},
emits: [
'update:modelValue',
'update:prompt',
'update:batchPrompts',
'update:mode',
'update:selectedReferenceId',
'reference-changed',
'generate',
'close',
],
methods: {
onReferenceUpdate(newId) {
// Emit order matters: consumers compare the changed id against their
// (already updated) selection in the reference-changed handler.
this.$emit('update:selectedReferenceId', newId);
this.$emit('reference-changed', newId);
},
},
}
</script>

View File

@@ -0,0 +1,107 @@
/**
* Shared state and helpers for the Generate Variation / Generate New dialog flow
* used by the asset-grid based visual managers (character cover, character avatar,
* scene assets).
*
* Requirements:
* - Component must provide `referenceAssetIds` (data or computed) — the ordered
* reference options for the variation dialog
* - Component must implement `startSingleGeneration()` and
* `buildBatchRequests(prompts)` (returns the generation-request array for
* batch mode)
*
* Provides:
* - All dialog/prompt state consumed by VisualAssetGenerateDialog and
* VisualAssetGenerateNewDialog via v-model bindings
* - hasReferenceAssets / canGenerate computed
* - startGeneration(): dispatches to single or batch generation
* - startBatchGeneration(): queues buildBatchRequests() output and resets the dialog
* - closeGenerateDialog() / closeGenerateNewDialog()
* - handleImageGenerationFailed(data): websocket handler unlocking the dialogs
*/
export default {
data() {
return {
generateDialogOpen: false,
promptInput: '',
batchPrompts: [],
generationMode: 'single',
isGenerating: false,
generateNewDialogOpen: false,
generateNewPromptInput: '',
isGeneratingNew: false,
selectedReferenceAssetId: null,
pendingGenerationRequest: null,
pendingGenerateNewRequest: null,
}
},
computed: {
hasReferenceAssets() {
return this.referenceAssetIds.length > 0;
},
canGenerate() {
if (this.generationMode === 'batch') {
// Batch mode: need at least one prompt in the list
return !!(this.batchPrompts.length > 0 && this.selectedReferenceAssetId);
}
// Single mode: need prompt
return !!(this.promptInput.trim() && this.selectedReferenceAssetId);
},
},
methods: {
startGeneration() {
if (this.isGenerating) return;
// Need at least one selected reference asset for IMAGE_EDIT
if (!this.selectedReferenceAssetId) {
console.warn('No reference asset selected for image generation');
return;
}
if (this.generationMode === 'batch') {
this.startBatchGeneration();
} else {
this.startSingleGeneration();
}
},
startBatchGeneration() {
if (this.batchPrompts.length === 0) return;
const requests = this.buildBatchRequests(this.batchPrompts);
if (this.addToVisualLibraryPendingQueue && typeof this.addToVisualLibraryPendingQueue === 'function') {
this.addToVisualLibraryPendingQueue(requests);
} else {
console.warn('addToVisualLibraryPendingQueue not available');
}
this.generateDialogOpen = false;
this.batchPrompts = [];
this.promptInput = '';
},
closeGenerateDialog() {
if (!this.isGenerating) {
this.generateDialogOpen = false;
this.promptInput = '';
this.batchPrompts = [];
this.generationMode = 'single';
}
},
closeGenerateNewDialog() {
if (!this.isGeneratingNew) {
this.generateNewDialogOpen = false;
this.generateNewPromptInput = '';
}
},
handleImageGenerationFailed(data) {
if (data.type !== 'image_generation_failed') return;
// Unlock dialogs to allow retry; prompts and dialogs stay open
this.isGenerating = false;
this.isGeneratingNew = false;
},
},
}

View File

@@ -0,0 +1,57 @@
<template>
<v-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" max-width="600">
<v-card>
<v-card-title>
<slot name="title"></slot>
</v-card-title>
<v-card-text>
<p class="text-caption mb-4">
<slot name="caption"></slot>
</p>
<v-textarea
:model-value="prompt"
@update:model-value="$emit('update:prompt', $event)"
label="Instructions"
:hint="hint"
rows="4"
auto-grow
:disabled="isGenerating"
></v-textarea>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="$emit('close')" :disabled="isGenerating">Cancel</v-btn>
<v-btn
color="primary"
@click="$emit('generate')"
:disabled="!prompt.trim() || isGenerating"
:loading="isGenerating"
>
Generate
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: 'VisualAssetGenerateNewDialog',
props: {
modelValue: Boolean,
prompt: {
type: String,
default: '',
},
hint: String,
isGenerating: Boolean,
},
emits: [
'update:modelValue',
'update:prompt',
'generate',
'close',
],
}
</script>

View File

@@ -0,0 +1,217 @@
<template>
<div>
<div v-if="assets.length === 0" class="text-center text-medium-emphasis py-8">
<v-icon size="48" color="grey">mdi-image-off-outline</v-icon>
<slot name="empty"></slot>
</div>
<div class="asset-container" :class="{ 'overflow-visible': overflowVisible }">
<div class="asset-grid">
<v-card
class="asset-card dropzone-card"
:class="{ 'dropzone-active': isDragging }"
@dragover.prevent="$emit('dragover', $event)"
@dragleave.prevent="$emit('dragleave', $event)"
@drop.prevent="$emit('drop', $event)"
elevation="2"
>
<div class="asset-image-container">
<div class="dropzone-content">
<v-icon size="32" color="grey">mdi-tray-arrow-down</v-icon>
<span class="text-caption mt-2">Drop image</span>
</div>
</div>
<v-card-text class="pa-2 text-caption text-truncate">
{{ dropLabel }}
</v-card-text>
</v-card>
<v-menu v-for="asset in assets" :key="asset.id">
<template v-slot:activator="{ props }">
<v-card
class="asset-card"
:class="cardClass(asset)"
v-bind="activatorProps(props)"
@click="cardClick($event, asset.id, props.onClick)"
elevation="2"
>
<div class="asset-image-container">
<v-img
:src="getSrc(asset.id)"
cover
class="asset-image"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" size="24"></v-progress-circular>
</div>
</template>
</v-img>
<slot name="badges" :asset="asset"></slot>
</div>
<v-card-text class="pa-2 text-caption text-truncate">
{{ asset.meta?.name || asset.id.slice(0, 10) }}
</v-card-text>
<slot name="card-overlay" :asset="asset"></slot>
</v-card>
</template>
<v-list>
<slot name="menu" :asset="asset"></slot>
</v-list>
</v-menu>
</div>
</div>
</div>
</template>
<script>
const ASPECT_CONFIG = {
portrait: { ratio: '3 / 4', minWidth: '140px' },
square: { ratio: '1 / 1', minWidth: '120px' },
landscape: { ratio: '16 / 9', minWidth: '180px' },
};
export default {
name: 'VisualAssetGrid',
props: {
assets: {
type: Array,
required: true,
},
aspect: {
type: String,
required: true,
validator: (v) => !!ASPECT_CONFIG[v],
},
dropLabel: {
type: String,
required: true,
},
isDragging: Boolean,
// Function props sourced from VisualAssetsMixin / AssetViewMixin in the parent.
getSrc: {
type: Function,
required: true,
},
activatorProps: {
type: Function,
required: true,
},
cardClick: {
type: Function,
required: true,
},
// (asset) => class binding for state borders: 'selected' (primary + glow),
// 'current' (defaultBadge), 'active' (primary), 'current active' (primary, thick).
cardClass: {
type: Function,
default: () => null,
},
// Lets card-overlay badges hang off the card edge (see VisualAssetBadge bottom-overhang).
overflowVisible: Boolean,
},
emits: ['dragover', 'dragleave', 'drop'],
computed: {
// Consumed by the scoped-CSS v-bind()s below.
aspectRatio() {
return ASPECT_CONFIG[this.aspect].ratio;
},
gridMinWidth() {
return ASPECT_CONFIG[this.aspect].minWidth;
},
},
}
</script>
<style scoped>
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(v-bind(gridMinWidth), 1fr));
gap: 12px;
}
.asset-card {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.asset-card:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
}
.asset-card.selected {
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 2px rgba(var(--v-theme-primary), 0.3);
}
.asset-card.current {
border-color: rgb(var(--v-theme-defaultBadge));
}
.asset-card.selected.current {
border-color: rgb(var(--v-theme-defaultBadge));
box-shadow: 0 0 0 2px rgba(var(--v-theme-defaultBadge), 0.3);
}
.asset-card.active {
border-color: rgb(var(--v-theme-primary));
}
.asset-card.current.active {
border-color: rgb(var(--v-theme-primary));
border-width: 3px;
}
.overflow-visible,
.overflow-visible .asset-grid {
overflow: visible;
}
.overflow-visible .asset-card {
position: relative;
overflow: visible;
}
.overflow-visible .asset-card :deep(.v-card__content) {
overflow: visible;
}
.asset-image-container {
position: relative;
aspect-ratio: v-bind(aspectRatio);
overflow: hidden;
}
.asset-image {
width: 100%;
height: 100%;
}
.dropzone-card {
cursor: pointer;
border: 2px dashed rgba(var(--v-theme-primary), 0.3);
transition: all 0.2s ease;
}
.dropzone-card:hover,
.dropzone-card.dropzone-active {
border-color: rgba(var(--v-theme-primary), 0.6);
background-color: rgba(var(--v-theme-primary), 0.05);
}
.dropzone-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: rgba(var(--v-theme-on-surface), 0.6);
transition: color 0.2s ease;
}
.dropzone-card:hover .dropzone-content,
.dropzone-card.dropzone-active .dropzone-content {
color: rgba(var(--v-theme-primary), 0.8);
}
</style>

View File

@@ -42,127 +42,83 @@
</div>
</v-alert>
<div v-if="assets.length === 0" class="text-center text-medium-emphasis py-8">
<v-icon size="48" color="grey">mdi-image-off-outline</v-icon>
<p class="mt-2">No {{ typeConfig.pluralLabel }} found for this scene</p>
<p class="text-caption">Generate one below, or drop an image onto the upload card.</p>
</div>
<div class="asset-container">
<div class="asset-grid">
<v-card
class="asset-card dropzone-card"
:class="{ 'dropzone-active': isDragging }"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
elevation="2"
>
<div class="asset-image-container">
<div class="dropzone-content">
<v-icon size="32" color="grey">mdi-tray-arrow-down</v-icon>
<span class="text-caption mt-2">Drop image</span>
</div>
</div>
<v-card-text class="pa-2 text-caption text-truncate">
Add {{ typeConfig.label }}
</v-card-text>
</v-card>
<v-menu v-for="asset in assets" :key="asset.id">
<template v-slot:activator="{ props }">
<v-card
class="asset-card"
:class="{
'current': coverImageId === asset.id || backdropAssetId === asset.id,
}"
v-bind="getActivatorProps(props)"
@click="handleAssetClick($event, asset.id, props.onClick)"
elevation="2"
>
<div class="asset-image-container">
<v-img
:src="getAssetSrc(asset.id)"
cover
class="asset-image"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" size="24"></v-progress-circular>
</div>
</template>
</v-img>
<div v-if="backdropAssetId === asset.id" class="current-badge badge-left">
<v-icon size="x-small" color="white">mdi-image-area</v-icon>
Backdrop
</div>
<div v-if="coverImageId === asset.id" class="current-badge badge-right">
<v-icon size="x-small" color="white">mdi-image-frame</v-icon>
Cover
</div>
</div>
<v-card-text class="pa-2 text-caption text-truncate">
{{ asset.meta?.name || asset.id.slice(0, 10) }}
</v-card-text>
</v-card>
</template>
<v-list>
<v-list-item
@click="setSceneCoverImage(asset.id)"
:disabled="coverImageId === asset.id"
>
<template v-slot:prepend>
<v-icon>mdi-image-frame</v-icon>
</template>
<v-list-item-title>Set as Scene Cover Image</v-list-item-title>
</v-list-item>
<v-list-item
v-if="backdropAssetId !== asset.id"
@click="setBackdrop({ assetId: asset.id })"
>
<template v-slot:prepend>
<v-icon>mdi-image-area</v-icon>
</template>
<v-list-item-title>Set as Scene Backdrop</v-list-item-title>
</v-list-item>
<v-list-item
v-else
@click="setBackdrop({ clear: true })"
>
<template v-slot:prepend>
<v-icon color="delete">mdi-image-remove-outline</v-icon>
</template>
<v-list-item-title>Unset Scene Backdrop</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item
@click="viewAsset(asset.id)"
>
<template v-slot:prepend>
<v-icon>mdi-eye-outline</v-icon>
</template>
<v-list-item-title>View Image</v-list-item-title>
</v-list-item>
<v-list-item
@click="openInVisualLibrary(asset.id)"
>
<template v-slot:prepend>
<v-icon>mdi-image-multiple-outline</v-icon>
</template>
<v-list-item-title>Open in Visual Library</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item
@click="confirmDelete(asset.id)"
>
<template v-slot:prepend>
<v-icon color="delete">mdi-close-box-outline</v-icon>
</template>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<VisualAssetGrid
:assets="assets"
aspect="landscape"
:drop-label="`Add ${typeConfig.label}`"
:is-dragging="isDragging"
:get-src="getAssetSrc"
:activator-props="getActivatorProps"
:card-click="handleAssetClick"
:card-class="cardStateClass"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<template #empty>
<p class="mt-2">No {{ typeConfig.pluralLabel }} found for this scene</p>
<p class="text-caption">Generate one below, or drop an image onto the upload card.</p>
</template>
<template #badges="{ asset }">
<VisualAssetBadge v-if="backdropAssetId === asset.id" icon="mdi-image-area" label="Backdrop" position="bottom-left" />
<VisualAssetBadge v-if="coverImageId === asset.id" icon="mdi-image-frame" label="Cover" />
</template>
<template #menu="{ asset }">
<v-list-item
@click="setSceneCoverImage(asset.id)"
:disabled="coverImageId === asset.id"
>
<template v-slot:prepend>
<v-icon>mdi-image-frame</v-icon>
</template>
<v-list-item-title>Set as Scene Cover Image</v-list-item-title>
</v-list-item>
<v-list-item
v-if="backdropAssetId !== asset.id"
@click="setBackdrop({ assetId: asset.id })"
>
<template v-slot:prepend>
<v-icon>mdi-image-area</v-icon>
</template>
<v-list-item-title>Set as Scene Backdrop</v-list-item-title>
</v-list-item>
<v-list-item
v-else
@click="setBackdrop({ clear: true })"
>
<template v-slot:prepend>
<v-icon color="delete">mdi-image-remove-outline</v-icon>
</template>
<v-list-item-title>Unset Scene Backdrop</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item
@click="viewAsset(asset.id)"
>
<template v-slot:prepend>
<v-icon>mdi-eye-outline</v-icon>
</template>
<v-list-item-title>View Image</v-list-item-title>
</v-list-item>
<v-list-item
@click="openInVisualLibrary(asset.id)"
>
<template v-slot:prepend>
<v-icon>mdi-image-multiple-outline</v-icon>
</template>
<v-list-item-title>Open in Visual Library</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item
@click="confirmDelete(asset.id)"
>
<template v-slot:prepend>
<v-icon color="delete">mdi-close-box-outline</v-icon>
</template>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</template>
</VisualAssetGrid>
<v-alert :icon="typeConfig.icon" density="compact" variant="text" color="grey" class="mt-4">
<p>{{ typeConfig.description }}</p>
@@ -171,210 +127,61 @@
</p>
</v-alert>
<v-row v-if="visualAgentReady" class="mt-2 generate-cards-row" dense>
<!-- Generate Variation Card -->
<v-col cols="12" md="6" v-if="hasReferenceAssets" class="pb-8">
<v-card class="generate-card" elevation="7">
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="mr-2" color="secondary">mdi-image</v-icon>
<strong>Generate Variation</strong>
</div>
<p class="text-caption text-medium-emphasis mb-0">
Create a variation of an existing scene image by modifying time of day, weather, mood, or details.
Uses image editing to transform a reference image based on your prompt.
</p>
<v-alert
v-if="!imageEditAvailable"
icon="mdi-alert-circle-outline"
density="compact"
variant="text"
color="warning"
class="mt-2 mb-0"
>
Image editing backend is not configured. Configure an image editing backend in Visual Agent settings to generate variations.
</v-alert>
</v-card-text>
<v-card-actions>
<v-btn
@click="openGenerateDialog"
color="secondary"
variant="tonal"
prepend-icon="mdi-image"
size="small"
:disabled="!imageEditAvailable"
block
>
Generate Variation
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<VisualAssetGenerateCards
v-if="visualAgentReady"
:show-variation="hasReferenceAssets"
:new-label="typeConfig.label"
:image-edit-available="imageEditAvailable"
:image-create-available="imageCreateAvailable"
@generate-variation="openGenerateDialog"
@generate-new="openGenerateNewDialog"
>
<template #variation-description>
Create a variation of an existing scene image by modifying time of day, weather, mood, or details.
Uses image editing to transform a reference image based on your prompt.
</template>
</VisualAssetGenerateCards>
<!-- Generate New Card -->
<v-col cols="12" md="6" class="pb-8">
<v-card class="generate-card" elevation="7">
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="mr-2" color="primary">mdi-image-plus</v-icon>
<strong>Generate New</strong>
</div>
<p class="text-caption text-medium-emphasis mb-0">
Create a completely new {{ typeConfig.label }} from scratch using natural language instructions.
The visual agent will generate a prompt and create a new image based on your description.
</p>
<v-alert
v-if="!imageCreateAvailable"
icon="mdi-alert-circle-outline"
density="compact"
variant="text"
color="warning"
class="mt-2 mb-0"
>
Image creation backend is not configured. Configure a text-to-image backend in Visual Agent settings to generate new images.
</v-alert>
</v-card-text>
<v-card-actions>
<v-btn
@click="openGenerateNewDialog"
color="primary"
variant="tonal"
prepend-icon="mdi-image-plus"
size="small"
:disabled="!imageCreateAvailable"
block
>
Generate New
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<VisualAssetGenerateDialog
v-model="generateDialogOpen"
v-model:prompt="promptInput"
v-model:batch-prompts="batchPrompts"
v-model:mode="generationMode"
v-model:selected-reference-id="selectedReferenceAssetId"
:reference-asset-ids="referenceAssetIds"
:assets-map="assetsMap"
:base64-by-id="base64ById"
aspect="landscape"
:is-generating="isGenerating"
:can-generate="canGenerate"
no-references-text="No reference images available for this scene."
prompt-hint="e.g., make it night time, add rain, change season to winter"
@generate="startGeneration"
@close="closeGenerateDialog"
>
<template #title>
Generate {{ typeConfig.label }} variation
</template>
<template #caption>
Enter a prompt to modify the reference image (e.g., 'make it night time', 'add rain', 'ruin the buildings', 'change season to winter').
</template>
</VisualAssetGenerateDialog>
<!-- Generate Variation Dialog -->
<v-dialog v-model="generateDialogOpen" max-width="600">
<v-card>
<v-card-title>
Generate {{ typeConfig.label }} variation
</v-card-title>
<v-card-text>
<p class="text-caption mb-4">
Enter a prompt to modify the reference image (e.g., 'make it night time', 'add rain', 'ruin the buildings', 'change season to winter').
</p>
<VisualReferenceCarousel
v-if="referenceAssetIds.length > 0"
v-model="selectedReferenceAssetId"
:asset-ids="referenceAssetIds"
:assets-map="assetsMap"
:base64-by-id="base64ById"
aspect="landscape"
:disabled="isGenerating"
class="mb-4"
/>
<div v-else class="mb-4">
<v-alert
icon="mdi-information"
density="compact"
variant="text"
color="info"
>
No reference images available for this scene.
</v-alert>
</div>
<v-tabs v-model="generationMode" density="compact" class="mb-2" color="primary">
<v-tab value="single">Single</v-tab>
<v-tab value="batch">Batch</v-tab>
</v-tabs>
<v-window v-model="generationMode">
<v-window-item value="single">
<v-textarea
v-model="promptInput"
label="Prompt"
hint="e.g., make it night time, add rain, change season to winter"
rows="3"
auto-grow
:disabled="isGenerating"
></v-textarea>
</v-window-item>
<v-window-item value="batch">
<EditableList
v-model="batchPrompts"
label="Add prompt"
hint="Press Ctrl+Enter (Cmd+Enter on Mac) to add."
:disabled="isGenerating"
/>
<v-card
variant="outlined"
color="muted"
class="mt-2"
>
<v-card-text class="pa-3">
<div class="d-flex align-start">
<v-icon class="mr-2 mt-1" color="primary" size="small">mdi-information-outline</v-icon>
<div>
<div class="text-caption text-muted">
Each prompt will create a separate generation using the same reference image and settings. Generations will be queued in the Visual Library.
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeGenerateDialog" :disabled="isGenerating">Cancel</v-btn>
<v-btn
color="primary"
@click="startGeneration"
:disabled="!canGenerate || isGenerating"
:loading="isGenerating"
>
{{ generationMode === 'batch' ? 'Queue Batch' : 'Generate' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Generate New Dialog -->
<v-dialog v-model="generateNewDialogOpen" max-width="600">
<v-card>
<v-card-title>
Generate new {{ typeConfig.label }}
</v-card-title>
<v-card-text>
<p class="text-caption mb-4">
Enter instructions for the new image. The visual agent will build a prompt from the current scene state and your instructions.
</p>
<v-textarea
v-model="generateNewPromptInput"
label="Instructions"
:hint="typeConfig.generateHint"
rows="4"
auto-grow
:disabled="isGeneratingNew"
></v-textarea>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeGenerateNewDialog" :disabled="isGeneratingNew">Cancel</v-btn>
<v-btn
color="primary"
@click="startGenerateNew"
:disabled="!generateNewPromptInput.trim() || isGeneratingNew"
:loading="isGeneratingNew"
>
Generate
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<VisualAssetGenerateNewDialog
v-model="generateNewDialogOpen"
v-model:prompt="generateNewPromptInput"
:is-generating="isGeneratingNew"
:hint="typeConfig.generateHint"
@generate="startGenerateNew"
@close="closeGenerateNewDialog"
>
<template #title>
Generate new {{ typeConfig.label }}
</template>
<template #caption>
Enter instructions for the new image. The visual agent will build a prompt from the current scene state and your instructions.
</template>
</VisualAssetGenerateNewDialog>
<ConfirmActionPrompt
ref="deleteConfirm"
@@ -401,10 +208,14 @@
import { v4 as uuidv4 } from 'uuid';
import VisualAssetsMixin from './VisualAssetsMixin.js';
import AssetViewMixin from './AssetViewMixin.js';
import VisualAssetGenerateMixin from './VisualAssetGenerateMixin.js';
import ConfirmActionPrompt from './ConfirmActionPrompt.vue';
import VisualReferenceCarousel from './VisualReferenceCarousel.vue';
import AssetView from './AssetView.vue';
import EditableList from './EditableList.vue';
import VisualAssetGrid from './VisualAssetGrid.vue';
import VisualAssetBadge from './VisualAssetBadge.vue';
import VisualAssetGenerateCards from './VisualAssetGenerateCards.vue';
import VisualAssetGenerateDialog from './VisualAssetGenerateDialog.vue';
import VisualAssetGenerateNewDialog from './VisualAssetGenerateNewDialog.vue';
import { VIS_TYPE, FORMAT_TYPE, GEN_TYPE } from '@/constants/visual';
// Per-vis-type copy and naming. Both scene illustration types share the
@@ -430,25 +241,15 @@ const TYPE_CONFIG = {
export default {
name: 'WorldStateManagerSceneVisualsAssets',
mixins: [VisualAssetsMixin, AssetViewMixin],
mixins: [VisualAssetsMixin, AssetViewMixin, VisualAssetGenerateMixin],
components: {
ConfirmActionPrompt,
VisualReferenceCarousel,
AssetView,
EditableList,
},
data() {
return {
generateDialogOpen: false,
promptInput: '',
batchPrompts: [],
generationMode: 'single',
isGenerating: false,
generateNewDialogOpen: false,
generateNewPromptInput: '',
isGeneratingNew: false,
selectedReferenceAssetId: null,
}
VisualAssetGrid,
VisualAssetBadge,
VisualAssetGenerateCards,
VisualAssetGenerateDialog,
VisualAssetGenerateNewDialog,
},
props: {
visType: {
@@ -493,9 +294,6 @@ export default {
.map(([id]) => id);
return [...sameType, ...otherTypes];
},
hasReferenceAssets() {
return this.referenceAssetIds.length > 0;
},
coverImageId() {
return this.scene?.data?.assets?.cover_image || null;
},
@@ -516,12 +314,6 @@ export default {
character: null,
};
},
canGenerate() {
if (this.generationMode === 'batch') {
return this.batchPrompts.length > 0 && this.selectedReferenceAssetId;
}
return this.promptInput.trim() && this.selectedReferenceAssetId;
},
},
watch: {
assets: {
@@ -532,6 +324,12 @@ export default {
},
},
methods: {
cardStateClass(asset) {
return {
'current': this.coverImageId === asset.id || this.backdropAssetId === asset.id,
};
},
setSceneCoverImage(assetId) {
if (!assetId) return;
@@ -565,27 +363,11 @@ export default {
}
},
closeGenerateDialog() {
if (!this.isGenerating) {
this.generateDialogOpen = false;
this.promptInput = '';
this.batchPrompts = [];
this.generationMode = 'single';
}
},
openGenerateNewDialog() {
this.generateNewDialogOpen = true;
this.generateNewPromptInput = '';
},
closeGenerateNewDialog() {
if (!this.isGeneratingNew) {
this.generateNewDialogOpen = false;
this.generateNewPromptInput = '';
}
},
startGenerateNew() {
if (!this.generateNewPromptInput.trim() || this.isGeneratingNew) return;
@@ -599,21 +381,6 @@ export default {
}));
},
startGeneration() {
if (this.isGenerating) return;
if (!this.selectedReferenceAssetId) {
console.warn('No reference asset selected for scene image generation');
return;
}
if (this.generationMode === 'batch') {
this.startBatchGeneration();
} else {
this.startSingleGeneration();
}
},
buildGenerationRequest(prompt) {
return {
prompt: prompt,
@@ -638,35 +405,20 @@ export default {
}));
},
startBatchGeneration() {
if (this.batchPrompts.length === 0) return;
const requests = this.batchPrompts.map((prompt, idx) => ({
buildBatchRequests(prompts) {
return prompts.map((prompt, idx) => ({
...this.buildGenerationRequest(prompt),
asset_attachment_context: {
allow_override: true,
asset_name: `${this.typeConfig.namePrefix}_scene_${uuidv4().slice(0, 10)}_${idx + 1}`,
},
}));
if (this.addToVisualLibraryPendingQueue && typeof this.addToVisualLibraryPendingQueue === 'function') {
this.addToVisualLibraryPendingQueue(requests);
} else {
console.warn('addToVisualLibraryPendingQueue not available');
}
this.generateDialogOpen = false;
this.batchPrompts = [];
this.promptInput = '';
},
handleMessage(data) {
this.handleSceneAssetMessage(data);
if (data.type === 'image_generation_failed') {
this.isGenerating = false;
this.isGeneratingNew = false;
}
this.handleImageGenerationFailed(data);
if (data.type === 'image_generated') {
const request = data.data?.request;
@@ -709,85 +461,3 @@ export default {
},
}
</script>
<style scoped>
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.asset-card {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.asset-card:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
}
.asset-card.current {
border-color: rgb(var(--v-theme-defaultBadge));
}
.asset-image-container {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
}
.asset-image {
width: 100%;
height: 100%;
}
.current-badge {
position: absolute;
bottom: 4px;
background: rgb(var(--v-theme-defaultBadge));
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 2px;
}
.badge-left {
left: 4px;
}
.badge-right {
right: 4px;
}
.dropzone-card {
cursor: pointer;
border: 2px dashed rgba(var(--v-theme-primary), 0.3);
transition: all 0.2s ease;
}
.dropzone-card:hover,
.dropzone-card.dropzone-active {
border-color: rgba(var(--v-theme-primary), 0.6);
background-color: rgba(var(--v-theme-primary), 0.05);
}
.dropzone-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: rgba(var(--v-theme-on-surface), 0.6);
transition: color 0.2s ease;
}
.dropzone-card:hover .dropzone-content,
.dropzone-card.dropzone-active .dropzone-content {
color: rgba(var(--v-theme-primary), 0.8);
}
</style>