From 1873e2337165a86f89610074bd50025ecbcc4c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 06:35:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(critical):=205=20=E4=B8=AA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8A=A5=E5=91=8A=E7=9A=84=E7=9C=9F=20bug=20=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=80=A7=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## P0 Bug 1: chat.js `t is not defined` (页面渲染崩溃) - chat.js 用了 30+ 处 t() 但**完全忘记 import i18n** - 路由进入 /h/chat 立即抛 ReferenceError,整页渲染失败 - 修复:补 `import { t } from '../../../lib/i18n.js'` ## P0 Bug 2: i18n 嵌套对象解析失败 (所有 humanizeError 显示原始 key) - 用户截图:`common.errorHint.generic` 直接显示而非翻译 - 根因:buildLocales() 不递归嵌套对象,把 common.error / errorHint / errorAction 这种嵌套 dict 当作 _() 翻译对象处理 → `result[lang].common.errorHint = 'errorHint'` (字符串) - 影响:t('common.errorHint.network'/auth/timeout/...) 全部返回 key 自身 → toast 用 humanizeError 的所有页面都看到原始 key - 修复:buildLocales 加 _isTranslationObject + _materialize 递归 ## P0 Bug 3: dev-api.js `Dynamic require of "node:fs" is not supported` - 用户截图:文件管理器红字 "Dynamic require of node:fs" - 根因:我之前在 hermes_fs_* / _validateFsPath / _hermesHome 用了 `const fs = require('node:fs')` —— 但 Vite 插件运行在 ESM 上下文, CommonJS dynamic require 不支持 - 修复:删除所有 require('node:fs/path/os') —— 文件顶部已经 ESM `import fs from 'fs'` 等,直接复用即可 ## P1 Bug 4: profiles/kanban/oauth fetch failed 错误丑陋 - 用户截图:3 个页面都显示红字 "fetch failed"(无任何上下文) - 修复 1(dev-api.js):hermes_dashboard_api_proxy 把 ECONNREFUSED / fetch failed 转成 "Hermes Dashboard 未运行(端口 9119 无服务)。 请在桌面端 ClawPanel 启动 Hermes Agent" — 含「未运行」关键字会被 humanizeError 的 gatewayDown 正则匹配 - 修复 2(3 个页面):用 humanizeError 拆出 message + hint + 折叠 raw, 渲染统一的 .page-inline-error 卡片(icon + 主行 + 副提示 + 技术详情 details)。错误对象不再 stringify,保留给 humanizeError 完整识别 ## P2 Bug 5: 重复的 require('node:path') in hermes_fs_list - 同 Bug 3,循环里又 require 了一次 path,现已用顶部 import ## 影响面 ✅ /h/chat 不再崩溃,profile switcher 正常显示 ✅ 所有用 humanizeError 的 toast 看到正确翻译(不再显示原始 key) ✅ 文件管理器 Web 模式正常列出 ~/.hermes 目录 ✅ Profile / Kanban / OAuth 显示「Hermes Dashboard 未运行」+ 「请前往仪表盘启动 Gateway 后重试」+ 折叠原始错误(点击展开) ✅ npm build pass --- scripts/dev-api.js | 33 ++++++++----- src/engines/hermes/pages/chat.js | 1 + src/engines/hermes/pages/kanban.js | 18 ++++++- src/engines/hermes/pages/oauth.js | 18 ++++++- src/engines/hermes/pages/profiles.js | 18 ++++++- src/locales/index.js | 26 ++++++++-- src/style/pages.css | 72 ++++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 22 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 3f8eed8..783d193 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -7227,15 +7227,10 @@ const handlers = { // Batch 3 §L: 文件管理器(Web 模式走 Node fs,限定 hermes_home 子树) _hermesHome() { - const fs = require('node:fs') - const path = require('node:path') - const os = require('node:os') if (process.env.HERMES_HOME) return process.env.HERMES_HOME return path.join(os.homedir(), '.hermes') }, _validateFsPath(rel) { - const fs = require('node:fs') - const path = require('node:path') const root = handlers._hermesHome() const target = rel ? path.resolve(root, rel) : root const canonRoot = fs.realpathSync.native?.(root) || root @@ -7243,14 +7238,13 @@ const handlers = { return target }, async hermes_fs_list({ path: p = '' } = {}) { - const fs = require('node:fs') const target = handlers._validateFsPath(p) if (!fs.existsSync(target)) throw new Error(`目录不存在: ${target}`) const stat = fs.statSync(target) if (!stat.isDirectory()) throw new Error(`不是目录: ${target}`) let entries = fs.readdirSync(target, { withFileTypes: true }).filter(e => !e.name.startsWith('.') || e.name === '.env') entries = entries.slice(0, 2000).map(e => { - const sub = require('node:path').join(target, e.name) + const sub = path.join(target, e.name) const m = fs.statSync(sub) return { name: e.name, @@ -7263,7 +7257,6 @@ const handlers = { return { path: target, entries } }, async hermes_fs_read({ path: p } = {}) { - const fs = require('node:fs') const target = handlers._validateFsPath(p) if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error(`不是文件: ${target}`) const stat = fs.statSync(target) @@ -7275,7 +7268,6 @@ const handlers = { return { path: target, size: stat.size, text, binary_b64 } }, async hermes_fs_write({ path: p, content } = {}) { - const fs = require('node:fs') const target = handlers._validateFsPath(p) if (Buffer.byteLength(content || '', 'utf8') > 5 * 1024 * 1024) throw new Error('内容过大') fs.writeFileSync(target, content || '', 'utf8') @@ -7318,12 +7310,29 @@ const handlers = { } return opts } + // 把网络错误(fetch failed / ECONNREFUSED)转成友好错,方便前端 humanizeError 归类 + const friendly = (err) => { + const msg = String(err?.message || err || '') + if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|aborted/i.test(msg)) { + return new Error(`Hermes Dashboard 未运行(端口 ${port} 无服务)。请在桌面端 ClawPanel 启动 Hermes Agent,或在 Settings 中配置远端 Dashboard 地址`) + } + return err instanceof Error ? err : new Error(msg) + } let token = await handlers._getDashboardToken(port, false).catch(() => null) - let resp = await globalThis.fetch(url, buildOpts(token)) + let resp + try { + resp = await globalThis.fetch(url, buildOpts(token)) + } catch (err) { + throw friendly(err) + } if (resp.status === 401) { // 强制刷新 + 重试 - token = await handlers._getDashboardToken(port, true) - resp = await globalThis.fetch(url, buildOpts(token)) + try { + token = await handlers._getDashboardToken(port, true) + resp = await globalThis.fetch(url, buildOpts(token)) + } catch (err) { + throw friendly(err) + } } const text = await resp.text().catch(() => '') if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text}(提示:请先启动 Dashboard)`) diff --git a/src/engines/hermes/pages/chat.js b/src/engines/hermes/pages/chat.js index 5544b16..a3b1a2c 100644 --- a/src/engines/hermes/pages/chat.js +++ b/src/engines/hermes/pages/chat.js @@ -19,6 +19,7 @@ import { api, invalidate } from '../../../lib/tauri-api.js' import { toast } from '../../../components/toast.js' import { showConfirm } from '../../../components/modal.js' import { getChatStore, getSourceLabel } from '../lib/chat-store.js' +import { t } from '../../../lib/i18n.js' // ----------------------------------------------------------- helpers diff --git a/src/engines/hermes/pages/kanban.js b/src/engines/hermes/pages/kanban.js index aa859dc..91bb752 100644 --- a/src/engines/hermes/pages/kanban.js +++ b/src/engines/hermes/pages/kanban.js @@ -26,6 +26,20 @@ function escHtml(s) { } function escAttr(s) { return escHtml(s) } +function renderInlineError(err) { + const h = humanizeError(err, t('engine.hermesKanbanTaskLoadFailed')) + return ` +
+
⚠️
+
+
${escHtml(h.message)}
+ ${h.hint ? `
${escHtml(h.hint)}
` : ''} + ${h.raw ? `
${escHtml(t('common.errorRawLabel'))}
${escHtml(h.raw)}
` : ''} +
+
+ ` +} + export function render() { const el = document.createElement('div') el.className = 'page' @@ -54,7 +68,7 @@ export function render() {
${loading ? `
${escHtml(t('common.loading'))}…
` : ''} - ${error ? `
${escHtml(error)}
` : ''} + ${error ? renderInlineError(error) : ''} ${(!loading && !error && board) ? renderBoard() : ''}
` @@ -140,7 +154,7 @@ export function render() { board = boardData boards = Array.isArray(boardsData) ? boardsData : (boardsData?.boards || []) } catch (e) { - error = String(e?.message || e) + error = e } finally { loading = false draw() diff --git a/src/engines/hermes/pages/oauth.js b/src/engines/hermes/pages/oauth.js index ffdf184..baaece4 100644 --- a/src/engines/hermes/pages/oauth.js +++ b/src/engines/hermes/pages/oauth.js @@ -23,6 +23,20 @@ function escHtml(s) { } function escAttr(s) { return escHtml(s) } +function renderInlineError(err) { + const h = humanizeError(err, t('engine.hermesOAuthTitle')) + return ` +
+
⚠️
+
+
${escHtml(h.message)}
+ ${h.hint ? `
${escHtml(h.hint)}
` : ''} + ${h.raw ? `
${escHtml(t('common.errorRawLabel'))}
${escHtml(h.raw)}
` : ''} +
+
+ ` +} + export function render() { const el = document.createElement('div') el.className = 'page' @@ -45,7 +59,7 @@ export function render() {
${loading ? `
${escHtml(t('common.loading'))}…
` : ''} - ${error ? `
${escHtml(error)}
` : ''} + ${error ? renderInlineError(error) : ''} ${(!loading && !error && !providers.length) ? `
🔐
@@ -112,7 +126,7 @@ export function render() { const data = await api.hermesDashboardApi('GET', OAUTH_BASE) providers = data?.providers || [] } catch (e) { - error = String(e?.message || e) + error = e } finally { loading = false draw() diff --git a/src/engines/hermes/pages/profiles.js b/src/engines/hermes/pages/profiles.js index 6c36e05..153b183 100644 --- a/src/engines/hermes/pages/profiles.js +++ b/src/engines/hermes/pages/profiles.js @@ -24,6 +24,20 @@ function escHtml(s) { } function escAttr(s) { return escHtml(s) } +function renderInlineError(err) { + const h = humanizeError(err, t('engine.hermesProfilesTitle')) + return ` +
+
⚠️
+
+
${escHtml(h.message)}
+ ${h.hint ? `
${escHtml(h.hint)}
` : ''} + ${h.raw ? `
${escHtml(t('common.errorRawLabel'))}
${escHtml(h.raw)}
` : ''} +
+
+ ` +} + export function render() { const el = document.createElement('div') el.className = 'page' @@ -47,7 +61,7 @@ export function render() {
${loading ? `
${escHtml(t('common.loading'))}…
` : ''} - ${error ? `
${escHtml(error)}
` : ''} + ${error ? renderInlineError(error) : ''} ${(!loading && !error && !profiles.length) ? `
📁
@@ -111,7 +125,7 @@ export function render() { raw: p, })) } catch (e) { - error = String(e?.message || e) + error = e } finally { loading = false draw() diff --git a/src/locales/index.js b/src/locales/index.js index b76dc32..0b86313 100644 --- a/src/locales/index.js +++ b/src/locales/index.js @@ -49,16 +49,34 @@ const MODULES = { engine, ciaoBug, cliConflict, glossary, hermesLazyDeps, notifications, } +/** 判断是否是 _() 调用产生的翻译对象(有 'zh-CN' 字符串字段) */ +function _isTranslationObject(v) { + return v && typeof v === 'object' && typeof v['zh-CN'] === 'string' +} + +/** 递归 materialize:把翻译对象转成当前语言的字符串,嵌套对象继续递归 */ +function _materialize(entries, lang) { + const out = {} + for (const [key, val] of Object.entries(entries)) { + if (_isTranslationObject(val)) { + out[key] = val[lang] || val['zh-CN'] || key + } else if (val && typeof val === 'object' && !Array.isArray(val)) { + // 嵌套字典(如 common.errorHint.{generic,network,...})— 递归 + out[key] = _materialize(val, lang) + } else { + out[key] = val + } + } + return out +} + /** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */ export function buildLocales() { const result = {} for (const lang of SUPPORTED_LANGS) { result[lang] = {} for (const [mod, entries] of Object.entries(MODULES)) { - result[lang][mod] = {} - for (const [key, translations] of Object.entries(entries)) { - result[lang][mod][key] = translations[lang] || translations['zh-CN'] || key - } + result[lang][mod] = _materialize(entries, lang) } } return result diff --git a/src/style/pages.css b/src/style/pages.css index c03708f..f12dd26 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -2657,6 +2657,78 @@ engines/hermes/style/hermes.css)。原来 .hm-memory-* 那一坨规则已无任何 JS / CSS 引用,已在此处删除。 */ +/* === 通用页面级内联错误(profiles / kanban / oauth 等加载失败时显示) === */ +.page-inline-error { + display: flex; + align-items: flex-start; + gap: 14px; + margin: 16px 0; + padding: 16px 18px; + border: 1px solid var(--border-primary); + border-left: 3px solid var(--error, #ef4444); + border-radius: 12px; + background: var(--bg-glass); + color: var(--text-primary); +} +.page-inline-error-icon { + font-size: 20px; + line-height: 1; + margin-top: 2px; + flex: 0 0 auto; +} +.page-inline-error-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.page-inline-error-message { + color: var(--error, #ef4444); + font-size: 14px; + font-weight: 700; + line-height: 1.5; +} +.page-inline-error-hint { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; +} +.page-inline-error-details { + margin-top: 4px; + font-size: 12px; + color: var(--text-tertiary); +} +.page-inline-error-details summary { + cursor: pointer; + user-select: none; + padding: 2px 0; + list-style: none; +} +.page-inline-error-details summary::before { + content: '▶'; + display: inline-block; + font-size: 9px; + margin-right: 6px; + transition: transform 0.18s ease; +} +.page-inline-error-details[open] summary::before { + transform: rotate(90deg); +} +.page-inline-error-details pre { + margin: 8px 0 0; + padding: 10px 12px; + background: var(--bg-tertiary, rgba(0,0,0,0.04)); + border-radius: 6px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11.5px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow: auto; +} + /* === Engine Select · Monolith 对角线全屏 === 左上三角 OpenClaw(石墨黑)vs 右下三角 Hermes(象牙白)。 用 position: fixed 跳出 #content 范围,覆盖整个 viewport(含 sidebar)。 */