feat(gateway): surface negotiated handshake protocol version in UI

Users have reported confusion about "when will ClawPanel update its
gateway protocol to v4". This is actually a misreading: ClawPanel v0.15+
already advertises `minProtocol=3, maxProtocol=4` in its connect frame,
and negotiates v4 transparently when the kernel is >= 2026.5.12. The
`v3|` prefix users were seeing in dev-api.js is the device signature
payload string schema version, which is a completely separate concept
from the handshake protocol version.

Make this visible and unambiguous:

UI
- Add a "Proto v4" badge next to the Gateway service name in
  /services once the WS handshake succeeds, with a tooltip explaining
  that this is the WS handshake protocol version (not the device
  signature payload v3 format).
- Add the same protocol info to the WebSocket row in /chat-debug.

API
- WsClient now exposes `negotiatedProtocol` which prefers the explicit
  field from the hello payload (`protocol` / `protocolVersion` /
  `negotiatedProtocol`) and falls back to inferring from serverVersion:
  kernels >= 2026.5.12 are reported as v4, older as v3. This matches
  the panel's advertised range of [3, 4].
- KernelSnapshot grows a `protocol` field so feature gates and UIs that
  already consume the snapshot can read it without touching wsClient.

Comments
- Expand the KERNEL_TARGET comment in feature-catalog.js to spell out
  the two-distinct-version-numbers rule explicitly.
- Add matching clarifying comments next to the `v3|...` payload string
  in both scripts/dev-api.js and src-tauri/src/commands/device.rs, so
  the next reader does not confuse payload schema with handshake.

## Verification
- node --check on all touched JS files
- npm run build
- cargo fmt --check && cargo check (clippy errors that surface are
  pre-existing debt in config.rs, untouched here)
- Playwright /services: mock wsClient state, observe `协议 v4` badge
  rendered with `rgba(99, 102, 241, 0.1)` background and accent color,
  for both the explicit-protocol path and the version-inferred path.
This commit is contained in:
晴天
2026-05-16 13:02:08 +08:00
parent cb4c9bcdfc
commit 7d75486a53
9 changed files with 76 additions and 4 deletions

View File

