Files
clawpanel/src/lib/humanize-error.js
晴天 7eababad4a feat(ux): toast 智能行动按钮 + 拓展 ⓘ 到 gateway/agents + sidebar 加术语表入口
延续上一轮小白 UX 改造的尾声三连:

## 1. toast 智能行动按钮(U2 收尾)
- humanizeError 输出新增 action 字段:{ label, route?, handler?, kind }
- 自动按错误 kind 给默认按钮:
  · gatewayDown → [去启动 Gateway] → /services
  · cmdMissing / permission → [打开设置] → /settings
  · auth → [检查 API Key] → /models
  · network / timeout / rateLimit / generic → 不给(重试由用户控制)
- toast 结构化分支渲染 .toast-action-btn 按钮,点击后用 navigate(route) 或调 handler,并自动关闭 toast
- common.js 加 errorAction.* 三个按钮文案 i18n(11 语言)

## 2. ⓘ 拓展到 gateway / agents
- gateway.js: token label 后加 ⓘ(apikey 术语),renderConfig 末尾 attachTermTooltips
- agents.js: addAgent 弹窗 workspace 字段 label 加 ⓘ,setTimeout 扫 document.body 绑定
- term-tooltip.js 精简表新增 4 个术语:workspace / provider / baseurl + scope(已有)

## 3. sidebar 加术语表入口
- 在两个引擎(OpenClaw + Hermes)的最后一个 section 加「术语」条目
- sidebar.js i18n 新增 glossary 键(11 语言)
- 之前只能从 dashboard quick-actions 进入,现在 sidebar 永久可达

## 4. 顺手 bug 修复
- gateway.js 文件末尾历史残留的多余 `}` (line 348) syntax 错误,删除
- 与之前 hermes/cron.js 同类问题,本次改 ⓘ 时被 node --check 暴露

## 累计变动
- 10 个文件修改
- 7 个新 i18n 键(11 语言)
- Build OK
2026-05-14 03:47:25 +08:00

141 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* humanize-error.js — 把后端原始 Error / Tauri Result 字符串映射成小白能看懂的友好文案。
*
* 用法:
* import { humanizeError } from '../lib/humanize-error.js'
* try { ... } catch (e) {
* toast(humanizeError(e, t('channels.loadFailed')), 'error')
* }
*
* 返回值:
* {
* message: string // 用户主行(默认走 context 或匹配到的友好键)
* hint: string // 副行小灰字行动建议
* raw: string // 原始错误字符串(折叠在「技术详情」里)
* }
*
* 设计原则:
* - 永远返回对象toast 组件需要稳定 shape
* - 永远保留 raw 给开发者排查
* - hint 不强制覆盖 contextcontext 已含「做什么 + 失败」时 message 用 context
*/
import { t } from './i18n.js'
const PATTERNS = [
// 网络
{
key: 'network',
re: /(failed to fetch|networkerror|networkfailure|enetunreach|econnreset|econnrefused|ehostunreach|err_network|fetch failed|connection refused|connection reset|getaddrinfo|dns error|no route to host|aborted|broken pipe|connect timed out|tcp connect)/i,
},
// Gateway 未启动(特殊的 connection refused / port not listen 情况)
{
key: 'gatewayDown',
re: /(gateway[^a-z]*(not[^a-z]*(running|ready|reachable)|down|offline|未启动)|managed gateway|未运行|gateway[^a-z]*未就绪)/i,
},
// 命令未找到 / 二进制丢失
{
key: 'cmdMissing',
re: /(command not found|not recognized as|no such file or directory|enoent|不是.*命令|未找到.*命令|cannot find|missing executable|exec format error)/i,
},
// 权限
{
key: 'permission',
re: /(permission denied|eacces|operation not permitted|access is denied|拒绝访问|无权限|权限不足|forbidden)/i,
},
// 鉴权401/403/无效 token/api key
{
key: 'auth',
re: /(401|unauthori[sz]ed|invalid (api[_ ]?key|token|credentials)|authentication[^a-z]*(failed|required)|signature.*verification.*failed|身份验证|未授权)/i,
},
// 限流
{
key: 'rateLimit',
re: /(429|too many requests|rate[_ ]?limit|quota[^a-z]*(exceeded|reached)|limit.*reached|流量限制|超过.*配额)/i,
},
// 超时
{
key: 'timeout',
re: /(timeout|timed out|deadline exceeded|超时)/i,
},
// 资源不存在404
{
key: 'notFound',
re: /(\b404\b|not found|does not exist|未找到|不存在|no such)/i,
},
// 服务繁忙500-504 / "busy" / "unavailable"
{
key: 'busy',
re: /(\b5\d\d\b|service unavailable|server error|internal server|temporarily unavailable|busy|繁忙)/i,
},
]
const RAW_MAX = 240 // 原始错误字符串保留长度(折叠区显示用,过长截断防止 toast 撑大)
/**
* 把任意 error / 字符串 / Tauri Result 转成原始字符串。
*/
function toRawString(e) {
if (e == null) return ''
if (typeof e === 'string') return e
if (e instanceof Error) return e.message || e.stack || String(e)
if (typeof e === 'object') {
// Tauri invoke 失败时通常是字符串;如果是 object看常见字段
if (typeof e.message === 'string') return e.message
if (typeof e.error === 'string') return e.error
try { return JSON.stringify(e) } catch { return String(e) }
}
return String(e)
}
// 不同错误类型默认对应的行动按钮label 走 i18nroute 直接跳转)
const DEFAULT_ACTIONS = {
gatewayDown: { labelKey: 'common.errorAction.startGateway', route: '/services' },
cmdMissing: { labelKey: 'common.errorAction.openSettings', route: '/settings' },
permission: { labelKey: 'common.errorAction.openSettings', route: '/settings' },
auth: { labelKey: 'common.errorAction.checkApiKey', route: '/models' },
// network / timeout / busy / rateLimit / notFound / generic不给默认 action重试由用户自己控制
}
/**
* @param {unknown} e - 原始错误Error / string / Tauri Result
* @param {string} [context] - 操作上下文文案(如 t('channels.saveFailed')
* @returns {{ message: string, hint: string, raw: string, action?: { label, route?, handler? } }}
*/
export function humanizeError(e, context) {
const raw = toRawString(e).trim()
const rawTruncated = raw.length > RAW_MAX ? raw.slice(0, RAW_MAX) + '…' : raw
// 1) 用 context 作为主行(已经是用户视角文案,比如「保存失败」)
// 没有 context 时用通用「操作未完成」
// 2) 匹配关键字定位具体原因 → 生成 hint
const ctx = (context && String(context).trim()) || ''
let kind = 'generic'
for (const p of PATTERNS) {
if (p.re.test(raw)) {
kind = p.key
break
}
}
const message = ctx || t(`common.error.${kind}`)
const hint = t(`common.errorHint.${kind}`)
const result = { message, hint, raw: rawTruncated, kind }
const defaultAction = DEFAULT_ACTIONS[kind]
if (defaultAction) {
result.action = { label: t(defaultAction.labelKey), route: defaultAction.route }
}
return result
}
/**
* 便捷帮手:直接拿格式化后的字符串(无 hint 折叠 UI给老 API 兼容)。
* humanizeErrorText(e, ctx) -> "保存失败 · 网络不通"
*/
export function humanizeErrorText(e, context) {
const h = humanizeError(e, context)
return h.hint ? `${h.message} · ${h.hint}` : h.message
}