mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
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:
@@ -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)`)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)。 */
|
||||
|
||||
Reference in New Issue
Block a user