@@ -190,7 +190,12 @@ export const KERNEL_FLOOR = {
export const KERNEL_TARGET = {
openclaw: {
// 内核协议在 5.12 升级到 v4MIN_CLIENT_PROTOCOL_VERSION=4新增增量 chat delta payloads
// 面板通过 connect frame `[minProtocol=3, maxProtocol=4]` 同时兼容新旧内核
// 面板通过 connect frame `[minProtocol=3, maxProtocol=4]` 同时兼容新旧内核
//
// ⚠️ 警告:这里的"协议 v3/v4"指 **Gateway WebSocket 握手帧协议版本**。
// 不要与 dev-api.js 中设备签名 payload 字符串前缀 `v3|deviceId|...` 混淆——
// 后者是 **device signature payload 字符串格式版本**,两者完全独立、互不相关。
// 即使在 v4 握手协议下,签名 payload 字符串仍以 `v3|` 开头(这是 payload schema 版本)。
official: '2026.5.12',
chinese: '2026.5.12-zh.2',
},

View File

@@ -29,6 +29,7 @@ const _listeners = []
* @property {boolean} isLatest 是否 >= target
* @property {Set<string>} features 当前启用的特性 id 集合
* @property {string} versionLabel 人类可读的版本显示,例如 "2026.5.6 汉化"
* @property {number|null} protocol 握手协商出的 Gateway WS 协议版本 (3 或 4),未握手为 null
*/
/**
@@ -104,6 +105,7 @@ export function buildSnapshot(engineId, version) {
versionLabel: version
? `${versionBase}${variant === 'chinese' ? ' 汉化' : ''}`
: '',
protocol: wsClient.negotiatedProtocol,
}
}
@@ -186,6 +188,7 @@ function refresh() {
|| _snapshot.engine !== next.engine
|| _snapshot.version !== next.version
|| _snapshot.features.size !== next.features.size
|| _snapshot.protocol !== next.protocol
_snapshot = next
if (changed) {
_listeners.forEach(fn => {

View File

@@ -128,6 +128,34 @@ export class WsClient {
get hello() { return this._hello }
get sessionKey() { return this._sessionKey }
get serverVersion() { return this._serverVersion }
/**
* 当前 Gateway 与 ClawPanel 协商出的握手协议版本 (3 或 4)。
*
* 注意:这里的 "协议版本" 指 Gateway WebSocket 握手帧协议 (kernel ws frame protocol),
* 不要与设备签名 payload 的前缀 `v3|deviceId|...` 混淆,后者是 device-signature
* payload 字符串格式版本,两者完全独立。
*
* 取值优先级:
* 1. hello payload 中显式回传的 protocol / protocolVersion / negotiatedProtocol 字段
* 2. 按 serverVersion 推断:OpenClaw 内核 >= 2026.5.12 → v4,否则 v3
* (ClawPanel 客户端永远声明 minProtocol=3, maxProtocol=4,所以协商只会是 3 或 4)
*
* 未握手时返回 null。
*/
get negotiatedProtocol() {
if (!this._hello) return null
const explicit = this._hello.protocol ?? this._hello.protocolVersion ?? this._hello.negotiatedProtocol
if (Number.isFinite(explicit)) return explicit
const ver = this._serverVersion
if (!ver) return null
const base = String(ver).replace(/-.*$/, '')
const parts = base.split('.').map(n => Number(n))
if (parts.length >= 3 && parts.every(Number.isFinite)) {
const [y, mo, d] = parts
if (y > 2026 || (y === 2026 && (mo > 5 || (mo === 5 && d >= 12)))) return 4
}
return 3
}
get reconnectState() { return this._reconnectState }
get reconnectAttempts() { return this._reconnectAttempts }
get lastConnectedAt() { return this._lastConnectedAt }
@@ -145,6 +173,7 @@ export class WsClient {
reconnectAttempts: this._reconnectAttempts,
reconnectState: this._reconnectState,
serverVersion: this._serverVersion,
negotiatedProtocol: this.negotiatedProtocol,
missedHeartbeats: this._missedHeartbeats,
pendingReconnect: this._pendingReconnect,
}

View File

@@ -202,4 +202,7 @@ export default {
cleanupConfirmUninstallConfig: _('确定要卸载所有 OpenClaw 并删除配置目录吗?\n⚠ 这将删除所有配置、Agent 数据和会话记录,无法恢复!', 'Uninstall all OpenClaw AND delete config directory?\n⚠ This will delete all config, agent data, and session history permanently!', '確定要卸載所有 OpenClaw 並刪除設定目錄嗎?\n⚠ 這將刪除所有設定、Agent 資料和會話記錄,無法恢復!'),
cleanupUninstalling: _('正在卸载,请稍候...', 'Uninstalling, please wait...', '正在卸載,請稍候...'),
cleanupUninstallFailed: _('卸载失败', 'Uninstall failed', '卸載失敗'),
// 协议版本徽章 (Gateway 卡片 + 诊断页)
protocolBadge: _('协议 v{proto}', 'Proto v{proto}', '協議 v{proto}'),
protocolBadgeTitle: _('当前 Gateway 与 ClawPanel 协商出的 WebSocket 握手协议版本 (与设备签名 payload 的 v3 格式无关)', 'WebSocket handshake protocol version negotiated with Gateway (independent of the device signature payload v3 format)', '當前 Gateway 與 ClawPanel 協商出的 WebSocket 握手協議版本 (與設備簽名 payload 的 v3 格式無關)'),
}

View File

@@ -212,7 +212,11 @@ async function runScan(page) {
// 5. WebSocket 连接
const wsOk = wsClient.connected && wsClient.gatewayReady
items.push({ label: 'WebSocket', ok: wsOk, detail: wsOk ? (wsClient.serverVersion ? `Gateway ${wsClient.serverVersion}` : t('chatDebug.connected')) : t('chatDebug.scanWsDown') })
const proto = wsOk ? wsClient.negotiatedProtocol : null
const wsDetail = wsOk
? `${wsClient.serverVersion ? `Gateway ${wsClient.serverVersion}` : t('chatDebug.connected')}${proto ? ` · ${t('services.protocolBadge', { proto })}` : ''}`
: t('chatDebug.scanWsDown')
items.push({ label: 'WebSocket', ok: wsOk, detail: wsDetail })
if (!wsOk && gwRunning) fixable = true
// 6. Token 配置

View File

@@ -11,6 +11,7 @@ import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGateway
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
import { wsClient } from '../lib/ws-client.js'
// HTML 转义,防止 XSS
function escapeHtml(str) {
@@ -476,13 +477,16 @@ function renderServices(container, services) {
const cliMissing = gw.cli_installed === false
const foreignGateway = !cliMissing && isForeignGatewayService(gw)
const foreignPidText = gw.pid ? ` (PID: ${gw.pid})` : ''
// 协议版本徽章(仅在 Gateway 跑起来并完成 WS 握手时显示)
const proto = wsClient.gatewayReady ? wsClient.negotiatedProtocol : null
const protoBadge = proto ? `<span class="proto-badge" title="${t('services.protocolBadgeTitle', { proto })}">${t('services.protocolBadge', { proto })}</span>` : ''
html += `
<div class="service-card" data-label="${gw.label}">
<div class="service-info">
<span class="status-dot ${cliMissing ? 'stopped' : foreignGateway ? 'warning' : gw.running ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${gw.label}</div>
<div class="service-name">${gw.label}${protoBadge}</div>
<div class="service-desc">${cliMissing
? t('services.cliNotInstalled')
: foreignGateway

View File

@@ -218,6 +218,21 @@
font-size: var(--font-size-md);
}
.service-name .proto-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 4px;
background: var(--accent-muted);
color: var(--accent);
border: 1px solid var(--accent-muted);
vertical-align: middle;
cursor: help;
}
.service-desc {
font-size: var(--font-size-xs);
color: var(--text-tertiary);