mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
New pages: - Plugin Hub: grid cards, search, install/toggle/enable plugins - Route Map: SVG visualization of channels→agents bindings with legends - Diagnose: gateway connectivity diagnosis with step-by-step checks Dashboard enhancements: - WebSocket status indicator (connected/handshaking/reconnecting/disconnected) - Connected channels overview with platform icons - Colored log level badges (ERROR/WARN/INFO/DEBUG) with timestamps - Channels data loading in dashboard secondary fetch Splash screen: - Multi-stage boot detection (JS not loaded vs boot slow vs timeout) - 15s: WebView2/resource load failure - 20s: "initializing..." hint with elapsed counter - 90s: true timeout error Backend (Rust): - diagnose.rs: gateway connectivity diagnosis command - messaging.rs: plugin management commands - service.rs: improvements - lib.rs: register new commands Frontend libs: - feature-gates.js: feature flag system - ws-client.js: reconnect state tracking - tauri-api.js: new API bindings - model-presets.js: provider fixes - Remove gateway-guardian-policy.js (unused) Dev API (scripts/dev-api.js): - list_all_plugins, toggle_plugin, install_plugin handlers - probe_gateway_port, diagnose_gateway_connection handlers i18n: dashboard, sidebar, diagnose, extensions, routeMap locale modules CSS: plugin-hub cards, route-map SVG styles
278 lines
13 KiB
JavaScript
278 lines
13 KiB
JavaScript
/**
|
|
* 路由地图 — 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(/</g, '<') }
|
|
|
|
export async function render() {
|
|
const page = document.createElement('div')
|
|
page.className = 'page'
|
|
|
|
page.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">${t('routeMap.title')}</h1>
|
|
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
|
|
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">${t('routeMap.clickToNavigate')}</span>
|
|
<button class="btn btn-sm btn-secondary" id="rm-refresh">${t('routeMap.refresh')}</button>
|
|
</div>
|
|
</div>
|
|
<p class="form-hint" style="margin-bottom:var(--space-md)">${t('routeMap.subtitle')}</p>
|
|
<div id="rm-stats" class="route-map-stats"></div>
|
|
<div id="rm-canvas" class="route-map-canvas">
|
|
<div class="stat-card loading-placeholder" style="height:300px"></div>
|
|
</div>
|
|
`
|
|
|
|
async function loadAndRender() {
|
|
const canvas = page.querySelector('#rm-canvas')
|
|
const statsEl = page.querySelector('#rm-stats')
|
|
canvas.innerHTML = '<div class="stat-card loading-placeholder" style="height:300px;display:flex;align-items:center;justify-content:center;color:var(--text-tertiary)">' + t('routeMap.loading') + '</div>'
|
|
|
|
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 = `<div class="stat-card" style="padding:var(--space-xl);text-align:center;color:var(--text-tertiary)">${t('routeMap.noData')}</div>`
|
|
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 = `
|
|
<div class="route-map-stat"><span class="route-map-stat-num">${agents.length}</span><span class="route-map-stat-label">${t('routeMap.statsAgents')}</span></div>
|
|
<div class="route-map-stat"><span class="route-map-stat-num">${platforms.length}</span><span class="route-map-stat-label">${t('routeMap.statsChannels')}</span></div>
|
|
<div class="route-map-stat"><span class="route-map-stat-num">${bindings.length}</span><span class="route-map-stat-label">${t('routeMap.statsBindings')}</span></div>
|
|
${subAgentCount ? `<div class="route-map-stat"><span class="route-map-stat-num">${subAgentCount}</span><span class="route-map-stat-label">${t('routeMap.subAgentRelations')}</span></div>` : ''}
|
|
`
|
|
|
|
renderTopology(canvas, agents, bindings, platforms)
|
|
} catch (e) {
|
|
canvas.innerHTML = `<div class="stat-card" style="padding:var(--space-lg);color:var(--text-danger)">${escAttr(e.message || e)}</div>`
|
|
}
|
|
}
|
|
|
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH + legendH}" viewBox="0 0 ${svgW} ${svgH + legendH}" class="route-map-svg">`
|
|
|
|
// Column headers
|
|
svg += `<text x="${PAD_LEFT + NODE_W / 2}" y="30" text-anchor="middle" class="route-map-col-label">${t('routeMap.channels')}</text>`
|
|
svg += `<text x="${PAD_LEFT + NODE_W + COL_GAP + NODE_W / 2}" y="30" text-anchor="middle" class="route-map-col-label">${t('routeMap.agents')}</text>`
|
|
svg += `<text x="${PAD_LEFT + NODE_W + COL_GAP / 2}" y="30" text-anchor="middle" class="route-map-col-label" style="font-size:11px;opacity:0.5">${t('routeMap.bindings')}</text>`
|
|
|
|
// 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 += `<path d="M${x1},${y1} C${cx1},${y1} ${cx2},${y2} ${x2},${y2}" fill="none" stroke="${color}" stroke-width="2" stroke-opacity="${opacity}" stroke-dasharray="${dash}"/>`
|
|
// Arrow
|
|
svg += `<circle cx="${x2 - 3}" cy="${y2}" r="3" fill="${color}" fill-opacity="${opacity}"/>`
|
|
// Edge label
|
|
if (e.hint) {
|
|
const mx = (x1 + x2) / 2
|
|
const my = (y1 + y2) / 2
|
|
svg += `<text x="${mx}" y="${my - 6}" text-anchor="middle" class="route-map-edge-label">${escAttr(e.hint)}</text>`
|
|
}
|
|
}
|
|
|
|
// Draw channel nodes
|
|
for (const n of channelNodes) {
|
|
const opacity = n.enabled ? 1 : 0.45
|
|
svg += `<g class="route-map-node" data-nav="channels" style="cursor:pointer;opacity:${opacity}">
|
|
<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="10" class="route-map-card" style="stroke:${n.color}"/>
|
|
<circle cx="${n.x + 22}" cy="${n.y + NODE_H / 2}" r="8" fill="${n.color}" fill-opacity="0.15"/>
|
|
<text x="${n.x + 22}" y="${n.y + NODE_H / 2 + 1}" text-anchor="middle" class="route-map-node-emoji" style="font-size:10px">${n.enabled ? '📡' : '⏸'}</text>
|
|
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 - 4}" class="route-map-node-label">${escAttr(n.label)}</text>
|
|
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 + 12}" class="route-map-node-sub">${n.enabled ? t('routeMap.enabled') : t('routeMap.disabled')}</text>
|
|
</g>`
|
|
}
|
|
|
|
// Draw agent nodes
|
|
for (const n of agentNodes) {
|
|
svg += `<g class="route-map-node" data-nav="agents" style="cursor:pointer">
|
|
<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="10" class="route-map-card ${n.isDefault ? 'route-map-card-default' : ''}"/>
|
|
<text x="${n.x + 22}" y="${n.y + NODE_H / 2 + 5}" text-anchor="middle" style="font-size:16px">${n.emoji}</text>
|
|
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 - 4}" class="route-map-node-label">${escAttr(n.label)}</text>
|
|
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 + 12}" class="route-map-node-sub">${n.isDefault ? '⭐ ' + t('routeMap.defaultAgent') : n.id}</text>
|
|
</g>`
|
|
}
|
|
|
|
// 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 += `<path d="M${x1},${y1} Q${cx},${(y1 + y2) / 2} ${x2},${y2}" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3" stroke-opacity="0.7"/>`
|
|
svg += `<circle cx="${x2}" cy="${y2}" r="2.5" fill="#f59e0b" fill-opacity="0.7"/>`
|
|
const mx = cx - 4
|
|
const my = (y1 + y2) / 2
|
|
svg += `<text x="${mx}" y="${my - 4}" text-anchor="end" class="route-map-edge-label" style="fill:#f59e0b">${t('routeMap.subAgentCall')}</text>`
|
|
}
|
|
|
|
// Legend
|
|
const ly = svgH + (a2aEdges.length > 0 ? 10 : 0)
|
|
svg += `<g class="route-map-legend">`
|
|
let lx = PAD_LEFT
|
|
// Solid line = explicit binding
|
|
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="var(--accent)" stroke-width="2"/>`
|
|
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:var(--text-secondary)">${t('routeMap.legendBinding')}</text>`
|
|
lx += 110
|
|
// Dashed line = default route
|
|
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="#94a3b8" stroke-width="2" stroke-dasharray="6,4"/>`
|
|
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:var(--text-secondary)">${t('routeMap.legendDefault')}</text>`
|
|
if (a2aEdges.length > 0) {
|
|
lx += 110
|
|
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>`
|
|
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:#f59e0b">${t('routeMap.subAgentCall')}</text>`
|
|
}
|
|
svg += `</g>`
|
|
|
|
svg += '</svg>'
|
|
|
|
container.innerHTML = `<div class="route-map-scroll">${svg}</div>`
|
|
|
|
// Click to navigate
|
|
container.querySelectorAll('.route-map-node').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const target = el.dataset.nav
|
|
if (target) navigate('/' + target)
|
|
})
|
|
})
|
|
}
|