diff --git a/src/App.tsx b/src/App.tsx index 21445e1..c526172 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { Startup } from './pages/Startup'; import { GatewaySessions } from './pages/GatewaySessions'; +import { GatewayRecoveryOverlay } from '@/components/gateway/GatewayRecoveryOverlay'; import { useSettingsStore } from './stores/settings'; import { useUpdateStore } from './stores/update'; import { useBootstrapStore } from './stores/bootstrap'; @@ -245,6 +246,7 @@ function App() { {showSettingsOverlay && } + {/* Global toast notifications */} state.restart); + const [uiState, setUiState] = useState(() => ( + getNextGatewayRecoveryUiState( + DEFAULT_GATEWAY_RECOVERY_UI_STATE, + useGatewayStore.getState().status, + ) + )); + + useEffect(() => { + return useGatewayStore.subscribe((state) => { + setUiState((current) => getNextGatewayRecoveryUiState(current, state.status)); + }); + }, []); + + if (uiState.phase === 'idle') { + return null; + } + + const handleRetry = async () => { + await restartGateway(); + }; + + if (uiState.phase === 'recovering') { + return ( + + + + + {t('startup.gatewayRecovery.recovering.title')} + + + {t('startup.gatewayRecovery.recovering.caption')} + + + + ); + } + + return ( + + + + + + + + + + + + + + + + {t('startup.gatewayRecovery.error.title')} + + + {t('startup.gatewayRecovery.error.body')} + + {uiState.error ? ( + + {uiState.error} + + ) : null} + void handleRetry()} + > + {t('startup.gatewayRecovery.error.retry')} + + + + + + + ); +} diff --git a/src/components/gateway/recovery-state.ts b/src/components/gateway/recovery-state.ts new file mode 100644 index 0000000..6f3120f --- /dev/null +++ b/src/components/gateway/recovery-state.ts @@ -0,0 +1,54 @@ +import type { GatewayStatus } from '@/types/gateway'; + +export type GatewayRecoveryPhase = 'idle' | 'recovering' | 'failed'; + +export interface GatewayRecoveryUiState { + phase: GatewayRecoveryPhase; + sessionActive: boolean; + error: string | null; +} + +export const DEFAULT_GATEWAY_RECOVERY_UI_STATE: GatewayRecoveryUiState = { + phase: 'idle', + sessionActive: false, + error: null, +}; + +export function getNextGatewayRecoveryUiState( + current: GatewayRecoveryUiState, + status: GatewayStatus, +): GatewayRecoveryUiState { + switch (status.state) { + case 'running': + return DEFAULT_GATEWAY_RECOVERY_UI_STATE; + case 'starting': + case 'reconnecting': + return { + phase: 'recovering', + sessionActive: true, + error: null, + }; + case 'error': + if (!current.sessionActive) { + return current; + } + return { + phase: 'failed', + sessionActive: true, + error: status.error ?? current.error ?? null, + }; + case 'stopped': + default: + if (!current.sessionActive) { + return DEFAULT_GATEWAY_RECOVERY_UI_STATE; + } + if (current.phase === 'failed') { + return current; + } + return { + phase: 'recovering', + sessionActive: true, + error: current.error, + }; + } +} diff --git a/src/components/update/UpdateAnnouncementDialog.tsx b/src/components/update/UpdateAnnouncementDialog.tsx index bf0318f..7b8060c 100644 --- a/src/components/update/UpdateAnnouncementDialog.tsx +++ b/src/components/update/UpdateAnnouncementDialog.tsx @@ -100,7 +100,7 @@ export function UpdateAnnouncementDialog() { )} {status === 'downloading' && progress && ( - + {t('updates.dialog.progress', { percent: Math.round(progress.percent), })} @@ -108,7 +108,7 @@ export function UpdateAnnouncementDialog() { )} {status === 'downloaded' && autoInstallCountdown != null && autoInstallCountdown >= 0 && ( - + {t('updates.status.autoInstalling', { seconds: autoInstallCountdown })} )} diff --git a/src/i18n/locales/en/setup.json b/src/i18n/locales/en/setup.json index 7eae742..6b09cf2 100644 --- a/src/i18n/locales/en/setup.json +++ b/src/i18n/locales/en/setup.json @@ -181,6 +181,19 @@ "title": "GeeClaw needs attention", "body": "We could not finish preparing the app. Try again, or check the details below.", "retry": "Try again" + }, + "gatewayRecovery": { + "recovering": { + "eyebrow": "Gateway recovery", + "title": "Restarting OpenClaw", + "body": "Some features are temporarily unavailable We will resume automatically once the gateway is back", + "caption": "This usually takes around ten seconds Please keep this window open" + }, + "error": { + "title": "The OpenClaw Gateway could not recover", + "body": "We tried to reconnect the OpenClaw Gateway, but this attempt did not succeed. You can retry now or inspect the details below.", + "retry": "Retry" + } } } } diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index ef22d5f..f230c01 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -1,6 +1,6 @@ { - "gatewayNotRunning": "网关未运行", - "gatewayRequired": "OpenClaw 网关需要运行才能使用聊天。它将自动启动,或者您可以从设置中启动。", + "gatewayNotRunning": "OpenClaw未运行", + "gatewayRequired": "OpenClaw 需要运行才能使用聊天,您可以从设置中启动", "welcome": { "title": "GeeClaw", "subtitle": "您的 AI 龙虾助手已就绪,在下方开始对话。", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index a07fb0f..cc90f18 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -9,7 +9,7 @@ "skills": "技能", "channels": "聊天频道", "onlineCount": "{{count}} 在线", - "offlineCount": "· {{count}} 在线", + "offlineCount": "{{count}} 在线", "dashboard": "仪表盘", "defaultAgent": "默认", "agentMainSessionHint": "点击进入会话", @@ -60,9 +60,9 @@ "saving": "保存中..." }, "gateway": { - "notRunning": "网关未运行", - "notRunningDesc": "OpenClaw 网关需要运行才能使用此功能。它将自动启动,或者您可以从设置中启动。", - "warning": "网关未运行。" + "notRunning": "OpenClaw 服务未运行", + "notRunningDesc": "OpenClaw 服务需要运行才能使用此功能,您可以从设置中启动", + "warning": "OpenClaw 服务未运行" }, "tray": { "tooltipRunning": "GeeClaw - 网关运行中", @@ -77,4 +77,4 @@ "checkUpdates": "检查更新...", "quit": "退出 GeeClaw" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/cron.json b/src/i18n/locales/zh/cron.json index 99c532c..016cae2 100644 --- a/src/i18n/locales/zh/cron.json +++ b/src/i18n/locales/zh/cron.json @@ -3,7 +3,7 @@ "subtitle": "通过设置定时任务实现工作自动化处理", "newTask": "新建任务", "refresh": "刷新", - "gatewayWarning": "网关未运行。没有活跃的网关,无法管理定时任务。", + "gatewayWarning": "OpenClaw 服务未运行,无法管理定时任务", "stats": { "total": "任务总数", "active": "已启用", @@ -12,7 +12,7 @@ }, "empty": { "title": "暂无定时任务", - "description": "创建定时任务以自动化 AI 工作流。任务可以在指定时间发送消息、运行查询或执行操作。", + "description": "创建定时任务以自动化 AI 工作流。任务可以在指定时间发送消息、运行查询或执行操作", "create": "创建第一个任务" }, "card": { @@ -29,16 +29,16 @@ "back": "返回", "taskFallback": "定时任务", "pageTitle": "运行记录", - "subtitle": "查看该定时任务的执行历史与只读消息记录。", + "subtitle": "查看该定时任务的执行历史与只读消息记录", "runListTitle": "运行历史", "runListDescription": "按最近执行时间倒序展示。", "messagesTitle": "消息记录", - "messagesDescription": "从左侧选择一次运行查看详细消息。", + "messagesDescription": "从左侧选择一次运行查看详细消息", "emptyTitle": "暂无运行记录", - "emptyDescription": "这个任务还没有产生任何可查看的执行记录。", + "emptyDescription": "这个任务还没有产生任何可查看的执行记录", "selectRun": "请先从左侧选择一次运行。", - "messagesEmpty": "这次运行暂时没有可显示的消息记录。", - "noSummary": "这次运行没有记录摘要。", + "messagesEmpty": "这次运行暂时没有可显示的消息记录", + "noSummary": "这次运行没有记录摘要", "durationUnknown": "无耗时信息", "status": { "ok": "成功", @@ -116,4 +116,4 @@ "dailyAt": "每天 {{time}}", "unknown": "未知" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index 9b79480..fbafd02 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -1,6 +1,6 @@ { - "pageSubtitle": "查看网关状态、频道连接与常用快捷操作", - "gateway": "网关", + "pageSubtitle": "查看 OpenClaw 状态、频道连接与常用快捷操作", + "gateway": "OpenClaw 服务", "channels": "频道", "skills": "技能", "uptime": "运行时间", @@ -9,7 +9,7 @@ "connectedOf": "已连接", "enabledOf": "已启用", "sinceRestart": "自上次重启", - "gatewayNotRunning": "网关未运行", + "gatewayNotRunning": "OpenClaw 服务未运行", "quickActions": { "title": "快捷操作", "description": "常用任务和快捷方式", diff --git a/src/i18n/locales/zh/setup.json b/src/i18n/locales/zh/setup.json index 8ef5547..4b9eaa7 100644 --- a/src/i18n/locales/zh/setup.json +++ b/src/i18n/locales/zh/setup.json @@ -181,6 +181,19 @@ "title": "GeeClaw 需要处理一下", "body": "我们暂时没能完成应用准备。您可以重试,或查看下面的详情。", "retry": "重试" + }, + "gatewayRecovery": { + "recovering": { + "eyebrow": "网关恢复中", + "title": "正在重启 OpenClaw", + "body": "部分功能暂时不可用 我们会在网关恢复后自动继续", + "caption": "这通常只需要十几秒,请保持窗口打开" + }, + "error": { + "title": "OpenClaw 服务未能恢复", + "body": "我们尝试重新连接 OpenClaw 网关,但这次没有成功。您可以直接重试,或查看下面的详情。", + "retry": "重试" + } } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/skills.json b/src/i18n/locales/zh/skills.json index 9b61ed4..e6209e5 100644 --- a/src/i18n/locales/zh/skills.json +++ b/src/i18n/locales/zh/skills.json @@ -1,9 +1,9 @@ { "title": "技能", - "subtitle": "安装专业技能,解锁 AI 超能力,同时启用过多技能(超过20个),会造成 AI 能力指数级下降,请按需启用。", + "subtitle": "安装专业技能,解锁 AI 超能力,同时启用过多技能(超过20个),会造成 AI 能力指数级下降,请按需启用", "refresh": "刷新", "openFolder": "打开技能文件夹", - "gatewayWarning": "网关未运行。没有活跃的网关,无法加载技能。", + "gatewayWarning": "OpenClaw 服务未运行,无法加载技能", "tabs": { "installed": "已安装", "marketplace": "市场" @@ -51,13 +51,13 @@ "unsupportedOs": "当前系统不受支持", "apiKey": "API 密钥", "apiKeyPlaceholder": "输入 API 密钥(可选)", - "apiKeyDesc": "此技能的主要 API 密钥。如果不需要或在别处配置,请留空。", + "apiKeyDesc": "此技能的主要 API 密钥。如果不需要或在别处配置,请留空", "envVars": "环境变量", "addVariable": "添加变量", "noEnvVars": "未配置环境变量。", "keyPlaceholder": "键名 (例如 BASE_URL)", "valuePlaceholder": "值", - "envNote": "注意:键名为空的行将在保存时自动移除。", + "envNote": "注意:键名为空的行将在保存时自动移除", "saving": "保存中...", "saveConfig": "保存配置", "configSaved": "配置已保存", @@ -94,7 +94,7 @@ "failedUninstall": "卸载失败", "installedUnavailable": "技能已安装,但仍缺少运行所需依赖", "enableRequirementsMissing": "技能所需依赖尚未满足", - "failedFolderNotFound": "技能文件夹尚不存在,请先安装一个技能。", + "failedFolderNotFound": "技能文件夹尚不存在,请先安装一个技能", "copiedPath": "路径已复制", "failedCopyPath": "复制路径失败", "failedOpenActualFolder": "打开技能实际目录失败", @@ -112,16 +112,16 @@ "featured": "精选", "install": "安装", "securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。", - "manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。", + "manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装", "searching": "正在搜索 ClawHub...", "loading": "正在加载市场...", - "noResults": "未找到匹配的技能。", - "emptyPrompt": "搜索新技能以扩展您的能力。", - "searchError": "ClawHub 搜索失败。请检查您的连接或安装。", + "noResults": "未找到匹配的技能", + "emptyPrompt": "搜索新技能以扩展您的能力", + "searchError": "ClawHub 搜索失败。请检查您的连接或安装", "loadError": "加载市场目录失败。", "skillHub": { "title": "推荐安装 SkillHub", - "description": "SkillHub 会优先使用国内镜像源,通常能显著降低技能安装失败和请求频繁的问题。点击后 GeeClaw 会自动准备 Python 并安装 SkillHub CLI。", + "description": "SkillHub 会优先使用国内镜像源,通常能显著降低技能安装失败和请求频繁的问题。点击后 GeeClaw 会自动准备 Python 并安装 SkillHub CLI", "install": "安装 SkillHub", "enabled": "SkillHub 加速已启用(v{{version}})" }, diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts index 0ea9dd1..c2ad04a 100644 --- a/src/lib/host-events.ts +++ b/src/lib/host-events.ts @@ -48,7 +48,15 @@ export function subscribeHostEvent( const listener = (payload: unknown) => { handler(payload as T); }; - ipc.on(ipcChannel, listener); + // preload's `on()` may wrap the callback in an internal subscription + // function and return a cleanup handle for that exact wrapper. Prefer the + // returned cleanup when available, but still call `off()` as a no-op-safe + // fallback for environments/tests that register the original listener. + const unsubscribe = ipc.on(ipcChannel, listener); + if (typeof unsubscribe === 'function') { + return unsubscribe; + } + // Fallback for environments where on() doesn't return cleanup return () => { ipc.off(ipcChannel, listener); }; diff --git a/tests/unit/gateway-recovery-overlay.test.tsx b/tests/unit/gateway-recovery-overlay.test.tsx new file mode 100644 index 0000000..3c0bacc --- /dev/null +++ b/tests/unit/gateway-recovery-overlay.test.tsx @@ -0,0 +1,81 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const translations: Record = { + 'startup.gatewayRecovery.recovering.eyebrow': '网关恢复中', + 'startup.gatewayRecovery.recovering.title': '正在重启 OpenClaw', + 'startup.gatewayRecovery.recovering.body': '部分功能暂时不可用 我们会在网关恢复后自动继续', + 'startup.gatewayRecovery.recovering.caption': '这通常只需要十几秒 请保持窗口打开', + 'startup.gatewayRecovery.error.title': 'OpenClaw 网关未能恢复', + 'startup.gatewayRecovery.error.body': '我们尝试重新连接 OpenClaw 网关,但这次没有成功。您可以直接重试,或查看下面的详情。', + 'startup.gatewayRecovery.error.retry': '重试', +}; + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => translations[key] ?? key, + }), + }; +}); + +describe('GatewayRecoveryOverlay', () => { + beforeEach(async () => { + vi.resetModules(); + const { useGatewayStore } = await import('@/stores/gateway'); + useGatewayStore.setState({ + status: { state: 'running', port: 28788 }, + lastError: null, + restart: vi.fn().mockResolvedValue(undefined), + }); + }); + + it('shows a blocking recovery dialog while the gateway is starting', async () => { + const { GatewayRecoveryOverlay } = await import('@/components/gateway/GatewayRecoveryOverlay'); + const { useGatewayStore } = await import('@/stores/gateway'); + render(); + + act(() => { + useGatewayStore.setState({ + status: { state: 'starting', port: 28788 }, + }); + }); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('正在重启 OpenClaw')).toBeInTheDocument(); + expect(screen.getByText('这通常只需要十几秒 请保持窗口打开')).toBeInTheDocument(); + }); + + it('shows the recovery failure page and retries from there', async () => { + const { GatewayRecoveryOverlay } = await import('@/components/gateway/GatewayRecoveryOverlay'); + const { useGatewayStore } = await import('@/stores/gateway'); + const restartMock = vi.fn().mockResolvedValue(undefined); + useGatewayStore.setState({ restart: restartMock }); + + render(); + + act(() => { + useGatewayStore.setState({ + status: { state: 'reconnecting', port: 28788, reconnectAttempts: 2 }, + }); + }); + + act(() => { + useGatewayStore.setState({ + status: { state: 'error', port: 28788, error: 'Gateway failed to restart' }, + }); + }); + + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByText('OpenClaw 网关未能恢复')).toBeInTheDocument(); + expect(screen.getByText('Gateway failed to restart')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '重试' })); + + await waitFor(() => { + expect(restartMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/gateway-recovery-state.test.ts b/tests/unit/gateway-recovery-state.test.ts new file mode 100644 index 0000000..2e8fba5 --- /dev/null +++ b/tests/unit/gateway-recovery-state.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { GatewayStatus } from '@/types/gateway'; +import { + DEFAULT_GATEWAY_RECOVERY_UI_STATE, + getNextGatewayRecoveryUiState, +} from '@/components/gateway/recovery-state'; + +function makeStatus(overrides: Partial): GatewayStatus { + return { + state: 'running', + port: 28788, + ...overrides, + }; +} + +describe('gateway recovery ui state', () => { + it('enters recovering mode when the gateway starts or reconnects', () => { + const nextState = getNextGatewayRecoveryUiState( + DEFAULT_GATEWAY_RECOVERY_UI_STATE, + makeStatus({ state: 'starting' }), + ); + + expect(nextState).toEqual({ + phase: 'recovering', + sessionActive: true, + error: null, + }); + }); + + it('ignores a plain stopped state when no recovery session is active', () => { + const nextState = getNextGatewayRecoveryUiState( + DEFAULT_GATEWAY_RECOVERY_UI_STATE, + makeStatus({ state: 'stopped' }), + ); + + expect(nextState).toEqual(DEFAULT_GATEWAY_RECOVERY_UI_STATE); + }); + + it('moves to failed mode when a recovery session ends in error', () => { + const recoveringState = getNextGatewayRecoveryUiState( + DEFAULT_GATEWAY_RECOVERY_UI_STATE, + makeStatus({ state: 'reconnecting' }), + ); + + const failedState = getNextGatewayRecoveryUiState( + recoveringState, + makeStatus({ state: 'error', error: 'Gateway failed to restart' }), + ); + + expect(failedState).toEqual({ + phase: 'failed', + sessionActive: true, + error: 'Gateway failed to restart', + }); + }); + + it('resets back to idle once the gateway is running again', () => { + const failedState = { + phase: 'failed' as const, + sessionActive: true, + error: 'Gateway failed to restart', + }; + + const nextState = getNextGatewayRecoveryUiState( + failedState, + makeStatus({ state: 'running' }), + ); + + expect(nextState).toEqual(DEFAULT_GATEWAY_RECOVERY_UI_STATE); + }); +}); diff --git a/tests/unit/host-events.test.ts b/tests/unit/host-events.test.ts index 4945750..78b901e 100644 --- a/tests/unit/host-events.test.ts +++ b/tests/unit/host-events.test.ts @@ -23,9 +23,10 @@ describe('host-events', () => { const onMock = vi.mocked(window.electron.ipcRenderer.on); const offMock = vi.mocked(window.electron.ipcRenderer.off); const captured: Array<(...args: unknown[]) => void> = []; + const cleanup = vi.fn(); onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => { captured.push(cb); - return () => {}; + return cleanup; }); const { subscribeHostEvent } = await import('@/lib/host-events'); @@ -39,16 +40,18 @@ describe('host-events', () => { expect(handler).toHaveBeenCalledWith({ state: 'running' }); unsubscribe(); - expect(offMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function)); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(offMock).not.toHaveBeenCalled(); }); it('maps weixin channel host events through IPC', async () => { const onMock = vi.mocked(window.electron.ipcRenderer.on); const offMock = vi.mocked(window.electron.ipcRenderer.off); const captured: Array<(...args: unknown[]) => void> = []; + const cleanup = vi.fn(); onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => { captured.push(cb); - return () => {}; + return cleanup; }); const { subscribeHostEvent } = await import('@/lib/host-events'); @@ -61,7 +64,8 @@ describe('host-events', () => { expect(handler).toHaveBeenCalledWith({ qr: 'base64-payload' }); unsubscribe(); - expect(offMock).toHaveBeenCalledWith('channel:openclaw-weixin-qr', expect.any(Function)); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(offMock).not.toHaveBeenCalled(); }); it('does not use SSE fallback by default for unknown events', async () => {
+ {t('startup.gatewayRecovery.recovering.caption')} +
+ {t('startup.gatewayRecovery.error.body')} +
+ {uiState.error} +