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:
晴天
2026-04-11 00:44:06 +08:00
parent c1fb674c44
commit 70d768be17
27 changed files with 2337 additions and 187 deletions

View File

@@ -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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

233
src/pages/plugin-hub.js Normal file
View 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') }
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
View 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') }
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)
})
})
}