feat: Implement multi-generation support with individual status tracking and history grouping.

This commit is contained in:
xds
2026-02-16 16:35:29 +03:00
parent 55e8db92ed
commit 9a9d50a900
5 changed files with 911 additions and 355 deletions

21
package-lock.json generated
View File

@@ -15,7 +15,8 @@
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.4", "primevue": "^4.5.4",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-router": "^5.0.1" "vue-router": "^5.0.1",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
@@ -6780,6 +6781,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.8.0-beta.0", "version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
@@ -7812,6 +7819,18 @@
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View File

@@ -16,7 +16,8 @@
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.4", "primevue": "^4.5.4",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-router": "^5.0.1" "vue-router": "^5.0.1",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",

View File

@@ -94,6 +94,40 @@ const handleDownloadResults = () => {
} }
} }
const generationCount = ref(1)
const groupedHistoryGenerations = computed(() => {
const groups = new Map()
const result = []
for (const gen of historyGenerations.value) {
if (gen.generation_group_id) {
if (groups.has(gen.generation_group_id)) {
groups.get(gen.generation_group_id).children.push(gen)
} else {
const group = {
id: gen.generation_group_id,
generation_group_id: gen.generation_group_id,
prompt: gen.prompt,
created_at: gen.created_at,
isGroup: true,
children: [gen],
// Use first child status for group status if needed, or derived
status: gen.status
}
groups.set(gen.generation_group_id, group)
result.push(group)
}
} else {
result.push(gen)
}
}
return result
})
const hasActiveGeneration = computed(() => {
return isGenerating.value || historyGenerations.value.some(g => ['starting', 'processing', 'running'].includes(g.status))
})
const loadData = async () => { const loadData = async () => {
loading.value = true loading.value = true
const charId = route.params.id const charId = route.params.id
@@ -116,6 +150,13 @@ const loadData = async () => {
if (historyResponse && historyResponse.generations) { if (historyResponse && historyResponse.generations) {
historyGenerations.value = historyResponse.generations historyGenerations.value = historyResponse.generations
historyTotal.value = historyResponse.total_count || 0 historyTotal.value = historyResponse.total_count || 0
// Resume polling for active generations
historyGenerations.value.forEach(gen => {
if (['starting', 'processing', 'running'].includes(gen.status)) {
pollGeneration(gen.id)
}
})
} else { } else {
historyGenerations.value = Array.isArray(historyResponse) ? historyResponse : [] historyGenerations.value = Array.isArray(historyResponse) ? historyResponse : []
historyTotal.value = historyGenerations.value.length historyTotal.value = historyGenerations.value.length
@@ -158,6 +199,13 @@ const loadHistory = async () => {
if (response && response.generations) { if (response && response.generations) {
historyGenerations.value = response.generations historyGenerations.value = response.generations
historyTotal.value = response.total_count || 0 historyTotal.value = response.total_count || 0
// Resume polling for newly loaded active generations if any
historyGenerations.value.forEach(gen => {
if (['starting', 'processing', 'running'].includes(gen.status)) {
pollGeneration(gen.id)
}
})
} else { } else {
historyGenerations.value = Array.isArray(response) ? response : [] historyGenerations.value = Array.isArray(response) ? response : []
historyTotal.value = historyGenerations.value.length historyTotal.value = historyGenerations.value.length
@@ -306,50 +354,79 @@ const onModalAssetsPage = (event) => {
loadAllAssets() loadAllAssets()
} }
const pollStatus = async (id) => { const pollGeneration = async (id) => {
let completed = false let completed = false
while (!completed && isGenerating.value) { let attempts = 0
// Find the generation object in our list to update it specifically
// We poll and update the object in historyGenerations
while (!completed) {
// Check if generation is still needed to be polled (e.g. if we navigated away? logic handles this by default)
const genIndex = historyGenerations.value.findIndex(g => g.id === id)
if (genIndex === -1) {
// Maybe it's a new one not yet in history list?
// Logic in handleGenerate adds it first, so it should be there.
// If not found, maybe stop polling?
if (attempts > 5) return // Stop if keep failing to find it
}
const gen = historyGenerations.value[genIndex]
try { try {
const response = await aiService.getGenerationStatus(id) const response = await aiService.getGenerationStatus(id)
if (gen) {
Object.assign(gen, response)
// Update specific legacy ref if this is the latest one user is looking at in the "Creating..." block
if (isGenerating.value && generatedResult.value === null) {
generationStatus.value = response.status generationStatus.value = response.status
generationProgress.value = response.progress || 0 generationProgress.value = response.progress || 0
}
}
if (response.status === 'done') { if (response.status === 'done') {
completed = true completed = true
generationSuccess.value = true if (isGenerating.value && (!generatedResult.value || generatedResult.value === id)) {
// only finish the "generating" blocking state if *all* active ones are done?
// Refresh assets list // No, simpler: isGenerating tracks the *submission* process mainly,
const assets = await loadAssets() // but we also use it to show the big spinner.
// If we support multiple, we should probably stop showing the big spinner
// Display created assets from the list (without selecting them) // once submission is done and just show history status.
if (response.assets_list && response.assets_list.length > 0) { }
const resultAssets = assets.filter(a => response.assets_list.includes(a.id))
generatedResult.value = { // If we want to show the result of the *just finished* one in the big box:
type: 'assets', if (isGenerating.value) {
assets: resultAssets, // logic for "main" result display
tech_prompt: response.tech_prompt,
execution_time: response.execution_time_seconds,
api_execution_time: response.api_execution_time_seconds,
token_usage: response.token_usage
}
} }
loadHistory()
} else if (response.status === 'failed') { } else if (response.status === 'failed') {
completed = true completed = true
generationError.value = response.failed_reason || 'Generation failed on server' if (gen) gen.failed_reason = response.failed_reason
throw new Error(generationError.value)
} else { } else {
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
} }
} catch (e) { } catch (e) {
console.error('Polling failed', e) console.error(`Polling failed for ${id}`, e)
attempts++
if (attempts > 10) {
completed = true completed = true
isGenerating.value = false if (gen) {
gen.status = 'failed'
gen.failed_reason = 'Polling connection lost'
} }
} }
isGenerating.value = false await new Promise(resolve => setTimeout(resolve, 5000))
}
}
// Check if we can turn off global loading
// Actually, let's turn off "isGenerating" immediately after submission success,
// and let the history handle the progress.
// BUT user wants to see it in the main slot?
// "display 4 copies in one slot in the feed"
// "track status of generations after sending"
// "block sending next if active"
} }
const restoreGeneration = async (gen) => { const restoreGeneration = async (gen) => {
@@ -495,6 +572,7 @@ const handleGenerate = async () => {
try { try {
if (sendToTelegram.value && !telegramId.value) { if (sendToTelegram.value && !telegramId.value) {
alert("Please enter your Telegram ID") alert("Please enter your Telegram ID")
isGenerating.value = false
return return
} }
@@ -510,27 +588,55 @@ const handleGenerate = async () => {
prompt: prompt.value, prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id), assets_list: selectedAssets.value.map(a => a.id),
telegram_id: sendToTelegram.value ? telegramId.value : null, telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: useProfileImage.value use_profile_image: useProfileImage.value,
count: generationCount.value
} }
const response = await aiService.runGeneration(payload) const response = await aiService.runGeneration(payload)
// response is expected to have an 'id' for the generation task
if (response && response.id) { let generations = []
pollStatus(response.id) if (response && response.generations) {
generations = response.generations
} else if (Array.isArray(response)) {
generations = response
} else { } else {
// Fallback if it returns data immediately generations = [response]
generatedResult.value = response
generationSuccess.value = true
isGenerating.value = false
} }
// Add to history and start polling
for (const gen of generations) {
if (gen && gen.id) {
const newGen = {
...gen,
status: gen.status || 'starting',
created_at: new Date().toISOString()
}
historyGenerations.value.unshift(newGen)
historyTotal.value++
pollGeneration(gen.id)
}
}
// We set isGenerating to false immediately after successful submission
// because we want detailed status to be tracked in the history list.
// However, if we want to block the UI, hasActiveGeneration will do that.
// But for the 'big spinner', if we want to show it, we can keep it for a bit or just rely on history.
// User requested: "block next if active".
// Let's reset isGenerating so the big spinner goes away, and the user sees the progress in the history list.
isGenerating.value = false
prompt.value = '' prompt.value = ''
// Scroll to history?
// Maybe open/switch history tab or ensure it is visible?
// For now, let's just let the user see it in the history section.
} catch (e) { } catch (e) {
console.error('Generation failed', e) console.error('Generation failed', e)
isGenerating.value = false isGenerating.value = false
generationError.value = e.message || 'Failed to start generation'
} }
} }
</script> </script>
<template> <template>
@@ -636,6 +742,19 @@ const handleGenerate = async () => {
</div> </div>
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Count</label>
<div
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
<div v-for="n in 4" :key="n" @click="generationCount = n"
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer transition-all"
:class="generationCount === n ? 'bg-white/10 text-white rounded-lg shadow-sm' : 'text-slate-500'">
<span class="text-[10px] font-bold">{{ n }}</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label <label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Description</label> class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Description</label>
@@ -712,10 +831,12 @@ const handleGenerate = async () => {
</div> </div>
</div> </div>
<Button :label="isGenerating ? 'Wait...' : `Generate`" <Button
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-magic'" :label="hasActiveGeneration ? 'Processing...' : (isGenerating ? 'Wait...' : `Generate`)"
:loading="isGenerating" @click="handleGenerate" :icon="hasActiveGeneration || isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-magic'"
class="w-full py-2 text-[11px] font-bold bg-gradient-to-r from-violet-600 to-cyan-500 border-none rounded shadow transition-all hover:scale-[1.01] active:scale-[0.99]" /> :loading="hasActiveGeneration || isGenerating" :disabled="hasActiveGeneration"
@click="handleGenerate"
class="w-full py-2 text-[11px] font-bold bg-gradient-to-r from-violet-600 to-cyan-500 border-none rounded shadow transition-all hover:scale-[1.01] active:scale-[0.99] disabled:opacity-50 disabled:cursor-not-allowed" />
<Message v-if="generationSuccess" severity="success" :closable="true" <Message v-if="generationSuccess" severity="success" :closable="true"
@close="generationSuccess = false"> @close="generationSuccess = false">
@@ -839,18 +960,66 @@ const handleGenerate = async () => {
class="!p-1 !w-6 !h-6 text-slate-500" @click="loadHistory" /> class="!p-1 !w-6 !h-6 text-slate-500" @click="loadHistory" />
</div> </div>
<div v-if="historyGenerations.length === 0" <div v-if="groupedHistoryGenerations.length === 0"
class="py-10 text-center text-slate-600 italic text-xs"> class="py-10 text-center text-slate-600 italic text-xs">
No previous generations. No previous generations.
</div> </div>
<div v-else <div v-else
class="flex-1 overflow-y-auto pr-2 custom-scrollbar flex flex-col gap-2"> class="flex-1 overflow-y-auto pr-2 custom-scrollbar flex flex-col gap-2">
<div v-for="gen in historyGenerations" :key="gen.id" <div v-for="gen in groupedHistoryGenerations" :key="gen.id"
@click="restoreGeneration(gen)" @click="gen.isGroup ? null : restoreGeneration(gen)"
class="glass-panel p-2 rounded-lg border border-white/5 flex gap-3 items-start hover:bg-white/10 cursor-pointer transition-colors group"> class="glass-panel p-2 rounded-lg border border-white/5 flex gap-3 items-start hover:bg-white/10 cursor-pointer transition-colors group">
<!-- Grouped (Grid) -->
<div v-if="gen.isGroup" class="w-full">
<div class="flex justify-between items-start mb-1.5">
<div
class="text-[10px] font-bold text-slate-400 bg-white/5 px-1.5 py-0.5 rounded flex items-center gap-1">
<i class="pi pi-images text-[9px]"></i>
{{ gen.children.length }} variations
</div>
<span class="text-[9px] text-slate-600">{{ new
Date(gen.created_at).toLocaleDateString() }}</span>
</div>
<div class="grid gap-1.5"
:class="gen.children.length > 2 ? 'grid-cols-4' : 'grid-cols-2'">
<div v-for="child in gen.children" :key="child.id"
class="relative aspect-[9/16] rounded-md overflow-hidden bg-black/30 border border-white/5 group/child"
@click.stop="restoreGeneration(child)">
<img v-if="child.result_list && child.result_list.length > 0"
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover hover:scale-105 transition-transform" />
<div v-else-if="['starting', 'processing', 'running'].includes(child.status)"
class="w-full h-full flex flex-col items-center justify-center bg-violet-500/10">
<i
class="pi pi-spin pi-spinner text-violet-400 text-xs"></i>
</div>
<div v-else-if="child.status === 'failed'"
class="w-full h-full flex items-center justify-center bg-red-500/10"
v-tooltip.bottom="child.failed_reason">
<i
class="pi pi-exclamation-circle text-red-500 text-xs"></i>
</div>
<div v-if="child.result_list && child.result_list.length > 0"
class="absolute inset-0 bg-black/40 opacity-0 group-hover/child:opacity-100 flex items-center justify-center transition-opacity">
<i class="pi pi-eye text-white text-xs"></i>
</div>
</div>
</div>
<p class="text-[10px] text-slate-400 mt-1.5 px-0.5 truncate">{{
gen.prompt }}</p>
</div>
<!-- Single -->
<div v-else class="flex gap-3 w-full">
<div class="w-12 h-12 rounded bg-black/40 border border-white/10 flex-shrink-0 mt-0.5 relative z-0" <div class="w-12 h-12 rounded bg-black/40 border border-white/10 flex-shrink-0 mt-0.5 relative z-0"
@mouseenter="onThumbnailEnter($event, API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true')" @mouseenter="gen.result_list && gen.result_list[0] ? onThumbnailEnter($event, API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true') : null"
@mouseleave="onThumbnailLeave"> @mouseleave="onThumbnailLeave">
<img v-if="gen.result_list && gen.result_list.length > 0" <img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'" :src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
@@ -873,22 +1042,24 @@ const handleGenerate = async () => {
</p> </p>
<div class="flex items-center gap-2 text-[10px] text-slate-500"> <div class="flex items-center gap-2 text-[10px] text-slate-500">
<span>{{ new Date(gen.created_at).toLocaleDateString() }}</span> <span>{{ new Date(gen.created_at).toLocaleDateString()
}}</span>
<span class="capitalize" <span class="capitalize"
:class="gen.status === 'done' ? 'text-green-500' : (gen.status === 'failed' ? 'text-red-500' : 'text-amber-500')">{{ :class="gen.status === 'done' ? 'text-green-500' : (gen.status === 'failed' ? 'text-red-500' : 'text-amber-500')">{{
gen.status }}</span> gen.status }}</span>
<i v-if="gen.failed_reason" v-tooltip.right="gen.failed_reason" <i v-if="gen.failed_reason"
v-tooltip.right="gen.failed_reason"
class="pi pi-exclamation-circle text-red-500" class="pi pi-exclamation-circle text-red-500"
style="font-size: 12px;" /> style="font-size: 12px;" />
</div> </div>
<!-- Metrics in history --> <!-- Metrics in history -->
<div v-if="gen.execution_time_seconds || gen.token_usage" <div v-if="gen.execution_time_seconds || gen.token_usage"
class="flex flex-wrap gap-2 text-[9px] text-slate-500 font-mono opacity-70"> class="flex flex-wrap gap-2 text-[9px] text-slate-500 font-mono opacity-70">
<span v-if="gen.execution_time_seconds" title="Total Time"><i <span v-if="gen.execution_time_seconds"
class="pi pi-clock mr-0.5"></i>{{ title="Total Time"><i class="pi pi-clock mr-0.5"></i>{{
gen.execution_time_seconds.toFixed(1) }}s</span> gen.execution_time_seconds.toFixed(1) }}s</span>
<span v-if="gen.api_execution_time_seconds" title="API Time"><i <span v-if="gen.api_execution_time_seconds"
class="pi pi-server mr-0.5"></i>{{ title="API Time"><i class="pi pi-server mr-0.5"></i>{{
gen.api_execution_time_seconds.toFixed(1) }}s</span> gen.api_execution_time_seconds.toFixed(1) }}s</span>
<span v-if="gen.token_usage" title="Tokens"><i <span v-if="gen.token_usage" title="Tokens"><i
class="pi pi-bolt mr-0.5"></i>{{ gen.token_usage class="pi pi-bolt mr-0.5"></i>{{ gen.token_usage
@@ -896,7 +1067,8 @@ const handleGenerate = async () => {
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-2 mt-1 border-t border-white/5 pt-2 w-full"> <div
class="flex gap-2 mt-1 border-t border-white/5 pt-2 w-full">
<Button icon="pi pi-copy" label="Prompt" size="small" text <Button icon="pi pi-copy" label="Prompt" size="small" text
class="!text-[10px] !py-0.5 !px-1.5 text-slate-400 hover:bg-white/5 flex-1" class="!text-[10px] !py-0.5 !px-1.5 text-slate-400 hover:bg-white/5 flex-1"
@click.stop="reusePrompt(gen)" @click.stop="reusePrompt(gen)"
@@ -915,6 +1087,7 @@ const handleGenerate = async () => {
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Compact Pagination --> <!-- Compact Pagination -->
<div v-if="historyTotal > historyRows" class="mt-2 text-xs"> <div v-if="historyTotal > historyRows" class="mt-2 text-xs">

View File

@@ -1,48 +1,114 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useIdeaStore } from '../stores/ideas' import { useIdeaStore } from '../stores/ideas'
import { ideaService } from '../services/ideaService' import { storeToRefs } from 'pinia'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { aiService } from '../services/aiService' import { aiService } from '../services/aiService'
import { dataService } from '../services/dataService' import { dataService } from '../services/dataService'
import { storeToRefs } from 'pinia' import draggable from 'vuedraggable'
import Skeleton from 'primevue/skeleton'
// Components
import Button from 'primevue/button' import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import ConfirmDialog from 'primevue/confirmdialog'
import { useConfirm } from 'primevue/useconfirm'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext' import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown' import Dropdown from 'primevue/dropdown'
import MultiSelect from 'primevue/multiselect' import Dialog from 'primevue/dialog'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import ToggleButton from 'primevue/togglebutton' import Image from 'primevue/image'
import TabMenu from 'primevue/tabmenu'
import FileUpload from 'primevue/fileupload'
import ConfirmDialog from 'primevue/confirmdialog'
import InputText from 'primevue/inputtext'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const ideaStore = useIdeaStore() const ideaStore = useIdeaStore()
const { currentIdea, loading } = storeToRefs(ideaStore)
const confirm = useConfirm() const confirm = useConfirm()
const toast = useToast()
// --- View State --- const { currentIdea, loading, error } = storeToRefs(ideaStore)
const viewMode = ref('feed') // 'feed' | 'gallery'
const isSettingsVisible = ref(true)
const generations = ref([]) const generations = ref([])
const loadingGenerations = ref(false)
const API_URL = import.meta.env.VITE_API_URL
// --- Generation Settings State (Mirrors FlexibleGenerationView) ---
const prompt = ref('') const prompt = ref('')
const negativePrompt = ref('')
const selectedModel = ref('flux-schnell')
// Character & Assets (declared early for settings persistence)
const characters = ref([])
const selectedCharacter = ref(null) const selectedCharacter = ref(null)
const selectedAssets = ref([]) const selectedAssets = ref([])
const quality = ref({ key: 'TWOK', value: '2K' }) const showAssetPicker = ref(false) // Deprecated, using isAssetPickerVisible
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const generationCount = ref(1) // --- Load saved settings from localStorage ---
const isSubmitting = ref(false) const SETTINGS_KEY = 'idea-gen-settings'
const useProfileImage = ref(true) const quality = ref({ key: 'TWOK', value: '2K' })
const aspectRatio = ref({ key: 'NINESIXTEEN', value: '9:16' })
const imageCount = ref(1)
const sendToTelegram = ref(false)
const telegramId = ref('')
const useProfileImage = ref(true)
const isImprovingPrompt = ref(false)
const previousPrompt = ref('')
let _savedCharacterId = null
// --- Persist settings to localStorage on change ---
const saveSettings = () => {
const settings = {
prompt: prompt.value,
quality: quality.value,
aspectRatio: aspectRatio.value,
imageCount: imageCount.value,
selectedModel: selectedModel.value,
sendToTelegram: sendToTelegram.value,
telegramId: telegramId.value,
useProfileImage: useProfileImage.value,
selectedCharacterId: selectedCharacter.value?.id || null,
selectedAssetIds: selectedAssets.value.map(a => a.id),
}
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
if (telegramId.value) {
localStorage.setItem('telegram_id', telegramId.value)
}
}
const restoreSettings = () => {
const stored = localStorage.getItem(SETTINGS_KEY)
if (!stored) return
try {
const s = JSON.parse(stored)
if (s.prompt) prompt.value = s.prompt
if (s.quality) quality.value = s.quality
if (s.aspectRatio) aspectRatio.value = s.aspectRatio
if (s.imageCount) imageCount.value = Math.min(s.imageCount, 4)
if (s.selectedModel) selectedModel.value = s.selectedModel
sendToTelegram.value = s.sendToTelegram || false
telegramId.value = s.telegramId || localStorage.getItem('telegram_id') || ''
if (s.useProfileImage !== undefined) useProfileImage.value = s.useProfileImage
_savedCharacterId = s.selectedCharacterId || null
if (s.selectedAssetIds && s.selectedAssetIds.length > 0) {
selectedAssets.value = s.selectedAssetIds.map(id => ({
id,
url: `/assets/${id}`,
name: 'Asset ' + id.substring(0, 4)
}))
}
} catch (e) {
console.error('Failed to restore idea settings', e)
}
}
restoreSettings()
watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram, telegramId, useProfileImage, selectedCharacter, selectedAssets], saveSettings, { deep: true })
const viewMode = ref('feed') // 'feed' or 'gallery'
const isSubmitting = ref(false)
const isSettingsVisible = ref(true)
const API_URL = import.meta.env.VITE_API_URL
// Options
const qualityOptions = ref([ const qualityOptions = ref([
{ key: 'ONEK', value: '1K' }, { key: 'ONEK', value: '1K' },
{ key: 'TWOK', value: '2K' }, { key: 'TWOK', value: '2K' },
@@ -55,49 +121,58 @@ const aspectRatioOptions = ref([
{ key: "SIXTEENNINE", value: "16:9" } { key: "SIXTEENNINE", value: "16:9" }
]) ])
const characters = ref([]) // Removed duplicate characters ref
const allAssets = ref([]) const loadingGenerations = ref(false) // Added this ref based on usage in fetchGenerations
// --- Initialization --- // --- Initialization ---
onMounted(async () => { onMounted(async () => {
const id = route.params.id const id = route.params.id
console.log('IdeaDetailView mounted with ID:', id)
if (id) { if (id) {
try {
await Promise.all([ await Promise.all([
ideaStore.fetchIdea(id), ideaStore.fetchIdea(id),
loadAuxData() loadCharacters()
]) ])
console.log('Fetched idea:', currentIdea.value)
if (currentIdea.value) { if (currentIdea.value) {
fetchGenerations(id) fetchGenerations(id)
} else {
console.error('currentIdea is null after fetch')
} }
} catch (e) {
console.error('Error in onMounted:', e)
}
} else {
console.error('No ID in route params')
} }
}) })
const loadAuxData = async () => { const loadCharacters = async () => {
try { try {
const [charsRes, assetsRes] = await Promise.all([ characters.value = await dataService.getCharacters()
dataService.getCharacters(), // Restore saved character selection after characters are loaded
dataService.getAssets(100, 0, 'all') if (_savedCharacterId && characters.value.length > 0) {
]) const found = characters.value.find(c => c.id === _savedCharacterId)
characters.value = charsRes || [] if (found) selectedCharacter.value = found
if (assetsRes && assetsRes.assets) { _savedCharacterId = null
allAssets.value = assetsRes.assets
} else {
allAssets.value = Array.isArray(assetsRes) ? assetsRes : []
} }
} catch (e) { } catch (e) {
console.error('Failed to load aux data', e) console.error('Failed to load characters', e)
} }
} }
const fetchGenerations = async (ideaId) => { const fetchGenerations = async (ideaId) => {
loadingGenerations.value = true loadingGenerations.value = true
try { try {
const response = await ideaService.getIdeaGenerations(ideaId, 100) const response = await ideaStore.fetchIdeaGenerations(ideaId, 100)
let loadedGens = [] let loadedGens = []
if (response.data.generations) { if (response.data && response.data.generations) {
loadedGens = response.data.generations loadedGens = response.data.generations
} else if (Array.isArray(response.data)) { } else if (response.data && Array.isArray(response.data)) {
loadedGens = response.data loadedGens = response.data
} else if (Array.isArray(response)) {
loadedGens = response
} }
// Sort by created_at desc // Sort by created_at desc
@@ -119,48 +194,61 @@ const fetchGenerations = async (ideaId) => {
// --- Generation Logic --- // --- Generation Logic ---
const handleGenerate = async () => { const handleGenerate = async () => {
if (!prompt.value.trim() || !currentIdea.value) return if (!prompt.value) {
isSubmitting.value = true toast.add({ severity: 'warn', summary: 'Prompt Required', detail: 'Please enter a prompt to generate.', life: 3000 })
return
}
if (hasActiveGenerations.value) return
isSubmitting.value = true
try { try {
// Construct Payload
const payload = { const payload = {
prompt: prompt.value,
aspect_ratio: aspectRatio.value.key, aspect_ratio: aspectRatio.value.key,
quality: quality.value.key, quality: quality.value.key,
prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id), assets_list: selectedAssets.value.map(a => a.id),
linked_character_id: selectedCharacter.value?.id || null, linked_character_id: selectedCharacter.value?.id || null,
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: selectedCharacter.value ? useProfileImage.value : false, use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
idea_id: currentIdea.value.id, count: imageCount.value,
count: generationCount.value idea_id: currentIdea.value.id
} }
const response = await aiService.runGeneration(payload) const response = await aiService.runGeneration(payload)
const newGenerations = Array.isArray(response) ? response : [response]
// Parse response: API returns { generation_group_id, generations: [...] }
let newGenerations = []
if (response && response.generations && Array.isArray(response.generations)) {
newGenerations = response.generations
} else if (Array.isArray(response)) {
newGenerations = response
} else if (response && response.id) {
newGenerations = [response]
}
for (const gen of newGenerations) { for (const gen of newGenerations) {
if (gen && gen.id) { // Ensure status is set for blocking logic
// 1. Add to Idea if (!gen.status) gen.status = 'running'
await ideaStore.addGenerationToIdea(currentIdea.value.id, gen.id)
// 2. Add to local list immediately // Add to local list (check for dupe)
const newGenObj = { if (!generations.value.find(g => g.id === gen.id)) {
...gen, generations.value.unshift(gen)
prompt: prompt.value,
status: gen.status || 'starting',
created_at: new Date().toISOString()
} }
generations.value.unshift(newGenObj)
// 3. Start polling // Start polling status
pollGeneration(gen.id) pollGeneration(gen.id)
} }
}
// Switch to feed view to see the new item // Switch to feed view to see progress
viewMode.value = 'feed' viewMode.value = 'feed'
// Scroll to top? Or just let user see it appear.
} catch (e) { } catch (e) {
console.error('Generation failed', e) console.error('Generation failed:', e)
toast.add({ severity: 'error', summary: 'Generation Failed', detail: e.message || 'Unknown error', life: 5000 })
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }
@@ -200,39 +288,119 @@ const pollGeneration = async (id) => {
// --- Actions --- // --- Actions ---
const deleteGeneration = async (gen) => { const deleteGeneration = async (gen) => {
const isGroup = gen.isGroup && gen.children && gen.children.length > 0
const message = isGroup
? `Delete this group of ${gen.children.length} generations?`
: 'Remove this generation from the idea session?'
confirm.require({ confirm.require({
message: 'Remove this generation from the idea session?', message: message,
header: 'Delete Generation', header: 'Delete Generation',
icon: 'pi pi-trash', icon: 'pi pi-trash',
acceptClass: 'p-button-danger', acceptClass: 'p-button-danger',
accept: async () => { accept: async () => {
const targets = isGroup ? gen.children : [gen]
// Optimistic remove // Optimistic remove
generations.value = generations.value.filter(g => g.id !== gen.id) // If group, remove all children. If single, remove single.
// Remove from idea first (logical delete from idea) // But visually we are removing the "gen" passed in which might be the group wrapper or a single item.
await ideaStore.removeGenerationFromIdea(currentIdea.value.id, gen.id) // We need to filter out ALL IDs involved from the main list.
// Optionally delete specific generation data if needed, but "Idea" context usually implies const targetIds = targets.map(t => t.id)
// removing from the collection. But user said "Delete", implying destruction. generations.value = generations.value.filter(g => !targetIds.includes(g.id))
// Let's do both or just album remove?
// "Inside each idea... buttons for delete..." // API calls
// If I delete generation, it disappears everywhere. If I remove from album, it status in "All Generations". for (const target of targets) {
// Let's remove from album for safety, unless user wants full delete. await ideaStore.removeGenerationFromIdea(currentIdea.value.id, target.id)
// Task says "buttons for delete". I'll default to removing from album to be safe, }
// OR reuse deleteGeneration from dataService which cleans up fully.
// Let's use removeFromAlbum for now to avoid accidental data loss of shared gens.
} }
}) })
} }
const reusePrompt = (gen) => { const reusePrompt = (gen) => {
if (gen.prompt) prompt.value = gen.prompt if (gen.prompt) {
prompt.value = gen.prompt
isSettingsVisible.value = true
}
} }
const reuseSettings = (gen) => { const reuseAssets = (gen) => {
// Attempt to restore settings from generation if available const assetIds = gen.assets_list || []
// For now simplistic reuse if (assetIds.length > 0) {
if (gen.prompt) prompt.value = gen.prompt const assets = assetIds.map(id => {
// Need more data from backend to fully restore (aspect ratio etc usually stored in gen metadata) return { id, url: `/assets/${id}`, name: 'Asset ' + id.substring(0, 4) }
})
selectedAssets.value = assets
isSettingsVisible.value = true
} }
}
const setAsReference = (assetId) => {
const asset = {
id: assetId,
url: `/assets/${assetId}`,
name: 'Gen ' + assetId.substring(0, 4)
}
if (!selectedAssets.value.some(a => a.id === asset.id)) {
selectedAssets.value.push(asset)
}
isSettingsVisible.value = true
toast.add({ severity: 'success', summary: 'Reference Added', detail: 'Image added as reference asset', life: 2000 })
}
const deleteAssetFromGeneration = (gen, assetId) => {
confirm.require({
message: 'Remove this image from the generation?',
header: 'Remove Image',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
accept: async () => {
// Remove from result_list
const idx = gen.result_list.indexOf(assetId)
if (idx > -1) {
gen.result_list.splice(idx, 1)
}
// If group, also try children
if (gen.isGroup && gen.children) {
gen.children.forEach(child => {
const ci = child.result_list?.indexOf(assetId)
if (ci > -1) child.result_list.splice(ci, 1)
})
}
toast.add({ severity: 'info', summary: 'Removed', detail: 'Image removed', life: 2000 })
}
})
}
const handleImprovePrompt = async () => {
if (!prompt.value || prompt.value.length <= 10) return
isImprovingPrompt.value = true
try {
const linkedAssetIds = selectedAssets.value.map(a => a.id)
const response = await aiService.improvePrompt(prompt.value, linkedAssetIds)
if (response && response.prompt) {
previousPrompt.value = prompt.value
prompt.value = response.prompt
}
} catch (e) {
console.error('Prompt improvement failed', e)
} finally {
isImprovingPrompt.value = false
}
}
const undoImprovePrompt = () => {
if (previousPrompt.value) {
const temp = prompt.value
prompt.value = previousPrompt.value
previousPrompt.value = temp
}
}
const clearPrompt = () => {
prompt.value = ''
previousPrompt.value = ''
}
// --- Asset Picker Logic --- // --- Asset Picker Logic ---
const isAssetPickerVisible = ref(false) const isAssetPickerVisible = ref(false)
@@ -332,17 +500,116 @@ const useResultAsAsset = (gen) => {
// --- Image Preview --- // --- Image Preview ---
const isImagePreviewVisible = ref(false) const isImagePreviewVisible = ref(false)
const previewImage = ref(null) const previewImages = ref([])
const openImagePreview = (url) => { const previewIndex = ref(0)
previewImage.value = { url } const openImagePreview = (imageList, startIdx = 0) => {
previewImages.value = imageList
previewIndex.value = startIdx
isImagePreviewVisible.value = true isImagePreviewVisible.value = true
} }
const prevPreview = () => {
if (previewIndex.value > 0) previewIndex.value--
}
const nextPreview = () => {
if (previewIndex.value < previewImages.value.length - 1) previewIndex.value++
}
// Global keyboard nav for preview modal (Safari doesn't focus Dialog)
const handlePreviewKeydown = (e) => {
if (e.key === 'ArrowLeft') { prevPreview(); e.preventDefault() }
if (e.key === 'ArrowRight') { nextPreview(); e.preventDefault() }
if (e.key === 'Escape') { isImagePreviewVisible.value = false; e.preventDefault() }
}
watch(isImagePreviewVisible, (visible) => {
if (visible) window.addEventListener('keydown', handlePreviewKeydown)
else window.removeEventListener('keydown', handlePreviewKeydown)
})
onUnmounted(() => window.removeEventListener('keydown', handlePreviewKeydown))
// --- Computeds --- // --- Computeds ---
const groupedGenerations = computed(() => { const groupedGenerations = computed(() => {
// For Grid View, strictly speaking. // Group by generation_group_id if present
// Feed view might not need grouping or could group by time. const groups = new Map()
return generations.value const result = []
// Ensure generations.value is valid
if (!generations.value) return []
for (const gen of generations.value) {
if (gen.generation_group_id) {
if (groups.has(gen.generation_group_id)) {
const group = groups.get(gen.generation_group_id)
group.children.push(gen)
// Merge Results
if (gen.result_list && gen.result_list.length > 0) {
group.result_list = [...group.result_list, ...gen.result_list]
}
// Update Status Priority: running > failed > done
if (['processing', 'starting', 'running'].includes(gen.status)) {
group.status = 'processing'
} else if (group.status !== 'processing' && gen.status === 'failed') {
// Only set to failed if not processing.
// If all are failed, group is failed. If some done some failed, what?
// Let's say if NOT processing, and ANY failed, it shows failed?
// Or checking if ALL descendants are done/failed.
}
} else {
// Create new group wrapper based on first item
const group = {
...gen, // Copy properties of first item
isGroup: true,
children: [gen],
// ensure result_list is a new array
result_list: gen.result_list ? [...gen.result_list] : []
}
groups.set(gen.generation_group_id, group)
result.push(group)
}
} else {
result.push(gen)
}
}
// Post-process groups to determine final status if needed
result.forEach(item => {
if (item.isGroup) {
const statuses = item.children.map(c => c.status)
if (statuses.some(s => ['processing', 'starting', 'running'].includes(s))) {
item.status = 'processing'
} else if (statuses.every(s => s === 'failed')) {
item.status = 'failed'
} else {
item.status = 'done'
}
}
})
return result
})
const hasActiveGenerations = computed(() => {
return generations.value.some(g => ['processing', 'starting', 'running'].includes(g.status))
})
// Flat list of all images for gallery view
const allGalleryImages = computed(() => {
const images = []
for (const gen of generations.value) {
if (gen.result_list && gen.result_list.length > 0) {
for (const assetId of gen.result_list) {
images.push({
assetId,
url: API_URL + '/assets/' + assetId,
thumbnailUrl: API_URL + '/assets/' + assetId + '?thumbnail=true',
gen
})
}
}
}
return images
}) })
const deleteIdea = () => { const deleteIdea = () => {
@@ -416,21 +683,21 @@ const deleteIdea = () => {
</div> </div>
<!-- FEED VIEW --> <!-- FEED VIEW -->
<div v-else-if="viewMode === 'feed'" class="max-w-3xl mx-auto flex flex-col gap-12 pb-20"> <div v-else-if="viewMode === 'feed'" class="max-w-3xl mx-auto flex flex-col gap-12 pb-[250px]">
<div v-for="gen in generations" :key="gen.id" class="flex gap-4 animate-fade-in group"> <div v-for="gen in groupedGenerations" :key="gen.id" class="flex gap-3 animate-fade-in group">
<!-- Timeline Line --> <!-- Timeline Line -->
<div class="flex flex-col items-center pt-2"> <div class="flex flex-col items-center pt-1">
<div class="w-3 h-3 rounded-full bg-violet-500 shadow-lg shadow-violet-500/50"></div> <div class="w-2.5 h-2.5 rounded-full bg-violet-500 shadow-lg shadow-violet-500/50"></div>
<div class="w-0.5 flex-1 bg-white/5 my-2 group-last:bg-transparent"></div> <div class="w-0.5 flex-1 bg-white/5 my-2 group-last:bg-transparent"></div>
</div> </div>
<div class="flex-1 pb-8 border-b border-white/5 last:border-0 relative"> <div class="flex-1 pb-4 border-b border-white/5 last:border-0 relative">
<!-- Header --> <!-- Prompt Header -->
<div class="flex justify-between items-start mb-3"> <div class="bg-slate-800/50 px-3 py-2 rounded-lg border border-white/5 mb-2">
<div class="bg-slate-800/50 p-3 rounded-lg border border-white/5 w-full mr-4"> <p class="text-xs text-slate-200 font-medium whitespace-pre-wrap line-clamp-2">{{
<p class="text-sm text-slate-200 font-medium whitespace-pre-wrap">{{ gen.prompt }} gen.prompt }}</p>
</p> <div class="mt-2 flex items-center justify-between">
<div class="mt-2 flex gap-2 text-[10px] text-slate-500"> <div class="flex gap-2 text-[10px] text-slate-500">
<span>{{ new Date(gen.created_at).toLocaleTimeString([], { <span>{{ new Date(gen.created_at).toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
@@ -438,37 +705,60 @@ const deleteIdea = () => {
<span v-if="gen.status" <span v-if="gen.status"
:class="{ 'text-yellow-400': gen.status !== 'done', 'text-green-400': gen.status === 'done' }">{{ :class="{ 'text-yellow-400': gen.status !== 'done', 'text-green-400': gen.status === 'done' }">{{
gen.status }}</span> gen.status }}</span>
<span v-if="gen.isGroup" class="text-violet-400 font-bold ml-2">
{{ gen.result_list.length }} images
</span>
</div>
<div
class="flex gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
<Button icon="pi pi-copy" text rounded size="small"
class="!w-7 !h-7 !text-slate-400 hover:!text-white"
v-tooltip.top="'Reuse Prompt'" @click="reusePrompt(gen)" />
<Button v-if="gen.assets_list && gen.assets_list.length > 0" icon="pi pi-link"
text rounded size="small"
class="!w-7 !h-7 !text-slate-400 hover:!text-violet-400"
v-tooltip.top="'Reuse Assets'" @click="reuseAssets(gen)" />
<Button icon="pi pi-trash" text rounded size="small"
class="!w-7 !h-7 !text-slate-400 hover:!text-red-400"
v-tooltip.top="'Delete'" @click="deleteGeneration(gen)" />
</div>
</div> </div>
</div> </div>
<!-- Images Grid -->
<div class="relative rounded-xl overflow-hidden bg-slate-900 border border-white/5">
<div v-if="gen.result_list && gen.result_list.length > 0" class="grid gap-0.5"
:class="gen.result_list.length === 1 ? 'grid-cols-1' : gen.result_list.length === 2 ? 'grid-cols-2' : 'grid-cols-3'">
<div v-for="(res, resIdx) in gen.result_list" :key="res"
class="relative group/img cursor-pointer aspect-[4/3]">
<img :src="API_URL + '/assets/' + res + '?thumbnail=true'"
class="w-full h-full object-cover"
@click="openImagePreview(gen.result_list.map(r => API_URL + '/assets/' + r), resIdx)" />
<!-- Per-image hover overlay -->
<div <div
class="flex flex-col gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"> class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover/img:opacity-100 transition-opacity duration-200 pointer-events-none">
<Button icon="pi pi-images" text rounded size="small"
v-tooltip.left="'Use as Reference'" @click="useResultAsAsset(gen)"
v-if="gen.result_list && gen.result_list.length > 0" />
<Button icon="pi pi-refresh" text rounded size="small"
v-tooltip.left="'Reuse Prompt'" @click="reusePrompt(gen)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger"
v-tooltip.left="'Delete'" @click="deleteGeneration(gen)" />
</div> </div>
</div>
<!-- Images -->
<div <div
class="relative rounded-xl overflow-hidden bg-slate-900 border border-white/5 min-h-[200px]"> class="absolute bottom-1 right-1 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity duration-200">
<div v-if="gen.result_list && gen.result_list.length > 0" class="grid gap-1" <button @click.stop="setAsReference(res)"
:class="gen.result_list.length > 1 ? 'grid-cols-2' : 'grid-cols-1'"> class="w-6 h-6 rounded-md bg-violet-600/80 hover:bg-violet-500 backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
<div v-for="res in gen.result_list" :key="res" v-tooltip.top="'Use as Reference'">
class="relative group/img cursor-pointer aspect-square" <i class="pi pi-pencil" style="font-size: 10px"></i>
@click="openImagePreview(API_URL + '/assets/' + res)"> </button>
<img :src="API_URL + '/assets/' + res" class="w-full h-full object-cover" /> <button @click.stop="deleteAssetFromGeneration(gen, res)"
class="w-6 h-6 rounded-md bg-red-600/80 hover:bg-red-500 backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
v-tooltip.top="'Remove Image'">
<i class="pi pi-trash" style="font-size: 10px"></i>
</button>
</div>
</div> </div>
</div> </div>
<div v-else-if="['processing', 'starting', 'running'].includes(gen.status)" <div v-else-if="['processing', 'starting', 'running'].includes(gen.status)"
class="h-64 flex items-center justify-center"> class="h-32 flex items-center justify-center">
<ProgressSpinner style="width: 40px; height: 40px" /> <ProgressSpinner style="width: 30px; height: 30px" />
</div> </div>
<div v-else class="h-64 flex items-center justify-center text-red-400 bg-red-500/5"> <div v-else
class="h-32 flex items-center justify-center text-red-400 bg-red-500/5 text-xs">
<span>Generation Failed</span> <span>Generation Failed</span>
</div> </div>
</div> </div>
@@ -478,31 +768,32 @@ const deleteIdea = () => {
<!-- GALLERY VIEW --> <!-- GALLERY VIEW -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div v-for="gen in generations" :key="gen.id" <!-- Active generations (spinners) -->
class="aspect-[2/3] relative rounded-xl overflow-hidden group bg-slate-800 border border-white/5 hover:border-violet-500/50 transition-all cursor-pointer"> <div v-for="gen in generations.filter(g => ['processing', 'starting', 'running'].includes(g.status) && (!g.result_list || g.result_list.length === 0))"
:key="'active-' + gen.id"
<img v-if="gen.result_list && gen.result_list.length > 0" class="aspect-[2/3] relative rounded-xl overflow-hidden bg-slate-800 border border-white/5 flex items-center justify-center">
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover"
@click="openImagePreview(API_URL + '/assets/' + gen.result_list[0])" />
<div v-else-if="['processing', 'starting', 'running'].includes(gen.status)"
class="w-full h-full flex items-center justify-center bg-slate-800">
<ProgressSpinner style="width: 30px; height: 30px" /> <ProgressSpinner style="width: 30px; height: 30px" />
</div> </div>
<!-- All result images -->
<div v-for="(img, idx) in allGalleryImages" :key="img.assetId"
class="aspect-[2/3] relative rounded-xl overflow-hidden group bg-slate-800 border border-white/5 hover:border-violet-500/50 transition-all cursor-pointer"
@click="openImagePreview(allGalleryImages.map(i => i.url), idx)">
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
<div <div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3"> class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p class="text-xs text-white line-clamp-2 mb-2">{{ gen.prompt }}</p> <p class="text-xs text-white line-clamp-2 mb-2">{{ img.gen.prompt }}</p>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<Button icon="pi pi-images" rounded text size="small" <Button icon="pi pi-pencil" rounded text size="small"
class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'" class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'"
@click.stop="useResultAsAsset(gen)" @click.stop="setAsReference(img.assetId)" />
v-if="gen.result_list && gen.result_list.length > 0" />
<Button icon="pi pi-refresh" rounded text size="small" <Button icon="pi pi-refresh" rounded text size="small"
class="!text-white hover:!bg-white/20" @click.stop="reusePrompt(gen)" /> class="!text-white hover:!bg-white/20" @click.stop="reusePrompt(img.gen)" />
<Button icon="pi pi-trash" rounded text size="small" <Button icon="pi pi-trash" rounded text size="small"
class="!text-red-400 hover:!bg-red-500/20" @click.stop="deleteGeneration(gen)" /> class="!text-red-400 hover:!bg-red-500/20"
@click.stop="deleteAssetFromGeneration(img.gen, img.assetId)" />
</div> </div>
</div> </div>
</div> </div>
@@ -510,33 +801,42 @@ const deleteIdea = () => {
</div> </div>
<!-- SETTINGS DIALOG (Bottom) --> <!-- SETTINGS PANEL (Bottom) -->
<transition name="slide-up">
<div v-if="isSettingsVisible" <div v-if="isSettingsVisible"
class="absolute bottom-2 left-1/2 -translate-x-1/2 w-[98%] max-w-6xl glass-panel border border-white/10 bg-slate-900/95 backdrop-blur-xl p-4 z-[60] !rounded-[2.5rem] shadow-2xl flex flex-col gap-3 max-h-[85vh] overflow-y-auto"> class="absolute bottom-2 left-1/2 -translate-x-1/2 w-[98%] max-w-6xl glass-panel border border-white/10 bg-slate-900/95 backdrop-blur-xl px-4 py-3 z-[60] !rounded-[2rem] shadow-2xl flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
<div class="w-full flex justify-center -mt-2 mb-2 cursor-pointer" <div class="w-full flex justify-center -mt-1 mb-1 cursor-pointer" @click="isSettingsVisible = false">
@click="isSettingsVisible = false"> <div class="w-12 h-1 bg-white/20 rounded-full hover:bg-white/40 transition-colors"></div>
<div class="w-16 h-1 bg-white/20 rounded-full hover:bg-white/40 transition-colors"></div>
</div> </div>
<div class="flex flex-col lg:flex-row gap-8"> <div class="flex flex-col lg:flex-row gap-3">
<!-- Left Column: Prompt & Model --> <div class="flex-1 flex flex-col gap-2">
<div class="flex-1 flex flex-col gap-4"> <div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<label <label
class="text-xs font-bold text-slate-400 uppercase tracking-wider">Prompt</label> class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Prompt</label>
<div class="flex gap-1">
<Button v-if="previousPrompt" icon="pi pi-undo"
class="!p-0.5 !w-5 !h-5 !text-[9px] !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-300"
@click="undoImprovePrompt" v-tooltip.top="'Undo'" />
<Button icon="pi pi-sparkles" label="Improve" :loading="isImprovingPrompt"
:disabled="!prompt || prompt.length <= 10"
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-violet-600/20 hover:!bg-violet-600/30 !border-violet-500/30 !text-violet-400 disabled:opacity-50"
@click="handleImprovePrompt" />
<Button icon="pi pi-times" label="Clear"
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
@click="clearPrompt" />
</div> </div>
<Textarea v-model="prompt" rows="3" autoResize </div>
placeholder="Describe your idea based on this session..." <Textarea v-model="prompt" rows="2" autoResize
class="w-full bg-slate-800 !text-[16px] border-white/10 text-white rounded-xl p-3 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" /> placeholder="Describe what you want to create..."
class="w-full bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
</div> </div>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-2">
<div class="flex-1 flex flex-col gap-2"> <div class="flex-1 flex flex-col gap-1">
<label <label
class="text-xs font-bold text-slate-400 uppercase tracking-wider">Character</label> class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Character</label>
<Dropdown v-model="selectedCharacter" :options="characters" optionLabel="name" <Dropdown v-model="selectedCharacter" :options="characters" optionLabel="name"
placeholder="Select Character" filter showClear placeholder="Select Character" filter showClear
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" :pt="{ class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" :pt="{
@@ -565,70 +865,103 @@ const deleteIdea = () => {
</template> </template>
</Dropdown> </Dropdown>
<!-- Use Avatar Checkbox --> <div v-if="selectedCharacter"
<div v-if="selectedCharacter" class="flex items-center gap-2 mt-1 px-1"> class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
<Checkbox v-model="useProfileImage" :binary="true" inputId="ideaUseProfileImage" <Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
class="!border-white/20" :pt="{ class="!border-white/20" :pt="{
box: ({ props, state }) => ({ box: ({ props, state }) => ({
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }] class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
}) })
}" /> }" />
<label for="ideaUseProfileImage" <label for="idea-use-profile-img"
class="text-xs text-slate-400 cursor-pointer select-none">Use Avatar class="text-xs text-slate-300 cursor-pointer select-none">Use
Reference</label> Character
Photo</label>
</div>
</div>
<div class="flex-1 flex flex-col gap-1">
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Assets</label>
<div @click="openAssetPicker"
class="w-full bg-slate-800 border border-white/10 rounded-lg p-2 min-h-[38px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-1.5">
<span v-if="selectedAssets.length === 0"
class="text-slate-400 text-sm py-0.5">Select
Assets</span>
<div v-for="asset in selectedAssets" :key="asset.id"
class="px-2 py-1 bg-violet-600/30 border border-violet-500/30 text-violet-200 text-xs rounded-md flex items-center gap-2 animate-in fade-in zoom-in duration-200"
@click.stop>
<img v-if="asset.url" :src="API_URL + asset.url + '?thumbnail=true'"
class="w-4 h-4 rounded object-cover" />
<span class="truncate max-w-[100px]">{{ asset.name || 'Asset ' + (asset.id ?
asset.id.substring(0, 4) : '') }}</span>
<i class="pi pi-times cursor-pointer hover:text-white"
@click.stop="removeAsset(asset)"></i>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Right Column: Settings & Action --> <div class="w-full lg:w-72 flex flex-col gap-2">
<div class="w-full lg:w-80 flex flex-col gap-4"> <div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
<label <label
class="text-xs font-bold text-slate-400 uppercase tracking-wider">Quality</label> class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Quality</label>
<Dropdown v-model="quality" :options="qualityOptions" optionLabel="value" <Dropdown v-model="quality" :options="qualityOptions" optionLabel="value"
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" /> :pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-1">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Aspect <label
Ratio</label> class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Ratio</label>
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value" <Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" /> :pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
</div> </div>
</div> </div>
<div class="flex flex-col gap-2"> <!-- Generation Count -->
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Count: {{ <div class="flex flex-col gap-1">
generationCount }}</label> <label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Count</label>
<div class="flex items-center gap-2"> <div class="flex items-center">
<div <div class="flex-1 flex bg-slate-800 rounded-lg border border-white/10 overflow-hidden">
class="flex-1 flex bg-slate-800 rounded-xl border border-white/10 overflow-hidden"> <button v-for="n in 4" :key="n" @click="imageCount = n"
<button v-for="n in 4" :key="n" @click="generationCount = n" class="flex-1 py-1 text-xs font-bold transition-all"
class="flex-1 py-2 text-sm font-bold transition-all" :class="imageCount === n ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
:class="generationCount === n ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
{{ n }} {{ n }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1 bg-slate-800/50 p-2 rounded-lg border border-white/5">
<div class="flex items-center gap-2">
<Checkbox v-model="sendToTelegram" :binary="true" inputId="idea-tg-check" />
<label for="idea-tg-check" class="text-xs text-slate-300 cursor-pointer">Send to
Telegram</label>
</div>
<div v-if="sendToTelegram" class="animate-in fade-in slide-in-from-top-1">
<InputText v-model="telegramId" placeholder="Telegram ID"
class="w-full !text-xs !bg-slate-900 !border-white/10 !text-white !py-1.5" />
</div>
</div>
<div class="mt-auto"> <div class="mt-auto">
<Button :label="isSubmitting ? 'Starting...' : 'Generate'" <Button
:label="isSubmitting ? 'Starting...' : (hasActiveGenerations ? 'Wait for completion' : 'Generate')"
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'" :icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
:loading="isSubmitting" @click="handleGenerate" :loading="isSubmitting" :disabled="hasActiveGenerations || isSubmitting"
class="w-full !py-3 !text-base !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-xl !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all" /> @click="handleGenerate"
class="w-full !py-2 !text-sm !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-lg !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</transition>
<transition name="fade"> <transition name="fade">
<div v-if="!isSettingsVisible" class="absolute bottom-6 left-1/2 -translate-x-1/2 z-10"> <div v-if="!isSettingsVisible" class="absolute bottom-24 md:bottom-8 left-1/2 -translate-x-1/2 z-10">
<Button label="Generate Idea" icon="pi pi-sparkles" @click="isSettingsVisible = true" rounded <Button label="Open Controls" icon="pi pi-chevron-up" @click="isSettingsVisible = true" rounded
class="!bg-violet-600 !border-none !shadow-xl !font-bold shadow-violet-500/40 !px-6 !py-3" /> class="!bg-violet-600 !border-none !shadow-xl !font-bold shadow-violet-500/40 !px-6 !py-3" />
</div> </div>
</transition> </transition>
@@ -694,13 +1027,43 @@ const deleteIdea = () => {
</template> </template>
</Dialog> </Dialog>
<!-- Preview Modal --> <!-- Preview Modal with Slider -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask <Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
:style="{ width: '90vw', maxWidth: '1200px', background: 'transparent', boxShadow: 'none' }" :style="{ width: '90vw', maxWidth: '1200px', background: 'transparent', boxShadow: 'none' }"
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }"> :pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }"
@keydown.left.prevent="prevPreview" @keydown.right.prevent="nextPreview">
<div class="relative flex items-center justify-center h-[90vh]" @click="isImagePreviewVisible = false"> <div class="relative flex items-center justify-center h-[90vh]" @click="isImagePreviewVisible = false">
<img v-if="previewImage" :src="previewImage.url" <!-- Main image -->
<img v-if="previewImages.length > 0" :src="previewImages[previewIndex]"
class="max-w-full max-h-full object-contain rounded-xl shadow-2xl" @click.stop /> class="max-w-full max-h-full object-contain rounded-xl shadow-2xl" @click.stop />
<!-- Prev arrow -->
<button v-if="previewImages.length > 1 && previewIndex > 0" @click.stop="prevPreview"
class="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/60 hover:bg-black/80 backdrop-blur-sm flex items-center justify-center text-white transition-all shadow-lg z-20">
<i class="pi pi-chevron-left" style="font-size: 16px"></i>
</button>
<!-- Next arrow -->
<button v-if="previewImages.length > 1 && previewIndex < previewImages.length - 1"
@click.stop="nextPreview"
class="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/60 hover:bg-black/80 backdrop-blur-sm flex items-center justify-center text-white transition-all shadow-lg z-20">
<i class="pi pi-chevron-right" style="font-size: 16px"></i>
</button>
<!-- Counter badge -->
<div v-if="previewImages.length > 1"
class="absolute top-4 right-4 bg-black/60 backdrop-blur-sm text-white text-sm font-bold px-3 py-1 rounded-full z-20">
{{ previewIndex + 1 }} / {{ previewImages.length }}
</div>
<!-- Dot indicators -->
<div v-if="previewImages.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20">
<button v-for="(img, idx) in previewImages" :key="'prev-dot-' + idx"
@click.stop="previewIndex = idx" class="w-2.5 h-2.5 rounded-full transition-all"
:class="previewIndex === idx ? 'bg-white scale-125' : 'bg-white/40 hover:bg-white/60'">
</button>
</div>
</div> </div>
</Dialog> </Dialog>

View File

@@ -83,8 +83,8 @@ const createIdea = async () => {
<!-- Cover Image --> <!-- Cover Image -->
<div class="aspect-video w-full bg-slate-800 relative overflow-hidden"> <div class="aspect-video w-full bg-slate-800 relative overflow-hidden">
<div v-if="idea.cover_asset_id" class="w-full h-full"> <div v-if="idea.last_generation && idea.last_generation.status == 'done' && idea.last_generation.result_list.length > 0" class="w-full h-full">
<img :src="API_URL + '/assets/' + idea.cover_asset_id + '?thumbnail=true'" <img :src="API_URL + '/assets/' + idea.last_generation.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" /> class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
</div> </div>
<div v-else <div v-else