884 lines
33 KiB
Vue
884 lines
33 KiB
Vue
<template>
|
||
<!-- Image config node wrapper | 文生图配置节点包裹层 -->
|
||
<div class="image-config-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||
<!-- Image config node | 文生图配置节点 -->
|
||
<div
|
||
class="image-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
|
||
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||
<!-- Header | 头部 -->
|
||
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||
<span
|
||
v-if="!isEditingLabel"
|
||
@dblclick="startEditLabel"
|
||
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||
title="双击编辑名称"
|
||
>{{ data.label }}</span>
|
||
<input
|
||
v-else
|
||
ref="labelInputRef"
|
||
v-model="editingLabelValue"
|
||
@blur="finishEditLabel"
|
||
@keydown.enter="finishEditLabel"
|
||
@keydown.escape="cancelEditLabel"
|
||
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||
/>
|
||
<div class="flex items-center gap-1">
|
||
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||
<n-icon :size="14">
|
||
<CopyOutline />
|
||
</n-icon>
|
||
</button>
|
||
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||
<n-icon :size="14">
|
||
<TrashOutline />
|
||
</n-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Config options | 配置选项 -->
|
||
<div class="p-3 space-y-3">
|
||
<!-- Model selector | 模型选择 -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-[var(--text-secondary)]">模型</span>
|
||
<n-dropdown :options="modelOptions" @select="handleModelSelect">
|
||
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||
{{ displayModelName }}
|
||
<n-icon :size="12"><ChevronDownOutline /></n-icon>
|
||
</button>
|
||
</n-dropdown>
|
||
</div>
|
||
|
||
<!-- Quality selector | 画质选择 -->
|
||
<div v-if="hasQualityOptions" class="flex items-center justify-between">
|
||
<span class="text-xs text-[var(--text-secondary)]">画质</span>
|
||
<n-dropdown :options="qualityOptions" @select="handleQualitySelect">
|
||
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||
{{ displayQuality }}
|
||
<n-icon :size="12"><ChevronForwardOutline /></n-icon>
|
||
</button>
|
||
</n-dropdown>
|
||
</div>
|
||
|
||
<!-- Size selector | 尺寸选择 -->
|
||
<div v-if="hasSizeOptions" class="space-y-1.5">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<span class="text-xs text-[var(--text-secondary)] flex-shrink-0">尺寸</span>
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<n-dropdown :options="sizeOptions" @select="handleSizeSelect">
|
||
<button
|
||
class="flex items-center justify-end gap-1 text-sm text-right text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||
{{ displaySize }}
|
||
<n-icon :size="12">
|
||
<ChevronForwardOutline />
|
||
</n-icon>
|
||
</button>
|
||
</n-dropdown>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<input
|
||
v-model="customSizeInput"
|
||
@keydown.enter.prevent="applyCustomSize"
|
||
class="flex-1 min-w-0 rounded-md border border-[var(--border-color)] bg-[var(--bg-primary)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none focus:border-[var(--accent-color)]"
|
||
placeholder="自定义 1088x1920"
|
||
/>
|
||
<button
|
||
@click="applyCustomSize"
|
||
class="flex-shrink-0 rounded-md border border-[var(--border-color)] px-2 py-1 text-xs text-[var(--text-secondary)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)]"
|
||
>应用</button>
|
||
</div>
|
||
<div v-if="customSizeError" class="text-[10px] leading-tight text-red-500">
|
||
{{ customSizeError }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Model tips | 模型提示 -->
|
||
<div v-if="currentModelConfig?.tips" class="text-xs text-[var(--text-tertiary)] bg-[var(--bg-tertiary)] rounded px-2 py-1">
|
||
💡 {{ currentModelConfig.tips }}
|
||
</div>
|
||
|
||
<!-- Connected inputs indicator | 连接输入指示 -->
|
||
<div
|
||
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
|
||
<span class="px-2 py-0.5 rounded-full"
|
||
:class="connectedPrompts.length > 0 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||
提示词 {{ connectedPrompts.length > 0 ? `${connectedPrompts.length}个` : '○' }}
|
||
</span>
|
||
<span class="px-2 py-0.5 rounded-full"
|
||
:class="connectedRefImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||
参考图 {{ connectedRefImages.length > 0 ? `${connectedRefImages.length}张` : '○' }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Generate button | 生成按钮 -->
|
||
<div v-if="hasConnectedImageWithContent" class="flex gap-2">
|
||
<!-- Create new (primary) | 新建节点(主按钮) -->
|
||
<button @click="handleGenerate('new')" :disabled="loading || !isConfigured"
|
||
class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<n-spin v-if="loading" :size="14" />
|
||
<template v-else>
|
||
<n-icon :size="14"><AddOutline /></n-icon>
|
||
新建生成
|
||
</template>
|
||
</button>
|
||
<!-- Replace existing (secondary) | 替换现有(次按钮) -->
|
||
<button @click="handleGenerate('replace')" :disabled="loading || !isConfigured"
|
||
class="flex-shrink-0 flex items-center justify-center gap-1 py-2 px-2.5 rounded-lg border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<n-spin v-if="loading" :size="14" />
|
||
<template v-else>
|
||
<n-icon :size="14"><RefreshOutline /></n-icon>
|
||
替换
|
||
</template>
|
||
</button>
|
||
</div>
|
||
<button v-else @click="handleGenerate('auto')" :disabled="loading || !isConfigured"
|
||
class="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<n-spin v-if="loading" :size="14" />
|
||
<template v-else>
|
||
<span
|
||
class="text-[var(--accent-color)] bg-white rounded-full w-4 h-4 flex items-center justify-center text-xs">◆</span>
|
||
立即生成
|
||
</template>
|
||
</button>
|
||
|
||
<!-- Error message | 错误信息 -->
|
||
<div v-if="error" class="text-xs text-red-500 mt-2">
|
||
{{ error.message || '生成失败' }}
|
||
</div>
|
||
|
||
<!-- Generated images preview | 生成图片预览 -->
|
||
<!-- <div v-if="generatedImages.length > 0" class="mt-3 space-y-2">
|
||
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
|
||
<div class="grid grid-cols-2 gap-2 max-w-[240px]">
|
||
<div
|
||
v-for="(img, idx) in generatedImages"
|
||
:key="idx"
|
||
class="aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] max-w-[110px]"
|
||
>
|
||
<img :src="img.url" class="w-full h-full object-cover" />
|
||
</div>
|
||
</div>
|
||
</div> -->
|
||
</div>
|
||
|
||
<!-- Handles | 连接点 -->
|
||
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||
<NodeHandleMenu :nodeId="id" nodeType="imageConfig" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||
</div>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* Image config node component | 文生图配置节点组件
|
||
* Configuration panel for text-to-image generation with API integration
|
||
*/
|
||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||
import { NIcon, NDropdown, NSpin } from 'naive-ui'
|
||
import { ChevronDownOutline, ChevronForwardOutline, CopyOutline, TrashOutline, RefreshOutline, AddOutline, ImageOutline, CreateOutline } from '@vicons/ionicons5'
|
||
import { useImageGeneration } from '../../hooks'
|
||
import { updateNode, addNode, addEdge, nodes, edges, duplicateNode, removeNode } from '../../stores/canvas'
|
||
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||
import { useModelStore } from '../../stores/pinia'
|
||
import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_QUALITY } from '../../stores/models'
|
||
import { parseMentions } from '../../hooks/useNodeRef'
|
||
|
||
// 使用 Pinia store 获取模型选项(根据渠道过滤)
|
||
const modelStore = useModelStore()
|
||
|
||
const props = defineProps({
|
||
id: String,
|
||
data: Object
|
||
})
|
||
|
||
// Vue Flow instance | Vue Flow 实例
|
||
const { updateNodeInternals } = useVueFlow()
|
||
|
||
// API config state | API 配置状态
|
||
const isConfigured = computed(() => !!modelStore.currentApiKey)
|
||
|
||
// Image generation hook | 图片生成 hook
|
||
const { loading, error, images: generatedImages, generate } = useImageGeneration()
|
||
|
||
// Local state | 本地状态
|
||
const normalizeQualityKey = (quality) => {
|
||
if (quality === 'standard' || quality === 'hd') return 'high'
|
||
return quality || DEFAULT_IMAGE_QUALITY
|
||
}
|
||
const showHandleMenu = ref(false)
|
||
const localModel = ref(props.data?.model || DEFAULT_IMAGE_MODEL)
|
||
const localSize = ref(props.data?.size || DEFAULT_IMAGE_SIZE)
|
||
const localQuality = ref(normalizeQualityKey(props.data?.quality))
|
||
const customSizeInput = ref(localSize.value)
|
||
const customSizeError = ref('')
|
||
|
||
// Label editing state | Label 编辑状态
|
||
const isEditingLabel = ref(false)
|
||
const editingLabelValue = ref('')
|
||
const labelInputRef = ref(null)
|
||
|
||
// ImageConfig node menu operations | 图片配置节点菜单操作
|
||
const operations = [
|
||
// { type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'imageConfig_imageConfig' }
|
||
]
|
||
|
||
// Handle menu select | 处理菜单选择
|
||
const handleSelect = (item) => {
|
||
const action = item.action
|
||
|
||
if (action === 'imageConfig_imageConfig') {
|
||
// Image-to-image (create new image node for editing) | 图生图(创建新图片节点用于编辑)
|
||
const currentNode = nodes.value.find(n => n.id === props.id)
|
||
const nodeX = currentNode?.position?.x || 0
|
||
const nodeY = currentNode?.position?.y || 0
|
||
|
||
// Create new image node for editing
|
||
const imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY }, {
|
||
label: '图片编辑'
|
||
})
|
||
|
||
// Connect current config to new image node
|
||
addEdge({
|
||
source: props.id,
|
||
target: imageNodeId,
|
||
sourceHandle: 'right',
|
||
targetHandle: 'left'
|
||
})
|
||
|
||
setTimeout(() => updateNodeInternals(imageNodeId), 50)
|
||
window.$message?.success('已创建图片编辑节点')
|
||
}
|
||
}
|
||
|
||
// Get current model config | 获取当前模型配置
|
||
const currentModelConfig = computed(() => getModelConfig(localModel.value))
|
||
|
||
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
|
||
const modelOptions = computed(() => modelStore.allImageModelOptions)
|
||
|
||
// Display model name | 显示模型名称
|
||
const displayModelName = computed(() => {
|
||
const model = modelOptions.value.find(m => m.key === localModel.value)
|
||
// 如果当前模型不在选项中,尝试从 allImageModels 找到
|
||
if (!model) {
|
||
const allModel = modelStore.allImageModels.find(m => m.key === localModel.value)
|
||
return allModel?.label || localModel.value || '选择模型'
|
||
}
|
||
return model?.label || localModel.value || '选择模型'
|
||
})
|
||
|
||
// Quality options based on model | 基于模型的画质选项
|
||
const qualityOptions = computed(() => {
|
||
return getModelQualityOptions(localModel.value)
|
||
})
|
||
|
||
// Check if model has quality options | 检查模型是否有画质选项
|
||
const hasQualityOptions = computed(() => {
|
||
return qualityOptions.value && qualityOptions.value.length > 0
|
||
})
|
||
|
||
// Display quality | 显示画质
|
||
const displayQuality = computed(() => {
|
||
const option = qualityOptions.value.find(o => o.key === localQuality.value)
|
||
return option?.label || '高 · 最终稿'
|
||
})
|
||
|
||
// Size options based on model and quality | 基于模型和画质的尺寸选项
|
||
const sizeOptions = computed(() => {
|
||
return getModelSizeOptions(localModel.value, localQuality.value)
|
||
})
|
||
|
||
// Check if model has size options | 检查模型是否有尺寸选项
|
||
const hasSizeOptions = computed(() => {
|
||
const config = getModelConfig(localModel.value)
|
||
return config?.sizes && config.sizes.length > 0
|
||
})
|
||
|
||
// Display size with label | 显示尺寸(带标签)
|
||
const displaySize = computed(() => {
|
||
const option = sizeOptions.value.find(o => o.key === localSize.value)
|
||
return option?.label || formatSizeLabel(localSize.value)
|
||
})
|
||
|
||
const parseImageSize = (value) => {
|
||
const normalized = String(value || '').trim().toLowerCase().replace('×', 'x')
|
||
const match = normalized.match(/^(\d{3,4})\s*x\s*(\d{3,4})$/)
|
||
if (!match) return null
|
||
return { width: Number(match[1]), height: Number(match[2]), key: `${Number(match[1])}x${Number(match[2])}` }
|
||
}
|
||
|
||
const formatSizeLabel = (value) => {
|
||
if (value === 'auto') return '自动 · 生成后显示实际像素'
|
||
const parsed = parseImageSize(value)
|
||
return parsed ? `自定义 · ${parsed.width}×${parsed.height}` : value
|
||
}
|
||
|
||
const validateImageSize = (value) => {
|
||
const parsed = parseImageSize(value)
|
||
if (!parsed) return { ok: false, message: '格式用 1088x1920' }
|
||
const { width, height } = parsed
|
||
const pixels = width * height
|
||
const longEdge = Math.max(width, height)
|
||
const shortEdge = Math.min(width, height)
|
||
if (width % 16 !== 0 || height % 16 !== 0) return { ok: false, message: '宽高需为 16 的倍数' }
|
||
if (longEdge > 3840) return { ok: false, message: '最长边不能超过 3840px' }
|
||
if (longEdge / shortEdge > 3) return { ok: false, message: '比例不能超过 3:1' }
|
||
if (pixels < 655360 || pixels > 8294400) return { ok: false, message: '总像素超出模型范围' }
|
||
return { ok: true, key: parsed.key }
|
||
}
|
||
|
||
// Initialize on mount | 挂载时初始化
|
||
onMounted(() => {
|
||
// 检查当前模型是否在可用模型列表中
|
||
const availableModels = modelStore.availableImageModels
|
||
const isModelAvailable = availableModels.some(m => m.key === localModel.value)
|
||
|
||
if (!localModel.value || !isModelAvailable) {
|
||
// 使用 store 中的默认模型或第一个可用模型
|
||
const selected = availableModels.find(m => m.key === modelStore.selectedImageModel)?.key
|
||
localModel.value = selected || availableModels[0]?.key || DEFAULT_IMAGE_MODEL
|
||
updateNode(props.id, { model: localModel.value })
|
||
}
|
||
if (props.data?.quality !== localQuality.value) {
|
||
updateNode(props.id, { quality: localQuality.value })
|
||
}
|
||
})
|
||
|
||
// 解析 textNode 内容中的 @ 引用,转换为简短引用(如 图 1)并收集图片
|
||
const resolveTextMentionsForImage = (textNode) => {
|
||
const content = textNode.data?.content || ''
|
||
const mentions = parseMentions(content)
|
||
|
||
if (mentions.length === 0) {
|
||
return { resolvedContent: content, refImages: [] }
|
||
}
|
||
|
||
// 收集引用的图片节点
|
||
const imageMentions = []
|
||
for (const mention of mentions) {
|
||
const referencedNode = nodes.value.find(n => n.id === mention.nodeId)
|
||
if (referencedNode?.type === 'image') {
|
||
const imageData = referencedNode.data?.base64 || referencedNode.data?.url
|
||
if (imageData) {
|
||
imageMentions.push({
|
||
order: mention.order,
|
||
nodeId: mention.nodeId,
|
||
imageData
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if (imageMentions.length === 0) {
|
||
return { resolvedContent: content, refImages: [] }
|
||
}
|
||
|
||
// 按出现顺序排序
|
||
imageMentions.sort((a, b) => a.order - b.order)
|
||
|
||
// 替换 @[nodeId] 为按顺序的 "图1"、"图2" 等
|
||
let resolvedContent = content
|
||
for (let i = 0; i < imageMentions.length; i++) {
|
||
const mention = imageMentions[i]
|
||
const placeholder = `@[${mention.nodeId}]`
|
||
// 按排序后的索引替换为 "图1"、"图2" 等
|
||
resolvedContent = resolvedContent.replace(placeholder, `图${i + 1}`)
|
||
}
|
||
|
||
// 返回解析后的内容和图片数组(按引用顺序)
|
||
const refImages = imageMentions.map(m => m.imageData)
|
||
|
||
return { resolvedContent, refImages }
|
||
}
|
||
|
||
// Computed connected prompts (sorted by order) | 计算连接的提示词(按顺序排列)
|
||
const connectedPrompts = computed(() => {
|
||
return getConnectedInputs().prompts
|
||
})
|
||
|
||
// Computed connected reference images | 计算连接的参考图
|
||
const connectedRefImages = computed(() => {
|
||
return getConnectedInputs().refImages
|
||
})
|
||
|
||
// 已连接的文本节点 ID 列表(用于 @ 提及时过滤)
|
||
const connectedTextNodeIds = computed(() => {
|
||
const incomingEdges = edges.value.filter(e => e.target === props.id)
|
||
const connectedIds = []
|
||
for (const edge of incomingEdges) {
|
||
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||
if (sourceNode?.type === 'text') {
|
||
connectedIds.push(sourceNode.id)
|
||
}
|
||
}
|
||
return connectedIds
|
||
})
|
||
|
||
// Get connected nodes | 获取连接的节点
|
||
const getConnectedInputs = () => {
|
||
// 1. First check @ mentions | 首先检查 @ 引用
|
||
// Only check connected TextNodes | 只检查已连接的 TextNode
|
||
const textNodes = nodes.value.filter(n => n.type === 'text' && connectedTextNodeIds.value.includes(n.id))
|
||
const mentionsPrompts = []
|
||
const mentionsRefImages = []
|
||
|
||
for (const textNode of textNodes) {
|
||
const { resolvedContent, refImages: nodeRefImages } = resolveTextMentionsForImage(textNode)
|
||
|
||
// 如果有解析出图片引用
|
||
if (nodeRefImages.length > 0) {
|
||
// 添加解析后的提示词内容
|
||
mentionsPrompts.push({
|
||
order: mentionsPrompts.length,
|
||
content: resolvedContent,
|
||
nodeId: textNode.id
|
||
})
|
||
|
||
// 添加参考图
|
||
for (const imageData of nodeRefImages) {
|
||
mentionsRefImages.push({
|
||
order: mentionsRefImages.length,
|
||
imageData,
|
||
nodeId: textNode.id
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Get edge-connected ImageNodes | 获取边连接的 ImageNode
|
||
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||
const edgeRefImages = [] // Array of { order, imageData, nodeId } | 参考图数组
|
||
|
||
for (const edge of connectedEdges) {
|
||
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||
if (!sourceNode) continue
|
||
|
||
if (sourceNode.type === 'image') {
|
||
// Prefer base64, fallback to url | 优先使用 base64,回退到 url
|
||
const imageData = sourceNode.data?.base64 || sourceNode.data?.url
|
||
if (imageData) {
|
||
// Get order from edge data, default to 1 | 从边数据获取顺序,默认为1
|
||
// Add offset of @ mentions count | 加上 @ 提及图片数量的偏移
|
||
const baseOrder = edge.data?.imageOrder || 1
|
||
const order = mentionsRefImages.length + baseOrder
|
||
edgeRefImages.push({ order, imageData, nodeId: sourceNode.id })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Merge and sort refImages | 合并并排序参考图
|
||
// Combine @ mentions refImages and edge-connected refImages | 合并 @ 提及和边连接的图片
|
||
const allRefImages = [...mentionsRefImages, ...edgeRefImages]
|
||
// Sort by order | 按顺序排序
|
||
allRefImages.sort((a, b) => a.order - b.order)
|
||
const sortedRefImages = allRefImages.map(r => r.imageData)
|
||
|
||
// 4. If there are @ mentions, use them | 如果有 @ 提及,使用它们
|
||
if (mentionsPrompts.length > 0) {
|
||
// Sort prompts by order | 按顺序排序提示词
|
||
mentionsPrompts.sort((a, b) => a.order - b.order)
|
||
const combinedPrompt = mentionsPrompts.map(p => p.content).join('\n\n')
|
||
|
||
return {
|
||
prompt: combinedPrompt,
|
||
prompts: mentionsPrompts,
|
||
refImages: sortedRefImages,
|
||
refImagesWithOrder: allRefImages,
|
||
fromMentions: true
|
||
}
|
||
}
|
||
|
||
// 5. Fallback to edge connections | 降级到边的连接
|
||
// (only prompts, no @ mentions) (只有提示词,没有 @ 提及)
|
||
const prompts = [] // Array of { order, content } | 提示词数组
|
||
|
||
for (const edge of connectedEdges) {
|
||
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||
if (!sourceNode) continue
|
||
|
||
if (sourceNode.type === 'text') {
|
||
const content = sourceNode.data?.content || ''
|
||
if (content) {
|
||
// Get order from edge data, default to 1 | 从边数据获取顺序,默认为1
|
||
const order = edge.data?.promptOrder || 1
|
||
prompts.push({ order, content, nodeId: sourceNode.id })
|
||
}
|
||
} else if (sourceNode.type === 'llmConfig') {
|
||
// LLM node output as prompt | LLM 节点输出作为提示词
|
||
const content = sourceNode.data?.outputContent || ''
|
||
if (content) {
|
||
const order = edge.data?.promptOrder || 1
|
||
prompts.push({ order, content, nodeId: sourceNode.id })
|
||
}
|
||
}
|
||
// Note: ImageNode handling moved to step 2 above | 注意:ImageNode 处理已移至步骤 2
|
||
}
|
||
|
||
// Sort prompts by order and concatenate | 按顺序排序并拼接
|
||
prompts.sort((a, b) => a.order - b.order)
|
||
const combinedPrompt = prompts.map(p => p.content).join('\n\n')
|
||
|
||
// Use edge-connected refImages (already sorted above) | 使用边连接的参考图(已在上面排序)
|
||
return { prompt: combinedPrompt, prompts, refImages: sortedRefImages, refImagesWithOrder: allRefImages, fromMentions: false }
|
||
}
|
||
|
||
// Handle model selection | 处理模型选择
|
||
const handleModelSelect = (key) => {
|
||
localModel.value = key
|
||
const config = getModelConfig(key)
|
||
|
||
// 同步 Quality 到模型默认值
|
||
if (config?.defaultParams?.quality) {
|
||
localQuality.value = config.defaultParams.quality
|
||
}
|
||
|
||
// 同步 Size 到模型默认值
|
||
const newSizeOptions = getModelSizeOptions(key, localQuality.value)
|
||
let defaultSize = config?.defaultParams?.size
|
||
|
||
if (!defaultSize && newSizeOptions.length > 0) {
|
||
defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
|
||
|| newSizeOptions.find(o => o.key.includes('1024'))?.key
|
||
|| newSizeOptions[0].key
|
||
}
|
||
|
||
localSize.value = defaultSize
|
||
customSizeInput.value = defaultSize === 'auto' ? '' : defaultSize
|
||
customSizeError.value = ''
|
||
|
||
// 更新节点数据
|
||
updateNode(props.id, {
|
||
model: key,
|
||
quality: localQuality.value,
|
||
size: defaultSize
|
||
})
|
||
}
|
||
|
||
// Handle quality selection | 处理画质选择
|
||
const handleQualitySelect = (quality) => {
|
||
localQuality.value = quality
|
||
updateNode(props.id, { quality })
|
||
}
|
||
|
||
// Handle size selection | 处理尺寸选择
|
||
const handleSizeSelect = (size) => {
|
||
localSize.value = size
|
||
customSizeInput.value = size === 'auto' ? '' : size
|
||
customSizeError.value = ''
|
||
updateNode(props.id, { size })
|
||
}
|
||
|
||
// Update size from manual input | 更新手动输入的尺寸
|
||
const updateSize = () => {
|
||
updateNode(props.id, { size: localSize.value })
|
||
}
|
||
|
||
const applyCustomSize = () => {
|
||
const result = validateImageSize(customSizeInput.value)
|
||
if (!result.ok) {
|
||
customSizeError.value = result.message
|
||
return
|
||
}
|
||
customSizeError.value = ''
|
||
localSize.value = result.key
|
||
customSizeInput.value = result.key
|
||
updateNode(props.id, { size: result.key })
|
||
}
|
||
|
||
// Created image node ID | 创建的图片节点 ID
|
||
const createdImageNodeId = ref(null)
|
||
|
||
// Find connected output image node | 查找已连接的输出图片节点
|
||
const findConnectedOutputImageNode = (onlyEmpty = true) => {
|
||
// Find edges where this node is the source | 查找以当前节点为源的边
|
||
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||
|
||
for (const edge of outputEdges) {
|
||
const targetNode = nodes.value.find(n => n.id === edge.target)
|
||
if (targetNode?.type === 'image') {
|
||
if (onlyEmpty) {
|
||
// Check if target is an image node with empty or no url | 检查目标是否为空白图片节点
|
||
if (!targetNode.data?.url || targetNode.data?.url === '') {
|
||
return targetNode.id
|
||
}
|
||
} else {
|
||
// Return any connected image node | 返回任意连接的图片节点
|
||
return targetNode.id
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// Check if there's a connected image node with content | 检查是否有已连接且有内容的图片节点
|
||
const hasConnectedImageWithContent = computed(() => {
|
||
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||
|
||
for (const edge of outputEdges) {
|
||
const targetNode = nodes.value.find(n => n.id === edge.target)
|
||
if (targetNode?.type === 'image' && targetNode.data?.url && targetNode.data.url !== '') {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
|
||
// Handle generate action | 处理生成操作
|
||
// mode: 'auto' = 自动判断, 'replace' = 替换现有, 'new' = 新建节点
|
||
const handleGenerate = async (mode = 'auto') => {
|
||
const { prompt, prompts, refImages, refImagesWithOrder } = getConnectedInputs()
|
||
|
||
if (!prompt && refImages.length === 0) {
|
||
window.$message?.warning('请连接文本节点(提示词)或图片节点(参考图)')
|
||
return
|
||
}
|
||
|
||
// Log prompt order for debugging | 记录提示词顺序用于调试
|
||
if (prompts.length > 1) {
|
||
console.log('[ImageConfigNode] 拼接提示词顺序:', prompts.map(p => `${p.order}: ${p.content.substring(0, 20)}...`))
|
||
}
|
||
|
||
// Log image order for debugging | 记录图片顺序用于调试
|
||
if (refImagesWithOrder && refImagesWithOrder.length > 1) {
|
||
console.log('[ImageConfigNode] 参考图顺序:', refImagesWithOrder.map(r => `${r.order}: ${r.nodeId}`))
|
||
}
|
||
|
||
if (!isConfigured.value) {
|
||
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||
return
|
||
}
|
||
|
||
let imageNodeId = null
|
||
|
||
if (mode === 'replace') {
|
||
// Replace mode: find any connected image node | 替换模式:查找任意连接的图片节点
|
||
imageNodeId = findConnectedOutputImageNode(false)
|
||
if (imageNodeId) {
|
||
updateNode(imageNodeId, {
|
||
loading: true,
|
||
url: '',
|
||
error: '',
|
||
model: localModel.value,
|
||
size: localSize.value,
|
||
quality: localQuality.value,
|
||
width: 0,
|
||
height: 0
|
||
})
|
||
}
|
||
} else if (mode === 'new') {
|
||
// New mode: always create new node | 新建模式:始终创建新节点
|
||
imageNodeId = null
|
||
} else {
|
||
// Auto mode: check for empty connected node first | 自动模式:先检查空白连接节点
|
||
imageNodeId = findConnectedOutputImageNode(true)
|
||
if (imageNodeId) {
|
||
updateNode(imageNodeId, {
|
||
loading: true,
|
||
error: '',
|
||
model: localModel.value,
|
||
size: localSize.value,
|
||
quality: localQuality.value,
|
||
width: 0,
|
||
height: 0
|
||
})
|
||
}
|
||
}
|
||
|
||
if (!imageNodeId) {
|
||
// Get current node position | 获取当前节点位置
|
||
const currentNode = nodes.value.find(n => n.id === props.id)
|
||
const nodeX = currentNode?.position?.x || 0
|
||
const nodeY = currentNode?.position?.y || 0
|
||
|
||
// Calculate Y offset if creating new node alongside existing | 如果是新建节点,计算Y偏移
|
||
let yOffset = 0
|
||
if (mode === 'new') {
|
||
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||
yOffset = outputEdges.length * 280 // Stack below existing outputs | 在现有输出下方堆叠
|
||
}
|
||
|
||
// Create image node with loading state | 创建带加载状态的图片节点
|
||
imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY + yOffset }, {
|
||
url: '',
|
||
loading: true,
|
||
label: '图像生成结果',
|
||
model: localModel.value,
|
||
size: localSize.value,
|
||
quality: localQuality.value
|
||
})
|
||
|
||
// Auto-connect imageConfig → image | 自动连接 生图配置 → 图片
|
||
addEdge({
|
||
source: props.id,
|
||
target: imageNodeId,
|
||
sourceHandle: 'right',
|
||
targetHandle: 'left'
|
||
})
|
||
}
|
||
|
||
createdImageNodeId.value = imageNodeId
|
||
|
||
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||
setTimeout(() => {
|
||
updateNodeInternals(imageNodeId)
|
||
}, 50)
|
||
|
||
try {
|
||
// Build request params | 构建请求参数
|
||
const params = {
|
||
model: localModel.value,
|
||
prompt: prompt,
|
||
size: localSize.value,
|
||
quality: localQuality.value,
|
||
n: 1
|
||
}
|
||
|
||
// Add reference image if provided | 如果有参考图则添加
|
||
if (refImages.length > 0) {
|
||
params.image = refImages
|
||
}
|
||
|
||
const result = await generate(params)
|
||
|
||
// Update image node with generated URL | 更新图片节点 URL
|
||
if (result && result.length > 0) {
|
||
updateNode(imageNodeId, {
|
||
url: result[0].url,
|
||
loading: false,
|
||
label: '文生图',
|
||
model: result[0].model || localModel.value,
|
||
size: result[0].size || localSize.value,
|
||
quality: result[0].quality || localQuality.value,
|
||
width: result[0].width || 0,
|
||
height: result[0].height || 0,
|
||
updatedAt: Date.now()
|
||
})
|
||
|
||
// Mark this config node as executed | 标记配置节点已执行
|
||
updateNode(props.id, { executed: true, outputNodeId: imageNodeId })
|
||
}
|
||
window.$message?.success('图片生成成功')
|
||
} catch (err) {
|
||
// Update node to show error | 更新节点显示错误
|
||
updateNode(imageNodeId, {
|
||
loading: false,
|
||
error: err.message || '生成失败',
|
||
updatedAt: Date.now()
|
||
})
|
||
window.$message?.error(err.message || '图片生成失败')
|
||
}
|
||
}
|
||
|
||
// Handle duplicate | 处理复制
|
||
const handleDuplicate = () => {
|
||
const newNodeId = duplicateNode(props.id)
|
||
window.$message?.success('节点已复制')
|
||
if (newNodeId) {
|
||
setTimeout(() => {
|
||
updateNodeInternals(newNodeId)
|
||
}, 50)
|
||
}
|
||
}
|
||
|
||
// Start editing label | 开始编辑 label
|
||
const startEditLabel = () => {
|
||
editingLabelValue.value = props.data?.label || ''
|
||
isEditingLabel.value = true
|
||
nextTick(() => {
|
||
labelInputRef.value?.focus()
|
||
labelInputRef.value?.select()
|
||
})
|
||
}
|
||
|
||
// Finish editing label | 完成编辑 label
|
||
const finishEditLabel = () => {
|
||
const newLabel = editingLabelValue.value.trim()
|
||
if (newLabel && newLabel !== props.data?.label) {
|
||
updateNode(props.id, { label: newLabel })
|
||
}
|
||
isEditingLabel.value = false
|
||
}
|
||
|
||
// Cancel editing label | 取消编辑 label
|
||
const cancelEditLabel = () => {
|
||
isEditingLabel.value = false
|
||
}
|
||
|
||
// Handle delete | 处理删除
|
||
const handleDelete = () => {
|
||
removeNode(props.id)
|
||
window.$message?.success('节点已删除')
|
||
}
|
||
|
||
// 监听模型变化,同步 Quality 和 Size
|
||
watch(() => props.data?.model, (newModel) => {
|
||
if (newModel && newModel !== localModel.value) {
|
||
localModel.value = newModel
|
||
const config = getModelConfig(newModel)
|
||
|
||
// 同步 Quality
|
||
if (config?.defaultParams?.quality) {
|
||
localQuality.value = config.defaultParams.quality
|
||
}
|
||
|
||
// 同步 Size
|
||
if (config?.defaultParams?.size) {
|
||
localSize.value = config.defaultParams.size
|
||
customSizeInput.value = localSize.value === 'auto' ? '' : localSize.value
|
||
}
|
||
}
|
||
})
|
||
|
||
watch(() => props.data?.size, (newSize) => {
|
||
if (newSize && newSize !== localSize.value) {
|
||
localSize.value = newSize
|
||
customSizeInput.value = newSize === 'auto' ? '' : newSize
|
||
}
|
||
})
|
||
|
||
watch(() => props.data?.quality, (newQuality) => {
|
||
const normalized = normalizeQualityKey(newQuality)
|
||
if (normalized !== localQuality.value) {
|
||
localQuality.value = normalized
|
||
}
|
||
})
|
||
|
||
// 修复 Vue Flow visibility: hidden 问题
|
||
watch(() => props.data, () => {
|
||
nextTick(() => {
|
||
updateNodeInternals(props.id)
|
||
})
|
||
}, { deep: true })
|
||
|
||
// Watch for auto-execute flag | 监听自动执行标志
|
||
watch(
|
||
() => props.data?.autoExecute,
|
||
(shouldExecute) => {
|
||
if (shouldExecute && !loading.value) {
|
||
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
|
||
updateNode(props.id, { autoExecute: false })
|
||
// Delay to ensure node connections are established | 延迟确保节点连接已建立
|
||
setTimeout(() => {
|
||
handleGenerate()
|
||
}, 100)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.image-config-node-wrapper {
|
||
position: relative;
|
||
padding-top: 20px;
|
||
}
|
||
|
||
.image-config-node {
|
||
cursor: default;
|
||
position: relative;
|
||
}
|
||
</style>
|