feat: Implement multi-generation support with individual status tracking and history grouping.
This commit is contained in:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user