mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +02:00
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:
@@ -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 : '';
|
||||
},
|
||||
},
|
||||
}
|
||||
65
talemate_frontend/src/components/VisualAssetBadge.vue
Normal file
65
talemate_frontend/src/components/VisualAssetBadge.vue
Normal 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>
|
||||
106
talemate_frontend/src/components/VisualAssetGenerateCards.vue
Normal file
106
talemate_frontend/src/components/VisualAssetGenerateCards.vue
Normal 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>
|
||||
204
talemate_frontend/src/components/VisualAssetGenerateDialog.vue
Normal file
204
talemate_frontend/src/components/VisualAssetGenerateDialog.vue
Normal 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>
|
||||
107
talemate_frontend/src/components/VisualAssetGenerateMixin.js
Normal file
107
talemate_frontend/src/components/VisualAssetGenerateMixin.js
Normal 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;
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
217
talemate_frontend/src/components/VisualAssetGrid.vue
Normal file
217
talemate_frontend/src/components/VisualAssetGrid.vue
Normal 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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user