feat: 工作流记忆与内置工具、知你客服脚本、Agent管理技能展示与能力配置、文档与Windows启动脚本;忽略 redis_temp 二进制目录

Made-with: Cursor
This commit is contained in:
renjianbo
2026-04-08 11:44:24 +08:00
parent 599b8f2851
commit bd3f8be781
66 changed files with 10104 additions and 469 deletions

View File

@@ -386,11 +386,44 @@ const scrollToBottom = () => {
})
}
/**
* 知你等工作流约定「自然语言 + 末尾单行 JSONintent/reply/user_profile」。
* 聊天区若原样展示会重复;展示前去掉末尾 JSON 行(可连续多行);若整段只有 JSON 则用 reply 作为正文。
*/
const stripOneTrailingWorkflowJsonLine = (raw: string): string => {
if (!raw || typeof raw !== 'string') return ''
const t = raw.trimEnd()
const lastNl = t.lastIndexOf('\n')
const lastLine = (lastNl >= 0 ? t.slice(lastNl + 1) : t).trim()
if (!lastLine.startsWith('{')) return raw
try {
const j = JSON.parse(lastLine) as Record<string, unknown>
if (!j || typeof j !== 'object') return raw
const reply = j.reply
if (typeof reply !== 'string') return raw
const head = lastNl >= 0 ? t.slice(0, lastNl).trimEnd() : ''
if (head) return head
return reply
} catch {
return raw
}
}
const stripTrailingWorkflowJsonLine = (raw: string): string => {
let cur = raw
for (let i = 0; i < 6; i++) {
const next = stripOneTrailingWorkflowJsonLine(cur)
if (next === cur) break
cur = next
}
return cur
}
// 格式化消息支持简单的Markdown
const formatMessage = (content: string) => {
if (!content) return ''
// 简单的换行处理
return content.replace(/\n/g, '<br>')
const display = stripTrailingWorkflowJsonLine(content)
return display.replace(/\n/g, '<br>')
}
// 格式化时间

View File

