Files
clawpanel/src/engines/openclaw/index.js
晴天 e717a7a098 feat(openclaw): P1-0 push.web.* 推送通知 - ClawPanel 关掉也能弹系统通知
OpenClaw 内核已实现 4 个 push.web.* RPC(vapidPublicKey / subscribe / unsubscribe / test),
但 ClawPanel 完全没接。这次打通整条链路:浏览器 → Service Worker → 内核 → 系统通知中心。

## 收益(用户视角)
- ClawPanel 浏览器标签/桌面应用关掉后,Agent / Cron / 渠道消息仍能弹到
  Windows / macOS / iOS / Android 系统通知中心
- 锁屏可见,可离线接收(推送服务由浏览器厂商分发)
- 是下一版主推卖点

## 实施(无需新增 Tauri 命令)
- wsClient.request 直接走 WebSocket 调内核 4 个 RPC

## 前端封装 src/lib/push-web.js
- isPushSupported() / pushPermission() / requestPushPermission()
- ensureServiceWorker() 注册 /push-sw.js(幂等)
- subscribePush() 完整流程:权限 → SW → push.web.vapidPublicKey → PushManager.subscribe → push.web.subscribe 上报内核
- unsubscribePush() 本地取消 + 通知内核清理
- sendTestPush(title, body) 调 push.web.test 广播测试
- getCurrentSubscription() / isLocallySubscribed() 状态查询
- urlBase64ToUint8Array / arrayBufferToBase64Url 工具函数
  (VAPID 公钥 base64url ↔ 二进制,订阅 keys 编码)

## Service Worker public/push-sw.js
- skipWaiting + clients.claim 立即激活
- push 事件:解析 JSON payload → showNotification(含 icon / badge / tag / requireInteraction)
- notificationclick:优先聚焦已打开标签 + postMessage 跳转 url;
  没有窗口就 openWindow 新开
- 所有路径容错(payload 解析失败 fallback 到默认文案)

## UI 页面 src/pages/notifications.js
- 状态行:通知权限 + 订阅状态(彩色徽章)
- 端点摘要(订阅成功后展示截断的 endpoint,方便用户确认)
- 三个动作按钮(互斥):启用 / 取消订阅 / 发测试通知
- 测试通知会显示「已投递到 N 个订阅」提示
- 不支持环境(Tauri 1.x 桌面壳或老浏览器)显示友好的「Push not supported here」空状态
- 全程走 humanizeError 友好错误提示

## i18n src/locales/modules/notifications.js
- 26 个键 × 11 语言全覆盖
- 含权限徽章 / 操作按钮 / 流程提示 / 不支持环境说明

## 入口
- OpenClaw 引擎「配置」section 新增「推送通知」入口
- sidebar.notifications i18n(短词「推送通知 / Push」)
- 路由 /notifications 注册到 OpenClaw 引擎
- Hermes 引擎暂不注册(push.web.* 是 OpenClaw 内核的 RPC)

## CSS
- 加 .push-status-row / .push-status-item / .push-status-label / .push-status-value
- 复用现有 .lazy-deps-badge.{ok,warn,unknown} 样式

## 待跟进
- iOS Safari 16.4+ 需用户先把 ClawPanel 添加到主屏才能收 push(已知限制,文档跟进)
- 真实流量(不只是 push.web.test)需 OpenClaw 内核侧把通知事件主动 send 出来;
  本 PR 把订阅渠道彻底打通,后续内核怎么用现成订阅发送是另一题
- 累计变动:4 新文件 + 4 修改
2026-05-14 04:27:33 +08:00

143 lines
6.4 KiB
JavaScript

