1221 lines
38 KiB
Vue
1221 lines
38 KiB
Vue
<template>
|
||
<!-- LLM Config node wrapper | LLM配置节点包裹层 -->
|
||
<div class="llm-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||
<!-- LLM Config node | LLM配置节点 -->
|
||
<div
|
||
class="llm-node bg-[var(--bg-secondary)] rounded-xl border min-w-[320px] max-w-[400px] relative transition-all duration-200"
|
||
:class="data.selected ? 'border-1 border-purple-500 shadow-lg shadow-purple-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)] bg-gradient-to-r from-purple-500/10 to-transparent">
|
||
<div class="flex items-center gap-2">
|
||
<n-icon :size="16" class="text-purple-500">
|
||
<ChatbubbleOutline />
|
||
</n-icon>
|
||
<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="双击编辑名称">{{ nodeLabel }}</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-purple-500" />
|
||
</div>
|
||
<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 content | 配置内容 -->
|
||
<div class="p-3 space-y-3">
|
||
<!-- System prompt | 系统提示词 -->
|
||
<div class="relative">
|
||
<label class="text-xs text-[var(--text-secondary)] mb-1 block">系统提示词</label>
|
||
<div class="textarea-wrapper" ref="textareaWrapper">
|
||
<div ref="systemPromptRef" class="editor-content" contenteditable="true" @input="handleInput"
|
||
@keydown="handleKeydown" @paste="handlePaste" @blur="handleBlur" @wheel.stop @mousedown.stop
|
||
:data-placeholder="placeholder"></div>
|
||
</div>
|
||
<!-- @ 提及预览 -->
|
||
<!-- <div class="mentions-preview mt-1 flex flex-wrap gap-1" v-if="mentionsPreview.length > 0">
|
||
<div
|
||
v-for="(mention, index) in mentionsPreview"
|
||
:key="index"
|
||
class="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 text-xs rounded"
|
||
>
|
||
<span>@{{ mention.name }}</span>
|
||
<button @click="removeMention(mention.nodeId)" class="hover:text-purple-800">×</button>
|
||
</div>
|
||
</div> -->
|
||
</div>
|
||
|
||
<!-- Model selection | 模型选择 -->
|
||
<div>
|
||
<label class="text-xs text-[var(--text-secondary)] mb-1 block">模型</label>
|
||
<n-select v-model:value="model" :options="modelOptions" label-field="label" value-field="key" size="small"
|
||
@update:value="updateConfig" />
|
||
</div>
|
||
|
||
<!-- Output format | 输出格式 -->
|
||
<div>
|
||
<label class="text-xs text-[var(--text-secondary)] mb-1 block">输出格式</label>
|
||
<n-select v-model:value="outputFormat" :options="formatOptions" size="small" @update:value="updateConfig" />
|
||
</div>
|
||
|
||
<!-- Generate button | 生成按钮 -->
|
||
<button @click="handleGenerate" :disabled="isGenerating"
|
||
class="w-full px-4 py-2 text-sm rounded-lg bg-purple-500 hover:bg-purple-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||
<n-spin v-if="isGenerating" :size="14" />
|
||
<n-icon v-else :size="14">
|
||
<SparklesOutline />
|
||
</n-icon>
|
||
{{ isGenerating ? '生成中...' : '执行生成' }}
|
||
</button>
|
||
|
||
<!-- Output preview | 输出预览 -->
|
||
<div v-if="outputContent" class="mt-2">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<label class="text-xs text-[var(--text-secondary)]">生成结果</label>
|
||
<button @click="handleCopyOutput"
|
||
class="text-xs text-[var(--text-secondary)] hover:text-purple-500 flex items-center gap-1 transition-colors">
|
||
<n-icon :size="12">
|
||
<CopyOutline />
|
||
</n-icon>
|
||
复制
|
||
</button>
|
||
</div>
|
||
<div @wheel.stop @mousedown.stop
|
||
class="bg-[var(--bg-tertiary)] rounded-lg p-2 text-xs text-[var(--text-primary)] max-h-[150px] overflow-y-auto border border-[var(--border-color)]">
|
||
<pre class="whitespace-pre-wrap">{{ outputContent }}</pre>
|
||
</div>
|
||
|
||
<!-- Split actions | 拆分操作 -->
|
||
<div class="mt-2 flex gap-2">
|
||
<button @click="handleSplitToTextWithImage" :disabled="isSplitting"
|
||
class="flex-1 px-3 py-1.5 text-xs rounded-lg border border-purple-400 text-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors disabled:opacity-50 flex items-center justify-center gap-1">
|
||
<n-spin v-if="isSplitting" :size="12" />
|
||
<n-icon v-else :size="12">
|
||
<ImageOutline />
|
||
</n-icon>
|
||
{{ isSplitting ? '拆分中...' : '拆分图文' }}
|
||
</button>
|
||
<button @click="handleSplitToTextOnly" :disabled="isSplitting"
|
||
class="flex-1 px-3 py-1.5 text-xs rounded-lg border border-purple-400 text-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors disabled:opacity-50 flex items-center justify-center gap-1">
|
||
<n-spin v-if="isSplitting" :size="12" />
|
||
<n-icon v-else :size="12">
|
||
<ListOutline />
|
||
</n-icon>
|
||
{{ isSplitting ? '拆分中...' : '拆分文本' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="splitMessage" class="mt-1 text-xs text-green-600 dark:text-green-400">{{ splitMessage }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Handles | 连接点 -->
|
||
<Handle type="target" :position="Position.Left" id="left" class="!bg-purple-500" />
|
||
<NodeHandleMenu :nodeId="id" nodeType="llmConfig" dotColor="#a855f7" :visible="showHandleMenu"
|
||
:operations="operations" @select="handleSelect" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mentions picker | @ 选择器 -->
|
||
<MentionsPicker v-model:visible="showMentionsPicker" :position="mentionsPosition" context="llmConfig"
|
||
:showSearch="false" :connectedNodeIds="hasConnectedNodes ? connectedTextNodeIds : []"
|
||
@select="handleMentionSelect" />
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* LLM Config node component | LLM配置节点组件
|
||
* For text generation tasks like story segmentation
|
||
*/
|
||
import { ref, watch, computed, nextTick, onMounted } from 'vue'
|
||
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||
import { NIcon, NSpin, NSelect } from 'naive-ui'
|
||
import { TrashOutline, CopyOutline, ChatbubbleOutline, SparklesOutline, ListOutline, ImageOutline, VideocamOutline, DocumentTextOutline } from '@vicons/ionicons5'
|
||
import { updateNode, removeNode, duplicateNode, addNode, addEdge, addNodes, addEdges, nodes, edges, startBatchOperation, endBatchOperation } from '../../stores/canvas'
|
||
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||
import MentionsPicker from '../MentionsPicker.vue'
|
||
import { useChat } from '../../hooks'
|
||
import { useModelStore } from '../../stores/pinia'
|
||
import { parseMentions, removeMention as removeMentionUtil } from '../../hooks/useNodeRef'
|
||
|
||
const props = defineProps({
|
||
id: String,
|
||
data: Object
|
||
})
|
||
|
||
// Vue Flow instance | Vue Flow 实例
|
||
const { updateNodeInternals } = useVueFlow()
|
||
|
||
// API config state | API 配置状态
|
||
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
|
||
|
||
// Local state | 本地状态
|
||
const showHandleMenu = ref(false)
|
||
const systemPrompt = ref(props.data?.systemPrompt || '')
|
||
const systemPromptRef = ref(null)
|
||
const textareaWrapper = ref(null)
|
||
const placeholder = '设定 AI 的角色和行为规则,输入 @ 可引用文本节点...'
|
||
const lastContent = ref('') // 上一次的内容,用于检测变化
|
||
|
||
// Label editing state | Label 编辑状态
|
||
const isEditingLabel = ref(false)
|
||
const editingLabelValue = ref('')
|
||
const labelInputRef = ref(null)
|
||
|
||
// Computed node label | 计算节点标签
|
||
const nodeLabel = computed(() => props.data?.label || 'LLM 文本生成')
|
||
|
||
// Mentions picker state | @ 选择器状态
|
||
const showMentionsPicker = ref(false)
|
||
const mentionsPosition = ref({ x: 0, y: 0 })
|
||
const mentionSearchStart = ref(-1)
|
||
|
||
// 内部更新标志
|
||
let isInternalUpdate = false
|
||
|
||
// ============ 参考 TextNode 的 contenteditable 逻辑 ============
|
||
|
||
// 从 contenteditable 中提取纯文本
|
||
const getEditableText = () => {
|
||
const el = systemPromptRef.value
|
||
if (!el) return ''
|
||
let text = ''
|
||
const walk = (node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
text += node.textContent
|
||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||
if (node.classList?.contains('mention-chip')) {
|
||
text += `@[${node.dataset.nodeId}]`
|
||
} else if (node.tagName === 'BR') {
|
||
text += '\n'
|
||
} else {
|
||
node.childNodes.forEach(walk)
|
||
}
|
||
}
|
||
}
|
||
el.childNodes.forEach(walk)
|
||
return text
|
||
}
|
||
|
||
// 创建 mention chip 元素
|
||
const createMentionChip = (node) => {
|
||
const chip = document.createElement('span')
|
||
chip.className = 'mention-chip'
|
||
chip.contentEditable = 'false'
|
||
chip.dataset.nodeId = node.id
|
||
chip.dataset.label = node.data?.label || node.data?.content?.slice(0, 20) || '文本'
|
||
|
||
const iconWrap = document.createElement('span')
|
||
iconWrap.className = 'mention-chip-icon'
|
||
iconWrap.textContent = '📝'
|
||
chip.appendChild(iconWrap)
|
||
|
||
const label = document.createElement('span')
|
||
label.className = 'mention-chip-label'
|
||
label.textContent = chip.dataset.label
|
||
chip.appendChild(label)
|
||
|
||
return chip
|
||
}
|
||
|
||
// 在 contenteditable 中插入 mention chip(替换 @searchText)
|
||
const insertMentionChipDOM = (node) => {
|
||
const el = systemPromptRef.value
|
||
if (!el) return
|
||
|
||
// 遍历文本节点,找到最后一个 @
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||
let lastAtNode = null
|
||
let lastAtOffset = -1
|
||
|
||
while (walker.nextNode()) {
|
||
const idx = walker.currentNode.textContent.lastIndexOf('@')
|
||
if (idx !== -1) {
|
||
lastAtNode = walker.currentNode
|
||
lastAtOffset = idx
|
||
}
|
||
}
|
||
|
||
if (!lastAtNode || lastAtOffset === -1) return
|
||
|
||
const chip = createMentionChip(node)
|
||
const spaceNode = document.createTextNode('\u00A0')
|
||
const beforeText = lastAtNode.textContent.substring(0, lastAtOffset)
|
||
|
||
if (beforeText) {
|
||
lastAtNode.textContent = beforeText
|
||
lastAtNode.parentNode.insertBefore(chip, lastAtNode.nextSibling)
|
||
lastAtNode.parentNode.insertBefore(spaceNode, chip.nextSibling)
|
||
} else {
|
||
const parent = lastAtNode.parentNode
|
||
parent.insertBefore(chip, lastAtNode)
|
||
parent.insertBefore(spaceNode, chip.nextSibling)
|
||
parent.removeChild(lastAtNode)
|
||
}
|
||
|
||
// 光标移到空格之后
|
||
const range = document.createRange()
|
||
range.setStartAfter(spaceNode)
|
||
range.collapse(true)
|
||
const sel = window.getSelection()
|
||
sel.removeAllRanges()
|
||
sel.addRange(range)
|
||
|
||
// 同步文本
|
||
isInternalUpdate = true
|
||
systemPrompt.value = getEditableText()
|
||
lastContent.value = systemPrompt.value
|
||
nextTick(() => { isInternalUpdate = false })
|
||
}
|
||
|
||
// 设置 contenteditable 内容(纯文本)
|
||
const setEditableContent = (text) => {
|
||
if (!systemPromptRef.value) return
|
||
systemPromptRef.value.innerHTML = ''
|
||
if (text) {
|
||
// 将 @[nodeId] 格式转换为纯文本显示
|
||
systemPromptRef.value.textContent = text
|
||
}
|
||
}
|
||
|
||
// 扫描 contenteditable 文本节点,将 @label 自动转为 chip
|
||
const convertTextMentionsToChips = () => {
|
||
const el = systemPromptRef.value
|
||
if (!el) return
|
||
|
||
// 获取所有可引用的文本节点
|
||
const textNodes = nodes.value.filter(n => n.type === 'text' && n.data?.content)
|
||
if (textNodes.length === 0) return
|
||
|
||
// 快速检查:无 @ 直接跳过
|
||
if (!el.textContent.includes('@')) return
|
||
|
||
// 构建正则,匹配 @[nodeId] 格式
|
||
const pattern = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||
|
||
// 收集需要替换的文本节点(跳过 chip 内部)
|
||
const targets = []
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||
while (walker.nextNode()) {
|
||
const node = walker.currentNode
|
||
if (node.parentElement?.closest('.mention-chip')) continue
|
||
pattern.lastIndex = 0
|
||
if (pattern.test(node.textContent)) {
|
||
targets.push(node)
|
||
}
|
||
}
|
||
if (targets.length === 0) return
|
||
|
||
// 替换文本节点为 chip + 文本片段
|
||
targets.forEach(textNode => {
|
||
const text = textNode.textContent
|
||
pattern.lastIndex = 0
|
||
const fragment = document.createDocumentFragment()
|
||
let lastIdx = 0
|
||
let match
|
||
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
if (match.index > lastIdx) {
|
||
fragment.appendChild(document.createTextNode(text.slice(lastIdx, match.index)))
|
||
}
|
||
const nodeId = match[1]
|
||
const node = textNodes.find(n => n.id === nodeId)
|
||
if (node) {
|
||
fragment.appendChild(createMentionChip(node))
|
||
fragment.appendChild(document.createTextNode('\u00A0'))
|
||
} else {
|
||
fragment.appendChild(document.createTextNode(match[0]))
|
||
}
|
||
lastIdx = pattern.lastIndex
|
||
}
|
||
|
||
if (lastIdx < text.length) {
|
||
fragment.appendChild(document.createTextNode(text.slice(lastIdx)))
|
||
}
|
||
|
||
textNode.parentNode.replaceChild(fragment, textNode)
|
||
})
|
||
}
|
||
|
||
// 防抖版本(用于输入事件,避免频繁 DOM 操作)
|
||
let _convertTimer = null
|
||
const debouncedConvertMentions = () => {
|
||
if (_convertTimer) clearTimeout(_convertTimer)
|
||
_convertTimer = setTimeout(convertTextMentionsToChips, 300)
|
||
}
|
||
|
||
// Handle paste - 参考 TextNode,纯文本粘贴
|
||
const handlePaste = (e) => {
|
||
e.preventDefault()
|
||
const text = e.clipboardData?.getData('text/plain') || ''
|
||
document.execCommand('insertText', false, text)
|
||
}
|
||
|
||
// 已连接的文本节点 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
|
||
})
|
||
|
||
// 是否有连接的节点
|
||
const hasConnectedNodes = computed(() => {
|
||
return connectedTextNodeIds.value.length > 0
|
||
})
|
||
|
||
// @ 提及预览列表
|
||
const mentionsPreview = computed(() => {
|
||
const mentions = parseMentions(systemPrompt.value)
|
||
const previews = []
|
||
|
||
for (const mention of mentions) {
|
||
const node = nodes.value.find(n => n.id === mention.nodeId)
|
||
if (node?.type === 'text') {
|
||
previews.push({
|
||
nodeId: mention.nodeId,
|
||
name: node.data?.label || node.data?.content?.slice(0, 20) || '文本',
|
||
content: node.data?.content || ''
|
||
})
|
||
}
|
||
}
|
||
|
||
return previews
|
||
})
|
||
|
||
// LLMConfig node menu operations | LLM配置节点菜单操作
|
||
const operations = [
|
||
{ type: 'imageConfig', label: '生图', icon: ImageOutline },
|
||
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline },
|
||
{ type: 'text', label: '文本', icon: DocumentTextOutline }
|
||
]
|
||
|
||
// Handle menu select | 处理菜单选择
|
||
const handleSelect = (item) => {
|
||
const currentNode = nodes.value.find(n => n.id === props.id)
|
||
const nodeX = currentNode?.position?.x || 0
|
||
const nodeY = currentNode?.position?.y || 0
|
||
|
||
const defaultData = {
|
||
imageConfig: { model: 'auto', size: '1024x1536', label: '文生图' },
|
||
videoConfig: { label: '视频生成' },
|
||
text: { content: '', label: '文本输入' }
|
||
}
|
||
|
||
const newId = addNode(item.type, { x: nodeX + 400, y: nodeY }, defaultData[item.type] || {})
|
||
|
||
addEdge({
|
||
source: props.id,
|
||
target: newId,
|
||
sourceHandle: 'right',
|
||
targetHandle: 'left'
|
||
})
|
||
|
||
setTimeout(() => updateNodeInternals(newId), 50)
|
||
window.$message?.success(`已创建${item.label}节点`)
|
||
}
|
||
|
||
// Handle keydown for mentions | 处理 @ 选择器的键盘事件
|
||
const handleKeydown = (e) => {
|
||
// 规范化 Shift+Enter 插入换行
|
||
if (e.key === 'Enter' && e.shiftKey) {
|
||
e.preventDefault()
|
||
document.execCommand('insertLineBreak')
|
||
return
|
||
}
|
||
|
||
if (showMentionsPicker.value) {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
showMentionsPicker.value = false
|
||
// Remove the incomplete @ | 移除不完整的 @
|
||
const editor = systemPromptRef.value
|
||
if (!editor) return
|
||
|
||
const selection = window.getSelection()
|
||
if (!selection.rangeCount) return
|
||
|
||
const range = selection.getRangeAt(0)
|
||
const cursorPos = range.startOffset
|
||
const textBeforeCursor = systemPrompt.value.slice(0, cursorPos)
|
||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||
|
||
if (lastAtIndex !== -1) {
|
||
systemPrompt.value = textBeforeCursor.slice(0, lastAtIndex) + systemPrompt.value.slice(cursorPos)
|
||
nextTick(() => {
|
||
editor.innerHTML = systemPrompt.value.replace(/\n/g, '<br>')
|
||
// Set cursor position | 设置光标位置
|
||
const newRange = document.createRange()
|
||
newRange.setStart(editor.firstChild || editor, lastAtIndex)
|
||
newRange.collapse(true)
|
||
selection.removeAllRanges()
|
||
selection.addRange(newRange)
|
||
})
|
||
}
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Handle mention selection | 处理 @ 引用选择
|
||
const handleMentionSelect = ({ nodeId }) => {
|
||
// 插入 mention chip 到 DOM
|
||
const node = nodes.value.find(n => n.id === nodeId)
|
||
if (!node) {
|
||
showMentionsPicker.value = false
|
||
return
|
||
}
|
||
|
||
insertMentionChipDOM(node)
|
||
|
||
// 更新 store
|
||
updateConfig()
|
||
showMentionsPicker.value = false
|
||
}
|
||
|
||
// Remove mention | 移除提及
|
||
const removeMention = (nodeId) => {
|
||
systemPrompt.value = removeMentionUtil(systemPrompt.value, nodeId)
|
||
// 同步到 editor
|
||
setEditableContent(systemPrompt.value)
|
||
nextTick(() => convertTextMentionsToChips())
|
||
updateConfig()
|
||
}
|
||
|
||
// 根据 DOM 光标位置计算纯文本中的位置(考虑 mention-chip 的转换)
|
||
const getTextPositionBeforeCursor = (editor, range) => {
|
||
const container = editor
|
||
let textLength = 0
|
||
let found = false
|
||
|
||
const walk = (node) => {
|
||
if (found) return
|
||
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
const nodeLength = node.textContent.length
|
||
if (range.startContainer === node) {
|
||
textLength += range.startOffset
|
||
found = true
|
||
return
|
||
}
|
||
textLength += nodeLength
|
||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||
if (node.classList?.contains('mention-chip')) {
|
||
// mention-chip 在纯文本中算作 @[nodeId](约 3-5 个字符)
|
||
const replacement = `@[${node.dataset.nodeId || ''}]`
|
||
if (range.startContainer === node || container.contains(range.startContainer) && isNodeInside(node, range.startContainer)) {
|
||
// 光标在 mention-chip 内部
|
||
found = true
|
||
return
|
||
}
|
||
textLength += replacement.length
|
||
} else if (node.tagName === 'BR') {
|
||
textLength += 1 // 换行符
|
||
} else {
|
||
for (const child of node.childNodes) {
|
||
walk(child)
|
||
if (found) return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
walk(container)
|
||
return textLength
|
||
}
|
||
|
||
// 检查节点是否在父节点内部
|
||
const isNodeInside = (parent, child) => {
|
||
let node = child
|
||
while (node) {
|
||
if (node === parent) return true
|
||
node = node.parentNode
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Handle input for @ trigger | 处理 @ 触发输入
|
||
const handleInput = (e) => {
|
||
const editor = e.target
|
||
isInternalUpdate = true
|
||
systemPrompt.value = getEditableText()
|
||
lastContent.value = systemPrompt.value
|
||
nextTick(() => { isInternalUpdate = false })
|
||
|
||
// 触发文本到 chip 的转换
|
||
debouncedConvertMentions()
|
||
|
||
// 获取光标位置
|
||
const selection = window.getSelection()
|
||
if (!selection.rangeCount) return
|
||
|
||
const range = selection.getRangeAt(0)
|
||
// 使用辅助函数计算纯文本中的光标位置
|
||
const cursorPos = getTextPositionBeforeCursor(editor, range)
|
||
const fullText = getEditableText()
|
||
const textBeforeCursor = fullText.slice(0, cursorPos)
|
||
|
||
// Check if cursor is after @ character | 检查光标是否在 @ 字符后
|
||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||
|
||
if (lastAtIndex !== -1) {
|
||
// Check if there's a space after @ (meaning user finished typing mention) | 检查 @ 后面是否有空格(用户已完成输入)
|
||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
|
||
|
||
// Check if there's a complete @[...] mention - 检查是否有完整的 @[...] 配对
|
||
const bracketMatch = textAfterAt.match(/\[([^\]]*)\]/)
|
||
const hasCompleteMention = bracketMatch !== null
|
||
|
||
// Show picker only if: @ exists, no space after @, not part of a complete @[...] mention, and has connected nodes
|
||
// 显示选择器的条件:@ 存在,@ 后没有空格,且不是完整的 @[...] 格式,且有连接线
|
||
if (!textAfterAt.includes(' ') && !hasCompleteMention && hasConnectedNodes.value) {
|
||
// Calculate position | 计算位置
|
||
showMentionsPicker.value = true
|
||
mentionSearchStart.value = lastAtIndex
|
||
|
||
// Get editor position | 获取 editor 位置
|
||
const rect = editor.getBoundingClientRect()
|
||
mentionsPosition.value = {
|
||
x: rect.left + 10,
|
||
y: rect.bottom + 5
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Hide picker if conditions not met | 如果条件不满足,隐藏选择器
|
||
showMentionsPicker.value = false
|
||
}
|
||
|
||
// Handle blur | 处理失去焦点
|
||
const handleBlur = () => {
|
||
// Delay hiding picker to allow click event | 延迟隐藏选择器以允许点击事件
|
||
setTimeout(() => {
|
||
showMentionsPicker.value = false
|
||
}, 200)
|
||
// 同步内容到 store
|
||
updateConfig()
|
||
}
|
||
|
||
// Watch for external data changes | 监听外部数据变化
|
||
watch(() => props.data, (newData) => {
|
||
if (newData?.systemPrompt !== undefined && newData.systemPrompt !== systemPrompt.value) {
|
||
systemPrompt.value = newData.systemPrompt
|
||
lastContent.value = systemPrompt.value
|
||
// Sync to editor | 同步到 editor
|
||
setEditableContent(systemPrompt.value)
|
||
// 立即将文本中的 @label 转为 chip
|
||
nextTick(() => convertTextMentionsToChips())
|
||
}
|
||
if (newData?.model !== undefined) model.value = newData.model
|
||
if (newData?.outputFormat !== undefined) outputFormat.value = newData.outputFormat
|
||
if (newData?.outputContent !== undefined) outputContent.value = newData.outputContent
|
||
|
||
// 修复 Vue Flow visibility: hidden 问题
|
||
nextTick(() => {
|
||
updateNodeInternals(props.id)
|
||
})
|
||
}, { deep: true })
|
||
|
||
// Watch content changes and sync to editor | 监听内容变化并同步到编辑器
|
||
watch(systemPrompt, (newVal) => {
|
||
if (isInternalUpdate) return
|
||
setEditableContent(newVal)
|
||
// 立即将文本中的 @label 转为 chip
|
||
nextTick(() => convertTextMentionsToChips())
|
||
lastContent.value = newVal
|
||
})
|
||
|
||
// Initialize editor content | 初始化 editor 内容
|
||
onMounted(() => {
|
||
// 检查当前模型是否在可用模型列表中
|
||
const availableModels = modelStore.availableChatModels
|
||
const isModelAvailable = availableModels.some(m => m.key === model.value)
|
||
|
||
if (!model.value || !isModelAvailable) {
|
||
// 使用 store 中的默认模型或第一个可用模型
|
||
model.value = modelStore.selectedChatModel || availableModels[0]?.key || 'gpt-4o-mini'
|
||
updateConfig()
|
||
}
|
||
|
||
if (systemPromptRef.value) {
|
||
if (props.data?.systemPrompt) {
|
||
systemPrompt.value = props.data.systemPrompt
|
||
}
|
||
lastContent.value = systemPrompt.value
|
||
// 设置纯文本内容
|
||
setEditableContent(systemPrompt.value)
|
||
// 立即将文本中的 @[...] 转为 chip
|
||
nextTick(() => convertTextMentionsToChips())
|
||
}
|
||
})
|
||
|
||
const outputFormat = ref(props.data?.outputFormat || 'text')
|
||
const outputContent = ref(props.data?.outputContent || '')
|
||
const isGenerating = ref(false)
|
||
const isSplitting = ref(false)
|
||
const splitMessage = ref('')
|
||
|
||
// Model Store (Pinia) | 模型配置 Store
|
||
const modelStore = useModelStore()
|
||
|
||
// 使用全部模型(不按渠道过滤)
|
||
const modelOptions = computed(() => modelStore.allChatModelOptions)
|
||
|
||
// 默认模型使用选中的模型
|
||
const model = ref(props.data?.model || modelStore.selectedChatModel || 'gpt-4o-mini')
|
||
// Format options | 格式选项
|
||
const formatOptions = [
|
||
{ label: '纯文本', value: 'text' },
|
||
{ label: 'JSON 结构', value: 'json' },
|
||
{ label: 'Markdown', value: 'markdown' }
|
||
]
|
||
|
||
// Chat hook | Chat hook
|
||
const chatHook = computed(() => {
|
||
return useChat({
|
||
systemPrompt: systemPrompt.value,
|
||
model: model.value,
|
||
mode: 'chat',
|
||
targetLanguage: 'keep'
|
||
})
|
||
})
|
||
|
||
// 防抖定时器
|
||
let updateConfigTimer = null
|
||
|
||
// Update config in store | 更新存储中的配置(防抖)
|
||
const updateConfig = () => {
|
||
if (updateConfigTimer) clearTimeout(updateConfigTimer)
|
||
updateConfigTimer = setTimeout(() => {
|
||
updateNode(props.id, {
|
||
systemPrompt: systemPrompt.value,
|
||
model: model.value,
|
||
outputFormat: outputFormat.value,
|
||
outputContent: outputContent.value
|
||
})
|
||
}, 150)
|
||
}
|
||
|
||
// Get input from connected nodes | 获取连接节点的输入
|
||
const getInputFromConnections = () => {
|
||
// 1. First check @ mentions | 首先检查 @ 引用
|
||
// LLMConfigNode 可以被 TextNode @ 引用
|
||
const textNodes = nodes.value.filter(n => n.type === 'text')
|
||
const mentionsInputs = []
|
||
|
||
for (const textNode of textNodes) {
|
||
const content = textNode.data?.content || ''
|
||
const mentions = parseMentions(content)
|
||
|
||
// Find mentions that reference this LLMConfigNode | 查找引用本节点的 @ 提及
|
||
const refsToThis = mentions.filter(m => m.nodeId === props.id)
|
||
if (refsToThis.length > 0) {
|
||
mentionsInputs.push({
|
||
nodeId: textNode.id,
|
||
content: content,
|
||
order: refsToThis[0].order
|
||
})
|
||
}
|
||
}
|
||
|
||
// If there are @ mentions, use them | 如果有 @ 提及,使用它们
|
||
if (mentionsInputs.length > 0) {
|
||
mentionsInputs.sort((a, b) => a.order - b.order)
|
||
return mentionsInputs.map(n => n.content).join('\n\n')
|
||
}
|
||
|
||
// 2. Fallback to edge connections | 降级到边的连接
|
||
const incomingEdges = edges.value.filter(e => e.target === props.id)
|
||
const inputs = []
|
||
|
||
for (const edge of incomingEdges) {
|
||
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||
if (sourceNode) {
|
||
if (sourceNode.type === 'text' && sourceNode.data?.content) {
|
||
inputs.push(sourceNode.data.content)
|
||
} else if (sourceNode.type === 'llmConfig' && sourceNode.data?.outputContent) {
|
||
inputs.push(sourceNode.data.outputContent)
|
||
}
|
||
}
|
||
}
|
||
|
||
return inputs.join('\n\n')
|
||
}
|
||
|
||
// Handle generate | 处理生成
|
||
const handleGenerate = async () => {
|
||
if (!isApiConfigured.value) {
|
||
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||
return
|
||
}
|
||
|
||
const input = getInputFromConnections()
|
||
if (!input && !systemPrompt.value) {
|
||
window.$message?.warning('请连接输入节点或设置系统提示词')
|
||
return
|
||
}
|
||
|
||
// 将 systemPrompt 中的 @[nodeId] 替换为节点内容
|
||
const resolveSystemPrompt = () => {
|
||
const mentions = parseMentions(systemPrompt.value)
|
||
let resolved = systemPrompt.value
|
||
|
||
for (const mention of mentions) {
|
||
const node = nodes.value.find(n => n.id === mention.nodeId)
|
||
if (node?.type === 'text' && node.data?.content) {
|
||
// 替换 @[nodeId] 为节点内容
|
||
const placeholder = `@[${mention.nodeId}]`
|
||
resolved = resolved.replace(placeholder, node.data.content)
|
||
}
|
||
}
|
||
|
||
return resolved
|
||
}
|
||
|
||
// 获取已经被 systemPrompt 引用的节点内容(按顺序)
|
||
const getReferencedContent = () => {
|
||
const mentions = parseMentions(systemPrompt.value)
|
||
const contents = []
|
||
|
||
for (const mention of mentions) {
|
||
const node = nodes.value.find(n => n.id === mention.nodeId)
|
||
if (node?.type === 'text' && node.data?.content) {
|
||
contents.push(node.data.content)
|
||
}
|
||
}
|
||
|
||
return contents
|
||
}
|
||
|
||
// 获取 user 消息(排除已经在 systemPrompt 中引用的内容)
|
||
const getUserMessage = (input, referencedContents) => {
|
||
if (!input) return ''
|
||
|
||
// 如果没有引用任何内容,使用原始输入
|
||
if (referencedContents.length === 0) {
|
||
return input
|
||
}
|
||
|
||
// 尝试从 input 中移除已引用的内容
|
||
let remainingInput = input
|
||
for (const refContent of referencedContents) {
|
||
// 移除已引用的内容(精确匹配)
|
||
remainingInput = remainingInput.replace(refContent, '')
|
||
}
|
||
|
||
// 清理多余的空白
|
||
remainingInput = remainingInput.trim()
|
||
|
||
return remainingInput
|
||
}
|
||
|
||
isGenerating.value = true
|
||
|
||
try {
|
||
// 解析 systemPrompt 并获取引用的内容
|
||
const resolvedSystemPrompt = resolveSystemPrompt()
|
||
const referencedContent = getReferencedContent()
|
||
const userMessage = getUserMessage(input, referencedContent)
|
||
|
||
const { send } = useChat({
|
||
systemPrompt: resolvedSystemPrompt,
|
||
model: model.value,
|
||
mode: 'chat',
|
||
targetLanguage: 'keep'
|
||
})
|
||
|
||
// 如果 user 消息为空,使用简单提示
|
||
const result = await send(userMessage || '请根据以上信息生成内容', true)
|
||
|
||
if (result) {
|
||
outputContent.value = result
|
||
updateNode(props.id, { outputContent: result, executed: true })
|
||
window.$message?.success('生成完成')
|
||
}
|
||
} catch (err) {
|
||
updateNode(props.id, { error: err.message || '生成失败' })
|
||
window.$message?.error(err.message || '生成失败')
|
||
} finally {
|
||
isGenerating.value = false
|
||
}
|
||
}
|
||
|
||
// Watch for auto-execute flag | 监听自动执行标志
|
||
watch(
|
||
() => props.data?.autoExecute,
|
||
(shouldExecute) => {
|
||
if (shouldExecute && !isGenerating.value) {
|
||
updateNode(props.id, { autoExecute: false })
|
||
setTimeout(() => handleGenerate(), 100)
|
||
}
|
||
}
|
||
)
|
||
|
||
// Start editing label | 开始编辑 label
|
||
const startEditLabel = () => {
|
||
editingLabelValue.value = nodeLabel.value
|
||
isEditingLabel.value = true
|
||
nextTick(() => {
|
||
labelInputRef.value?.focus()
|
||
labelInputRef.value?.select()
|
||
})
|
||
}
|
||
|
||
// Finish editing label | 完成编辑 label
|
||
const finishEditLabel = () => {
|
||
const newLabel = editingLabelValue.value.trim()
|
||
if (newLabel && newLabel !== nodeLabel.value) {
|
||
updateNode(props.id, { label: newLabel })
|
||
}
|
||
isEditingLabel.value = false
|
||
}
|
||
|
||
// Cancel editing label | 取消编辑 label
|
||
const cancelEditLabel = () => {
|
||
isEditingLabel.value = false
|
||
}
|
||
|
||
// Handle delete | 处理删除
|
||
const handleDelete = () => {
|
||
removeNode(props.id)
|
||
}
|
||
|
||
// Handle duplicate | 处理复制
|
||
const handleDuplicate = () => {
|
||
const newNodeId = duplicateNode(props.id)
|
||
window.$message?.success('节点已复制')
|
||
if (newNodeId) {
|
||
setTimeout(() => {
|
||
updateNodeInternals(newNodeId)
|
||
}, 50)
|
||
}
|
||
}
|
||
|
||
// Handle copy output | 处理复制输出
|
||
const handleCopyOutput = async () => {
|
||
if (!outputContent.value) return
|
||
try {
|
||
await navigator.clipboard.writeText(outputContent.value)
|
||
window.$message?.success('已复制到剪贴板')
|
||
} catch (err) {
|
||
window.$message?.error('复制失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse text into paragraphs
|
||
* 解析文本为段落 - 连续的非空行合并为一个段落,空行作为分隔符
|
||
*/
|
||
const parseParagraphs = (text) => {
|
||
const lines = text.split('\n')
|
||
const paragraphs = []
|
||
let current = ''
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim()
|
||
if (trimmed === '') {
|
||
// Empty line - separator
|
||
if (current.trim()) {
|
||
paragraphs.push(current.trim())
|
||
current = ''
|
||
}
|
||
} else {
|
||
// Non-empty line - append to current paragraph
|
||
current += (current ? '\n' : '') + line
|
||
}
|
||
}
|
||
|
||
// Don't forget the last paragraph
|
||
if (current.trim()) {
|
||
paragraphs.push(current.trim())
|
||
}
|
||
|
||
return paragraphs
|
||
}
|
||
|
||
/**
|
||
* Split output by newline into text + image nodes
|
||
* 按换行拆分为文本节点 + 图片配置节点
|
||
*/
|
||
const handleSplitToTextWithImage = () => {
|
||
if (!outputContent.value) return
|
||
// Split by paragraphs (continuous non-empty lines as one segment)
|
||
const segments = parseParagraphs(outputContent.value)
|
||
|
||
if (segments.length === 0) {
|
||
window.$message?.warning('内容为空,无法拆分')
|
||
return
|
||
}
|
||
|
||
isSplitting.value = true
|
||
splitMessage.value = ''
|
||
|
||
try {
|
||
// Get current node position
|
||
const currentNode = nodes.value.find(n => n.id === props.id)
|
||
const baseX = (currentNode?.position?.x || 0) + 450
|
||
const baseY = (currentNode?.position?.y || 0)
|
||
const rowSpacing = 200
|
||
const colSpacing = 350
|
||
|
||
// Start batch operation manually
|
||
startBatchOperation()
|
||
|
||
// Prepare node specs for batch creation
|
||
const nodeSpecs = []
|
||
const edgeSpecs = []
|
||
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const content = segments[i]
|
||
const segY = baseY + i * rowSpacing
|
||
|
||
// Create text node
|
||
const textSpec = {
|
||
type: 'text',
|
||
position: { x: baseX, y: segY },
|
||
data: {
|
||
content: content,
|
||
label: `片段 ${i + 1}`
|
||
}
|
||
}
|
||
nodeSpecs.push(textSpec)
|
||
|
||
// Create imageConfig node
|
||
const imageConfigSpec = {
|
||
type: 'imageConfig',
|
||
position: { x: baseX + colSpacing, y: segY },
|
||
data: {
|
||
label: `图片 ${i + 1}`,
|
||
model: 'auto',
|
||
size: '1024x1536'
|
||
}
|
||
}
|
||
nodeSpecs.push(imageConfigSpec)
|
||
}
|
||
|
||
// Batch create nodes
|
||
const createdIds = addNodes(nodeSpecs, false)
|
||
|
||
// Create edges
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const textId = createdIds[i * 2]
|
||
const imageConfigId = createdIds[i * 2 + 1]
|
||
|
||
// Connect LLM → text
|
||
edgeSpecs.push({
|
||
source: props.id,
|
||
target: textId,
|
||
sourceHandle: 'right',
|
||
targetHandle: 'left'
|
||
})
|
||
|
||
// Connect text → imageConfig
|
||
edgeSpecs.push({
|
||
source: textId,
|
||
target: imageConfigId,
|
||
type: 'promptOrder',
|
||
data: { promptOrder: 1 },
|
||
sourceHandle: 'right',
|
||
targetHandle: 'left'
|
||
})
|
||
}
|
||
|
||
// Batch create edges
|
||
addEdges(edgeSpecs, false)
|
||
|
||
// End batch operation
|
||
endBatchOperation()
|
||
|
||
splitMessage.value = `已拆分 ${segments.length} 个图文节点`
|
||
window.$message?.success(`已拆分为 ${segments.length} 个图文节点`)
|
||
} catch (err) {
|
||
window.$message?.error(`拆分失败: ${err.message}`)
|
||
} finally {
|
||
isSplitting.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Split output by newlines into individual text nodes
|
||
* 按换行拆分为多个文本节点
|
||
*/
|
||
const handleSplitToTextOnly = () => {
|
||
if (!outputContent.value) return
|
||
|
||
// Split by paragraphs (continuous non-empty lines as one segment)
|
||
const segments = parseParagraphs(outputContent.value)
|
||
|
||
if (segments.length <= 1) {
|
||
window.$message?.warning('内容无法拆分(只有一段或内容为空)')
|
||
return
|
||
}
|
||
|
||
doSplitToTextNodes(segments)
|
||
}
|
||
|
||
const doSplitToTextNodes = (segments) => {
|
||
isSplitting.value = true
|
||
splitMessage.value = ''
|
||
|
||
try {
|
||
const currentNode = nodes.value.find(n => n.id === props.id)
|
||
const baseX = (currentNode?.position?.x || 0) + 450
|
||
const baseY = (currentNode?.position?.y || 0)
|
||
const rowSpacing = 180
|
||
|
||
// Start batch operation manually
|
||
startBatchOperation()
|
||
|
||
// Prepare node specs for batch creation
|
||
const nodeSpecs = segments.map((content, index) => ({
|
||
type: 'text',
|
||
position: { x: baseX, y: baseY + index * rowSpacing },
|
||
data: {
|
||
content: content,
|
||
label: `拆分片段 ${index + 1}`
|
||
}
|
||
}))
|
||
|
||
// Batch create nodes
|
||
const createdIds = addNodes(nodeSpecs, false)
|
||
|
||
// Prepare edge specs for batch creation
|
||
const edgeSpecs = createdIds.map(nodeId => ({
|
||
source: props.id,
|
||
target: nodeId,
|
||
sourceHandle: 'right',
|
||
targetHandle: 'left'
|
||
}))
|
||
|
||
// Batch create edges
|
||
addEdges(edgeSpecs, false)
|
||
|
||
// End batch operation
|
||
endBatchOperation()
|
||
|
||
splitMessage.value = `已拆分为 ${segments.length} 个文本节点`
|
||
window.$message?.success(`已拆分为 ${segments.length} 个文本节点`)
|
||
} catch (err) {
|
||
window.$message?.error(`拆分失败: ${err.message}`)
|
||
} finally {
|
||
isSplitting.value = false
|
||
}
|
||
}
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.llm-node-wrapper {
|
||
padding-right: 50px;
|
||
padding-top: 20px;
|
||
position: relative;
|
||
}
|
||
|
||
.llm-node {
|
||
cursor: default;
|
||
position: relative;
|
||
}
|
||
|
||
.llm-node textarea {
|
||
cursor: text;
|
||
}
|
||
|
||
.llm-node pre {
|
||
cursor: text;
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
}
|
||
|
||
/* Textarea wrapper - 参考 TextNode */
|
||
.textarea-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
/* Editor styles | 编辑器样式 */
|
||
.editor-content {
|
||
min-height: 60px;
|
||
max-height: 120px;
|
||
padding: 8px 10px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
outline: none;
|
||
overflow-y: auto;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
border: 1px solid var(--border-color);
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
.editor-content:focus {
|
||
border-color: var(--accent-color, #a855f7);
|
||
}
|
||
|
||
.editor-content:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: var(--text-secondary);
|
||
opacity: 0.5;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Mention chip - 参考 TextNode */
|
||
.mention-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 8px 2px 2px;
|
||
margin: 0 2px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
vertical-align: middle;
|
||
cursor: default;
|
||
user-select: none;
|
||
line-height: 1.4;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.mention-chip img {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.mention-chip-icon {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.mention-chip-label {
|
||
max-width: 80px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|