@@ -62,6 +62,10 @@
<el-icon><Operation style="transform: rotate(90deg)" /></el-icon>
垂直分布
</el-dropdown-item>
<el-dropdown-item command="normalize-edge-handles" divided>
<el-icon><ConnectionIcon /></el-icon>
修正连线锚点
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -6214,6 +6218,84 @@ const onEdgesChange = (changes: any[]) => {
}
}
/** 节点中心与尺寸(无测量值时用与自动布局一致的默认宽高) */
const getNodeLayoutMetrics = (n: Node) => {
const dw = n.dimensions?.width
const dh = n.dimensions?.height
const w = dw && dw > 0 ? dw : 200
const h = dh && dh > 0 ? dh : 80
return {
w,
h,
cx: n.position.x + w / 2,
cy: n.position.y + h / 2,
}
}
/**
* 根据两节点相对位置选择连接点,避免「吸附」总接到上下锚点造成锯齿线。
* 条件节点 true/false 出口保留,仅修正目标侧锚点。
*/
const resolveOptimalEdgeHandles = (
sourceId: string,
targetId: string,
currentSourceHandle?: string | null
): { sourceHandle: string; targetHandle: string } => {
const src = nodes.value.find((node) => node.id === sourceId)
const tgt = nodes.value.find((node) => node.id === targetId)
if (!src || !tgt) {
return { sourceHandle: currentSourceHandle || 'right', targetHandle: 'left' }
}
const a = getNodeLayoutMetrics(src)
const b = getNodeLayoutMetrics(tgt)
const dx = b.cx - a.cx
const dy = b.cy - a.cy
if (currentSourceHandle === 'true' || currentSourceHandle === 'false') {
if (Math.abs(dx) >= Math.abs(dy)) {
return { sourceHandle: currentSourceHandle, targetHandle: 'left' }
}
return { sourceHandle: currentSourceHandle, targetHandle: 'top' }
}
const horizontalish = Math.abs(dx) >= Math.abs(dy) * 0.85
if (horizontalish) {
return { sourceHandle: 'right', targetHandle: 'left' }
}
if (dy > 8) {
return { sourceHandle: 'bottom', targetHandle: 'top' }
}
return { sourceHandle: 'right', targetHandle: 'left' }
}
/** 批量将已有边的锚点校正为与布局一致的方向 */
const normalizeAllWorkflowEdgeHandles = (opts?: { silent?: boolean }): number => {
let changed = 0
for (const edge of edges.value) {
const { sourceHandle, targetHandle } = resolveOptimalEdgeHandles(
edge.source,
edge.target,
edge.sourceHandle
)
if (edge.sourceHandle !== sourceHandle || edge.targetHandle !== targetHandle) {
updateEdge(edge.id, { sourceHandle, targetHandle })
edge.sourceHandle = sourceHandle
edge.targetHandle = targetHandle
changed++
}
}
if (changed > 0) {
hasChanges.value = true
pushHistory('normalize edge handles')
if (!opts?.silent) {
ElMessage.success(`已修正 ${changed} 条连线的锚点`)
}
} else if (!opts?.silent) {
ElMessage.info('连线锚点已在最佳方向')
}
return changed
}
// 连接验证函数 - 允许所有方向的连接(包括左右)
const isValidConnection = (connection: Connection) => {
console.log('验证连接:', connection)
@@ -6228,14 +6310,11 @@ const isValidConnection = (connection: Connection) => {
return false
}
// 检查是否已经存在相同的连接
// 同一对节点只保留一条边(锚点不同也视为重复,避免重复连线)
const existingEdge = edges.value.find(
e => e.source === connection.source &&
e.target === connection.target &&
(e.sourceHandle === connection.sourceHandle || (!e.sourceHandle && !connection.sourceHandle)) &&
(e.targetHandle === connection.targetHandle || (!e.targetHandle && !connection.targetHandle))
(e) => e.source === connection.source && e.target === connection.target
)
if (existingEdge) {
return false
}
@@ -6265,19 +6344,22 @@ const onConnect = (connection: Connection) => {
return
}
// 检查是否已经存在相同的连接
// 同一对节点只保留一条边
const existingEdge = edges.value.find(
e => e.source === connection.source &&
e.target === connection.target &&
(e.sourceHandle === connection.sourceHandle || (!e.sourceHandle && !connection.sourceHandle)) &&
(e.targetHandle === connection.targetHandle || (!e.targetHandle && !connection.targetHandle))
(e) => e.source === connection.source && e.target === connection.target
)
if (existingEdge) {
ElMessage.warning('连接已存在')
return
}
const { sourceHandle, targetHandle } = resolveOptimalEdgeHandles(
connection.source,
connection.target,
connection.sourceHandle
)
// 清除选中的边
selectedEdge.value = null
@@ -6285,8 +6367,8 @@ const onConnect = (connection: Connection) => {
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle || undefined,
targetHandle: connection.targetHandle || undefined,
sourceHandle,
targetHandle,
type: 'bezier', // 使用贝塞尔曲线(平滑曲线)
animated: true,
selectable: true,
@@ -6906,6 +6988,11 @@ const handleClear = () => {
// 节点对齐功能
const handleAlignNodes = (command: string) => {
if (command === 'normalize-edge-handles') {
normalizeAllWorkflowEdgeHandles()
return
}
// 获取选中的节点(支持多选)
const selectedNodes = nodes.value.filter(node => node.selected)
@@ -7054,147 +7141,117 @@ const handleAutoLayout = async () => {
return
}
// 构建邻接表(有向图)
const graph: Record<string, string[]> = {}
const inDegree: Record<string, number> = {}
// 初始化
nodes.value.forEach(node => {
graph[node.id] = []
inDegree[node.id] = 0
// 最长路径分层Sugiyama 简化rank[v]=1+max(rank[p]),同一 rank 的节点放在同一列,避免旧版「单节点层累加 currentX + 分支后重置 X」造成的重叠与超长飞线
const nodeIds = nodes.value.map((n) => n.id)
const pred = new Map<string, string[]>()
nodeIds.forEach((id) => pred.set(id, []))
edges.value.forEach((edge) => {
if (!edge.source || !edge.target) return
if (!pred.has(edge.target)) return
pred.get(edge.target)!.push(edge.source)
})
// 构建图
edges.value.forEach(edge => {
if (graph[edge.source] && !graph[edge.source].includes(edge.target)) {
graph[edge.source].push(edge.target)
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
}
const rank: Record<string, number> = {}
nodeIds.forEach((id) => {
const ps = pred.get(id) || []
if (ps.length === 0) rank[id] = 0
})
// 拓扑排序,将节点分层
const layers: string[][] = []
const visited = new Set<string>()
const queue: string[] = []
// 找到所有入度为0的节点开始节点
Object.keys(inDegree).forEach(nodeId => {
if (inDegree[nodeId] === 0) {
queue.push(nodeId)
}
})
// 如果没有入度为0的节点使用开始节点
if (queue.length === 0 && startNode) {
queue.push(startNode.id)
if (startNode && rank[startNode.id] === undefined) rank[startNode.id] = 0
let relaxIter = 0
let changed = true
while (changed && relaxIter < nodeIds.length + 12) {
changed = false
relaxIter++
edges.value.forEach((e) => {
if (rank[e.source] === undefined) return
const next = rank[e.source]! + 1
if (rank[e.target] === undefined || rank[e.target]! < next) {
rank[e.target] = next
changed = true
}
})
}
// 分层遍历
while (queue.length > 0) {
const layer: string[] = []
const layerSize = queue.length
for (let i = 0; i < layerSize; i++) {
const nodeId = queue.shift()!
if (visited.has(nodeId)) continue
visited.add(nodeId)
layer.push(nodeId)
// 处理该节点的所有出边
const neighbors = graph[nodeId] || []
neighbors.forEach(neighborId => {
inDegree[neighborId] = (inDegree[neighborId] || 0) - 1
if (inDegree[neighborId] === 0 && !visited.has(neighborId)) {
queue.push(neighborId)
}
})
}
if (layer.length > 0) {
layers.push(layer)
}
}
// 处理未访问的节点(可能是孤立节点)
nodes.value.forEach(node => {
if (!visited.has(node.id)) {
if (layers.length === 0) {
layers.push([node.id])
} else {
layers[layers.length - 1].push(node.id)
let maxR = 0
nodeIds.forEach((id) => {
if (rank[id] !== undefined) maxR = Math.max(maxR, rank[id]!)
})
nodeIds.forEach((id) => {
if (rank[id] === undefined) rank[id] = maxR + 1
})
maxR = Math.max(maxR, ...nodeIds.map((id) => rank[id]!))
const rankSet = new Set<number>()
nodeIds.forEach((id) => rankSet.add(rank[id]!))
const sortedRanks = [...rankSet].sort((a, b) => a - b)
const rankToCol = new Map<number, number>(sortedRanks.map((r, i) => [r, i]))
let layers: string[][] = Array.from({ length: sortedRanks.length }, () => [])
nodeIds.forEach((id) => {
const col = rankToCol.get(rank[id]!) ?? 0
layers[col].push(id)
})
// 层内排序重心法barycenter减少跨层边交叉多轮上下扫掠
const reorderLayersReduceCrossings = (ly: string[][], edgs: typeof edges.value): string[][] => {
if (ly.length <= 1) return ly.map(l => [...l])
const out = ly.map(l => [...l])
const pred = new Map<string, string[]>()
const succ = new Map<string, string[]>()
edgs.forEach((e) => {
if (!e?.source || !e?.target) return
if (!pred.has(e.target)) pred.set(e.target, [])
pred.get(e.target)!.push(e.source)
if (!succ.has(e.source)) succ.set(e.source, [])
succ.get(e.source)!.push(e.target)
})
const rounds = 8
for (let it = 0; it < rounds; it++) {
for (let k = 1; k < out.length; k++) {
const prev = out[k - 1]
const pos = new Map(prev.map((id, i) => [id, i]))
out[k].sort((a, b) => {
const pa = (pred.get(a) || []).filter((x) => pos.has(x))
const pb = (pred.get(b) || []).filter((x) => pos.has(x))
const ba = pa.length ? pa.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / pa.length : 0
const bb = pb.length ? pb.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / pb.length : 0
return ba - bb || a.localeCompare(b)
})
}
for (let k = out.length - 2; k >= 0; k--) {
const next = out[k + 1]
const pos = new Map(next.map((id, i) => [id, i]))
out[k].sort((a, b) => {
const sa = (succ.get(a) || []).filter((x) => pos.has(x))
const sb = (succ.get(b) || []).filter((x) => pos.has(x))
const ba = sa.length ? sa.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / sa.length : 0
const bb = sb.length ? sb.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / sb.length : 0
return ba - bb || a.localeCompare(b)
})
}
}
})
return out
}
layers = reorderLayersReduceCrossings(layers, edges.value)
// 布局参数
// 布局参数:按列摆开(列 = 拓扑深度),列内垂直堆叠;主线性链呈一行,分支呈一列,避免旧算法横向漂移与飞线
const nodeWidth = 200
const nodeHeight = 80
const horizontalSpacing = 320 // 水平间距(节点之间的水平距离)
const verticalSpacing = 150 // 垂直间距(用于有分支的情况)
const startX = 100
const startY = 200
// 检查是否是简单的线性工作流(每层只有一个节点)
let isLinearWorkflow = true
layers.forEach(layer => {
if (layer.length > 1) {
isLinearWorkflow = false
}
})
// 如果每层只有一个节点,使用水平线性布局(从左到右,所有节点在同一水平线)
if (isLinearWorkflow && layers.length > 1) {
// 水平线性布局:所有节点水平排列在同一水平线上
layers.forEach((layer, layerIndex) => {
layer.forEach((nodeId) => {
const nodeX = startX + layerIndex * horizontalSpacing
const nodeY = startY // 所有节点在同一水平线上
updateNode(nodeId, {
position: { x: nodeX, y: nodeY }
})
const horizontalSpacing = 300
const verticalSpacing = 130
const startX = 80
const startY = 220
layers.forEach((layer, colIndex) => {
const nodeX = startX + colIndex * horizontalSpacing
const n = layer.length
const y0 = startY - ((n - 1) * verticalSpacing) / 2
layer.forEach((nodeId, j) => {
updateNode(nodeId, {
position: { x: nodeX, y: y0 + j * verticalSpacing }
})
})
} else {
// 层次布局:有分支的工作流
// 优化策略:尽量让单节点层水平排列,多节点层才垂直排列
let baseY = startY
let currentX = startX
let consecutiveSingleNodeLayers = 0
layers.forEach((layer, layerIndex) => {
if (layer.length === 1) {
// 单节点层:水平排列
consecutiveSingleNodeLayers++
const nodeId = layer[0]
const nodeX = currentX
// 如果连续多个单节点层,保持水平对齐
const nodeY = baseY + (consecutiveSingleNodeLayers > 3 ? 20 : 0) // 如果连续太多,稍微下移
updateNode(nodeId, {
position: { x: nodeX, y: nodeY }
})
currentX += horizontalSpacing
} else {
// 多节点层水平居中排列使用新的Y坐标
consecutiveSingleNodeLayers = 0
baseY += verticalSpacing
currentX = startX // 重置X位置
const layerWidth = (layer.length - 1) * horizontalSpacing
const layerStartX = startX
layer.forEach((nodeId, nodeIndex) => {
const nodeX = layerStartX + nodeIndex * horizontalSpacing
updateNode(nodeId, {
position: { x: nodeX, y: baseY }
})
})
// 更新currentX为下一层的起始位置
currentX = layerStartX + layerWidth + horizontalSpacing
}
})
}
})
// 自动调整视口,使所有节点可见
await nextTick()
@@ -7233,7 +7290,12 @@ const handleAutoLayout = async () => {
}
}, 100)
ElMessage.success(`自动布局完成,共 ${layers.length} 层,${nodes.value.length} 个节点`)
await nextTick()
const fixedHandles = normalizeAllWorkflowEdgeHandles({ silent: true })
ElMessage.success(
`自动布局完成:${layers.length} 列(最长路径分层)+ 层内排序减交叉` +
(fixedHandles ? `,已优化 ${fixedHandles} 条连线锚点` : '')
)
hasChanges.value = true
}

View File

@@ -0,0 +1,76 @@
/**
* 从 Agent 工作流中提取 / 写入 LLM 节点的工具skills配置。
*/
import type { WorkflowNode } from '@/types'
/** 与后端 tools_bootstrap 内置工具一致,用于列表展示与配置勾选 */
export const BUILTIN_SKILL_OPTIONS: { name: string; label: string }[] = [
{ name: 'http_request', label: 'HTTP 请求' },
{ name: 'file_read', label: '读文件' },
{ name: 'file_write', label: '写文件' },
{ name: 'text_analyze', label: '文本分析' },
{ name: 'datetime', label: '日期时间' },
{ name: 'math_calculate', label: '数学计算' },
{ name: 'system_info', label: '系统信息' },
{ name: 'json_process', label: 'JSON 处理' },
{ name: 'database_query', label: '数据库查询' },
{ name: 'adb_log', label: 'ADB 日志' }
]
export const BUILTIN_SKILL_LABELS: Record<string, string> = Object.fromEntries(
BUILTIN_SKILL_OPTIONS.map((o) => [o.name, o.label])
)
function isLlmNode(n: WorkflowNode): boolean {
const t = (n.type || '').toLowerCase()
const dt = (n.data?.type || '').toLowerCase()
return t === 'llm' || t === 'template' || dt === 'llm'
}
/** 工作流中所有 LLM 节点上已选工具的并集(去重排序) */
export function extractSkillToolNames(workflow_config: { nodes?: WorkflowNode[] } | null | undefined): string[] {
const set = new Set<string>()
for (const n of workflow_config?.nodes || []) {
if (!isLlmNode(n)) continue
const raw = n.data?.tools ?? n.data?.selected_tools
if (Array.isArray(raw)) {
raw.forEach((x) => set.add(String(x)))
}
}
return Array.from(set).sort()
}
/**
* 作为「能力配置」写入时的目标节点:优先 llm-unified否则第一个带工具的 LLM否则第一个 LLM。
*/
export function findPrimaryLlmNodeForTools(nodes: WorkflowNode[] | undefined): WorkflowNode | null {
if (!nodes?.length) return null
const byId = nodes.find((n) => n.id === 'llm-unified' && isLlmNode(n))
if (byId) return byId
const withTools = nodes.find(
(n) =>
isLlmNode(n) &&
(n.data?.enable_tools === true ||
(Array.isArray(n.data?.tools) && n.data.tools.length > 0) ||
(Array.isArray(n.data?.selected_tools) && n.data.selected_tools.length > 0))
)
if (withTools) return withTools
return nodes.find((n) => isLlmNode(n)) || null
}
export function patchWorkflowSkillTools(
workflow_config: { nodes: WorkflowNode[]; edges: unknown[] },
toolNames: string[]
): { nodes: WorkflowNode[]; edges: unknown[] } {
const wf = JSON.parse(JSON.stringify(workflow_config)) as { nodes: WorkflowNode[]; edges: unknown[] }
const target = findPrimaryLlmNodeForTools(wf.nodes)
if (!target) return wf
const names = [...new Set(toolNames)].filter(Boolean).sort()
if (!target.data) target.data = {}
target.data.enable_tools = names.length > 0
target.data.tools = names
target.data.selected_tools = names
return wf
}

View File

@@ -62,7 +62,23 @@
stripe
>
<el-table-column prop="name" label="名称" width="200" />
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="技能 (tools)" min-width="220">
<template #default="{ row }">
<div v-if="skillTagsForRow(row).length" class="skill-tags">
<el-tag
v-for="t in skillTagsForRow(row)"
:key="t"
size="small"
class="skill-tag"
effect="plain"
>
{{ skillLabel(t) }}
</el-tag>
</div>
<span v-else class="skill-empty"></span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
@@ -76,12 +92,16 @@
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="480" fixed="right">
<el-table-column label="操作" width="540" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button link type="primary" @click="handleSkillConfig(row)">
<el-icon><Tools /></el-icon>
能力配置
</el-button>
<el-button
v-if="row.status === 'published' || row.status === 'running'"
link
@@ -240,6 +260,65 @@
</el-button>
</template>
</el-dialog>
<!-- 能力 / 技能配置 -->
<el-dialog
v-model="skillDialogVisible"
title="能力配置LLM 工具)"
width="560px"
destroy-on-close
@close="handleSkillDialogClose"
>
<div v-loading="skillLoading">
<el-alert type="info" :closable="false" show-icon class="skill-alert">
<template #title>
<span style="font-size: 13px;">
勾选内置工具并保存后将写入工作流中主 LLM 节点优先
<code>llm-unified</code>
<code>tools</code> / <code>selected_tools</code>并同步
<code>enable_tools</code>多节点场景请以设计器为准
</span>
</template>
</el-alert>
<div v-if="skillAgentName" class="skill-agent-name">Agent{{ skillAgentName }}</div>
<el-form label-width="100px" style="margin-top: 12px">
<el-form-item label="">
<el-checkbox-group v-model="skillBuiltinSelected" class="skill-checkbox-group">
<el-checkbox
v-for="opt in skillBuiltinOptions"
:key="opt.name"
:label="opt.name"
border
>
{{ opt.label }}
<span class="skill-name">({{ opt.name }})</span>
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item v-if="skillExtraNames.length" label="其它工具名">
<div class="skill-extra-wrap">
<el-tag
v-for="ex in skillExtraNames"
:key="ex"
type="warning"
closable
class="skill-extra-tag"
@close="removeExtraSkill(ex)"
>
{{ ex }}
</el-tag>
<span class="skill-extra-hint">来自当前工作流可关闭以移除</span>
</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="skillDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="skillSaving" @click="handleSkillSave">
保存
</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
@@ -262,10 +341,18 @@ import {
Upload,
Download,
UploadFilled,
ChatDotRound
ChatDotRound,
Tools
} from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import type { Agent } from '@/stores/agent'
import {
BUILTIN_SKILL_OPTIONS,
BUILTIN_SKILL_LABELS,
extractSkillToolNames,
patchWorkflowSkillTools,
findPrimaryLlmNodeForTools
} from '@/utils/agentSkills'
const router = useRouter()
const agentStore = useAgentStore()
@@ -312,6 +399,80 @@ const importFileContent = ref<any>(null)
const importing = ref(false)
const uploadRef = ref()
// 能力 / 技能配置
const skillDialogVisible = ref(false)
const skillLoading = ref(false)
const skillSaving = ref(false)
const skillAgentId = ref<string | null>(null)
const skillAgentName = ref('')
const skillBuiltinSelected = ref<string[]>([])
const skillExtraNames = ref<string[]>([])
const skillBuiltinOptions = BUILTIN_SKILL_OPTIONS
const BUILTIN_NAME_SET = new Set(BUILTIN_SKILL_OPTIONS.map((o) => o.name))
function skillLabel(name: string): string {
return BUILTIN_SKILL_LABELS[name] || name
}
function skillTagsForRow(agent: Agent): string[] {
return extractSkillToolNames(agent.workflow_config)
}
function handleSkillDialogClose() {
skillAgentId.value = null
skillAgentName.value = ''
skillBuiltinSelected.value = []
skillExtraNames.value = []
}
async function handleSkillConfig(agent: Agent) {
skillAgentId.value = agent.id
skillAgentName.value = agent.name
skillDialogVisible.value = true
skillLoading.value = true
try {
const full = await agentStore.fetchAgent(agent.id)
const all = extractSkillToolNames(full.workflow_config)
skillBuiltinSelected.value = all.filter((n) => BUILTIN_NAME_SET.has(n))
skillExtraNames.value = all.filter((n) => !BUILTIN_NAME_SET.has(n))
const primary = findPrimaryLlmNodeForTools(full.workflow_config?.nodes)
if (!primary) {
ElMessage.warning('当前工作流中未找到 LLM 节点,请先在「设计」中添加 LLM 节点后再配置能力。')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '加载 Agent 失败')
skillDialogVisible.value = false
} finally {
skillLoading.value = false
}
}
function removeExtraSkill(name: string) {
skillExtraNames.value = skillExtraNames.value.filter((x) => x !== name)
}
async function handleSkillSave() {
if (!skillAgentId.value) return
skillSaving.value = true
try {
const full = await agentStore.fetchAgent(skillAgentId.value)
const merged = [
...new Set([...skillBuiltinSelected.value, ...skillExtraNames.value].filter(Boolean))
].sort()
const wf = patchWorkflowSkillTools(full.workflow_config, merged)
await agentStore.updateAgent(skillAgentId.value, {
workflow_config: wf
})
ElMessage.success('能力配置已保存')
skillDialogVisible.value = false
await loadAgents()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
skillSaving.value = false
}
}
// 表单验证规则
const rules = {
name: [
@@ -662,4 +823,59 @@ onMounted(() => {
display: flex;
justify-content: flex-end;
}
.skill-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
max-width: 100%;
}
.skill-tag {
margin: 0;
}
.skill-empty {
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.skill-alert {
margin-bottom: 8px;
}
.skill-agent-name {
font-weight: 500;
margin-bottom: 4px;
}
.skill-checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.skill-name {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: 4px;
}
.skill-extra-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.skill-extra-tag {
margin: 0;
}
.skill-extra-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>