feat: 工作流记忆与内置工具、知你客服脚本、Agent管理技能展示与能力配置、文档与Windows启动脚本;忽略 redis_temp 二进制目录
Made-with: Cursor
This commit is contained in:
@@ -386,11 +386,44 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 知你等工作流约定「自然语言 + 末尾单行 JSON(intent/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>')
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
76
frontend/src/utils/agentSkills.ts
Normal file
76
frontend/src/utils/agentSkills.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user