diff --git a/src/lib/channel-runtime.js b/src/lib/channel-runtime.js
new file mode 100644
index 0000000..31fd8a7
--- /dev/null
+++ b/src/lib/channel-runtime.js
@@ -0,0 +1,170 @@
+const EMPTY_STATUS = Object.freeze({
+ supported: false,
+ ts: 0,
+ partial: false,
+ warnings: Object.freeze([]),
+ channelOrder: Object.freeze([]),
+ channelLabels: Object.freeze({}),
+ channelDetailLabels: Object.freeze({}),
+ channelAccounts: Object.freeze({}),
+ channelDefaultAccountId: Object.freeze({}),
+ channels: Object.freeze({}),
+ eventLoop: null,
+})
+
+export function normalizeChannelRuntimeStatus(raw) {
+ if (!raw || typeof raw !== 'object') {
+ return { ...EMPTY_STATUS }
+ }
+
+ return {
+ supported: true,
+ ts: Number.isFinite(raw.ts) ? raw.ts : Date.now(),
+ partial: raw.partial === true,
+ warnings: Array.isArray(raw.warnings) ? raw.warnings.filter(Boolean).map(String) : [],
+ channelOrder: Array.isArray(raw.channelOrder) ? raw.channelOrder.filter(Boolean).map(String) : [],
+ channelLabels: plainObject(raw.channelLabels),
+ channelDetailLabels: plainObject(raw.channelDetailLabels),
+ channelAccounts: normalizeChannelAccounts(raw.channelAccounts),
+ channelDefaultAccountId: plainObject(raw.channelDefaultAccountId),
+ channels: plainObject(raw.channels),
+ eventLoop: raw.eventLoop && typeof raw.eventLoop === 'object' ? raw.eventLoop : null,
+ }
+}
+
+export function getChannelRuntimeSummary(status, channelId, fallbackLabel = '') {
+ const normalized = status && typeof status === 'object' && Object.prototype.hasOwnProperty.call(status, 'supported')
+ ? status
+ : normalizeChannelRuntimeStatus(status)
+ const channel = String(channelId || '')
+ const accounts = normalizeChannelAccounts(normalized.channelAccounts)[channel] || []
+ const counts = countAccountStates(accounts)
+ const supported = normalized.supported === true
+ const state = supported ? pickSummaryState({ accounts, counts }) : 'unsupported'
+
+ return {
+ supported,
+ channel,
+ label: normalized.channelLabels?.[channel] || fallbackLabel || channel,
+ detailLabel: normalized.channelDetailLabels?.[channel] || '',
+ defaultAccountId: normalized.channelDefaultAccountId?.[channel] || '',
+ rawChannel: normalized.channels?.[channel] || null,
+ accounts,
+ counts,
+ state,
+ lastError: firstAccountValue(accounts, 'lastError'),
+ lastInboundAt: latestNumber(accounts, 'lastInboundAt'),
+ lastOutboundAt: latestNumber(accounts, 'lastOutboundAt'),
+ lastTransportActivityAt: latestNumber(accounts, 'lastTransportActivityAt'),
+ }
+}
+
+export function getRuntimeStateMeta(state) {
+ switch (state) {
+ case 'error':
+ return { label: '异常', tone: 'error', icon: 'alert-triangle' }
+ case 'connected':
+ return { label: '已连接', tone: 'success', icon: 'check' }
+ case 'running':
+ return { label: '运行中', tone: 'accent', icon: 'play' }
+ case 'configured':
+ return { label: '已配置', tone: 'warning', icon: 'gear' }
+ case 'disabled':
+ return { label: '已停用', tone: 'muted', icon: 'pause' }
+ case 'missing':
+ return { label: '未配置', tone: 'muted', icon: 'circle' }
+ case 'unsupported':
+ return { label: '内核不支持', tone: 'warning', icon: 'alert-triangle' }
+ default:
+ return { label: '未知', tone: 'muted', icon: 'info' }
+ }
+}
+
+export function formatRuntimeAge(value, now = Date.now()) {
+ const ts = Number(value)
+ if (!Number.isFinite(ts) || ts <= 0) return ''
+ const delta = Math.max(0, Number(now) - ts)
+ const seconds = Math.floor(delta / 1000)
+ if (seconds < 45) return '刚刚'
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes} 分钟前`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 48) return `${hours} 小时前`
+ const days = Math.floor(hours / 24)
+ return `${days} 天前`
+}
+
+function normalizeChannelAccounts(value) {
+ const result = {}
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return result
+ for (const [channel, accounts] of Object.entries(value)) {
+ if (!Array.isArray(accounts)) continue
+ result[channel] = accounts
+ .filter(account => account && typeof account === 'object')
+ .map(account => {
+ const accountId = account.accountId == null || account.accountId === '' ? 'default' : String(account.accountId)
+ return {
+ ...account,
+ accountId,
+ state: pickAccountState(account),
+ lastError: account.lastError ? String(account.lastError) : '',
+ }
+ })
+ }
+ return result
+}
+
+function plainObject(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
+ return { ...value }
+}
+
+function pickAccountState(account) {
+ if (account.lastError) return 'error'
+ if (account.enabled === false) return 'disabled'
+ if (account.connected === true || account.linked === true) return 'connected'
+ if (account.running === true) return 'running'
+ if (account.configured === true) return 'configured'
+ return 'missing'
+}
+
+function pickSummaryState({ accounts, counts }) {
+ if (!accounts.length) return 'missing'
+ if (counts.error > 0) return 'error'
+ if (counts.connected > 0) return 'connected'
+ if (counts.running > 0) return 'running'
+ if (counts.configured > 0) return 'configured'
+ if (counts.disabled > 0) return 'disabled'
+ return 'missing'
+}
+
+function countAccountStates(accounts) {
+ const counts = {
+ total: accounts.length,
+ error: 0,
+ connected: 0,
+ running: 0,
+ configured: 0,
+ disabled: 0,
+ missing: 0,
+ }
+ for (const account of accounts) {
+ const state = account.state || pickAccountState(account)
+ counts[state] = (counts[state] || 0) + 1
+ }
+ return counts
+}
+
+function latestNumber(accounts, key) {
+ let latest = 0
+ for (const account of accounts) {
+ const value = Number(account?.[key])
+ if (Number.isFinite(value) && value > latest) latest = value
+ }
+ return latest || null
+}
+
+function firstAccountValue(accounts, key) {
+ const found = accounts.find(account => account?.[key])
+ return found?.[key] ? String(found[key]) : ''
+}
diff --git a/src/pages/channels.js b/src/pages/channels.js
index 49059d8..24da556 100644
--- a/src/pages/channels.js
+++ b/src/pages/channels.js
@@ -11,6 +11,12 @@ import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js'
import { wsClient } from '../lib/ws-client.js'
+import {
+ formatRuntimeAge,
+ getChannelRuntimeSummary,
+ getRuntimeStateMeta,
+ normalizeChannelRuntimeStatus,
+} from '../lib/channel-runtime.js'
// ── 渠道注册表:面板内置向导,覆盖 OpenClaw 官方渠道 + 国内扩展渠道 ──
@@ -301,6 +307,8 @@ export async function render() {
configured: [],
bindings: [],
agents: [],
+ runtimeStatus: normalizeChannelRuntimeStatus(null),
+ runtimeStatusError: '',
routeIntent: parseChannelsRouteIntent(),
routeIntentConsumed: false,
routeIntentHintShown: false,
@@ -346,6 +354,26 @@ async function loadPlatforms(page, state) {
renderAvailable(page, state)
renderAgentBindings(page, state)
applyRouteIntent(page, state)
+ refreshChannelRuntimeStatus(page, state, { probe: true, timeoutMs: 5000 })
+}
+
+async function loadChannelRuntimeStatus(state, options = {}) {
+ state.runtimeStatusError = ''
+ try {
+ const raw = await wsClient.requestCompat('channels.status', {
+ probe: options.probe !== false,
+ timeoutMs: options.timeoutMs || 5000,
+ }, null)
+ state.runtimeStatus = normalizeChannelRuntimeStatus(raw)
+ } catch (e) {
+ state.runtimeStatus = normalizeChannelRuntimeStatus(null)
+ state.runtimeStatusError = e?.message || String(e)
+ }
+}
+
+async function refreshChannelRuntimeStatus(page, state, options = {}) {
+ await loadChannelRuntimeStatus(state, options)
+ renderConfigured(page, state)
}
function applyRouteIntent(page, state) {
@@ -390,6 +418,129 @@ function platformLabel(pid) {
return PLATFORM_REGISTRY[pid]?.label || CHANNEL_LABELS[pid] || pid
}
+function renderRuntimeNotice(state) {
+ if (state.runtimeStatusError) {
+ return `
+
+ ${icon('alert-triangle', 14)}
+ 无法读取 Gateway 渠道运行态:${escapeAttr(state.runtimeStatusError)}
+
+ `
+ }
+ if (!state.runtimeStatus?.supported) {
+ return `
+
+ ${icon('alert-triangle', 14)}
+ 当前 OpenClaw 内核不支持通用渠道运行态,请升级到新版内核;配置编辑仍可继续使用。
+
+ `
+ }
+ const warnings = Array.isArray(state.runtimeStatus.warnings) ? state.runtimeStatus.warnings : []
+ if (state.runtimeStatus.partial || warnings.length) {
+ return `
+
+ ${icon('alert-triangle', 14)}
+ ${state.runtimeStatus.partial ? '运行态结果不完整' : '运行态探测有提示'}${warnings.length ? `:${escapeAttr(warnings.join(';'))}` : ''}
+
+ `
+ }
+ return ''
+}
+
+function renderRuntimeBadge(summary) {
+ if (!summary.supported) return ''
+ const meta = getRuntimeStateMeta(summary.state)
+ return `${icon(meta.icon, 12)} ${meta.label}`
+}
+
+function renderRuntimeSummary(summary) {
+ if (!summary.supported) {
+ return `通用运行态不可用
`
+ }
+ const parts = []
+ if (summary.counts.total) parts.push(`${summary.counts.total} 个账号`)
+ if (summary.counts.connected) parts.push(`${summary.counts.connected} 已连接`)
+ if (summary.counts.running) parts.push(`${summary.counts.running} 运行中`)
+ if (summary.counts.error) parts.push(`${summary.counts.error} 异常`)
+ if (summary.lastInboundAt) parts.push(`最近接收 ${formatRuntimeAge(summary.lastInboundAt)}`)
+ if (summary.lastOutboundAt) parts.push(`最近发送 ${formatRuntimeAge(summary.lastOutboundAt)}`)
+ if (summary.lastError) parts.push(`错误:${summary.lastError}`)
+ return `${escapeAttr(parts.join(' · ') || 'Gateway 暂未返回账号状态')}
`
+}
+
+function renderRuntimeAccountInfo(summary, accountId) {
+ if (!summary.supported) return ''
+ const normalizedAccountId = accountId || 'default'
+ const account = summary.accounts.find(a => (a.accountId || 'default') === normalizedAccountId)
+ || (!accountId ? summary.accounts.find(a => a.accountId === summary.defaultAccountId) : null)
+ if (!account) return ''
+ const meta = getRuntimeStateMeta(account.state)
+ const details = []
+ if (account.lastError) details.push(account.lastError)
+ if (account.healthState) details.push(`health=${account.healthState}`)
+ if (account.lastInboundAt) details.push(`收 ${formatRuntimeAge(account.lastInboundAt)}`)
+ if (account.lastOutboundAt) details.push(`发 ${formatRuntimeAge(account.lastOutboundAt)}`)
+ if (account.probe && typeof account.probe === 'object' && account.probe.ok === false) details.push('探测失败')
+ return `
+
+ ${icon(meta.icon, 12)} ${meta.label}
+
+ `
+}
+
+function renderRuntimeActions(summary, accountId = '') {
+ if (!summary.supported) {
+ return ``
+ }
+ const accountAttr = escapeAttr(accountId || '')
+ const stopDisabled = summary.state === 'missing' || summary.state === 'configured'
+ const logoutDisabled = summary.state === 'missing'
+ return `
+
+
+
+
+ `
+}
+
+async function handleRuntimeAction(pid, action, accountId, btn, page, state) {
+ const channel = getChannelBindingKey(pid)
+ const prevHtml = btn?.innerHTML
+ if (btn) {
+ btn.disabled = true
+ btn.textContent = action === 'refresh' ? '刷新中' : '处理中'
+ }
+ try {
+ if (action === 'refresh') {
+ await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 })
+ renderConfigured(page, state)
+ toast('渠道运行态已刷新', 'success')
+ return
+ }
+
+ if (!wsClient.connected || !wsClient.gatewayReady) {
+ throw new Error('Gateway WebSocket 未连接,请先启动 OpenClaw Gateway')
+ }
+
+ const params = { channel }
+ if (accountId) params.accountId = accountId
+ await wsClient.request(`channels.${action}`, params)
+ await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 })
+ renderConfigured(page, state)
+ const actionText = action === 'start' ? '启动' : action === 'stop' ? '停止' : '注销'
+ toast(`${platformLabel(pid)} ${actionText}完成`, 'success')
+ } catch (e) {
+ state.runtimeStatusError = e?.message || String(e)
+ renderConfigured(page, state)
+ toast(humanizeError(e, '渠道运行时操作失败'), 'error')
+ } finally {
+ if (btn) {
+ btn.disabled = false
+ if (prevHtml != null) btn.innerHTML = prevHtml
+ }
+ }
+}
+
function renderConfigured(page, state) {
const el = page.querySelector('#platforms-configured')
if (!state.configured.length) {
@@ -400,12 +551,14 @@ function renderConfigured(page, state) {
el.innerHTML = `
${t('channels.configured')}
+ ${renderRuntimeNotice(state)}
${state.configured.map(p => {
const reg = PLATFORM_REGISTRY[p.id]
const label = platformLabel(p.id)
const ic = icon(reg?.iconName || 'radio', 22)
const channelKey = getChannelBindingKey(p.id)
+ const runtimeSummary = getChannelRuntimeSummary(state.runtimeStatus, channelKey, label)
const accounts = Array.isArray(p.accounts) ? p.accounts : []
const hasAccounts = accounts.length > 0
const supportsMulti = MULTI_INSTANCE_PLATFORMS.includes(p.id)
@@ -426,6 +579,7 @@ function renderConfigured(page, state) {
${escapeAttr(accId)}
${acc.appId ? `${escapeAttr(acc.appId)}` : ''}
${badgesHtml}
+ ${renderRuntimeAccountInfo(runtimeSummary, acc.accountId || '')}
@@ -440,10 +594,13 @@ function renderConfigured(page, state) {
${ic}
${label}
${t('channels.accountCount', { count: accounts.length })}
+ ${renderRuntimeBadge(runtimeSummary)}
+ ${renderRuntimeSummary(runtimeSummary)}
${accountsHtml}
+ ${renderRuntimeActions(runtimeSummary)}
${supportsMulti ? `` : ''}
${reg ? `` : `${t('channels.noGuide')}`}
@@ -465,9 +622,12 @@ function renderConfigured(page, state) {
${ic}
${label}
${agentBadges}
+ ${renderRuntimeBadge(runtimeSummary)}
+ ${renderRuntimeSummary(runtimeSummary)}
+ ${renderRuntimeActions(runtimeSummary)}
${supportsMulti ? `` : ''}
${reg ? `` : `${t('channels.noGuide')}`}
@@ -691,6 +851,11 @@ function renderConfigured(page, state) {
card.querySelector('[data-action="add-account"]')?.addEventListener('click', () => openConfigDialog(pid, page, state, ''))
card.querySelector('[data-action="edit"]')?.addEventListener('click', () => openConfigDialog(pid, page, state))
+ card.querySelectorAll('[data-runtime-action]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ handleRuntimeAction(pid, btn.dataset.runtimeAction, btn.dataset.accountId || '', btn, page, state)
+ })
+ })
card.querySelectorAll('[data-action="edit-account"]').forEach(btn => {
btn.addEventListener('click', () => {
diff --git a/src/style/pages.css b/src/style/pages.css
index 554f569..412aaa7 100644
--- a/src/style/pages.css
+++ b/src/style/pages.css
@@ -1005,6 +1005,7 @@
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
+ flex-wrap: wrap;
}
.platform-emoji {
font-size: 22px;
@@ -1035,6 +1036,144 @@
flex-wrap: wrap;
}
+.channel-runtime-notice {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-sm);
+ padding: 10px 12px;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ line-height: 1.55;
+ margin-bottom: var(--space-md);
+}
+.channel-runtime-notice.warning {
+ color: var(--warning);
+ background: var(--warning-muted);
+ border-color: rgba(217, 119, 6, 0.25);
+}
+.channel-runtime-notice.error {
+ color: var(--error);
+ background: var(--error-muted);
+ border-color: rgba(220, 38, 38, 0.25);
+}
+.runtime-badge,
+.runtime-account {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ min-height: 22px;
+ padding: 2px 7px;
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ white-space: nowrap;
+}
+.runtime-badge.success,
+.runtime-account.success {
+ color: var(--success);
+ background: var(--success-muted);
+}
+.runtime-badge.error,
+.runtime-account.error {
+ color: var(--error);
+ background: var(--error-muted);
+}
+.runtime-badge.warning,
+.runtime-account.warning {
+ color: var(--warning);
+ background: var(--warning-muted);
+}
+.runtime-badge.accent,
+.runtime-account.accent {
+ color: var(--accent);
+ background: var(--accent-muted);
+}
+.runtime-badge.muted,
+.runtime-account.muted {
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+}
+.channel-runtime-summary {
+ margin: -4px 0 var(--space-md);
+ padding: 8px 10px;
+ border-radius: var(--radius-md);
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ font-size: var(--font-size-xs);
+ line-height: 1.55;
+ word-break: break-word;
+}
+.channel-runtime-summary.error {
+ color: var(--error);
+ background: var(--error-muted);
+}
+.channel-runtime-summary.connected {
+ color: var(--success);
+ background: var(--success-muted);
+}
+.channel-runtime-summary.running {
+ color: var(--accent);
+ background: var(--accent-muted);
+}
+.channel-runtime-summary.configured {
+ color: var(--warning);
+ background: var(--warning-muted);
+}
+.platform-accounts {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-md);
+}
+.account-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ flex-wrap: wrap;
+ padding: 8px 10px;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+}
+.account-id {
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.account-appid {
+ min-width: 0;
+ max-width: 160px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--font-size-xs);
+ color: var(--text-tertiary);
+}
+.account-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: auto;
+}
+
+@media (max-width: 640px) {
+ .platforms-grid {
+ grid-template-columns: 1fr;
+ }
+ .platform-card {
+ padding: var(--space-md);
+ }
+ .platform-card-actions .btn {
+ min-height: 36px;
+ }
+ .account-actions {
+ width: 100%;
+ margin-left: 0;
+ }
+}
+
/* 可接入平台选择按钮 */
.platform-pick {
display: flex;
@@ -3147,4 +3286,4 @@
transition: none !important;
animation: none !important;
}
-}
\ No newline at end of file
+}
diff --git a/tests/channel-runtime.test.js b/tests/channel-runtime.test.js
new file mode 100644
index 0000000..f26ef3a
--- /dev/null
+++ b/tests/channel-runtime.test.js
@@ -0,0 +1,93 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ formatRuntimeAge,
+ getChannelRuntimeSummary,
+ normalizeChannelRuntimeStatus,
+} from '../src/lib/channel-runtime.js'
+
+test('normalizeChannelRuntimeStatus marks missing RPC result as unsupported', () => {
+ const status = normalizeChannelRuntimeStatus(null)
+
+ assert.equal(status.supported, false)
+ assert.deepEqual(status.channelOrder, [])
+ assert.deepEqual(status.channels, {})
+})
+
+test('getChannelRuntimeSummary preserves normalized unsupported status', () => {
+ const status = normalizeChannelRuntimeStatus(null)
+ const summary = getChannelRuntimeSummary(status, 'telegram', 'Telegram')
+
+ assert.equal(summary.supported, false)
+ assert.equal(summary.state, 'unsupported')
+ assert.equal(summary.label, 'Telegram')
+ assert.deepEqual(summary.accounts, [])
+})
+
+test('getChannelRuntimeSummary prefers account errors over connected state', () => {
+ const status = normalizeChannelRuntimeStatus({
+ ts: 1000,
+ channelOrder: ['telegram'],
+ channelLabels: { telegram: 'Telegram' },
+ channelAccounts: {
+ telegram: [
+ {
+ accountId: 'bot-a',
+ configured: true,
+ enabled: true,
+ running: true,
+ connected: true,
+ lastError: '401 Unauthorized',
+ },
+ ],
+ },
+ channelDefaultAccountId: { telegram: 'bot-a' },
+ channels: {},
+ })
+
+ const summary = getChannelRuntimeSummary(status, 'telegram')
+
+ assert.equal(summary.supported, true)
+ assert.equal(summary.state, 'error')
+ assert.equal(summary.label, 'Telegram')
+ assert.equal(summary.defaultAccountId, 'bot-a')
+ assert.equal(summary.accounts[0].state, 'error')
+ assert.equal(summary.accounts[0].lastError, '401 Unauthorized')
+})
+
+test('getChannelRuntimeSummary counts account states and preserves unknown channel fields', () => {
+ const status = normalizeChannelRuntimeStatus({
+ ts: 2000,
+ partial: true,
+ warnings: ['probe timeout'],
+ channelOrder: ['slack'],
+ channelLabels: { slack: 'Slack' },
+ channelAccounts: {
+ slack: [
+ { accountId: 'team-a', configured: true, running: true },
+ { accountId: 'team-b', configured: true, connected: true, audit: { messages: 3 } },
+ { accountId: 'team-c', enabled: false, configured: true },
+ ],
+ },
+ channelDefaultAccountId: { slack: 'team-a' },
+ channels: { slack: { custom: 'value' } },
+ })
+
+ const summary = getChannelRuntimeSummary(status, 'slack')
+
+ assert.equal(status.supported, true)
+ assert.equal(status.partial, true)
+ assert.deepEqual(status.warnings, ['probe timeout'])
+ assert.deepEqual(status.channels.slack, { custom: 'value' })
+ assert.equal(summary.state, 'connected')
+ assert.equal(summary.counts.connected, 1)
+ assert.equal(summary.counts.running, 1)
+ assert.equal(summary.counts.disabled, 1)
+})
+
+test('formatRuntimeAge returns compact relative time labels', () => {
+ assert.equal(formatRuntimeAge(1_000, 61_000), '1 分钟前')
+ assert.equal(formatRuntimeAge(1_000, 3_601_000), '1 小时前')
+ assert.equal(formatRuntimeAge(1_000, 172_801_000), '2 天前')
+})