fix(critical): 5 个用户报告的真 bug 一次性修

## 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
This commit is contained in:
晴天
2026-05-14 06:35:20 +08:00
parent 6a0d87479c
commit 1873e23371
7 changed files with 164 additions and 22 deletions

View File

@@ -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`)

View File

@@ -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

View File

@@ -26,6 +26,20 @@ function escHtml(s) {
}
function escAttr(s) { return escHtml(s) }
function renderInlineError(err) {
const h = humanizeError(err, t('engine.hermesKanbanTaskLoadFailed'))
return `
<div class="page-inline-error">
<div class="page-inline-error-icon">⚠️</div>
<div class="page-inline-error-body">
<div class="page-inline-error-message">${escHtml(h.message)}</div>
${h.hint ? `<div class="page-inline-error-hint">${escHtml(h.hint)}</div>` : ''}
${h.raw ? `<details class="page-inline-error-details"><summary>${escHtml(t('common.errorRawLabel'))}</summary><pre>${escHtml(h.raw)}</pre></details>` : ''}
</div>
</div>
`
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
@@ -54,7 +68,7 @@ export function render() {
</div>
<div id="hm-kanban-content">
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
${error ? renderInlineError(error) : ''}
${(!loading && !error && board) ? renderBoard() : ''}
</div>
`
@@ -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()

View File

@@ -23,6 +23,20 @@ function escHtml(s) {
}
function escAttr(s) { return escHtml(s) }
function renderInlineError(err) {
const h = humanizeError(err, t('engine.hermesOAuthTitle'))
return `
<div class="page-inline-error">
<div class="page-inline-error-icon">⚠️</div>
<div class="page-inline-error-body">
<div class="page-inline-error-message">${escHtml(h.message)}</div>
${h.hint ? `<div class="page-inline-error-hint">${escHtml(h.hint)}</div>` : ''}
${h.raw ? `<details class="page-inline-error-details"><summary>${escHtml(t('common.errorRawLabel'))}</summary><pre>${escHtml(h.raw)}</pre></details>` : ''}
</div>
</div>
`
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
@@ -45,7 +59,7 @@ export function render() {
</div>
<div id="hm-oauth-content">
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
${error ? renderInlineError(error) : ''}
${(!loading && !error && !providers.length) ? `
<div class="empty-state empty-compact">
<div class="empty-icon">🔐</div>
@@ -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()

View File

@@ -24,6 +24,20 @@ function escHtml(s) {
}
function escAttr(s) { return escHtml(s) }
function renderInlineError(err) {
const h = humanizeError(err, t('engine.hermesProfilesTitle'))
return `
<div class="page-inline-error">
<div class="page-inline-error-icon">⚠️</div>
<div class="page-inline-error-body">
<div class="page-inline-error-message">${escHtml(h.message)}</div>
${h.hint ? `<div class="page-inline-error-hint">${escHtml(h.hint)}</div>` : ''}
${h.raw ? `<details class="page-inline-error-details"><summary>${escHtml(t('common.errorRawLabel'))}</summary><pre>${escHtml(h.raw)}</pre></details>` : ''}
</div>
</div>
`
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
@@ -47,7 +61,7 @@ export function render() {
</div>
<div id="hm-profiles-content">
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
${error ? renderInlineError(error) : ''}
${(!loading && !error && !profiles.length) ? `
<div class="empty-state empty-compact">
<div class="empty-icon">📁</div>
@@ -111,7 +125,7 @@ export function render() {
raw: p,
}))
} catch (e) {
error = String(e?.message || e)
error = e
} finally {
loading = false
draw()

View File

@@ -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

View File

@@ -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。 */