/**
* 路由地图 — 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 = `'
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)
})
})
}