mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 05:10:14 +08:00
feat: new pages + dashboard enhancements + backend improvements
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
This commit is contained in:
@@ -7,6 +7,7 @@ import { getActiveInstance, onGatewayChange } from '../lib/app-state.js'
|
||||
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
|
||||
import { navigate } from '../router.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
import { wsClient } from '../lib/ws-client.js'
|
||||
|
||||
let _unsubGw = null
|
||||
let _loadInFlight = false
|
||||
@@ -136,7 +137,8 @@ async function _loadDashboardDataInner(page, fullRefresh) {
|
||||
api.listAgents(),
|
||||
api.readMcpConfig(),
|
||||
api.listBackups(),
|
||||
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
|
||||
api.listConfiguredPlatforms().catch(() => []),
|
||||
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
|
||||
const logsP = api.readLogTail('gateway', 20).catch(() => '')
|
||||
|
||||
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
|
||||
@@ -189,10 +191,11 @@ async function _loadDashboardDataInner(page, fullRefresh) {
|
||||
}
|
||||
|
||||
// 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览
|
||||
const [agentsRes, mcpRes, backupsRes] = await secondaryP
|
||||
const [agentsRes, mcpRes, backupsRes, channelsRes] = await secondaryP
|
||||
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
|
||||
const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
|
||||
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
|
||||
const channels = channelsRes.status === 'fulfilled' ? (channelsRes.value || []) : []
|
||||
let statusSummary = null
|
||||
if (shouldLoadStatusSummary) {
|
||||
try {
|
||||
@@ -206,7 +209,7 @@ async function _loadDashboardDataInner(page, fullRefresh) {
|
||||
}
|
||||
|
||||
renderStatCards(page, services, version, agents, config, panelConfig)
|
||||
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary)
|
||||
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary, channels)
|
||||
|
||||
// 第三波:日志(最低优先级)
|
||||
const logs = await logsP
|
||||
@@ -310,7 +313,7 @@ function renderStatCards(page, services, version, agents, config, panelConfig) {
|
||||
`
|
||||
}
|
||||
|
||||
function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {
|
||||
function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary, channels) {
|
||||
const containerEl = page.querySelector('#dashboard-overview-container')
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
const foreignGateway = isForeignGatewayService(gw)
|
||||
@@ -414,6 +417,8 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderWsStatus()}
|
||||
${renderChannelsOverview(channels)}
|
||||
${renderSessionStatus(sessions)}
|
||||
</div>
|
||||
`
|
||||
@@ -459,6 +464,82 @@ function renderSessionStatus(sessions) {
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderWsStatus() {
|
||||
const connected = wsClient.connected
|
||||
const ready = wsClient.gatewayReady
|
||||
const reconnecting = wsClient.reconnectState === 'attempting' || wsClient.reconnectState === 'scheduled'
|
||||
const attempts = wsClient.reconnectAttempts
|
||||
const serverVer = wsClient.serverVersion
|
||||
|
||||
let statusColor, statusLabel, statusDetail
|
||||
if (ready) {
|
||||
statusColor = 'var(--success)'
|
||||
statusLabel = t('dashboard.wsConnected')
|
||||
statusDetail = serverVer ? `Gateway ${serverVer}` : ''
|
||||
} else if (connected) {
|
||||
statusColor = 'var(--warning)'
|
||||
statusLabel = t('dashboard.wsHandshaking')
|
||||
statusDetail = ''
|
||||
} else if (reconnecting) {
|
||||
statusColor = 'var(--warning)'
|
||||
statusLabel = t('dashboard.wsReconnecting')
|
||||
statusDetail = `#${attempts}`
|
||||
} else {
|
||||
statusColor = 'var(--text-tertiary)'
|
||||
statusLabel = t('dashboard.wsDisconnected')
|
||||
statusDetail = ''
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="config-section" style="margin-top:16px">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:8px">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor}"></span>
|
||||
WebSocket ${statusLabel}
|
||||
${statusDetail ? `<span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${escapeHtml(statusDetail)}</span>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const CHANNEL_ICONS = { qqbot: '🐧', qq: '🐧', feishu: '🪶', dingtalk: '📌', telegram: '✈️', discord: '🎮', slack: '💬', weixin: '💚', wechat: '💚', webchat: '🌐', whatsapp: '📱', line: '🟢', teams: '👥', matrix: '🔗' }
|
||||
|
||||
function renderChannelsOverview(channels) {
|
||||
if (!channels || channels.length === 0) return ''
|
||||
const items = channels.map(ch => {
|
||||
const icon = CHANNEL_ICONS[ch.platform] || '📡'
|
||||
const enabled = ch.enabled !== false
|
||||
const dot = enabled ? 'var(--success)' : 'var(--text-tertiary)'
|
||||
const name = ch.name || ch.platform || ch.id || ''
|
||||
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:20px;background:var(--bg-secondary);font-size:var(--font-size-xs);white-space:nowrap">
|
||||
<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${dot}"></span>
|
||||
${icon} ${escapeHtml(name)}
|
||||
</span>`
|
||||
})
|
||||
return `
|
||||
<div class="config-section" style="margin-top:12px">
|
||||
<div class="config-section-title">${t('dashboard.connectedChannels')} <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${channels.length}</span></div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">${items.join('')}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function parseLogLine(line) {
|
||||
// 常见日志格式: [2024-01-15 14:30:25] [INFO] message 或 2024-01-15T14:30:25 INFO message
|
||||
const m = line.match(/^[\[(]?(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?\s*[\[(]?\s*(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\s*[\])]?\s*(.*)$/i)
|
||||
if (m) return { time: m[1].replace('T', ' ').replace(/\.\d+$/, ''), level: m[2].toUpperCase().replace('WARNING', 'WARN'), msg: m[3] }
|
||||
// 简单 level 前缀: INFO: xxx / [ERROR] xxx
|
||||
const m2 = line.match(/^[\[(]?\s*(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\s*[\]):]\s*(.*)$/i)
|
||||
if (m2) return { time: '', level: m2[1].toUpperCase().replace('WARNING', 'WARN'), msg: m2[2] }
|
||||
return { time: '', level: '', msg: line }
|
||||
}
|
||||
|
||||
const LOG_LEVEL_STYLE = {
|
||||
ERROR: 'background:rgba(239,68,68,0.12);color:#ef4444;border:1px solid rgba(239,68,68,0.2)',
|
||||
FATAL: 'background:rgba(239,68,68,0.12);color:#ef4444;border:1px solid rgba(239,68,68,0.2)',
|
||||
WARN: 'background:rgba(234,179,8,0.12);color:#ca8a04;border:1px solid rgba(234,179,8,0.2)',
|
||||
INFO: 'background:rgba(59,130,246,0.10);color:#3b82f6;border:1px solid rgba(59,130,246,0.15)',
|
||||
DEBUG: 'background:rgba(148,163,184,0.10);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)',
|
||||
TRACE: 'background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.1)',
|
||||
}
|
||||
|
||||
function renderLogs(page, logs) {
|
||||
const logsEl = page.querySelector('#recent-logs')
|
||||
if (!logs) {
|
||||
@@ -466,7 +547,13 @@ function renderLogs(page, logs) {
|
||||
return
|
||||
}
|
||||
const lines = logs.trim().split('\n')
|
||||
logsEl.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
|
||||
logsEl.innerHTML = lines.map(l => {
|
||||
const parsed = parseLogLine(l)
|
||||
if (!parsed.level) return `<div class="log-line">${escapeHtml(l)}</div>`
|
||||
const badge = `<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:0.5px;${LOG_LEVEL_STYLE[parsed.level] || ''}">${parsed.level}</span>`
|
||||
const time = parsed.time ? `<span style="color:var(--text-tertiary);font-size:11px;opacity:0.7;margin-right:4px">${escapeHtml(parsed.time)}</span>` : ''
|
||||
return `<div class="log-line" style="display:flex;align-items:center;gap:6px">${time}${badge}<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">${escapeHtml(parsed.msg)}</span></div>`
|
||||
}).join('')
|
||||
logsEl.scrollTop = logsEl.scrollHeight
|
||||
}
|
||||
|
||||
|
||||
116
src/pages/diagnose.js
Normal file
116
src/pages/diagnose.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Gateway 连接诊断页面
|
||||
*/
|
||||
import { api, isTauriRuntime } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
const STEP_LABELS = {
|
||||
config: () => t('diagnose.stepConfig'),
|
||||
device_key: () => t('diagnose.stepDeviceKey'),
|
||||
allowed_origins: () => t('diagnose.stepOrigins'),
|
||||
tcp_port: () => t('diagnose.stepTcp'),
|
||||
http_health: () => t('diagnose.stepHttp'),
|
||||
err_log: () => t('diagnose.stepErrLog'),
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">${t('diagnose.title')}</h1>
|
||||
<p class="page-desc">${t('diagnose.desc')}</p>
|
||||
</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<button class="btn btn-primary" id="btn-diagnose">${t('diagnose.runDiagnose')}</button>
|
||||
</div>
|
||||
<div id="diagnose-summary" style="margin-bottom:16px"></div>
|
||||
<div id="diagnose-steps" class="card-grid" style="margin-bottom:24px">
|
||||
<div class="empty-state" style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('diagnose.noData')}</div>
|
||||
</div>
|
||||
<div id="diagnose-env" style="display:none">
|
||||
<h3 style="margin-bottom:12px">${t('diagnose.envInfo')}</h3>
|
||||
<div class="stat-card" id="env-content" style="font-size:var(--font-size-sm);overflow-x:auto"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const btnDiagnose = page.querySelector('#btn-diagnose')
|
||||
|
||||
btnDiagnose.onclick = async () => {
|
||||
btnDiagnose.disabled = true
|
||||
btnDiagnose.textContent = t('diagnose.running')
|
||||
page.querySelector('#diagnose-summary').innerHTML = ''
|
||||
page.querySelector('#diagnose-steps').innerHTML = '<div class="stat-card loading-placeholder" style="height:40px;margin:8px 0"></div>'.repeat(6)
|
||||
|
||||
try {
|
||||
const result = await api.diagnoseGatewayConnection()
|
||||
renderResult(page, result)
|
||||
} catch (e) {
|
||||
toast.error(`${t('diagnose.diagnoseFailed')}: ${e}`)
|
||||
page.querySelector('#diagnose-steps').innerHTML = `<div class="empty-state" style="padding:32px;color:var(--text-error)">${t('diagnose.diagnoseFailed')}: ${e}</div>`
|
||||
} finally {
|
||||
btnDiagnose.disabled = false
|
||||
btnDiagnose.textContent = t('diagnose.runDiagnose')
|
||||
}
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
function renderResult(page, result) {
|
||||
// Summary
|
||||
const summaryEl = page.querySelector('#diagnose-summary')
|
||||
if (result.overallOk) {
|
||||
summaryEl.innerHTML = `<div class="stat-card" style="background:var(--success-bg,#f0fdf4);border:1px solid var(--success-border,#86efac);padding:12px 16px">${t('diagnose.allPassed')}</div>`
|
||||
} else {
|
||||
summaryEl.innerHTML = `<div class="stat-card" style="background:var(--error-bg,#fef2f2);border:1px solid var(--error-border,#fca5a5);padding:12px 16px">⚠️ ${result.summary}</div>`
|
||||
}
|
||||
|
||||
// Steps
|
||||
const stepsEl = page.querySelector('#diagnose-steps')
|
||||
stepsEl.innerHTML = result.steps.map(step => {
|
||||
const label = STEP_LABELS[step.name]?.() || step.name
|
||||
const icon = step.ok ? '✅' : '❌'
|
||||
const status = step.ok ? t('diagnose.passed') : t('diagnose.failed')
|
||||
const bgColor = step.ok ? 'var(--bg-secondary,#f9fafb)' : 'var(--error-bg,#fef2f2)'
|
||||
return `
|
||||
<div class="stat-card" style="background:${bgColor};padding:12px 16px;margin-bottom:8px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span>${icon}</span>
|
||||
<strong style="white-space:nowrap">${label}</strong>
|
||||
</div>
|
||||
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary);white-space:nowrap">${step.durationMs}ms</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:var(--font-size-sm);color:var(--text-secondary);word-break:break-all">${escHtml(step.message)}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
// Env info
|
||||
const envEl = page.querySelector('#diagnose-env')
|
||||
envEl.style.display = ''
|
||||
const env = result.env
|
||||
const rows = [
|
||||
[t('diagnose.openclawDir'), env.openclawDir],
|
||||
[t('diagnose.port'), env.port],
|
||||
[t('diagnose.authMode'), env.authMode],
|
||||
[t('diagnose.deviceKey'), env.deviceKeyExists ? '✅' : '❌'],
|
||||
]
|
||||
let html = '<table style="width:100%;border-collapse:collapse">'
|
||||
for (const [k, v] of rows) {
|
||||
html += `<tr><td style="padding:4px 12px 4px 0;font-weight:600;white-space:nowrap;color:var(--text-secondary)">${k}</td><td style="padding:4px 0;word-break:break-all">${escHtml(String(v))}</td></tr>`
|
||||
}
|
||||
html += '</table>'
|
||||
|
||||
if (env.errLogExcerpt) {
|
||||
html += `<details style="margin-top:12px"><summary style="cursor:pointer;font-weight:600;color:var(--text-secondary)">${t('diagnose.errLogExcerpt')}</summary><pre style="margin-top:8px;font-size:12px;max-height:200px;overflow:auto;background:var(--bg-tertiary,#1e1e1e);color:var(--text-primary);padding:8px;border-radius:6px">${escHtml(env.errLogExcerpt)}</pre></details>`
|
||||
}
|
||||
|
||||
page.querySelector('#env-content').innerHTML = html
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
233
src/pages/plugin-hub.js
Normal file
233
src/pages/plugin-hub.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 插件中心 — OpenClaw 扩展插件管理与浏览
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { navigate } from '../router.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
import { openAIDrawerWithError } from '../components/ai-drawer.js'
|
||||
|
||||
const PLUGIN_ICONS = {
|
||||
qqbot: '🐧', feishu: '🪶', dingtalk: '📌', telegram: '✈️',
|
||||
discord: '🎮', slack: '💬', weixin: '💚', wechat: '💚',
|
||||
webchat: '🌐', whatsapp: '📱', signal: '🔒', line: '🟢',
|
||||
teams: '👥', matrix: '🔗', irc: '📡',
|
||||
}
|
||||
|
||||
let _allPlugins = []
|
||||
let _searchQuery = ''
|
||||
|
||||
function esc(s) { return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<') }
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
_searchQuery = ''
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">${t('extensions.title')}</h1>
|
||||
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<button class="btn btn-sm btn-secondary" id="ph-refresh">${t('extensions.refresh')}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="ph-go-channels">${t('extensions.goToChannels')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-bottom:var(--space-md)">${t('extensions.subtitle')}</p>
|
||||
<div id="ph-stats" class="route-map-stats"></div>
|
||||
<div style="display:flex;gap:10px;margin-bottom:var(--space-md);flex-wrap:wrap">
|
||||
<div style="flex:1;min-width:200px;position:relative">
|
||||
<input type="text" class="form-input" id="ph-search" placeholder="${t('extensions.searchPlaceholder')}" style="width:100%;padding-left:32px">
|
||||
<svg style="position:absolute;left:10px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--text-tertiary)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<input type="text" class="form-input" id="ph-pkg-input" placeholder="${t('extensions.installPlaceholder')}" style="width:220px">
|
||||
<button class="btn btn-primary btn-sm" id="ph-install-btn" style="white-space:nowrap">${t('extensions.installBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ph-install-msg" style="display:none;margin-bottom:var(--space-md)"></div>
|
||||
<div id="ph-list">
|
||||
<div class="stat-card loading-placeholder" style="height:200px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
page.querySelector('#ph-refresh').onclick = () => loadPlugins(page)
|
||||
page.querySelector('#ph-go-channels').onclick = () => navigate('/channels')
|
||||
page.querySelector('#ph-install-btn').onclick = () => handleInstall(page)
|
||||
page.querySelector('#ph-pkg-input').onkeydown = (e) => { if (e.key === 'Enter') handleInstall(page) }
|
||||
page.querySelector('#ph-search').oninput = (e) => {
|
||||
_searchQuery = e.target.value.trim().toLowerCase()
|
||||
renderPluginList(page)
|
||||
}
|
||||
|
||||
// Event delegation for toggle buttons
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-toggle-plugin]')
|
||||
if (!btn) return
|
||||
const pluginId = btn.dataset.togglePlugin
|
||||
const newEnabled = btn.dataset.toggleTo === 'true'
|
||||
btn.disabled = true
|
||||
btn.textContent = '...'
|
||||
try {
|
||||
await api.togglePlugin(pluginId, newEnabled)
|
||||
toast(t('extensions.toggleSuccess'), 'success')
|
||||
await loadPlugins(page)
|
||||
} catch (err) {
|
||||
toast(`${t('extensions.toggleFailed')}: ${err}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = newEnabled ? t('extensions.enable') : t('extensions.disable')
|
||||
}
|
||||
})
|
||||
|
||||
// Expand/collapse install messages
|
||||
page.addEventListener('click', (e) => {
|
||||
if (e.target.closest('#ph-install-msg-toggle')) {
|
||||
const detail = page.querySelector('#ph-install-msg-detail')
|
||||
const toggle = page.querySelector('#ph-install-msg-toggle')
|
||||
if (detail && toggle) {
|
||||
const expanded = detail.style.display !== 'none'
|
||||
detail.style.display = expanded ? 'none' : 'block'
|
||||
toggle.textContent = expanded ? t('extensions.showDetail') : t('extensions.hideDetail')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => loadPlugins(page), 0)
|
||||
return page
|
||||
}
|
||||
|
||||
async function handleInstall(page) {
|
||||
const input = page.querySelector('#ph-pkg-input')
|
||||
const btn = page.querySelector('#ph-install-btn')
|
||||
const msgEl = page.querySelector('#ph-install-msg')
|
||||
const pkg = input.value.trim()
|
||||
if (!pkg) return
|
||||
|
||||
btn.disabled = true
|
||||
btn.textContent = t('extensions.installing')
|
||||
msgEl.style.display = 'block'
|
||||
msgEl.innerHTML = `<div style="padding:10px 14px;border-radius:8px;background:var(--bg-secondary);color:var(--text-tertiary);font-size:13px">${t('extensions.installing')}</div>`
|
||||
|
||||
try {
|
||||
const result = await api.installPlugin(pkg)
|
||||
const output = result.output ? esc(result.output).substring(0, 120) : ''
|
||||
msgEl.innerHTML = `<div style="padding:10px 14px;border-radius:8px;background:var(--success-bg,#f0fdf4);border:1px solid var(--success-border,#86efac);color:var(--success);font-size:13px">
|
||||
✅ ${t('extensions.installSuccess')}${output ? ' — ' + output : ''}
|
||||
</div>`
|
||||
toast(t('extensions.installSuccess'), 'success')
|
||||
input.value = ''
|
||||
await loadPlugins(page)
|
||||
setTimeout(() => { msgEl.style.display = 'none' }, 5000)
|
||||
} catch (e) {
|
||||
const errStr = String(e.message || e)
|
||||
const short = errStr.length > 100 ? errStr.substring(0, 100) + '...' : errStr
|
||||
const hasDetail = errStr.length > 100
|
||||
msgEl.innerHTML = `<div style="padding:10px 14px;border-radius:8px;background:var(--error-bg,#fef2f2);border:1px solid var(--error-border,#fca5a5);font-size:13px">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--error)">
|
||||
<span>❌ ${t('extensions.installFailed')}: ${esc(short)}</span>
|
||||
${hasDetail ? `<button id="ph-install-msg-toggle" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;white-space:nowrap;padding:0">${t('extensions.showDetail')}</button>` : ''}
|
||||
</div>
|
||||
${hasDetail ? `<pre id="ph-install-msg-detail" style="display:none;margin-top:8px;font-size:11px;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-secondary);background:var(--bg-secondary);padding:8px;border-radius:6px">${esc(errStr)}</pre>` : ''}
|
||||
</div>`
|
||||
toast(t('extensions.installFailed'), 'error')
|
||||
openAIDrawerWithError({
|
||||
scene: 'plugin-install',
|
||||
title: t('extensions.installFailed') + ': ' + pkg,
|
||||
hint: t('extensions.installPlaceholder'),
|
||||
error: errStr,
|
||||
})
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = t('extensions.installBtn')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlugins(page) {
|
||||
const listEl = page.querySelector('#ph-list')
|
||||
const statsEl = page.querySelector('#ph-stats')
|
||||
listEl.innerHTML = `<div class="stat-card loading-placeholder" style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-tertiary)">${t('extensions.loading')}</div>`
|
||||
|
||||
try {
|
||||
const result = await api.listAllPlugins()
|
||||
_allPlugins = result?.plugins || []
|
||||
|
||||
if (_allPlugins.length === 0) {
|
||||
statsEl.innerHTML = ''
|
||||
listEl.innerHTML = `<div class="stat-card" style="padding:var(--space-xl);text-align:center;color:var(--text-tertiary)">${t('extensions.noPlugins')}</div>`
|
||||
return
|
||||
}
|
||||
|
||||
const enabled = _allPlugins.filter(p => p.enabled).length
|
||||
const builtin = _allPlugins.filter(p => p.builtin).length
|
||||
|
||||
statsEl.innerHTML = `
|
||||
<div class="route-map-stat"><span class="route-map-stat-num">${_allPlugins.length}</span><span class="route-map-stat-label">${t('extensions.statsInstalled')}</span></div>
|
||||
<div class="route-map-stat"><span class="route-map-stat-num">${enabled}</span><span class="route-map-stat-label">${t('extensions.statsEnabled')}</span></div>
|
||||
${builtin ? `<div class="route-map-stat"><span class="route-map-stat-num">${builtin}</span><span class="route-map-stat-label">${t('extensions.statsBuiltin')}</span></div>` : ''}
|
||||
`
|
||||
|
||||
renderPluginList(page)
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="stat-card" style="padding:var(--space-lg);color:var(--error)">${esc(e.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderPluginList(page) {
|
||||
const listEl = page.querySelector('#ph-list')
|
||||
if (!listEl) return
|
||||
|
||||
const filtered = _searchQuery
|
||||
? _allPlugins.filter(p => {
|
||||
const q = _searchQuery
|
||||
return (p.id || '').toLowerCase().includes(q) ||
|
||||
(p.description || '').toLowerCase().includes(q) ||
|
||||
(p.version || '').toLowerCase().includes(q)
|
||||
})
|
||||
: _allPlugins
|
||||
|
||||
if (filtered.length === 0 && _searchQuery) {
|
||||
listEl.innerHTML = `<div class="stat-card" style="padding:var(--space-lg);text-align:center;color:var(--text-tertiary)">
|
||||
${t('extensions.noSearchResults', { query: esc(_searchQuery) })}
|
||||
</div>`
|
||||
return
|
||||
}
|
||||
|
||||
listEl.innerHTML = `<div class="plugin-grid">${filtered.map(p => renderPluginCard(p)).join('')}</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-md);font-size:var(--font-size-xs)">${t('extensions.restartHint')}</div>`
|
||||
}
|
||||
|
||||
function renderPluginCard(p) {
|
||||
const icon = PLUGIN_ICONS[p.id.toLowerCase()] || '🧩'
|
||||
const statusClass = p.enabled ? 'plugin-status-enabled' : (p.installed ? 'plugin-status-disabled' : 'plugin-status-missing')
|
||||
const statusText = p.enabled ? t('extensions.enabled') : (p.installed ? t('extensions.disabled') : t('extensions.notInstalled'))
|
||||
const badges = []
|
||||
if (p.builtin) badges.push(`<span class="plugin-badge plugin-badge-builtin">${t('extensions.builtin')}</span>`)
|
||||
if (p.version) badges.push(`<span class="plugin-badge plugin-badge-version">${t('extensions.version')} ${esc(p.version)}</span>`)
|
||||
|
||||
// Toggle button: installed plugins can be enabled/disabled
|
||||
let toggleBtn = ''
|
||||
if (p.installed) {
|
||||
if (p.enabled) {
|
||||
toggleBtn = `<button class="btn btn-sm btn-secondary" data-toggle-plugin="${esc(p.id)}" data-toggle-to="false">${t('extensions.disable')}</button>`
|
||||
} else {
|
||||
toggleBtn = `<button class="btn btn-sm btn-primary" data-toggle-plugin="${esc(p.id)}" data-toggle-to="true">${t('extensions.enable')}</button>`
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="plugin-card ${p.enabled ? '' : 'plugin-card-inactive'}">
|
||||
<div class="plugin-card-header">
|
||||
<span class="plugin-card-icon">${icon}</span>
|
||||
<div class="plugin-card-title">
|
||||
<span class="plugin-card-name">${esc(p.id)}</span>
|
||||
<div class="plugin-card-badges">${badges.join('')}</div>
|
||||
</div>
|
||||
<span class="plugin-status-dot ${statusClass}" title="${statusText}"></span>
|
||||
</div>
|
||||
<div class="plugin-card-desc">${esc(p.description) || t('extensions.noDescription')}</div>
|
||||
<div class="plugin-card-footer">
|
||||
<span class="plugin-card-status">${statusText}</span>
|
||||
${toggleBtn}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
277
src/pages/route-map.js
Normal file
277
src/pages/route-map.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 路由地图 — 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user