/**
* OpenClaw 引擎
* 包装现有 OpenClaw 逻辑为统一的 Engine 接口,不改动原有代码
*/
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, isGatewayForeign,
onGatewayChange, startGatewayPoll, stopGatewayPoll, onReadyChange } from '../../lib/app-state.js'
import { initFeatureGates, isFeatureAvailable } from '../../lib/feature-gates.js'
import { t } from '../../lib/i18n.js'
export default {
id: 'openclaw',
name: 'OpenClaw',
description: 'OpenClaw AI Agent Framework',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
/** 检测 OpenClaw 是否已安装 */
async detect() {
const ready = await detectOpenclawStatus()
return { installed: ready, ready }
},
/** 启动 OpenClaw 引擎相关逻辑 */
async boot() {
await detectOpenclawStatus()
await initFeatureGates().catch(() => {})
startGatewayPoll()
},
/** 清理(停止轮询等) */
cleanup() {
stopGatewayPoll()
},
/** 侧边栏菜单项 */
getNavItems() {
if (!isOpenclawReady()) {
return [{
section: '',
items: [
{ route: '/setup', label: t('sidebar.setup'), icon: 'setup' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
]
}, {
section: '',
items: [
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}]
}
return [{
section: t('sidebar.sectionMonitor'),
items: [
{ route: '/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
{ route: '/chat', label: t('sidebar.chat'), icon: 'chat' },
{ route: '/route-map', label: t('sidebar.routeMap'), icon: 'route-map' },
{ route: '/services', label: t('sidebar.services'), icon: 'services' },
{ route: '/logs', label: t('sidebar.logs'), icon: 'logs' },
]
}, {
section: t('sidebar.sectionConfig'),
items: [
{ route: '/models', label: t('sidebar.models'), icon: 'models' },
{ route: '/agents', label: t('sidebar.agents'), icon: 'agents' },
{ route: '/gateway', label: t('sidebar.gateway'), icon: 'gateway' },
{ route: '/channels', label: t('sidebar.channels'), icon: 'channels' },
{ route: '/communication', label: t('sidebar.communication'), icon: 'settings' },
{ route: '/notifications', label: t('sidebar.notifications'), icon: 'channels' },
{ route: '/security', label: t('sidebar.security'), icon: 'security' },
]
}, {
section: t('sidebar.sectionData'),
items: [
{ route: '/memory', label: t('sidebar.memory'), icon: 'memory', gate: 'memory' },
{ route: '/dreaming', label: t('sidebar.dreaming'), icon: 'dreaming', gate: 'dreaming' },
{ route: '/cron', label: t('sidebar.cron'), icon: 'clock', gate: 'cron' },
{ route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
]
}, {
section: t('sidebar.sectionExtension'),
items: [
{ route: '/skills', label: t('sidebar.skills'), icon: 'skills', gate: 'skills' },
{ route: '/plugin-hub', label: t('sidebar.pluginHub'), icon: 'extensions' },
]
}, {
section: '',
items: [
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/chat-debug', label: t('sidebar.checkRepair'), icon: 'diagnose' },
{ route: '/glossary', label: t('sidebar.glossary'), icon: 'about' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}]
},
/** 路由注册表 */
getRoutes() {
return [
{ path: '/dashboard', loader: () => import('../../pages/dashboard.js') },
{ path: '/chat', loader: () => import('../../pages/chat.js') },
{ path: '/chat-debug', loader: () => import('../../pages/chat-debug.js') },
{ path: '/services', loader: () => import('../../pages/services.js') },
{ path: '/logs', loader: () => import('../../pages/logs.js') },
{ path: '/models', loader: () => import('../../pages/models.js') },
{ path: '/agents', loader: () => import('../../pages/agents.js') },
{ path: '/agent-detail', loader: () => import('../../pages/agent-detail.js') },
{ path: '/gateway', loader: () => import('../../pages/gateway.js') },
{ path: '/memory', loader: () => import('../../pages/memory.js') },
{ path: '/dreaming', loader: () => import('../../pages/dreaming.js') },
{ path: '/skills', loader: () => import('../../pages/skills.js') },
{ path: '/security', loader: () => import('../../pages/security.js') },
{ path: '/about', loader: () => import('../../pages/about.js') },
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
{ path: '/setup', loader: () => import('../../pages/setup.js') },
{ path: '/channels', loader: () => import('../../pages/channels.js') },
{ path: '/cron', loader: () => import('../../pages/cron.js') },
{ path: '/usage', loader: () => import('../../pages/usage.js') },
{ path: '/communication', loader: () => import('../../pages/communication.js') },
{ path: '/notifications', loader: () => import('../../pages/notifications.js') },
{ path: '/settings', loader: () => import('../../pages/settings.js') },
{ path: '/route-map', loader: () => import('../../pages/route-map.js') },
{ path: '/plugin-hub', loader: () => import('../../pages/plugin-hub.js') },
{ path: '/diagnose', loader: () => import('../../pages/chat-debug.js') },
{ path: '/glossary', loader: () => import('../../pages/glossary.js') },
]
},
getSetupRoute() { return '/setup' },
getDefaultRoute() { return '/dashboard' },
isReady() { return isOpenclawReady() },
isGatewayRunning() { return isGatewayRunning() },
isGatewayForeign() { return isGatewayForeign() },
onStateChange(fn) { return onGatewayChange(fn) },
onReadyChange(fn) { return onReadyChange(fn) },
/** 功能门控:基于 OpenClaw 版本号 */
isFeatureAvailable(featureId) { return isFeatureAvailable(featureId) },
}