| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433 |
- <script setup>
- import { ref, computed, reactive, onMounted, markRaw, nextTick } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { VueFlow, useVueFlow } from '@vue-flow/core'
- import { Background } from '@vue-flow/background'
- import {
- NInput, NSelect, NButton, NIcon, useMessage
- } from 'naive-ui'
- import {
- ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
- SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
- PlayOutline, CloseOutline, ColorWandOutline, LinkOutline, TrashOutline, OpenOutline,
- TimeOutline
- } from '@vicons/ionicons5'
- import { useWorkflowStore } from '../../stores/workflow'
- import { useSkillStore } from '../../stores/skill'
- import { runWorkflow, autoAssociate } from '../../api/workflow'
- import { getAgentTemplate } from '../../api/agentTemplate'
- import { getModelList } from '../../api/model'
- import InputNode from '../../components/workflow/nodes/InputNode.vue'
- import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
- import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
- import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
- import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
- import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
- import SmartActionNode from '../../components/workflow/nodes/SmartActionNode.vue'
- import VirtualGroupNode from '../../components/workflow/nodes/VirtualGroupNode.vue'
- import CodeEditor from '../../components/skill/CodeEditor.vue'
- import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, IO_TYPES } from '../../utils/ioInference'
- import { useVersionHistory } from '../../composables/useVersionHistory'
- import { useWorkflowRunner } from '../../composables/useWorkflowRunner'
- import { useNodeFields } from '../../composables/useNodeFields'
- import '@vue-flow/core/dist/style.css'
- import '@vue-flow/core/dist/theme-default.css'
- const route = useRoute()
- const router = useRouter()
- const wfStore = useWorkflowStore()
- const skillStore = useSkillStore()
- const message = useMessage()
- const workflowId = computed(() => route.params.id ? Number(route.params.id) : null)
- const workflowName = ref('')
- const workflowDesc = ref('')
- const saving = ref(false)
- // ========== SmartAction 弹出编辑器 ==========
- const showPromptEditor = ref(false)
- const promptEditValue = ref('')
- // ========== Vue Flow ==========
- const {
- onConnect, addEdges, addNodes, removeNodes, removeEdges,
- updateNodeData: vfUpdateNode,
- project, vueFlowRef, getNodes, getEdges, fitView, onInit,
- onNodeDragStart, onNodeDrag, onNodeDragStop
- } = useVueFlow()
- const selectedNode = ref(null)
- // 从 userInput 节点提取输入字段定义
- const inputFields = computed(() => {
- const fields = []
- for (const node of getNodes.value) {
- if (node.type === 'userInput' && node.data?.variables) {
- for (const v of node.data.variables) {
- if (v.name) fields.push(v)
- }
- }
- }
- return fields
- })
- const formData = reactive({})
- const canRun = computed(() => !!workflowId.value && !running.value && getNodes.value.length > 0)
- // ========== 自动关联 ==========
- const associating = ref(false)
- const hasAssociations = ref(false)
- // ========== 版本历史 ==========
- const {
- showVersionPanel, versions, versionsLoading, selectedVersion, versionContent,
- editingMessage, editingMessageText,
- toggleVersionPanel, handleVersionSelect, handleRollback,
- startEditMessage, saveMessage, cancelEditMessage,
- formatTime, formatVersionJson
- } = useVersionHistory()
- // ========== 工作流运行状态 ==========
- const {
- running, showRunDialog, showResult, workflowResult,
- nodeStatuses, activeTab, nodeResults, expandedLogs, expandedContext, expandedThinking,
- lastRunId, lastRunWorkflowId,
- startRun, openRunDialog, cancelRun, closeResult,
- statusText, logTypeIcon, logTypeLabel, formatTimestamp, toggleLogs,
- toggleContext, toggleThinking, formatContextValue
- } = useWorkflowRunner({
- getNodes,
- nodeTypeInfo: {
- userInput: { color: '#10B981', typeLabel: '用户输入' },
- llm: { color: '#8B5CF6', typeLabel: '大模型' },
- skill: { color: '#06B6D4', typeLabel: '技能' },
- agent: { color: '#F59E0B', typeLabel: '智能体' },
- smartAction: { color: '#EC4899', typeLabel: '智能操作' },
- condition: { color: '#F97316', typeLabel: '条件' },
- output: { color: '#EF4444', typeLabel: '输出' }
- },
- workflowIdRef: workflowId
- })
- const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
- const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
- onConnect((params) => {
- const sourceNode = getNodes.value.find(n => n.id === params.source)
- const targetNode = getNodes.value.find(n => n.id === params.target)
- const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode, getNodes.value, getEdges.value) : null
- addEdges([{
- ...params,
- type: 'bezier',
- animated: true,
- style: inferred?.style || defaultEdgeStyle,
- data: inferred?.data || {}
- }])
- })
- // ========== 自动关联虚线组拖动联动 ==========
- // 虚线框(virtualGroup)拖动时,同步移动其内部所有技能子节点
- // 使用绝对画布坐标而非 parentId 机制(parentId 依赖 dimensions 异步测量,不可靠)
- const dragStartPositions = new Map()
- onNodeDragStart(({ node }) => {
- if (node?.type === 'virtualGroup') {
- dragStartPositions.set(node.id, { ...node.position })
- }
- })
- onNodeDrag(({ node }) => {
- if (node?.type !== 'virtualGroup') return
- const start = dragStartPositions.get(node.id)
- if (!start) return
- const dx = node.position.x - start.x
- const dy = node.position.y - start.y
- if (dx === 0 && dy === 0) return
- // 联动所有归属该虚框的技能节点(通过 data.virtualGroupId 标记)
- getNodes.value.forEach(n => {
- if (n.data?.virtualGroupId === node.id) {
- n.position = { x: n.position.x + dx, y: n.position.y + dy }
- }
- })
- dragStartPositions.set(node.id, { ...node.position })
- })
- onNodeDragStop(() => {
- dragStartPositions.clear()
- })
- // 批量更新边的映射数据和样式
- function applyEdgeMappingUpdates(updates) {
- for (const upd of updates) {
- const edge = getEdges.value.find(e => e.id === upd.id)
- if (edge) {
- edge.data = { ...edge.data, ...upd.data }
- edge.style = upd.style
- }
- }
- }
- // ========== 节点类型定义 ==========
- const nodeTypes = [
- { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
- { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
- { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
- { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
- { type: 'smartAction', label: '智能操作', icon: markRaw(ColorWandOutline), color: '#EC4899' },
- { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
- { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
- ]
- const nodeTypeInfo = {
- userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
- llm: { color: '#8B5CF6', typeLabel: '大模型', icon: markRaw(SparklesOutline) },
- skill: { color: '#06B6D4', typeLabel: '技能', icon: markRaw(FlaskOutline) },
- agent: { color: '#F59E0B', typeLabel: '智能体', icon: markRaw(RocketOutline) },
- smartAction: { color: '#EC4899', typeLabel: '智能操作', icon: markRaw(ColorWandOutline) },
- condition: { color: '#F97316', typeLabel: '条件', icon: markRaw(GitBranchOutline) },
- output: { color: '#EF4444', typeLabel: '输出', icon: markRaw(ExitOutline) }
- }
- function getDefaultData(type) {
- switch (type) {
- case 'userInput': return { label: '用户输入', variables: [] }
- case 'llm': return { label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
- case 'agent': return { label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
- case 'skill': return { label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
- case 'smartAction': return { label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
- case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
- case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
- default: return { label: '节点' }
- }
- }
- let nodeIdCounter = 0
- function genId() { return `node-${++nodeIdCounter}-${Date.now()}` }
- // ========== 拖拽到画布 ==========
- function onDragOver(e) {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
- function onDrop(e) {
- const type = e.dataTransfer.getData('application/vueflow')
- if (!type) return
- const bounds = vueFlowRef.value.getBoundingClientRect()
- const position = project({
- x: e.clientX - bounds.left,
- y: e.clientY - bounds.top
- })
- const id = genId()
- addNodes([{
- id,
- type,
- position,
- data: getDefaultData(type)
- }])
- }
- function onDragStart(e, type) {
- e.dataTransfer.setData('application/vueflow', type)
- e.dataTransfer.effectAllowed = 'move'
- }
- // ========== 节点选中 ==========
- function onNodeClick({ node }) {
- selectedNode.value = node
- }
- function onPaneClick() {
- selectedNode.value = null
- selectedEdgeId.value = null
- contextMenu.value = null
- }
- // ========== 节点右键菜单 ==========
- const contextMenu = ref(null) // { x, y, nodeId }
- function onNodeContextMenu({ event, node }) {
- event.preventDefault()
- contextMenu.value = { x: event.clientX, y: event.clientY, nodeId: node.id }
- }
- function closeContextMenu() {
- contextMenu.value = null
- }
- function deleteNodeById(nodeId) {
- const affectedNodeIds = new Set()
- for (const edge of getEdges.value) {
- if (edge.source === nodeId || edge.target === nodeId) {
- affectedNodeIds.add(edge.source)
- affectedNodeIds.add(edge.target)
- }
- }
- removeNodes(nodeId)
- if (selectedNode.value?.id === nodeId) {
- selectedNode.value = null
- }
- closeContextMenu()
- for (const id of affectedNodeIds) {
- if (id !== nodeId) {
- applyEdgeMappingUpdates(refreshMappingsForNode(id, getNodes.value, getEdges.value))
- }
- }
- }
- // ========== 连接线选中与删除 ==========
- const selectedEdgeId = ref(null)
- function onEdgeClick({ edge }) {
- selectedEdgeId.value = edge.id
- selectedNode.value = null
- }
- function deleteSelectedEdge() {
- if (!selectedEdgeId.value) return
- const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
- const sourceId = edge?.source
- const targetId = edge?.target
- removeEdges(selectedEdgeId.value)
- selectedEdgeId.value = null
- if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
- if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
- }
- function onKeyDown(e) {
- if (e.key === 'Delete' || e.key === 'Backspace') {
- const tag = e.target?.tagName
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
- if (selectedNode.value) {
- deleteNodeById(selectedNode.value.id)
- } else {
- deleteSelectedEdge()
- }
- }
- }
- const selectedData = computed(() => selectedNode.value?.data || null)
- const selectedEdge = computed(() => {
- if (!selectedEdgeId.value) return null
- return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
- })
- // ========== 智能体选项 ==========
- const agentOptions = computed(() =>
- (skillStore.skills || []).map(s => ({
- label: s.translatedName || s.name || s.folderName,
- value: s.folderName
- }))
- )
- // ========== 数据库模型选项 ==========
- const dbModels = ref([])
- const defaultModelId = ref(null)
- const modelOptions = computed(() =>
- dbModels.value.map(m => ({
- label: `${m.name} (${m.modelName})`,
- value: m.id
- }))
- )
- async function fetchDbModels() {
- try {
- const res = await getModelList()
- dbModels.value = res.data || []
- const defaultModel = dbModels.value.find(m => m.isDefault)
- defaultModelId.value = defaultModel?.id || null
- } catch (e) {
- message.error('加载模型列表失败')
- }
- }
- // 失败策略选项
- const failStrategyOptions = [
- { label: '终止工作流', value: 'abort' },
- { label: '跳过继续', value: 'skip' }
- ]
- // ========== 节点字段操作(来自 composable) ==========
- const {
- onFieldChange, onModelChange,
- addListField, removeListField,
- addVariable, removeVariable,
- addOutputField, removeOutputField,
- addElif, removeElif, onConditionChange,
- syncLlmFieldsFromTemplate
- } = useNodeFields({
- selectedNode,
- selectedData,
- vfUpdateNode,
- getNodes: () => getNodes,
- getEdges: () => getEdges,
- applyEdgeMappingUpdates,
- skillStore,
- agentOptions
- })
- // 兼容原有命名(template 中使用)
- const addLlmField = addListField
- const removeLlmField = removeListField
- const addAgentField = addListField
- const removeAgentField = removeListField
- const addConditionInput = () => addListField('inputs')
- const removeConditionInput = removeListField.bind(null, 'inputs')
- // ========== 边状态文案 ==========
- function edgeStatusText(status) {
- return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
- }
- // ========== SmartAction 弹出编辑器 ==========
- function openPromptEditor() {
- promptEditValue.value = selectedData.value?.actionPrompt || ''
- showPromptEditor.value = true
- }
- function confirmPromptEdit() {
- onFieldChange('actionPrompt', promptEditValue.value)
- showPromptEditor.value = false
- }
- // ========== 自动关联 ==========
- async function handleAutoAssociate() {
- if (associating.value) return
- clearAssociations()
- associating.value = true
- try {
- const graphData = JSON.stringify({
- nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
- edges: getEdges.value.filter(e => !e.data?.virtual).map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
- })
- const res = await autoAssociate(graphData)
- const associations = res.data || []
- if (associations.length === 0) {
- message.info('未发现隐式关联的技能')
- return
- }
- const groups = {}
- for (const a of associations) {
- if (!groups[a.sourceNodeId]) groups[a.sourceNodeId] = []
- const list = groups[a.sourceNodeId]
- if (!list.some(x => x.folderName === a.skillFolderName)) {
- list.push({ folderName: a.skillFolderName, skillName: a.skillName || a.skillFolderName })
- }
- }
- const skillNodeMap = {}
- const GROUP_WIDTH = 240
- const GROUP_PADDING_X = 12 // 左右内边距
- const GROUP_PADDING_TOP = 30 // 顶部预留 label 高度
- const GROUP_PADDING_BOTTOM = 14 // 底部内边距
- const SKILL_HEIGHT = 96 // 单个技能节点占位高度(含余量)
- const SKILL_GAP = 12
- const GAP_TO_ORIGIN = 120
- const ts = Date.now()
- const groupNodes = [] // 虚框父节点
- const skillNodes = [] // 技能子节点
- const edgesToAdd = []
- for (const [sourceNodeId, items] of Object.entries(groups)) {
- const sourceNode = getNodes.value.find(n => n.id === sourceNodeId)
- if (!sourceNode) continue
- // 源节点实际宽度(Vue Flow 渲染后测量),fallback 估计值 200
- const sourceWidth = sourceNode.dimensions?.width || sourceNode.width || 200
- const groupHeight = GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM
- + items.length * SKILL_HEIGHT
- + (items.length - 1) * SKILL_GAP
- // 水平居中:源节点中心 X - 虚框宽度 / 2
- const groupX = sourceNode.position.x + sourceWidth / 2 - GROUP_WIDTH / 2
- const groupY = sourceNode.position.y + GAP_TO_ORIGIN
- const groupId = 'vg-' + sourceNodeId + '-' + ts
- groupNodes.push({
- id: groupId,
- type: 'virtualGroup',
- position: { x: groupX, y: groupY },
- data: {
- virtual: true,
- width: GROUP_WIDTH,
- height: groupHeight,
- label: items.length + ' 个关联技能'
- },
- style: { width: GROUP_WIDTH + 'px', height: groupHeight + 'px' },
- class: 'virtual-group-node',
- draggable: true, // 允许整体拖动
- selectable: false
- })
- items.forEach((item, idx) => {
- if (skillNodeMap[item.folderName]) return
- const existing = getNodes.value.find(n => n.type === 'skill' && n.data?.skillId === item.folderName)
- if (existing) {
- skillNodeMap[item.folderName] = existing.id
- return
- }
- const skillId = 'virtual-' + item.folderName + '-' + ts
- // 绝对画布坐标:父节点位置 + 相对偏移。
- // 不使用 parentId/extent(依赖 dimensions 异步测量,未完成时子节点会跑到画布左上角)。
- // 改为通过 data.virtualGroupId 标记归属,onNodeDrag 时手动联动子节点位置。
- const skillAbsX = groupX + GROUP_PADDING_X
- const skillAbsY = groupY + GROUP_PADDING_TOP + idx * (SKILL_HEIGHT + SKILL_GAP)
- skillNodes.push({
- id: skillId,
- type: 'skill',
- position: { x: skillAbsX, y: skillAbsY },
- data: {
- label: item.skillName,
- skillId: item.folderName,
- skillName: item.skillName,
- virtual: true,
- virtualGroupId: groupId // 标记归属,拖动联动时按此查找
- },
- class: 'virtual-node',
- draggable: false, // 子节点不单独拖动,事件穿透到父节点实现整体拖动
- selectable: false,
- connectable: false
- })
- skillNodeMap[item.folderName] = skillId
- })
- edgesToAdd.push({
- id: 've-' + groupId,
- source: groupId,
- sourceHandle: 'top',
- target: sourceNodeId,
- targetHandle: 'bottom-target',
- type: 'straight',
- animated: false,
- style: { stroke: '#818cf8', strokeWidth: 1.5, strokeDasharray: '6 4' },
- class: 'virtual-group-edge',
- data: { virtual: true }
- })
- }
- // 一次性添加所有节点(父+子)与边。子节点使用绝对坐标,无需等待 dimensions 测量。
- const allNodes = [...groupNodes, ...skillNodes]
- if (allNodes.length) addNodes(allNodes)
- if (edgesToAdd.length) addEdges(edgesToAdd)
- hasAssociations.value = true
- const totalSkills = Object.keys(skillNodeMap).length
- message.success(`发现 ${totalSkills} 个隐式关联技能(${Object.keys(groups).length} 组)`)
- } catch (e) {
- message.error(e.response?.data?.message || '关联分析失败')
- } finally {
- associating.value = false
- }
- }
- function clearAssociations() {
- const virtualNodes = getNodes.value.filter(n => n.data?.virtual)
- const virtualEdges = getEdges.value.filter(e => e.data?.virtual)
- if (virtualEdges.length) removeEdges(virtualEdges.map(e => e.id))
- if (virtualNodes.length) removeNodes(virtualNodes.map(n => n.id))
- hasAssociations.value = false
- }
- // ========== 保存 ==========
- async function handleSave() {
- if (!workflowName.value.trim()) {
- message.warning('请输入工作流名称')
- return
- }
- saving.value = true
- try {
- const graphData = JSON.stringify({
- nodes: getNodes.value.filter(n => !n.data?.virtual).map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
- edges: getEdges.value.filter(e => !e.data?.virtual).map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
- })
- if (workflowId.value) {
- await wfStore.save(workflowId.value, {
- name: workflowName.value,
- description: workflowDesc.value,
- graphData
- })
- message.success('工作流已保存')
- } else {
- const created = await wfStore.create(workflowName.value, workflowDesc.value)
- await wfStore.save(created.id, {
- name: workflowName.value,
- description: workflowDesc.value,
- graphData
- })
- message.success('工作流已创建')
- router.replace(`/orchestration/edit/${created.id}`)
- }
- } catch (e) {
- message.error(e.response?.data?.message || '保存失败')
- } finally {
- saving.value = false
- }
- }
- function goBack() {
- router.push('/orchestration')
- }
- // ========== 文件选择(运行时上传) ==========
- function onFileSelect(e, isDir = false) {
- const files = Array.from(e.target.files || [])
- files.forEach(f => {
- const relativePath = isDir ? f.webkitRelativePath : f.name
- uploadFiles.value.push({ file: f, relativePath })
- })
- e.target.value = ''
- }
- // 文件上传列表(运行对话框使用)
- const uploadFiles = ref([])
- // 启动运行(包装 composable,处理上传文件 + 输入字段)
- async function handleStartRun() {
- const inputs = {}
- inputFields.value.forEach(f => {
- if (formData[f.name]) inputs[f.name] = formData[f.name]
- })
- await startRun(workflowId.value, inputs, uploadFiles.value)
- uploadFiles.value = []
- }
- function handleOpenRunDialog() {
- openRunDialog(inputFields, formData)
- uploadFiles.value = []
- }
- // 回滚应用到画布(供 useVersionHistory 调用)
- async function applyVersionToCanvas(wf) {
- if (!wf) return
- workflowName.value = wf.name || ''
- workflowDesc.value = wf.description || ''
- if (wf.graphData) {
- const data = JSON.parse(wf.graphData)
- addNodes(data.nodes || [])
- addEdges((data.edges || []).map(e => ({
- ...e,
- type: 'bezier',
- animated: true,
- style: getEdgeStyle(e.data?.status),
- })))
- for (const node of getNodes.value) {
- applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
- }
- }
- }
- // 包装版本历史的方法以适配本组件 API
- async function handleRollbackWrap(v) {
- await handleRollback(workflowId.value, v, applyVersionToCanvas)
- }
- async function handleVersionSelectWrap(v) {
- await handleVersionSelect(workflowId.value, v)
- }
- async function toggleVersionPanelWrap() {
- await toggleVersionPanel(workflowId.value)
- }
- async function saveMessageWrap(v) {
- await saveMessage(workflowId.value, v)
- }
- // ========== 初始化 ==========
- onMounted(async () => {
- skillStore.fetchSkills()
- fetchDbModels()
- if (workflowId.value) {
- // 编辑已有工作流
- try {
- const wf = await wfStore.fetchOne(workflowId.value)
- workflowName.value = wf.name
- workflowDesc.value = wf.description || ''
- if (wf.graphData) {
- applyGraphData(wf.graphData)
- }
- } catch {
- message.error('工作流不存在')
- goBack()
- }
- } else if (route.query.templateId) {
- // 从模板创建:加载模板 graphData 作为初始内容
- try {
- const tplId = Number(route.query.templateId)
- const { data: tpl } = await getAgentTemplate(tplId)
- if (tpl) {
- // 用模板名作为工作流名前缀,便于识别
- if (tpl.name) workflowName.value = `${tpl.name}-工作流`
- if (tpl.description) workflowDesc.value = tpl.description
- if (tpl.graphData) {
- applyGraphData(tpl.graphData)
- message.info(`已加载模板「${tpl.name}」`)
- }
- }
- } catch (e) {
- /* 全局拦截器已处理 */
- message.warning('模板加载失败,将创建空白工作流')
- }
- }
- await nextTick()
- fitView({ padding: 0.5 })
- })
- // 将 graphData(JSON 字符串)应用到画布
- function applyGraphData(graphData) {
- try {
- const parsed = typeof graphData === 'string' ? JSON.parse(graphData) : graphData
- addNodes((parsed.nodes || []).map(n => {
- const defaults = getDefaultData(n.type)
- return { ...n, data: { ...defaults, ...n.data, label: defaults.label } }
- }))
- const edgeData = (parsed.edges || []).map(e => ({
- ...e,
- type: 'bezier',
- animated: true,
- style: defaultEdgeStyle,
- data: e.data || {}
- }))
- if (edgeData.length) addEdges(edgeData)
- for (const node of getNodes.value) {
- applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
- }
- } catch { /* ignore */ }
- }
- </script>
- <template>
- <div class="workflow-editor" @keydown="onKeyDown" tabindex="0">
- <!-- 顶部工具栏 -->
- <div class="editor-toolbar">
- <div class="toolbar-left">
- <button class="icon-btn" title="返回列表" @click="goBack">
- <n-icon size="18"><ArrowBackOutline /></n-icon>
- </button>
- <n-input
- v-model:value="workflowName"
- placeholder="工作流名称"
- style="width: 220px"
- size="small"
- />
- </div>
- <div class="toolbar-right">
- <button
- :class="['version-btn', { active: showVersionPanel }]"
- @click="toggleVersionPanelWrap"
- :disabled="!workflowId"
- >
- <n-icon size="16"><TimeOutline /></n-icon>
- <span>版本历史</span>
- </button>
- <button
- class="associate-btn"
- :class="{ active: hasAssociations }"
- @click="handleAutoAssociate"
- :disabled="associating"
- >
- <n-icon size="16"><LinkOutline /></n-icon>
- <span>{{ associating ? '分析中...' : hasAssociations ? '重新关联' : '自动关联' }}</span>
- </button>
- <button v-if="hasAssociations" class="clear-assoc-btn" @click="clearAssociations" title="清除关联">
- <n-icon size="14"><TrashOutline /></n-icon>
- </button>
- <button
- class="run-btn"
- :class="{ 'is-running': running }"
- @click="handleOpenRunDialog"
- :disabled="!canRun"
- >
- <n-icon size="16"><PlayOutline /></n-icon>
- <span>{{ running ? '执行中...' : '运行' }}</span>
- </button>
- <button class="primary-btn" @click="handleSave" :disabled="saving">
- <n-icon size="16"><SaveOutline /></n-icon>
- <span>{{ saving ? '保存中...' : '完成' }}</span>
- </button>
- </div>
- </div>
- <div class="editor-body">
- <!-- 左侧节点工具栏 -->
- <div class="sidebar-nodes">
- <div class="sidebar-title">节点</div>
- <div
- v-for="nt in nodeTypes"
- :key="nt.type"
- class="node-type-item"
- draggable="true"
- @dragstart="onDragStart($event, nt.type)"
- >
- <div class="nt-icon" :style="{ background: nt.color }">
- <n-icon size="14"><component :is="nt.icon" /></n-icon>
- </div>
- <span class="nt-label">{{ nt.label }}</span>
- </div>
- </div>
- <!-- 中间画布 -->
- <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
- <VueFlow
- :default-viewport="{ zoom: 1, x: 0, y: 0 }"
- :min-zoom="0.1"
- :max-zoom="2"
- :elements-selectable="true"
- @node-click="onNodeClick"
- @node-contextmenu="onNodeContextMenu"
- @pane-click="onPaneClick"
- @edge-click="onEdgeClick"
- :snap-to-grid="true"
- :snap-grid="[20, 20]"
- >
- <template #node-userInput="nodeProps"><InputNode :data="nodeProps.data" /></template>
- <template #node-llm="nodeProps"><LLMNode :data="nodeProps.data" /></template>
- <template #node-agent="nodeProps"><AgentNode :data="nodeProps.data" /></template>
- <template #node-skill="nodeProps"><SkillNode :data="nodeProps.data" /></template>
- <template #node-output="nodeProps"><OutputNode :data="nodeProps.data" /></template>
- <template #node-condition="nodeProps"><ConditionNode :data="nodeProps.data" /></template>
- <template #node-smartAction="nodeProps"><SmartActionNode :data="nodeProps.data" /></template>
- <template #node-virtualGroup="nodeProps"><VirtualGroupNode :data="nodeProps.data" :style="{ width: nodeProps.data.width + 'px', height: nodeProps.data.height + 'px' }" /></template>
- <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
- </VueFlow>
- </div>
- <!-- 右侧面板 - Tab 页 -->
- <div class="sidebar-right">
- <div class="sidebar-tabs">
- <button class="tab-btn" :class="{ active: activeTab === 'props' }" @click="activeTab = 'props'">节点属性</button>
- <button class="tab-btn" :class="{ active: activeTab === 'result' }" @click="activeTab = 'result'">
- 运行结果
- <span v-if="nodeResults.length" class="result-badge">{{ nodeResults.length }}</span>
- </button>
- </div>
- <div class="sidebar-tab-content">
- <!-- 节点属性 Tab -->
- <div v-show="activeTab === 'props'" class="tab-pane">
- <template v-if="selectedData">
- <!-- 用户输入节点 -->
- <template v-if="selectedNode?.type === 'userInput'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输入变量</span>
- <button class="text-btn" @click="addVariable">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- </div>
- <div class="var-form-row">
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- <n-input :value="v.description" placeholder="描述" size="small" @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- </div>
- <button class="text-btn danger" @click="removeVariable(i)">删除</button>
- </div>
- <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
- </div>
- </template>
- <!-- 大模型处理节点 -->
- <template v-if="selectedNode?.type === 'llm'">
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select
- :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions"
- placeholder="选择模型(默认使用默认模型)"
- size="small"
- clearable
- @update:value="v => onModelChange(v, dbModels)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">系统提示词</label>
- <n-input
- :value="selectedData?.systemPrompt"
- type="textarea" :rows="3"
- placeholder="输入系统提示词"
- size="small"
- @update:value="v => onFieldChange('systemPrompt', v)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">用户提示词</label>
- <n-input
- :value="selectedData?.userPrompt"
- type="textarea" :rows="3"
- placeholder="输入用户提示词,可用 {{变量名}} 引用变量"
- size="small"
- @update:value="v => onFieldChange('userPrompt', v)"
- />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <div style="display:flex;gap:4px">
- <button class="text-btn" @click="syncLlmFieldsFromTemplate('inputs')">从模板同步</button>
- <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
- </div>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select
- :value="selectedData?.failStrategy || 'abort'"
- :options="failStrategyOptions"
- size="small"
- @update:value="v => onFieldChange('failStrategy', v)"
- />
- </div>
- </template>
- <!-- 智能体节点 -->
- <template v-if="selectedNode?.type === 'agent'">
- <div class="prop-section">
- <label class="prop-label">选择智能体</label>
- <n-select
- :value="selectedData?.agentId"
- :options="agentOptions"
- placeholder="选择技能"
- size="small"
- filterable
- @update:value="v => onFieldChange('agentId', v)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select
- :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions"
- placeholder="默认使用默认模型"
- size="small"
- clearable
- @update:value="v => onModelChange(v, dbModels)"
- />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select
- :value="selectedData?.failStrategy || 'abort'"
- :options="failStrategyOptions"
- size="small"
- @update:value="v => onFieldChange('failStrategy', v)"
- />
- </div>
- </template>
- <!-- 技能节点 -->
- <template v-if="selectedNode?.type === 'skill'">
- <div class="prop-section">
- <label class="prop-label">选择技能</label>
- <n-select
- :value="selectedData?.skillId"
- :options="agentOptions"
- placeholder="选择技能"
- size="small"
- filterable
- @update:value="v => onFieldChange('skillId', v)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select
- :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions"
- placeholder="默认使用默认模型"
- size="small"
- clearable
- @update:value="v => onModelChange(v, dbModels)"
- />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select
- :value="selectedData?.failStrategy || 'abort'"
- :options="failStrategyOptions"
- size="small"
- @update:value="v => onFieldChange('failStrategy', v)"
- />
- </div>
- </template>
- <!-- 智能操作节点 -->
- <template v-if="selectedNode?.type === 'smartAction'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">操作要求</span>
- <button class="text-btn" @click="openPromptEditor" title="在编辑器中打开">
- <n-icon size="12"><OpenOutline /></n-icon> 展开
- </button>
- </div>
- <n-input
- :value="selectedData?.actionPrompt"
- type="textarea" :rows="5"
- placeholder="描述你希望 AI 执行的操作..."
- size="small"
- @update:value="v => onFieldChange('actionPrompt', v)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select
- :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions"
- placeholder="默认使用默认模型"
- size="small"
- clearable
- @update:value="v => onModelChange(v, dbModels)"
- />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select
- :value="selectedData?.failStrategy || 'abort'"
- :options="failStrategyOptions"
- size="small"
- @update:value="v => onFieldChange('failStrategy', v)"
- />
- </div>
- </template>
- <!-- 条件分支节点 -->
- <template v-if="selectedNode?.type === 'condition'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">条件分支</span>
- <button class="text-btn" @click="addElif">+ ELIF</button>
- </div>
- <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
- <div class="cond-form-row">
- <span class="cond-tag">{{ c.type }}</span>
- <n-input
- :value="c.expression"
- placeholder="自然语言条件,可用 {{变量名}}"
- size="small"
- @update:value="v => onConditionChange(i, 'expression', v)"
- />
- <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
- </div>
- </div>
- </div>
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addConditionInput">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
- <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
- </div>
- </template>
- <!-- 输出节点 -->
- <template v-if="selectedNode?.type === 'output'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">接收字段</span>
- <button class="text-btn" @click="addOutputField">+ 添加</button>
- </div>
- <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="f.name" placeholder="字段名" size="small" @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="f.label" placeholder="中文名" size="small" @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
- </div>
- </template>
- </template>
- <!-- 边映射信息 -->
- <template v-else-if="selectedEdge?.data">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">连接映射</span>
- <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
- {{ edgeStatusText(selectedEdge.data.status) }}
- </span>
- </div>
- <!-- 已映射字段 -->
- <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
- <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField" class="mapping-item mapped">
- <span class="mapping-field">{{ m.sourceField }}</span>
- <span class="mapping-arrow">→</span>
- <span class="mapping-field">{{ m.targetField }}</span>
- </div>
- </div>
- <!-- 类型不匹配 -->
- <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
- <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name" class="mapping-item mismatch">
- <span class="mapping-field">{{ tm.source.name }}</span>
- <span class="mapping-type">({{ tm.source.type }})</span>
- <span class="mapping-arrow">→</span>
- <span class="mapping-field">{{ tm.target.name }}</span>
- <span class="mapping-type">({{ tm.target.type }})</span>
- </div>
- </div>
- <!-- 未匹配的目标前置数据 -->
- <div v-if="selectedEdge.data.unmatchedTarget?.length">
- <div class="prop-label" style="margin-top:8px">未匹配前置数据</div>
- <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
- <span class="mapping-field">{{ f.name }}</span>
- <span class="mapping-type">({{ f.type }})</span>
- </div>
- </div>
- <!-- 多余的源输出 -->
- <div v-if="selectedEdge.data.unmatchedSource?.length">
- <div class="prop-label" style="margin-top:8px">多余输出</div>
- <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
- <span class="mapping-field">{{ f.name }}</span>
- <span class="mapping-type">({{ f.type }})</span>
- </div>
- </div>
- <div v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)" class="empty-hint">
- 无需映射
- </div>
- </div>
- </template>
- <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
- </div>
- <!-- 运行结果 Tab -->
- <div v-show="activeTab === 'result'" class="tab-pane">
- <div v-if="nodeResults.length === 0" class="empty-hint">暂无运行结果</div>
- <div
- v-for="r in nodeResults"
- :key="r.nodeId"
- class="node-result-card"
- :style="{ borderLeftColor: r.color }"
- >
- <div class="nrc-header" @click="toggleLogs(r.nodeId)" style="cursor: pointer">
- <div class="nrc-icon" :style="{ background: r.color }">
- <n-icon size="12"><component :is="nodeTypeInfo[r.type]?.icon" /></n-icon>
- </div>
- <div class="nrc-info">
- <span class="nrc-label">{{ r.label }}</span>
- <span class="nrc-type">{{ r.typeLabel }}</span>
- </div>
- <span v-if="r.logs && r.logs.length" class="nrc-log-count">{{ r.logs.length }} 条日志</span>
- <span class="nrc-status" :class="'status-' + r.status">{{ statusText(r.status) }}</span>
- <span v-if="r.logs && r.logs.length" class="nrc-expand" :class="{ expanded: expandedLogs[r.nodeId] }">▶</span>
- </div>
- <!-- 流式思考过程(实时累加,节点完成后保留为历史)
- 折叠时仅隐藏 body,streamingThinking 仍持续累加,展开后可继续输出 -->
- <div v-if="r.streamingThinking" class="nrc-thinking" :class="{ live: r.status === 'running' }">
- <div class="nrc-thinking-header" @click="toggleThinking(r.nodeId)" style="cursor: pointer">
- <span class="nrc-thinking-tag">💭 思考过程</span>
- <span v-if="r.status === 'running'" class="nrc-thinking-live">实时输出中…</span>
- <span v-else class="nrc-thinking-static">{{ r.streamingThinking.length }} 字</span>
- <span class="nrc-thinking-expand" :class="{ expanded: expandedThinking[r.nodeId] !== false }">▶</span>
- </div>
- <pre v-show="expandedThinking[r.nodeId] !== false" class="nrc-thinking-body">{{ r.streamingThinking }}</pre>
- </div>
- <div class="nrc-vars" v-if="r.output">
- <div class="nrc-var" v-for="(value, key) in r.output" :key="key">
- <span class="nrc-var-key" :style="{ color: r.color }">{{ key }}</span>
- <span class="nrc-var-value">{{ typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) }}</span>
- </div>
- </div>
- <!-- 完整上下文快照(节点执行后的全部 variables,debug 视图) -->
- <div v-if="r.contextSnapshot" class="nrc-ctx-toggle" @click="toggleContext(r.nodeId)">
- <span class="nrc-ctx-icon" :class="{ expanded: expandedContext[r.nodeId] }">▶</span>
- <span class="nrc-ctx-label">查看完整上下文({{ Object.keys(r.contextSnapshot).length }} 个变量)</span>
- </div>
- <div v-if="expandedContext[r.nodeId] && r.contextSnapshot" class="nrc-ctx-panel">
- <div class="nrc-ctx-var" v-for="(value, key) in r.contextSnapshot" :key="key">
- <span class="nrc-ctx-key">{{ key }}</span>
- <span class="nrc-ctx-value">{{ formatContextValue(value) }}</span>
- </div>
- </div>
- <!-- 执行日志 -->
- <div v-if="expandedLogs[r.nodeId] && r.logs && r.logs.length" class="nrc-logs">
- <div class="nrc-log" v-for="(log, idx) in r.logs" :key="idx" :class="'log-type-' + (log.type || 'info').toLowerCase()">
- <span class="nrc-log-icon">{{ logTypeIcon(log.type) }}</span>
- <span class="nrc-log-time">{{ formatTimestamp(log.timestamp) }}</span>
- <span class="nrc-log-tag">{{ logTypeLabel(log.type) }}</span>
- <span class="nrc-log-msg">{{ log.message }}</span>
- <div v-if="log.detail" class="nrc-log-detail">{{ log.detail }}</div>
- </div>
- </div>
- </div>
- <div v-if="showResult && workflowResult" class="nrc-clear-row">
- <a v-if="lastRunId && lastRunWorkflowId"
- :href="`/api/workflows/${lastRunWorkflowId}/runs/${lastRunId}/download`"
- class="text-btn" style="text-decoration:none; color: var(--primary)">下载工作目录</a>
- <button class="text-btn danger" @click="closeResult">清除结果</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 运行对话框 -->
- <div class="dialog-overlay" v-if="showRunDialog" @click.self="cancelRun">
- <div class="run-dialog">
- <div class="dialog-header">
- <span class="dialog-title">运行工作流</span>
- <button class="icon-btn" @click="cancelRun"><n-icon size="16"><CloseOutline /></n-icon></button>
- </div>
- <div class="dialog-body">
- <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
- <div v-for="f in inputFields" :key="f.name" class="run-field">
- <label class="run-field-label">{{ f.label || f.name }}</label>
- <n-input
- v-model:value="formData[f.name]"
- :placeholder="f.description || f.name"
- :type="f.type === 'number' ? 'text' : 'text'"
- size="small"
- />
- </div>
- <div class="run-upload">
- <label class="run-field-label">上传文件到工作目录</label>
- <div class="upload-btns">
- <div class="upload-area" @click="$refs.fileInput.click()">
- <input ref="fileInput" type="file" multiple style="display:none" @change="onFileSelect($event, false)" />
- <span class="upload-hint">选择文件</span>
- </div>
- <div class="upload-area" @click="$refs.dirInput.click()">
- <input ref="dirInput" type="file" webkitdirectory style="display:none" @change="onFileSelect($event, true)" />
- <span class="upload-hint">选择目录</span>
- </div>
- </div>
- <div v-if="uploadFiles.length" class="upload-list">
- <div v-for="(f, i) in uploadFiles" :key="i" class="upload-item">
- <span class="upload-name">{{ f.relativePath }}</span>
- <button class="upload-remove" @click="uploadFiles.splice(i, 1)">×</button>
- </div>
- </div>
- </div>
- </div>
- <div class="dialog-footer">
- <button class="cancel-btn" @click="cancelRun">取消</button>
- <button class="primary-btn" @click="handleStartRun">
- <n-icon size="14"><PlayOutline /></n-icon>
- <span>开始执行</span>
- </button>
- </div>
- </div>
- </div>
- <!-- 操作要求编辑器弹出框 -->
- <div class="dialog-overlay" v-if="showPromptEditor" @click.self="showPromptEditor = false">
- <div class="prompt-editor-dialog">
- <div class="dialog-header">
- <span class="dialog-title">编辑操作要求</span>
- <button class="icon-btn" @click="showPromptEditor = false"><n-icon size="16"><CloseOutline /></n-icon></button>
- </div>
- <div class="prompt-editor-body">
- <CodeEditor v-model="promptEditValue" language="markdown" height="100%" :minimap="false" />
- </div>
- <div class="dialog-footer">
- <button class="cancel-btn" @click="showPromptEditor = false">取消</button>
- <button class="primary-btn" @click="confirmPromptEdit">确认</button>
- </div>
- </div>
- </div>
- <!-- 版本历史面板 -->
- <transition name="slide-right">
- <div v-if="showVersionPanel" class="version-panel">
- <div class="vp-header">
- <h3 class="vp-title">版本历史</h3>
- <button class="icon-btn" @click="toggleVersionPanelWrap"><n-icon size="16"><CloseOutline /></n-icon></button>
- </div>
- <div class="vp-list">
- <n-spin v-if="versionsLoading" size="small" />
- <div v-else-if="!versions.length" class="vp-empty">暂无版本(保存后自动创建)</div>
- <div v-else class="version-items">
- <div
- v-for="v in versions"
- :key="v.number"
- class="version-item"
- :class="{ active: selectedVersion?.number === v.number }"
- >
- <div class="vi-main" @click="handleVersionSelectWrap(v)">
- <div class="vi-row">
- <span class="vi-version">v{{ v.number }}<span v-if="v.current" class="vi-current">当前</span></span>
- <span class="vi-time">{{ formatTime(v.createdAt) }}</span>
- </div>
- <div v-if="editingMessage !== v.number" class="vi-message" @dblclick="startEditMessage(v)">
- {{ v.message || '双击编辑备注' }}
- </div>
- <div v-else class="vi-message-edit">
- <n-input v-model:value="editingMessageText" size="tiny" placeholder="版本备注"
- @keyup.enter="saveMessageWrap(v)" @keyup.escape="cancelEditMessage" />
- <n-button size="tiny" type="primary" @click="saveMessageWrap(v)">✓</n-button>
- <n-button size="tiny" @click="cancelEditMessage">✕</n-button>
- </div>
- </div>
- <div class="vi-actions">
- <button v-if="!v.current" class="vi-action-btn rollback-btn"
- @click.stop="handleRollbackWrap(v)" title="回滚到此版本">↩</button>
- </div>
- </div>
- </div>
- </div>
- <div v-if="selectedVersion" class="vp-content">
- <div class="vp-content-header">
- <span>v{{ selectedVersion.number }} 的数据</span>
- </div>
- <pre class="version-json">{{ formatVersionJson(versionContent) }}</pre>
- </div>
- </div>
- </transition>
- <!-- 右键菜单 -->
- <Teleport to="body">
- <div
- v-if="contextMenu"
- class="context-menu-overlay"
- @click="closeContextMenu"
- @contextmenu.prevent="closeContextMenu"
- >
- <div class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
- <button class="ctx-item ctx-danger" @click="deleteNodeById(contextMenu.nodeId)">
- 删除节点
- </button>
- </div>
- </div>
- </Teleport>
- </div>
- </template>
- <style scoped>
- .workflow-editor {
- height: calc(100vh - var(--header-height));
- display: flex;
- flex-direction: column;
- margin: -24px -32px 0;
- background: #13111a;
- }
- .editor-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 16px;
- background: var(--bg-card, #1a1a2e);
- border-bottom: 1px solid rgba(255,255,255,0.08);
- flex-shrink: 0;
- }
- .toolbar-left, .toolbar-right {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .editor-body {
- flex: 1;
- display: flex;
- overflow: hidden;
- }
- /* 左侧节点栏 */
- .sidebar-nodes {
- width: 180px;
- background: var(--bg-card, #1a1a2e);
- border-right: 1px solid rgba(255,255,255,0.08);
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 6px;
- flex-shrink: 0;
- }
- .sidebar-title {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary, #999);
- text-transform: uppercase;
- letter-spacing: 1px;
- margin-bottom: 6px;
- }
- .node-type-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-radius: 8px;
- cursor: grab;
- transition: background 0.15s;
- border: 1px solid rgba(255,255,255,0.06);
- }
- .node-type-item:hover {
- background: rgba(255,255,255,0.06);
- }
- .node-type-item:active {
- cursor: grabbing;
- }
- .nt-icon {
- width: 28px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- color: #fff;
- flex-shrink: 0;
- }
- .nt-label {
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
- }
- /* 中间画布 */
- .canvas-area {
- flex: 1;
- background: #0f0d17;
- }
- /* 覆盖 VueFlow 默认主题的节点白色背景 */
- .canvas-area :deep(.vue-flow__node) {
- background: transparent !important;
- padding: 0 !important;
- border: none !important;
- box-shadow: none !important;
- }
- /* 选中连接线高亮 - stroke 必须作用于 path 子元素 */
- .canvas-area :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
- stroke: #facc15 !important;
- stroke-width: 3 !important;
- }
- .canvas-area :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
- stroke: #facc15 !important;
- }
- .canvas-area :deep(.vue-flow__edge) {
- cursor: pointer;
- }
- /* 右侧面板 - Tab 页 */
- .sidebar-right {
- width: 300px;
- background: var(--bg-card, #1a1a2e);
- border-left: 1px solid rgba(255,255,255,0.08);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- overflow: hidden;
- }
- .sidebar-tabs {
- display: flex;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- flex-shrink: 0;
- }
- .tab-btn {
- flex: 1;
- padding: 10px 0;
- border: none;
- background: transparent;
- color: var(--text-secondary, #999);
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 4px;
- }
- .tab-btn:hover { color: var(--text-primary, #e0e0e0); }
- .tab-btn.active {
- color: var(--text-primary, #e0e0e0);
- font-weight: 600;
- }
- .tab-btn.active::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 20%;
- right: 20%;
- height: 2px;
- background: var(--color-primary, #2563EB);
- border-radius: 1px;
- }
- .result-badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 16px;
- height: 16px;
- padding: 0 4px;
- border-radius: 8px;
- background: var(--color-primary, #2563EB);
- color: #fff;
- font-size: 10px;
- font-weight: 600;
- }
- .sidebar-tab-content {
- flex: 1;
- overflow-y: auto;
- }
- .tab-pane {
- padding: 12px;
- }
- .empty-props-hint {
- color: var(--text-tertiary, #555);
- font-size: 13px;
- text-align: center;
- padding: 40px 0;
- }
- /* 节点结果卡片 */
- .node-result-card {
- background: rgba(255,255,255,0.03);
- border-radius: 8px;
- border-left: 3px solid #666;
- margin-bottom: 10px;
- overflow: hidden;
- }
- .nrc-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid rgba(255,255,255,0.04);
- }
- .nrc-icon {
- width: 22px;
- height: 22px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 5px;
- color: #fff;
- flex-shrink: 0;
- }
- .nrc-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 1px;
- }
- .nrc-label {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .nrc-type {
- font-size: 10px;
- color: var(--text-tertiary, #666);
- }
- .nrc-status {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .nrc-status.status-running { background: rgba(99,102,241,0.15); color: #818cf8; }
- .nrc-status.status-success { background: rgba(16,185,129,0.15); color: #34d399; }
- .nrc-status.status-failed { background: rgba(239,68,68,0.15); color: #f87171; }
- .nrc-status.status-skipped { background: rgba(107,114,128,0.15); color: #9ca3af; }
- .nrc-status.status-no_run { background: rgba(107,114,128,0.10); color: #6b7280; }
- .nrc-vars {
- padding: 6px 10px;
- }
- /* 流式思考过程区块 */
- .nrc-thinking {
- margin: 6px 10px;
- border-radius: 6px;
- background: rgba(168, 85, 247, 0.05);
- border: 1px solid rgba(168, 85, 247, 0.2);
- overflow: hidden;
- }
- .nrc-thinking.live {
- border-color: rgba(168, 85, 247, 0.5);
- box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15);
- }
- .nrc-thinking-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 5px 10px;
- font-size: 11px;
- background: rgba(168, 85, 247, 0.08);
- }
- .nrc-thinking-tag { color: #c084fc; font-weight: 600; }
- .nrc-thinking-live {
- color: #ec4899;
- font-size: 10px;
- animation: nrc-pulse 1.2s ease-in-out infinite;
- }
- .nrc-thinking-static { color: #888; font-size: 10px; }
- .nrc-thinking-expand {
- margin-left: auto;
- font-size: 10px;
- color: var(--text-tertiary, #666);
- transition: transform 0.2s;
- flex-shrink: 0;
- }
- .nrc-thinking-expand.expanded {
- transform: rotate(90deg);
- }
- .nrc-thinking-body {
- margin: 0;
- padding: 8px 12px;
- max-height: 320px;
- overflow: auto;
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
- font-size: 11px;
- line-height: 1.6;
- color: var(--text-secondary, #b0b0b0);
- white-space: pre-wrap;
- word-break: break-word;
- }
- @keyframes nrc-pulse {
- 0%, 100% { opacity: 0.55; }
- 50% { opacity: 1; }
- }
- .nrc-var {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 4px 0;
- border-bottom: 1px solid rgba(255,255,255,0.02);
- }
- .nrc-var:last-child { border-bottom: none; }
- .nrc-var-key {
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- }
- .nrc-var-value {
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-secondary, #aaa);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 120px;
- overflow-y: auto;
- }
- /* 完整上下文快照折叠区(debug 视图) */
- .nrc-ctx-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 10px;
- cursor: pointer;
- border-top: 1px solid rgba(255,255,255,0.04);
- font-size: 11px;
- color: var(--text-secondary, #aaa);
- user-select: none;
- }
- .nrc-ctx-toggle:hover { background: rgba(99,102,241,0.05); }
- .nrc-ctx-icon {
- font-size: 9px;
- color: var(--text-tertiary, #888);
- transition: transform 0.2s;
- }
- .nrc-ctx-icon.expanded { transform: rotate(90deg); }
- .nrc-ctx-label { font-weight: 500; }
- .nrc-ctx-panel {
- padding: 6px 10px;
- border-top: 1px solid rgba(255,255,255,0.04);
- max-height: 280px;
- overflow-y: auto;
- background: rgba(0,0,0,0.15);
- }
- .nrc-ctx-var {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 4px 0;
- border-bottom: 1px solid rgba(255,255,255,0.02);
- }
- .nrc-ctx-var:last-child { border-bottom: none; }
- .nrc-ctx-key {
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 11px;
- font-weight: 600;
- color: #818cf8;
- }
- .nrc-ctx-value {
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 11px;
- color: var(--text-secondary, #ccc);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 80px;
- overflow-y: auto;
- }
- .nrc-clear-row {
- text-align: center;
- padding: 10px 0 4px;
- border-top: 1px solid rgba(255,255,255,0.06);
- margin-top: 8px;
- }
- .nrc-log-count {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- background: rgba(99,102,241,0.1);
- color: #818cf8;
- flex-shrink: 0;
- }
- .nrc-expand {
- font-size: 10px;
- color: var(--text-tertiary, #666);
- transition: transform 0.2s;
- flex-shrink: 0;
- }
- .nrc-expand.expanded {
- transform: rotate(90deg);
- }
- .nrc-logs {
- border-top: 1px solid rgba(255,255,255,0.04);
- padding: 6px 10px;
- max-height: 400px;
- overflow-y: auto;
- }
- .nrc-log {
- padding: 4px 0;
- border-bottom: 1px solid rgba(255,255,255,0.02);
- font-size: 11px;
- line-height: 1.5;
- }
- .nrc-log:last-child { border-bottom: none; }
- .nrc-log-icon {
- margin-right: 4px;
- }
- .nrc-log-time {
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 10px;
- color: var(--text-tertiary, #555);
- margin-right: 6px;
- }
- .nrc-log-tag {
- font-size: 10px;
- padding: 1px 4px;
- border-radius: 3px;
- margin-right: 6px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .log-type-tool_call .nrc-log-tag { background: rgba(59,130,246,0.15); color: #60a5fa; }
- .log-type-tool_result .nrc-log-tag { background: rgba(16,185,129,0.15); color: #34d399; }
- .log-type-thinking .nrc-log-tag { background: rgba(168,85,247,0.15); color: #c084fc; }
- .log-type-error .nrc-log-tag { background: rgba(239,68,68,0.15); color: #f87171; }
- .log-type-info .nrc-log-tag { background: rgba(107,114,128,0.15); color: #9ca3af; }
- .nrc-log-msg {
- color: var(--text-secondary, #ccc);
- word-break: break-all;
- }
- .nrc-log-detail {
- margin-top: 3px;
- margin-left: 22px;
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 10px;
- color: var(--text-tertiary, #888);
- background: rgba(0,0,0,0.2);
- padding: 4px 6px;
- border-radius: 4px;
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 100px;
- overflow-y: auto;
- }
- .prop-section {
- margin-bottom: 14px;
- }
- .prop-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 6px;
- }
- .prop-label {
- display: block;
- font-size: 12px;
- font-weight: 500;
- color: var(--text-secondary, #999);
- margin-bottom: 6px;
- }
- .var-form {
- background: rgba(255,255,255,0.03);
- border-radius: 6px;
- padding: 8px;
- margin-bottom: 8px;
- }
- .var-form-row {
- display: flex;
- gap: 6px;
- margin-bottom: 6px;
- }
- .cond-form {
- margin-bottom: 8px;
- }
- .cond-form-row {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .cond-tag {
- font-size: 11px;
- padding: 2px 8px;
- border-radius: 4px;
- background: rgba(249,115,22,0.15);
- color: #F97316;
- flex-shrink: 0;
- font-weight: 600;
- }
- .text-btn {
- background: none;
- border: none;
- color: var(--color-accent, #2563EB);
- font-size: 12px;
- cursor: pointer;
- padding: 2px 4px;
- }
- .text-btn:hover { text-decoration: underline; }
- .text-btn.danger { color: #EF4444; }
- .empty-hint {
- font-size: 12px;
- color: var(--text-tertiary, #555);
- font-style: italic;
- }
- .passthrough-hint {
- font-size: 11px;
- color: var(--text-tertiary, #555);
- margin-top: 6px;
- padding: 4px 8px;
- background: rgba(249,115,22,0.08);
- border-radius: 4px;
- color: #fb923c;
- }
- /* 通用按钮 */
- .icon-btn {
- width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- border: 1px solid rgba(255,255,255,0.12);
- background: transparent;
- color: var(--text-secondary, #999);
- cursor: pointer;
- transition: all 0.15s;
- }
- .icon-btn:hover {
- background: rgba(255,255,255,0.08);
- color: #fff;
- }
- .primary-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: var(--color-primary, #2563EB);
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .primary-btn:hover { filter: brightness(1.15); }
- .primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
- /* 自动关联按钮 */
- .associate-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: rgba(129, 140, 248, 0.12);
- color: #818cf8;
- border: 1px solid rgba(129, 140, 248, 0.25);
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .associate-btn:hover { background: rgba(129, 140, 248, 0.2); border-color: rgba(129, 140, 248, 0.4); }
- .associate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
- .associate-btn.active { background: rgba(129, 140, 248, 0.18); border-color: rgba(129, 140, 248, 0.35); }
- .clear-assoc-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: transparent;
- color: var(--text-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .clear-assoc-btn:hover { color: #f87171; border-color: rgba(239,68,68,0.3); background: rgba(239,68,68,0.08); }
- /* 虚拟节点(自动关联产生的技能节点) */
- .canvas-area :deep(.vue-flow__node.virtual-node) {
- opacity: 0.75;
- pointer-events: none;
- }
- .canvas-area :deep(.vue-flow__node.virtual-node .base-node) {
- /* 内容也穿透:鼠标按下技能节点时事件落到下层虚框,由父节点接管拖拽实现整体移动 */
- pointer-events: none;
- border-style: dashed !important;
- border-color: #818cf8 !important;
- background: rgba(129, 140, 248, 0.06) !important;
- }
- .canvas-area :deep(.vue-flow__node.virtual-node .base-node:hover) {
- border-color: #a5b4fc !important;
- }
- /* 虚线分组容器节点:必须保留 pointer-events 以接收拖动事件实现整体拖动 */
- .canvas-area :deep(.vue-flow__node.virtual-group-node) {
- pointer-events: all !important;
- }
- .canvas-area :deep(.vue-flow__node.virtual-group-node .vue-flow__handle) {
- pointer-events: none !important;
- opacity: 0 !important;
- width: 1px !important;
- height: 1px !important;
- min-width: 1px !important;
- min-height: 1px !important;
- background: transparent !important;
- border: none !important;
- }
- /* 虚线分组边 */
- .canvas-area :deep(.vue-flow__edge.virtual-group-edge .vue-flow__edge-path) {
- stroke: #818cf8;
- stroke-width: 1.5;
- stroke-dasharray: 5 4;
- opacity: 0.7;
- }
- /* 运行按钮 */
- .run-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: #10B981;
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .run-btn:hover { filter: brightness(1.15); }
- .run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
- .run-btn.is-running {
- background: #6366f1;
- animation: pulse-btn 1.5s infinite;
- }
- @keyframes pulse-btn {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
- }
- /* 节点执行状态 */
- .canvas-area :deep(.vue-flow__node.node-status-running) {
- animation: node-pulse 1.5s infinite;
- }
- .canvas-area :deep(.vue-flow__node.node-status-success) {
- filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.5));
- }
- .canvas-area :deep(.vue-flow__node.node-status-failed) {
- filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.6));
- }
- .canvas-area :deep(.vue-flow__node.node-status-skipped) {
- opacity: 0.4;
- }
- @keyframes node-pulse {
- 0%, 100% { filter: drop-shadow(0 0 6px rgba(37, 99, 235, 0.5)); }
- 50% { filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.9)); }
- }
- /* 运行对话框 */
- .dialog-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.75);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
- }
- .run-dialog {
- background: var(--bg-card, #1a1a2e);
- border-radius: 12px;
- width: 400px;
- max-height: 80vh;
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(255,255,255,0.1);
- }
- .prompt-editor-dialog {
- background: var(--bg-card, #1a1a2e);
- border-radius: 12px;
- width: 80vw;
- max-width: 960px;
- height: 75vh;
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(255,255,255,0.1);
- }
- .prompt-editor-body {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- }
- .dialog-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 18px;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- }
- .dialog-title { font-size: 15px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
- .dialog-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
- .dialog-footer {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- padding: 12px 18px;
- border-top: 1px solid rgba(255,255,255,0.08);
- }
- .cancel-btn {
- padding: 7px 16px;
- border: 1px solid rgba(255,255,255,0.15);
- background: transparent;
- color: var(--text-secondary, #999);
- border-radius: 6px;
- cursor: pointer;
- font-size: 13px;
- }
- .cancel-btn:hover { background: rgba(255,255,255,0.06); }
- .run-field { margin-bottom: 12px; }
- .run-field-label {
- display: block;
- font-size: 12px;
- color: var(--text-secondary, #999);
- margin-bottom: 4px;
- }
- /* 文件上传区域 */
- .run-upload { margin-top: 16px; }
- .upload-btns { display: flex; gap: 8px; }
- .upload-area {
- flex: 1;
- border: 1px dashed rgba(255,255,255,0.2);
- border-radius: 6px;
- padding: 12px;
- text-align: center;
- cursor: pointer;
- transition: border-color 0.2s;
- }
- .upload-area:hover { border-color: var(--primary); }
- .upload-hint { font-size: 12px; color: var(--text-secondary, #999); }
- .upload-list { margin-top: 8px; }
- .upload-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 8px;
- font-size: 12px;
- color: var(--text-primary, #eee);
- background: rgba(255,255,255,0.05);
- border-radius: 4px;
- margin-bottom: 4px;
- }
- .upload-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .upload-remove {
- background: none;
- border: none;
- color: var(--text-secondary, #999);
- cursor: pointer;
- font-size: 16px;
- padding: 0 4px;
- line-height: 1;
- }
- .upload-remove:hover { color: #f87171; }
- /* 边映射状态 */
- .edge-status-badge {
- font-size: 11px;
- padding: 2px 8px;
- border-radius: 4px;
- font-weight: 600;
- }
- .edge-status-badge.es-ok { background: rgba(34,197,94,0.15); color: #4ade80; }
- .edge-status-badge.es-unmapped { background: rgba(107,114,128,0.15); color: #9ca3af; }
- .edge-status-badge.es-partial { background: rgba(245,158,11,0.15); color: #fbbf24; }
- .edge-status-badge.es-mismatch { background: rgba(239,68,68,0.15); color: #f87171; }
- .mapping-list {
- margin-top: 6px;
- }
- .mapping-item {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 4px 8px;
- border-radius: 4px;
- margin-bottom: 3px;
- font-size: 12px;
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- }
- .mapping-item.mapped { background: rgba(34,197,94,0.08); }
- .mapping-item.mismatch { background: rgba(239,68,68,0.08); }
- .mapping-item.unmapped { background: rgba(245,158,11,0.08); }
- .mapping-item.extra { background: rgba(107,114,128,0.06); }
- .mapping-field {
- color: var(--text-primary, #e0e0e0);
- font-weight: 500;
- }
- .mapping-arrow {
- color: var(--text-tertiary, #666);
- }
- .mapping-type {
- color: var(--text-tertiary, #666);
- font-size: 10px;
- }
- /* 右键菜单 */
- .context-menu-overlay {
- position: fixed;
- inset: 0;
- z-index: 200;
- }
- .context-menu {
- position: fixed;
- background: #1e1b2e;
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- padding: 4px;
- min-width: 140px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
- }
- .ctx-item {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 8px 12px;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: var(--text-primary, #e0e0e0);
- font-size: 13px;
- cursor: pointer;
- transition: background 0.12s;
- text-align: left;
- }
- .ctx-item:hover {
- background: rgba(255, 255, 255, 0.08);
- }
- .ctx-item.ctx-danger {
- color: #f87171;
- }
- .ctx-item.ctx-danger:hover {
- background: rgba(239, 68, 68, 0.15);
- }
- /* 版本历史面板 */
- .version-panel {
- position: fixed;
- right: 0;
- top: 0;
- bottom: 0;
- width: 380px;
- background: var(--bg-secondary, #fff);
- border-left: 1px solid var(--border-color, #e5e7eb);
- display: flex;
- flex-direction: column;
- z-index: 100;
- box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
- }
- .version-panel .vp-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color, #e5e7eb); }
- .version-panel .vp-title { font-size: 15px; font-weight: 600; margin: 0; }
- .version-panel .vp-list { max-height: 300px; overflow-y: auto; padding: 8px; }
- .version-panel .vp-empty { text-align: center; color: var(--text-tertiary, #999); padding: 24px 0; font-size: 13px; }
- .version-panel .version-items { display: flex; flex-direction: column; gap: 4px; }
- .version-panel .version-item { display: flex; align-items: stretch; border-radius: 6px; border: 1px solid transparent; cursor: pointer; transition: all 0.15s; }
- .version-panel .version-item:hover { background: rgba(37, 99, 235, 0.06); }
- .version-panel .version-item.active { background: rgba(37, 99, 235, 0.1); border-color: rgba(37, 99, 235, 0.3); }
- .version-panel .vi-main { flex: 1; padding: 8px 10px; min-width: 0; }
- .version-panel .vi-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
- .version-panel .vi-version { font-size: 13px; font-weight: 600; color: var(--color-accent, #2563eb); }
- .version-panel .vi-current { font-size: 10px; font-weight: 500; background: rgba(34, 197, 94, 0.15); color: #22c55e; padding: 0 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle; }
- .version-panel .vi-time { font-size: 11px; color: var(--text-tertiary, #999); }
- .version-panel .vi-message { font-size: 12px; color: var(--text-secondary, #666); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
- .version-panel .vi-message-edit { display: flex; gap: 4px; margin-top: 4px; align-items: center; }
- .version-panel .vi-actions { display: flex; flex-direction: column; justify-content: center; gap: 2px; padding: 4px 6px; opacity: 0; transition: opacity 0.15s; }
- .version-panel .version-item:hover .vi-actions { opacity: 1; }
- .vi-action-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; background: transparent; transition: background 0.15s; }
- .rollback-btn { color: var(--color-danger, #ef4444); }
- .rollback-btn:hover { background: rgba(239, 68, 68, 0.15); }
- .version-panel .vp-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; border-top: 1px solid var(--border-color, #e5e7eb); }
- .version-panel .vp-content-header { padding: 8px 16px; font-size: 13px; font-weight: 500; color: var(--text-secondary, #666); border-bottom: 1px solid var(--border-color, #e5e7eb); flex-shrink: 0; }
- .version-json { flex: 1; overflow: auto; padding: 12px 16px; margin: 0; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; color: var(--text-secondary, #666); font-family: 'Consolas', 'Monaco', monospace; background: transparent; }
- /* 版本按钮 */
- .version-btn {
- display: inline-flex; align-items: center; gap: 4px; padding: 5px 10px;
- border: 1px solid var(--border-color, #e5e7eb); border-radius: 6px;
- background: var(--bg-secondary, #fff); color: var(--text-secondary, #666);
- cursor: pointer; font-size: 13px; transition: all 0.2s;
- }
- .version-btn:hover:not(:disabled) { border-color: var(--color-accent, #2563eb); color: var(--color-accent, #2563eb); }
- .version-btn.active { background: var(--color-accent, #2563eb); color: #fff; border-color: var(--color-accent, #2563eb); }
- .version-btn:disabled { opacity: 0.4; cursor: not-allowed; }
- /* 右侧滑入动画 */
- .slide-right-enter-active, .slide-right-leave-active { transition: transform 0.25s ease; }
- .slide-right-enter-from, .slide-right-leave-to { transform: translateX(100%); }
- </style>
|