/** * 路由地图 — Channel → Binding → Agent 全局拓扑可视化 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { navigate } from '../router.js' import { t } from '../lib/i18n.js' const CHANNEL_COLORS = { qqbot: '#22d3ee', qq: '#22d3ee', telegram: '#3b82f6', discord: '#818cf8', slack: '#f59e0b', feishu: '#6366f1', dingtalk: '#3b82f6', weixin: '#22c55e', wechat: '#22c55e', webchat: '#a78bfa', whatsapp: '#22c55e', signal: '#60a5fa', line: '#22c55e', teams: '#6366f1', matrix: '#f472b6', irc: '#94a3b8', } const NODE_W = 180, NODE_H = 56, COL_GAP = 260, ROW_GAP = 16, PAD_TOP = 80, PAD_LEFT = 40 function escAttr(s) { return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/

${t('routeMap.title')}

${t('routeMap.clickToNavigate')}

${t('routeMap.subtitle')}

` async function loadAndRender() { const canvas = page.querySelector('#rm-canvas') const statsEl = page.querySelector('#rm-stats') canvas.innerHTML = '
' + t('routeMap.loading') + '
' try { const [agentsRaw, bindingsRaw, platformsRaw] = await Promise.all([ api.listAgents(), api.listAllBindings().catch(() => []), api.listConfiguredPlatforms().catch(() => []), ]) const agents = Array.isArray(agentsRaw) ? agentsRaw : (agentsRaw?.agents || []) const bindings = Array.isArray(bindingsRaw) ? bindingsRaw : (bindingsRaw?.bindings || []) const platforms = Array.isArray(platformsRaw) ? platformsRaw : [] if (agents.length === 0 && platforms.length === 0) { canvas.innerHTML = `
${t('routeMap.noData')}
` statsEl.innerHTML = '' return } // Fetch agent details to get sub-agent relationships const agentDetails = await Promise.all( agents.map(a => api.getAgentDetail(a.id || a.name || 'main').catch(() => null)) ) // Merge detail data into agents for (let i = 0; i < agents.length; i++) { if (agentDetails[i]) { agents[i] = { ...agents[i], ...agentDetails[i] } } } // Stats bar const subAgentCount = agents.filter(a => { const allow = a.tools?.agentToAgent?.allow return Array.isArray(allow) && allow.length > 0 }).length statsEl.innerHTML = `
${agents.length}${t('routeMap.statsAgents')}
${platforms.length}${t('routeMap.statsChannels')}
${bindings.length}${t('routeMap.statsBindings')}
${subAgentCount ? `
${subAgentCount}${t('routeMap.subAgentRelations')}
` : ''} ` renderTopology(canvas, agents, bindings, platforms) } catch (e) { canvas.innerHTML = `
${escAttr(e.message || e)}
` } } page.querySelector('#rm-refresh').onclick = () => loadAndRender() setTimeout(loadAndRender, 0) return page } function renderTopology(container, agents, bindings, platforms) { // Build channel nodes (left column) const channelNodes = platforms.map((p, i) => { const id = p.platform || p.id || p.channel || `ch-${i}` const label = p.label || p.platform || id const enabled = p.enabled !== false return { id, label, enabled, color: CHANNEL_COLORS[id.toLowerCase()] || '#94a3b8', type: 'channel', originalIndex: i } }) // Build agent nodes (right column) const defaultAgentId = agents.find(a => a.default || a.isDefault)?.id || agents[0]?.id || 'main' const agentNodes = agents.map((a, i) => { const id = a.id || a.name || `agent-${i}` const identity = a.identity || {} const emoji = identity.emoji || '🤖' const label = identity.name || id const isDefault = a.default || a.isDefault || id === defaultAgentId return { id, label, emoji, isDefault, type: 'agent', originalIndex: i } }) // Build edges from bindings const edges = [] for (const b of bindings) { const agentId = b.agentId || b.agent || '' const channel = b.match?.channel || b.channel || '' const enabled = b.enabled !== false const peer = b.match?.peer const accountId = b.match?.accountId let hint = '' if (peer) hint = t('routeMap.peer') else if (accountId) hint = `${t('routeMap.account')}: ${accountId}` edges.push({ from: channel, to: agentId, enabled, hint, channel, agentId }) } // Add implicit default agent edges for channels without bindings const boundChannels = new Set(edges.map(e => e.from)) for (const ch of channelNodes) { if (!boundChannels.has(ch.id) && ch.enabled) { edges.push({ from: ch.id, to: defaultAgentId, enabled: true, hint: t('routeMap.defaultAgent'), channel: ch.id, agentId: defaultAgentId, implicit: true }) } } // Layout: 3 columns — Channels | gap | Agents const leftCount = Math.max(channelNodes.length, 1) const rightCount = Math.max(agentNodes.length, 1) const maxRows = Math.max(leftCount, rightCount) const svgW = PAD_LEFT * 2 + NODE_W * 2 + COL_GAP const svgH = PAD_TOP + maxRows * (NODE_H + ROW_GAP) + 40 // Position nodes channelNodes.forEach((n, i) => { n.x = PAD_LEFT n.y = PAD_TOP + i * (NODE_H + ROW_GAP) + (maxRows - leftCount) * (NODE_H + ROW_GAP) / 2 }) agentNodes.forEach((n, i) => { n.x = PAD_LEFT + NODE_W + COL_GAP n.y = PAD_TOP + i * (NODE_H + ROW_GAP) + (maxRows - rightCount) * (NODE_H + ROW_GAP) / 2 }) // Build agent-to-agent edges from tools.agentToAgent.allow const a2aEdges = [] for (const a of agents) { const allow = a.tools?.agentToAgent?.allow if (!Array.isArray(allow) || a.tools?.agentToAgent?.enabled === false) continue const fromId = a.id || a.name || 'main' for (const targetId of allow) { if (targetId && targetId !== fromId) { a2aEdges.push({ from: fromId, to: targetId }) } } } // Build node lookup const nodeMap = {} for (const n of [...channelNodes, ...agentNodes]) nodeMap[n.id] = n // Extra height for legend if we have a2a edges const legendH = a2aEdges.length > 0 ? 60 : 20 // Render SVG let svg = `` // Column headers svg += `${t('routeMap.channels')}` svg += `${t('routeMap.agents')}` svg += `${t('routeMap.bindings')}` // Draw edges for (const e of edges) { const src = nodeMap[e.from] const dst = nodeMap[e.to] if (!src || !dst) continue const x1 = src.x + NODE_W const y1 = src.y + NODE_H / 2 const x2 = dst.x const y2 = dst.y + NODE_H / 2 const cx1 = x1 + COL_GAP * 0.35 const cx2 = x2 - COL_GAP * 0.35 const opacity = e.enabled ? 0.7 : 0.25 const dash = e.implicit ? '6,4' : 'none' const color = e.enabled ? (src.color || '#6366f1') : '#94a3b8' svg += `` // Arrow svg += `` // Edge label if (e.hint) { const mx = (x1 + x2) / 2 const my = (y1 + y2) / 2 svg += `${escAttr(e.hint)}` } } // Draw channel nodes for (const n of channelNodes) { const opacity = n.enabled ? 1 : 0.45 svg += ` ${n.enabled ? '📡' : '⏸'} ${escAttr(n.label)} ${n.enabled ? t('routeMap.enabled') : t('routeMap.disabled')} ` } // Draw agent nodes for (const n of agentNodes) { svg += ` ${n.emoji} ${escAttr(n.label)} ${n.isDefault ? '⭐ ' + t('routeMap.defaultAgent') : n.id} ` } // Draw agent-to-agent sub-agent edges (amber dashed, curved right of agent column) for (let i = 0; i < a2aEdges.length; i++) { const e = a2aEdges[i] const src = nodeMap[e.from] const dst = nodeMap[e.to] if (!src || !dst) continue const x1 = src.x + NODE_W const y1 = src.y + NODE_H / 2 const x2 = dst.x + NODE_W const y2 = dst.y + NODE_H / 2 const bulge = 40 + i * 12 const cx = Math.max(x1, x2) + bulge svg += `` svg += `` const mx = cx - 4 const my = (y1 + y2) / 2 svg += `${t('routeMap.subAgentCall')}` } // Legend const ly = svgH + (a2aEdges.length > 0 ? 10 : 0) svg += `` let lx = PAD_LEFT // Solid line = explicit binding svg += `` svg += `${t('routeMap.legendBinding')}` lx += 110 // Dashed line = default route svg += `` svg += `${t('routeMap.legendDefault')}` if (a2aEdges.length > 0) { lx += 110 svg += `` svg += `${t('routeMap.subAgentCall')}` } svg += `` svg += '' container.innerHTML = `
${svg}
` // Click to navigate container.querySelectorAll('.route-map-node').forEach(el => { el.addEventListener('click', () => { const target = el.dataset.nav if (target) navigate('/' + target) }) }) }