feat(ux): 小白 UX 全面改造 - 错误友好度 + 致命操作强确认 + 空状态 + 新手引导 + 术语表

面向小白用户的产品定位重塑,从七大 UX 痛点逐一改造:

## U1 错误友好度(59 处改造)
- 新工具 src/lib/humanize-error.js:自动把后端原始错误(fetch failed、ENETUNREACH、ENOENT 等)
  映射成「主行 + hint 行动建议 + 折叠技术详情」三段式结构化对象
- toast 组件升级支持 { message, hint, raw } 结构化入参,向后完全兼容
- 14 个 page 文件中所有 toast(t('xxx.failed') + ': ' + e, 'error') 替换为 toast(humanizeError(e, t(...)), 'error')
- common.js 加 error.* / errorHint.* 共 13 个新 i18n 键(11 语言):
  网络/Gateway 未启动/命令缺失/权限/超时/限流/未找到/鉴权/服务繁忙/通用

## U2 致命操作强确认(14 处改造)
- showConfirm 升级支持结构化对象 { message, impact[], title, confirmText, cancelText, variant }
- 加 .modal-impact-list 红边样式(让小白看清楚删了会丢什么)
- 14 处致命操作改造,每处显示影响列表 + 红色「删除/移除/重置」按钮 + 灰色「保留」取消:
  · agents.js 删除 Agent(动态显示 N 个绑定影响)
  · channels.js 移除平台(动态算 N 个 binding)+ 移除 Agent binding
  · memory.js 删除记忆文件
  · services.js 卸载 Gateway(3 段影响)+ 删除备份
  · models.js 批量删模型
  · chat.js 删除会话 + 重置会话
  · dreaming.js 重置梦境日记 + 清空 grounded 短期记忆
  · agent-detail.js 解除渠道绑定
  · cron.js 删除任务(OpenClaw + Hermes 两端)
- skills.js 原生 confirm() 改 showConfirm
- hermes-cron.js 原生 confirm() 改 showConfirm,顺手修末尾多余 `}` 的 syntax 残留

## U3-C 空状态 emoji+CTA(5 页面)
- 通用 .empty-state 组件(大 emoji + 标题 + 副本 + CTA 按钮 + 紧凑变体)
- agents.js: 🤖 + 「+ 新建 Agent」CTA
- memory.js: 🧠 + 「+ 新建记忆文件」CTA(紧凑版)
- cron.js:  + 「+ 新建任务」CTA
- skills.js: 🛠️ + 「技能商店」CTA(点击切 Tab)
- channels.js: 💬 + 紧凑提示
- CTA 巧妙复用页面顶部已有按钮的 click,零重复逻辑

## U3-B Dashboard 新手任务卡片
- 蓝紫渐变卡片,4 步任务自动检测:启动 Gateway / 添加模型 / 创建 Agent / 第一次聊天
- 已完成:✓ 徽章 + 删除线 + 60% 透明
- 未完成:编号徽章 + 蓝色 CTA 按钮跳对应页面
- 全部完成 → 庆祝条「🎉 全部搞定!」+ 关闭按钮
- localStorage 标记,用户主动关闭后永久隐藏
- 14 个新 i18n 键,文案小白化(Gateway 是「发动机」/ Agent 是「分身」/ 模型给 AI 装「大脑」)

## U3-A 术语表页(/glossary)
- 25 个核心术语 × 4 大分类(核心 8 / 模型 6 / 接入 5 / 进阶 6)
- 搜索框实时过滤 + Tab 切换分类 + 卡片网格布局
- 每条术语:「比喻 + 一句话」描述(避免循环引用)+ 「打开页面 →」CTA 直达配置
- 3 语言(zh-CN / en / zh-TW)完整翻译,其他 8 语言 fallback
- 双引擎(OpenClaw + Hermes)共用路由
- dashboard quick-actions 加「📖 面板术语」入口

## U3-D 术语 ⓘ tooltip
- 通用 src/lib/term-tooltip.js helper:termHelpHtml(id) + attachTermTooltips(root)
- 8 个高频术语精简表(OAuth / Webhook / Bot Token / API Key / Token / Context Window / Binding / Scope)
- channels.js 字段 label 智能匹配关键词自动追加 ⓘ(覆盖 8 个渠道全部敏感字段)
- models.js 添加/编辑 provider 的 API Key label 也加 ⓘ
- 点 ⓘ → 弹小型 modal 含解释 + 「打开术语表 →」CTA
- attachTermTooltips 内部去重,可安全多次调用

## 累计交付
- 4 个新文件(humanize-error.js / term-tooltip.js / glossary.js page / glossary.js i18n)
- 6 个升级文件(toast / modal / components.css / dashboard / channels / models)
- 14 个 page 错误 toast 友好化(59 处)
- 14 处致命操作强确认
- 5 处空状态升级 + Dashboard 新手卡片 + 术语表 + ⓘ tooltip
- 109 个新 i18n 键(11 语言)
- Build 全程通过
This commit is contained in:
晴天
2026-05-14 03:38:47 +08:00
parent 1d6843c4fb
commit e710db6ffb
39 changed files with 1510 additions and 109 deletions

View File

@@ -17,26 +17,51 @@ function escapeAttr(str) {
/**
* 自定义确认弹窗,替代原生 confirm()
* Tauri WebView 不支持原生 confirm/alert必须用自定义弹窗
* @param {string} message 确认消息
* @returns {Promise<boolean>} 用户选择确认返回 true取消返回 false
*
* 入参 message 支持两种:
* 1) string —— 旧用法(向后兼容)
* 2) { message, impact?, ...options } —— 结构化致命操作确认:
* - message: 主问题("删除 Agent 'main'"
* - impact: 影响列表 string[],渲染成 ul让小白看清楚删了会丢什么
* - title/confirmText/cancelText/variant: 同 options 同名字段
*
* @param {string|object} message 确认消息或结构化对象
* @param {object} [options] 旧版 options 字段(仍兼容)
* @returns {Promise<boolean>} 用户选择确认返回 true取消返回 false
*/
export function showConfirm(message, options = {}) {
// options:
// title: 标题文本(默认 t('common.confirmAction')
// confirmText: 确认按钮文字(默认 t('common.confirm')
// cancelText: 取消按钮文字(默认 t('common.cancel')
// variant: 'danger' | 'primary'(默认 'danger',决定确认按钮颜色)
// 结构化入参:把对象字段合并到 optionsmessage 取其 .message 字段
let actualMessage = message
let impactList = null
if (message && typeof message === 'object') {
actualMessage = message.message ?? ''
impactList = Array.isArray(message.impact) ? message.impact.filter(Boolean) : null
// 对象字段优先于 options 同名字段
options = {
title: message.title ?? options.title,
confirmText: message.confirmText ?? options.confirmText,
cancelText: message.cancelText ?? options.cancelText,
variant: message.variant ?? options.variant,
}
}
const title = options.title || t('common.confirmAction')
const confirmText = options.confirmText || t('common.confirm')
const cancelText = options.cancelText || t('common.cancel')
const variant = options.variant === 'primary' ? 'btn-primary' : 'btn-danger'
const impactHtml = impactList && impactList.length
? `<ul class="modal-impact-list">${impactList.map(i => `<li>${escapeAttr(i)}</li>`).join('')}</ul>`
: ''
return new Promise((resolve) => {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal" style="max-width:400px">
<div class="modal" style="max-width:420px">
<div class="modal-title">${escapeAttr(title)}</div>
<div class="modal-body" style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.6">${escapeAttr(message)}</div>
<div class="modal-body" style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.6">${escapeAttr(actualMessage)}</div>
${impactHtml}
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">${escapeAttr(cancelText)}</button>
<button class="btn ${variant} btn-sm" data-action="confirm">${escapeAttr(confirmText)}</button>
@@ -59,8 +84,8 @@ export function showConfirm(message, options = {}) {
if (e.key === 'Enter') { e.preventDefault(); close(true) }
else if (e.key === 'Escape') close(false)
})
// 聚焦确认按钮以接收键盘事件
overlay.querySelector('[data-action="confirm"]').focus()
// 聚焦取消按钮(致命操作默认不要默认聚焦确认)
overlay.querySelector('[data-action="cancel"]').focus()
})
}

View File

@@ -1,6 +1,15 @@
/**
* Toast 通知组件
*
* 入参 message 支持两种:
* 1) string —— 原来的纯文本(向后兼容)
* 2) { message, hint?, raw? } —— humanize-error.js 的友好错误对象:
* - message: 主行(用户视角)
* - hint: 副行小灰字(行动建议)
* - raw: 折叠在「技术详情」里的原始错误字符串
*/
import { t } from '../lib/i18n.js'
let _container = null
function ensureContainer() {
@@ -12,21 +21,58 @@ function ensureContainer() {
return _container
}
function isStructuredError(v) {
return v && typeof v === 'object' && typeof v.message === 'string'
}
export function toast(message, type = 'info', options = {}) {
const duration = options.duration || 3000
// 结构化错误对象需要展示「主行 + hint + 技术详情折叠」duration 给长一些
const structured = isStructuredError(message)
const duration = options.duration || (structured && (message.hint || message.raw) ? 6000 : 3000)
const action = options.action // 可选的操作按钮DOM 元素)
const container = ensureContainer()
const el = document.createElement('div')
el.className = `toast ${type}`
el.className = `toast ${type}${structured ? ' toast-structured' : ''}`
const textSpan = document.createElement('span')
if (options.html) {
textSpan.innerHTML = message
if (structured) {
const body = document.createElement('div')
body.className = 'toast-body'
const mainRow = document.createElement('div')
mainRow.className = 'toast-main'
mainRow.textContent = message.message
body.appendChild(mainRow)
if (message.hint) {
const hintRow = document.createElement('div')
hintRow.className = 'toast-hint'
hintRow.textContent = message.hint
body.appendChild(hintRow)
}
if (message.raw) {
const detail = document.createElement('details')
detail.className = 'toast-raw'
const summary = document.createElement('summary')
summary.textContent = t('common.errorRawLabel')
detail.appendChild(summary)
const pre = document.createElement('pre')
pre.textContent = message.raw
detail.appendChild(pre)
body.appendChild(detail)
}
el.appendChild(body)
} else {
textSpan.textContent = message
const textSpan = document.createElement('span')
if (options.html) {
textSpan.innerHTML = message
} else {
textSpan.textContent = message
}
el.appendChild(textSpan)
}
el.appendChild(textSpan)
// 如果有操作按钮,添加到 toast 中
if (action instanceof HTMLElement) {

View File

@@ -119,6 +119,7 @@ export default {
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
{ path: '/settings', loader: () => import('../../pages/settings.js') },
{ path: '/about', loader: () => import('../../pages/about.js') },
{ path: '/glossary', loader: () => import('../../pages/glossary.js') },
]
},

View File

@@ -392,8 +392,18 @@ export function render() {
el.querySelectorAll('.hm-cron-del').forEach(btn => {
btn.addEventListener('click', async () => {
const job = jobs.find(j => (j.id || j.name) === btn.dataset.id)
const msg = t('engine.cronConfirmDelete').replace('{name}', job?.name || btn.dataset.id)
if (!confirm(msg)) return
const name = job?.name || btn.dataset.id
const ok = await showConfirm({
title: t('engine.cronDeleteTitle', { name }),
message: t('engine.cronConfirmDelete', { name }),
impact: [
t('engine.cronDeleteImpactStop'),
t('engine.cronDeleteImpactHistory'),
],
confirmText: t('engine.cronDeleteBtn'),
cancelText: t('engine.cronDeleteCancel'),
})
if (!ok) return
btn.disabled = true
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' }) } catch (_) {}
await loadJobs(); draw()

View File

@@ -120,6 +120,7 @@ export default {
{ path: '/route-map', loader: () => import('../../pages/route-map.js') },
{ path: '/plugin-hub', loader: () => import('../../pages/plugin-hub.js') },
{ path: '/diagnose', loader: () => import('../../pages/chat-debug.js') },
{ path: '/glossary', loader: () => import('../../pages/glossary.js') },
]
},

125
src/lib/humanize-error.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* 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)
}
/**
* @param {unknown} e - 原始错误Error / string / Tauri Result
* @param {string} [context] - 操作上下文文案(如 t('channels.saveFailed')
* @returns {{ message: string, hint: string, raw: string }}
*/
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}`)
return { message, hint, raw: rawTruncated }
}
/**
* 便捷帮手:直接拿格式化后的字符串(无 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
}

114
src/lib/term-tooltip.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* 术语提示 — 在页面中嵌入「ⓘ」按钮,点击弹出术语解释小卡片
*
* 用法:
* import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js'
*
* // 在页面 HTML 中插入按钮:
* <label>${t('webhook')} ${termHelpHtml('webhook')}</label>
*
* // 渲染完成后绑定 click
* attachTermTooltips(rootEl)
*
* 数据源:内嵌一份精简的术语映射表(仅高频术语),
* 与 `pages/glossary.js` 完整术语表互补。
*/
import { t, getLang } from './i18n.js'
import { showContentModal } from '../components/modal.js'
import { navigate } from '../router.js'
// 高频术语精简映射(与 glossary.js 数据保持一致;仅纳入页面 ⓘ 旁要弹出的)
const TERMS = {
oauth: {
zhCN: { name: 'OAuth', desc: '一种「不用给密码也能让别人代你登录」的协议。授权 ClawPanel 接入 Discord 时走的就是 OAuth比直接给 token 更安全。' },
en: { name: 'OAuth', desc: 'A protocol that lets services log in on your behalf without sharing your password. ClawPanel uses OAuth when connecting Discord — safer than handing over a raw token.' },
zhTW: { name: 'OAuth', desc: '一種「不用給密碼也能讓別人代你登入」的協定。授權 ClawPanel 接入 Discord 時走的就是 OAuth比直接給 token 更安全。' },
},
webhook: {
zhCN: { name: 'Webhook', desc: '一个外部应用「打你电话」的号码。比如 Discord 收到消息后就请求这个地址通知 ClawPanel触发 AI 回复。' },
en: { name: 'Webhook', desc: 'A URL external apps "call back" to. e.g. when Discord receives a message it pings this URL so ClawPanel knows and triggers the AI to respond.' },
zhTW: { name: 'Webhook', desc: '一個外部應用「打你電話」的號碼。例如 Discord 收到訊息後就請求這個位址通知 ClawPanel觸發 AI 回覆。' },
},
bottoken: {
zhCN: { name: 'Bot Token', desc: 'Telegram/Discord 等平台给你的机器人发的「身份卡」。把它配到 ClawPanelAI 就能以这个机器人的身份说话。' },
en: { name: 'Bot Token', desc: 'The "ID card" issued by Telegram/Discord/etc. to your bot. Once you put it in ClawPanel, the AI can speak as this bot identity.' },
zhTW: { name: 'Bot Token', desc: 'Telegram/Discord 等平台給你的機器人發的「身分卡」。把它配到 ClawPanelAI 就能以這個機器人的身分說話。' },
},
apikey: {
zhCN: { name: 'API Key', desc: '类似服务商发的「会员卡密码」。AI 调用要扣费,凭这把钥匙服务商才知道是你在用,并按使用量计费。' },
en: { name: 'API Key', desc: 'Like a "member-card password" issued by the provider. AI calls cost money — this key tells the provider it\'s you so they can bill correctly.' },
zhTW: { name: 'API Key', desc: '類似服務商發的「會員卡密碼」。AI 呼叫要扣費,憑這把鑰匙服務商才知道是你在用,並按使用量計費。' },
},
token: {
zhCN: { name: 'Token计费单位', desc: 'AI 模型按「Token」收费 —— 大致相当于一个汉字、一个英文单词或半个标点。一次对话用 1000 Token = 大概 700 字。' },
en: { name: 'Token', desc: 'The unit AI models bill in. Roughly equals one character (Chinese), one English word, or half a punctuation mark. 1000 tokens ≈ 750 English words.' },
zhTW: { name: 'Token計費單位', desc: 'AI 模型按「Token」收費 —— 大致相當於一個中文字、一個英文單字或半個標點。一次對話用 1000 Token = 大概 700 字。' },
},
context: {
zhCN: { name: '上下文窗口', desc: 'AI 一次对话能「记住」多少字。比如 32K = 大概 2 万汉字。超出窗口的早期对话 AI 会忘掉。' },
en: { name: 'Context Window', desc: 'How much text the AI can "remember" in one chat. e.g. 32K ≈ 24K English words. Anything older than the window gets forgotten.' },
zhTW: { name: '上下文視窗', desc: 'AI 一次對話能「記住」多少字。例如 32K = 大概 2 萬中文字。超出視窗的早期對話 AI 會忘掉。' },
},
binding: {
zhCN: { name: '绑定', desc: '把「哪个 Agent」和「哪个渠道」配对的规则。比如「营销 Agent 接 Discord技术 Agent 接 Slack」就是两条 Binding。' },
en: { name: 'Binding', desc: 'A rule pairing "which Agent" with "which Channel". e.g. "marketing Agent to Discord, technical Agent to Slack" is two bindings.' },
zhTW: { name: '綁定', desc: '把「哪個 Agent」和「哪個頻道」配對的規則。' },
},
scope: {
zhCN: { name: '权限范围Scope', desc: '机器人能做什么的「白名单」—— 比如读消息、发消息、改群成员等。Scope 给少了功能不足,给多了有安全风险。' },
en: { name: 'Scope (Permissions)', desc: 'A whitelist of what the bot is allowed to do — read messages, send messages, manage members, etc. Too few = missing features; too many = security risk.' },
zhTW: { name: '權限範圍Scope', desc: '機器人能做什麼的「白名單」—— 例如讀訊息、發訊息、改群成員等。Scope 給少了功能不足,給多了有安全風險。' },
},
}
function pickLang(item) {
const lang = getLang()
if (lang.startsWith('zh-CN')) return item.zhCN
if (lang.startsWith('zh-TW')) return item.zhTW || item.zhCN
return item.en
}
/**
* 返回一个 ⓘ 按钮的 HTML 字符串。点击后弹出术语解释 modal。
* @param {string} termId
* @returns {string} HTML
*/
export function termHelpHtml(termId) {
if (!TERMS[termId]) return ''
return `<button type="button" class="term-help" data-term-help="${termId}" aria-label="${t('glossary.title')}" tabindex="-1">ⓘ</button>`
}
/**
* 在容器里扫描所有 [data-term-help] 按钮,给加 click handler 弹 modal。
* 重复调用安全(用 dataset 标记防重复绑定)。
* @param {HTMLElement} root
*/
export function attachTermTooltips(root) {
if (!root) return
root.querySelectorAll('[data-term-help]').forEach(btn => {
if (btn.dataset.termHelpBound === '1') return
btn.dataset.termHelpBound = '1'
btn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const termId = btn.dataset.termHelp
const term = TERMS[termId]
if (!term) return
const txt = pickLang(term)
const overlay = showContentModal({
title: txt.name,
content: `<p style="font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.7;margin:0">${escapeHtml(txt.desc)}</p>`,
buttons: [{ label: t('glossary.title') + ' →', className: 'btn btn-primary btn-sm', id: 'btn-go-glossary' }],
width: 420,
})
overlay.querySelector('#btn-go-glossary')?.addEventListener('click', () => {
overlay.close?.()
navigate('/glossary')
})
})
})
}
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

View File

@@ -37,13 +37,14 @@ import extensions from './modules/extensions.js'
import engine from './modules/engine.js'
import ciaoBug from './modules/ciaoBug.js'
import cliConflict from './modules/cliConflict.js'
import glossary from './modules/glossary.js'
const MODULES = {
common, sidebar, instance, dashboard, services, settings,
models, agents, agentDetail, gateway, security, communication, channels,
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions,
engine, ciaoBug, cliConflict,
engine, ciaoBug, cliConflict, glossary,
}
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */

View File

@@ -59,6 +59,11 @@ export default {
accountOptional: _('账号 ID可选', 'Account ID (optional)'),
accountOptionalPlaceholder: _('留空表示所有账号', 'Leave empty to match all accounts'),
removeBindingConfirm: _('确定解除渠道绑定 {channel} ?', 'Unbind channel {channel}?'),
removeBindingTitle: _('解除渠道绑定', 'Unbind channel', '解除頻道綁定', 'チャンネル連携を解除', '채널 연결 해제', 'Huỷ liên kết kênh', 'Desvincular canal', 'Desvincular canal', 'Отвязать канал', 'Délier le canal', 'Kanalverknüpfung lösen'),
removeBindingBtn: _('解除', 'Unbind', '解除', '解除', '해제', 'Huỷ', 'Desvincular', 'Desvincular', 'Отвязать', 'Délier', 'Lösen'),
removeBindingCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
removeBindingImpactAgent: _('该 Agent 在此渠道上将不再被触发', 'The Agent will no longer be triggered through this channel', '該 Agent 在此頻道上將不再被觸發', 'この Agent はこのチャンネルで起動されなくなります', '이 Agent는 이 채널에서 더 이상 트리거되지 않습니다', 'Agent sẽ không còn được kích hoạt trên kênh này', 'El Agent ya no se activará por este canal', 'O Agent não será mais acionado por este canal', 'Agent больше не будет запускаться через этот канал', 'L\u2019Agent ne sera plus déclenché sur ce canal', 'Der Agent wird über diesen Kanal nicht mehr ausgelöst'),
removeBindingImpactChannel: _('渠道平台本身的设置不会被改动', 'The channel platform settings themselves will not be changed', '頻道平台本身的設定不會被改動', 'チャンネルプラットフォーム自体の設定は変更されません', '채널 플랫폼 자체의 설정은 변경되지 않습니다', 'Cấu hình của nền tảng kênh sẽ không thay đổi', 'La configuración de la plataforma del canal no se modificará', 'A configuração da plataforma do canal não será alterada', 'Настройки самой платформы канала не изменятся', 'Les paramètres de la plateforme du canal ne seront pas modifiés', 'Die Einstellungen der Kanalplattform werden nicht geändert'),
loadFailed: _('加载失败', 'Load failed'),
toolsTitle: _('工具权限', 'Tool Permissions'),
toolsDesc: _('配置 Agent 可用工具的基础 profile 与额外 allow / deny 规则。', 'Configure the base tool profile and extra allow / deny rules for this Agent.'),

View File

@@ -42,6 +42,11 @@ export default {
confirmDelete: _('确定删除 Agent「{id}」?\n\n此操作将删除该 Agent 的所有数据和会话。', 'Delete Agent "{id}"?\n\nThis will delete all data and conversations for this agent.', '確定刪除 Agent「{id}」?\n\n此操作將刪除該 Agent 的所有資料和對話。', 'Agent「{id}」を削除しますか?\\n\\nこの Agent のすべてのデータと会話が削除されます。', 'Agent「{id}」를 삭제하시겠습니까?\\n\\n이 Agent의 모든 데이터와 대화가 삭제됩니다.'),
deleted: _('已删除', 'Deleted', '已刪除', '削除済み', '삭제됨', 'Đã xóa', 'Eliminado', 'Excluído', 'Удалён', 'Supprimé', 'Gelöscht'),
deleteFailed: _('删除失败', 'Delete failed', '刪除失敗', '削除失敗', '삭제 실패', 'Xóa thất bại', 'Error al eliminar', 'Falha ao excluir', 'Ошибка удаления', 'Échec de la suppression', 'Löschen fehlgeschlagen'),
deleteConfirmTitle: _('删除 Agent', 'Delete Agent', '刪除 Agent', 'Agent を削除', 'Agent 삭제', 'Xóa Agent', 'Eliminar Agent', 'Excluir Agent', 'Удалить Agent', 'Supprimer l\u2019Agent', 'Agent löschen'),
deleteConfirmBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xóa', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
deleteCancelBtn: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
deleteImpactBindings: _('将解除该 Agent 的 {n} 个渠道绑定', 'Will unbind the {n} channel binding(s) of this Agent', '將解除該 Agent 的 {n} 個頻道綁定', 'この Agent の {n} 件のチャンネル連携が解除されます', '이 Agent의 {n}개 채널 연결이 해제됩니다', 'Sẽ huỷ {n} liên kết kênh của Agent này', 'Se desvincularán los {n} canales de este Agent', 'As {n} vinculações de canal deste Agent serão removidas', '{n} привязок каналов этого Agent будут отменены', 'Les {n} liaisons de canal de cet Agent seront supprimées', 'Die {n} Kanalverknüpfungen dieses Agent werden aufgehoben'),
deleteImpactConfig: _('Agent 配置文件将被删除(无法恢复)', 'Agent config files will be deleted (cannot be recovered)', 'Agent 設定檔案將被刪除(無法復原)', 'Agent の設定ファイルが削除されます(復元できません)', 'Agent 설정 파일이 삭제됩니다 (복구할 수 없음)', 'Tệp cấu hình của Agent sẽ bị xóa (không thể khôi phục)', 'Los archivos de configuración del Agent se eliminarán (irreversible)', 'Os arquivos de configuração do Agent serão excluídos (irreversível)', 'Файлы конфигурации Agent будут удалены (без возможности восстановления)', 'Les fichiers de configuration de l\u2019Agent seront supprimés (irréversible)', 'Die Konfigurationsdateien des Agent werden gelöscht (nicht wiederherstellbar)'),
backingUp: _('正在备份 Agent「{id}」...', 'Backing up Agent "{id}"...', '正在備份 Agent「{id}」...', 'Agent「{id}」をバックアップ中...', 'Agent「{id}」 백업 중...'),
backupDone: _('备份完成: {file}', 'Backup done: {file}', '備份完成: {file}', 'バックアップ完了: {file}', '백업 완료: {file}'),
backupFailed: _('备份失败', 'Backup failed', '備份失敗', 'バックアップ失敗', '백업 실패'),

View File

@@ -181,6 +181,11 @@ export default {
addChannelBinding: _('添加渠道绑定', 'Add Channel Binding', '新增頻道綁定'),
enableChannelFirst: _('请先在「渠道列表」中接入并启用至少一个渠道', 'Please connect and enable at least one channel in "Channel List" first', '請先在「頻道列表」中接入並啟用至少一個頻道'),
confirmRemoveBinding: _('确定移除该绑定?', 'Remove this binding?', '確定移除該綁定?'),
removeBindingTitle: _('移除 Agent 绑定', 'Remove Agent binding', '移除 Agent 綁定', 'Agent 連携を削除', 'Agent 연결 제거', 'Xoá liên kết Agent', 'Eliminar vinculación de Agent', 'Remover vínculo de Agent', 'Удалить привязку Agent', 'Supprimer la liaison Agent', 'Agent-Verknüpfung entfernen'),
removeBindingBtn: _('移除', 'Remove', '移除', '削除', '제거', 'Xoá', 'Eliminar', 'Remover', 'Удалить', 'Supprimer', 'Entfernen'),
removeBindingCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
removeBindingImpactAgent: _('该 Agent 将不再通过此渠道接收消息', 'The Agent will stop receiving messages from this channel', '該 Agent 將不再透過此頻道接收訊息', 'この Agent はこのチャンネルからメッセージを受信しなくなります', '이 Agent는 이 채널을 통한 메시지 수신을 중지합니다', 'Agent sẽ không nhận tin nhắn qua kênh này nữa', 'El Agent dejará de recibir mensajes por este canal', 'O Agent deixará de receber mensagens por este canal', 'Agent перестанет принимать сообщения по этому каналу', 'L\u2019Agent ne recevra plus de messages via ce canal', 'Der Agent erhält keine Nachrichten mehr über diesen Kanal'),
removeBindingImpactConfig: _('渠道平台本身的配置和凭证会被保留', 'The channel platform configuration and credentials will be kept', '頻道平台本身的設定和憑證會被保留', 'チャンネルプラットフォーム自体の設定と認証情報は保持されます', '채널 플랫폼 자체의 설정과 자격 증명은 유지됩니다', 'Cấu hình và thông tin xác thực của nền tảng kênh sẽ được giữ lại', 'La configuración y credenciales de la plataforma del canal se conservarán', 'A configuração e credenciais da plataforma do canal serão mantidas', 'Конфигурация и учётные данные платформы канала будут сохранены', 'La configuration et les identifiants de la plateforme du canal seront conservés', 'Konfiguration und Anmeldedaten der Kanalplattform bleiben erhalten'),
bindingRemoved: _('绑定已移除', 'Binding removed', '綁定已移除'),
addBindingForAgent: _('为 {agent} 添加渠道绑定', 'Add channel binding for {agent}', '為 {agent} 新增頻道綁定'),
channel: _('渠道', 'Channel', '頻道'),
@@ -195,6 +200,12 @@ export default {
enabled: _('已启用', 'Enabled', '已啟用'),
operationFailed: _('操作失败', 'Operation failed', '操作失敗'),
confirmRemovePlatform: _('确定移除该平台及其所有配置?', 'Remove this platform and all its config?', '確定移除該平台及其所有設定?'),
removePlatformTitle: _('移除平台「{name}」', 'Remove platform "{name}"', '移除平台「{name}」', 'プラットフォーム「{name}」を削除', '플랫폼「{name}」 제거', 'Xoá nền tảng "{name}"', 'Eliminar plataforma "{name}"', 'Remover plataforma "{name}"', 'Удалить платформу "{name}"', 'Supprimer la plateforme "{name}"', 'Plattform "{name}" entfernen'),
removePlatformBtn: _('移除', 'Remove', '移除', '削除', '제거', 'Xoá', 'Eliminar', 'Remover', 'Удалить', 'Supprimer', 'Entfernen'),
removePlatformCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
removePlatformImpactBindings: _('解除该平台关联的 {n} 个 Agent 绑定', 'Unbind the {n} Agent binding(s) linked to this platform', '解除該平台關聯的 {n} 個 Agent 綁定', 'このプラットフォームに紐づく {n} 件の Agent 連携を解除', '이 플랫폼과 연결된 {n}개 Agent 연결을 해제', 'Huỷ {n} liên kết Agent gắn với nền tảng này', 'Se desvincularán los {n} Agent vinculados a esta plataforma', 'Serão removidas as {n} vinculações de Agent ligadas a esta plataforma', 'Будут отменены {n} привязок Agent к этой платформе', 'Les {n} liaisons d\u2019Agent associées à cette plateforme seront supprimées', '{n} Agent-Verknüpfungen mit dieser Plattform werden aufgehoben'),
removePlatformImpactConfig: _('该平台的配置和凭证将被删除', 'Platform configuration and credentials will be deleted', '該平台的設定和憑證將被刪除', 'プラットフォームの設定と認証情報が削除されます', '플랫폼 설정과 자격 증명이 삭제됩니다', 'Cấu hình và thông tin xác thực của nền tảng sẽ bị xoá', 'La configuración y las credenciales de la plataforma serán eliminadas', 'A configuração e credenciais da plataforma serão excluídas', 'Конфигурация и учётные данные платформы будут удалены', 'La configuration et les identifiants de la plateforme seront supprimés', 'Konfiguration und Anmeldedaten der Plattform werden gelöscht'),
removePlatformImpactStop: _('Gateway 重启后该平台将停止接收消息', 'After Gateway restart, this platform will stop receiving messages', 'Gateway 重啟後該平台將停止接收訊息', 'Gateway 再起動後、このプラットフォームはメッセージの受信を停止', 'Gateway 재시작 후 이 플랫폼은 메시지 수신을 중지', 'Sau khi khởi động lại Gateway, nền tảng này sẽ ngừng nhận tin nhắn', 'Tras reiniciar el Gateway, esta plataforma dejará de recibir mensajes', 'Após reiniciar o Gateway, esta plataforma deixará de receber mensagens', 'После перезапуска Gateway эта платформа перестанет принимать сообщения', 'Après le redémarrage du Gateway, cette plateforme cessera de recevoir les messages', 'Nach Gateway-Neustart wird diese Plattform keine Nachrichten mehr empfangen'),
supportsActions: _('支持操作', 'Supports actions', '支援操作'),
connectedClickEdit: _('已接入,点击编辑', 'Connected, click to edit', '已接入,点擊編輯'),
qqDiagAllPassed: _('全部检查通过 ✓', 'All checks passed ✓', '全部檢查通過 ✓'),

View File

@@ -32,9 +32,19 @@ export default {
sessionCreated: _('会话已创建', 'Session created', '對話已建立'),
cannotDeleteMain: _('主会话不能删除', 'Cannot delete the main session', '主對話不能刪除'),
confirmDeleteSession: _('确定删除会话「{label}」?', 'Delete session "{label}"?', '確定刪除對話「{label}」?', 'このセッションを削除しますか?'),
deleteSessionTitle: _('删除会话「{label}」', 'Delete session "{label}"', '刪除對話「{label}」', 'セッション「{label}」を削除', '세션「{label}」 삭제', 'Xoá cuộc trò chuyện "{label}"', 'Eliminar sesión "{label}"', 'Excluir sessão "{label}"', 'Удалить сессию "{label}"', 'Supprimer la session "{label}"', 'Sitzung "{label}" löschen'),
deleteSessionBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xoá', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
deleteSessionCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
deleteSessionImpactHistory: _('会话所有聊天记录将永久删除', 'All chat history of this session will be permanently deleted', '對話所有聊天紀錄將永久刪除', 'セッションのすべてのチャット履歴が永久に削除されます', '세션의 모든 채팅 기록이 영구적으로 삭제됩니다', 'Toàn bộ lịch sử trò chuyện sẽ bị xoá vĩnh viễn', 'Todo el historial de chat de esta sesión se eliminará permanentemente', 'Todo o histórico de chat desta sessão será excluído permanentemente', 'Вся история чатов сессии будет удалена навсегда', 'Tout l\u2019historique de chat de cette session sera supprimé définitivement', 'Der gesamte Chatverlauf dieser Sitzung wird dauerhaft gelöscht'),
deleteSessionImpactCannotUndo: _('删除后无法恢复', 'Cannot be recovered after deletion', '刪除後無法復原', '削除後は復元できません', '삭제 후에는 복구할 수 없습니다', 'Sau khi xoá không thể khôi phục', 'No se podrá recuperar tras la eliminación', 'Não poderá ser recuperado após a exclusão', 'После удаления восстановить нельзя', 'Impossible à restaurer après suppression', 'Nach dem Löschen nicht wiederherstellbar'),
sessionDeleted: _('会话已删除', 'Session deleted', '對話已刪除'),
deleteFailed: _('删除失败: ', 'Delete failed: ', '刪除失敗: '),
confirmResetSession: _('确定要重置会话「{label}」吗?\n\n重置后将清空该会话的所有聊天记录此操作不可撤销。', 'Reset session "{label}"?\n\nThis will clear all chat history for this session. This action cannot be undone.', '確定要重置對話「{label}」吗?\n\n重置后將清空該對話的所有聊天紀錄此操作不可撤销。'),
resetSessionTitle: _('重置会话「{label}」', 'Reset session "{label}"', '重置對話「{label}」', 'セッション「{label}」をリセット', '세션「{label}」 초기화', 'Khôi phục cuộc trò chuyện "{label}"', 'Restablecer sesión "{label}"', 'Redefinir sessão "{label}"', 'Сбросить сессию "{label}"', 'Réinitialiser la session "{label}"', 'Sitzung "{label}" zurücksetzen'),
resetSessionBtn: _('重置', 'Reset', '重置', 'リセット', '초기화', 'Khôi phục', 'Restablecer', 'Redefinir', 'Сбросить', 'Réinitialiser', 'Zurücksetzen'),
resetSessionCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
resetSessionImpactHistory: _('该会话所有聊天记录将清空,无法恢复', 'All chat history of this session will be cleared and cannot be recovered', '該對話所有聊天紀錄將清空,無法復原', 'このセッションのすべてのチャット履歴が消去され、復元できません', '이 세션의 모든 채팅 기록이 삭제되며 복구할 수 없습니다', 'Toàn bộ lịch sử trò chuyện sẽ bị xoá và không thể khôi phục', 'Se eliminará todo el historial de chat de esta sesión, sin posibilidad de recuperación', 'Todo o histórico de chat desta sessão será apagado e não poderá ser recuperado', 'Вся история чата сессии будет удалена без возможности восстановления', 'Tout l\u2019historique de chat de la session sera effacé sans possibilité de récupération', 'Der gesamte Chatverlauf dieser Sitzung wird gelöscht und kann nicht wiederhergestellt werden'),
resetSessionImpactContext: _('AI 将从零开始,不再记得之前的对话内容', 'The AI will start fresh and no longer remember previous conversation', 'AI 將從零開始,不再記得之前的對話內容', 'AI は最初からやり直し、以前の会話を覚えていません', 'AI는 처음부터 다시 시작하며 이전 대화를 기억하지 못합니다', 'AI sẽ bắt đầu lại từ đầu và không nhớ cuộc trò chuyện trước', 'La IA empezará de cero y no recordará la conversación previa', 'A IA começará do zero e não lembrará da conversa anterior', 'ИИ начнёт с нуля и забудет предыдущий разговор', 'L\u2019IA repartira de zéro et ne se souviendra plus de la conversation précédente', 'Die KI beginnt von vorne und erinnert sich nicht mehr an das vorherige Gespräch'),
sessionResetDone: _('会话已重置', 'Session has been reset', '對話已重置'),
resetFailed: _('重置失败: ', 'Reset failed: ', '重置失敗: '),
sessionRenamed: _('会话已重命名', 'Session renamed', '對話已重命名'),

View File

@@ -69,4 +69,35 @@ export default {
upgradeFailed: _('升级失败', 'Upgrade failed', '升級失敗', 'アップグレード失敗', '업그레이드 실패', 'Nâng cấp thất bại'),
unknownCommand: _('未知命令', 'Unknown command', '未知命令', '不明なコマンド', '알 수 없는 명령', 'Lệnh không xác định'),
processing: _('处理中...', 'Processing...', '處理中...', '処理中...', '처리 중...', 'Đang xử lý...', 'Procesando...', 'Processando...', 'Обработка...', 'Traitement...', 'Verarbeitung...'),
// ---------------------------------------------------------------------
// 友好错误文案(给 humanize-error 工具用)
// 每个错误同时有 短文案 (error.*) + 行动建议 (errorHint.*)
// ---------------------------------------------------------------------
error: {
network: _('网络不通', 'Network unreachable', '網路不通', 'ネットワークに接続できません', '네트워크에 연결할 수 없습니다', 'Không có kết nối mạng', 'Sin conexión', 'Sem conexão', 'Нет сети', 'Pas de réseau', 'Keine Verbindung'),
gatewayDown: _('Gateway 未启动', 'Gateway is not running', 'Gateway 未啟動', 'Gateway が起動していません', 'Gateway가 실행되고 있지 않습니다', 'Gateway chưa khởi động', 'Gateway no está en ejecución', 'Gateway não está em execução', 'Gateway не запущен', 'Gateway non démarré', 'Gateway läuft nicht'),
cmdMissing: _('命令未找到', 'Command not found', '命令未找到', 'コマンドが見つかりません', '명령을 찾을 수 없습니다', 'Không tìm thấy lệnh', 'Comando no encontrado', 'Comando não encontrado', 'Команда не найдена', 'Commande introuvable', 'Befehl nicht gefunden'),
permission: _('权限不足', 'Permission denied', '權限不足', '権限がありません', '권한이 부족합니다', 'Không có quyền', 'Permiso denegado', 'Permissão negada', 'Доступ запрещён', 'Permission refusée', 'Zugriff verweigert'),
timeout: _('请求超时', 'Request timed out', '請求逾時', 'リクエストがタイムアウトしました', '요청 시간이 초과되었습니다', 'Yêu cầu hết hạn', 'Tiempo agotado', 'Tempo esgotado', 'Превышено время ожидания', 'Délai dépassé', 'Zeitüberschreitung'),
busy: _('服务繁忙', 'Service is busy', '服務繁忙', 'サービスが混雑しています', '서비스가 사용 중입니다', 'Dịch vụ đang bận', 'Servicio ocupado', 'Serviço ocupado', 'Сервис занят', 'Service occupé', 'Dienst ausgelastet'),
notFound: _('未找到', 'Not found', '未找到', '見つかりません', '찾을 수 없습니다', 'Không tìm thấy', 'No encontrado', 'Não encontrado', 'Не найдено', 'Introuvable', 'Nicht gefunden'),
auth: _('身份验证失败', 'Authentication failed', '身分驗證失敗', '認証に失敗しました', '인증에 실패했습니다', 'Xác thực thất bại', 'Autenticación fallida', 'Falha na autenticação', 'Ошибка авторизации', 'Échec d\u2019authentification', 'Authentifizierung fehlgeschlagen'),
rateLimit: _('调用过于频繁', 'Rate limit exceeded', '呼叫過於頻繁', '呼び出し回数が多すぎます', '호출이 너무 잦습니다', 'Gọi quá thường xuyên', 'Demasiadas solicitudes', 'Muitas solicitações', 'Слишком много запросов', 'Trop de requêtes', 'Zu viele Anfragen'),
generic: _('操作未完成', 'Action failed', '操作未完成', '操作が完了しませんでした', '작업이 완료되지 않았습니다', 'Thao tác chưa hoàn thành', 'Acción fallida', 'Ação falhou', 'Действие не выполнено', 'Action échouée', 'Aktion fehlgeschlagen'),
},
errorHint: {
network: _('请检查网络连接,或确认你正在访问的服务器是否可达', 'Check your network connection, or confirm the target server is reachable', '請檢查網路連線,或確認目標伺服器是否可達', 'ネットワーク接続を確認するか、対象サーバーに到達できるか確認してください', '네트워크 연결을 확인하거나 대상 서버에 도달할 수 있는지 확인하세요', 'Hãy kiểm tra kết nối mạng hoặc xác nhận máy chủ đích có thể truy cập', 'Verifica tu conexión de red o confirma que el servidor destino sea accesible', 'Verifique sua conexão ou se o servidor de destino está acessível', 'Проверьте сеть или доступность целевого сервера', 'Vérifiez votre réseau ou la disponibilité du serveur cible', 'Prüfen Sie Ihre Netzwerkverbindung oder die Erreichbarkeit des Zielservers'),
gatewayDown: _('请前往仪表盘启动 Gateway 后重试', 'Open the dashboard to start the Gateway, then retry', '請前往儀表板啟動 Gateway 後重試', 'ダッシュボードで Gateway を起動してから再試行してください', '대시보드에서 Gateway를 시작한 후 다시 시도하세요', 'Vui lòng vào bảng điều khiển để khởi động Gateway rồi thử lại', 'Abre el panel para iniciar Gateway y reintenta', 'Abra o painel para iniciar o Gateway e tente novamente', 'Откройте панель и запустите Gateway, затем повторите', 'Ouvrez le tableau de bord pour démarrer Gateway, puis réessayez', 'Öffnen Sie das Dashboard, um das Gateway zu starten, und versuchen Sie es erneut'),
cmdMissing: _('请确认依赖已正确安装;可在设置页指定可执行文件路径', 'Make sure the dependency is installed; you can specify the executable path in Settings', '請確認相依套件已安裝;可在設定頁指定可執行檔案路徑', '依存関係がインストールされていることを確認してください。設定ページで実行ファイルのパスを指定できます', '의존성이 설치되어 있는지 확인하세요; 설정 페이지에서 실행 파일 경로를 지정할 수 있습니다', 'Hãy bảo đảm phụ thuộc đã được cài đặt; bạn có thể chỉ định đường dẫn tệp thực thi trong Cài đặt', 'Asegúrate de que la dependencia esté instalada; puedes especificar la ruta del ejecutable en Configuración', 'Verifique se a dependência está instalada; você pode especificar o caminho do executável em Configurações', 'Убедитесь, что зависимость установлена; путь к исполняемому файлу можно указать в настройках', 'Vérifiez que la dépendance est installée ; vous pouvez préciser le chemin de l\u2019exécutable dans Paramètres', 'Stellen Sie sicher, dass die Abhängigkeit installiert ist; den Pfad zur ausführbaren Datei können Sie in den Einstellungen festlegen'),
permission: _('请用管理员/sudo 重试,或检查目标文件/目录的权限', 'Retry with admin/sudo, or check the target file/directory permissions', '請以管理員/sudo 重試,或檢查目標檔案/目錄的權限', '管理者/sudo で再試行するか、対象ファイル/ディレクトリの権限を確認してください', '관리자/sudo로 다시 시도하거나 대상 파일/디렉터리 권한을 확인하세요', 'Hãy thử lại bằng quyền quản trị/sudo hoặc kiểm tra quyền của tệp/thư mục đích', 'Reintenta como administrador/sudo o revisa los permisos del archivo/directorio destino', 'Tente como administrador/sudo ou verifique as permissões do arquivo/diretório alvo', 'Повторите от имени администратора/sudo или проверьте права на файл/папку', 'Réessayez en administrateur/sudo ou vérifiez les permissions du fichier/dossier cible', 'Versuchen Sie es als Administrator/sudo erneut oder prüfen Sie die Berechtigungen der Zieldatei/des Zielordners'),
timeout: _('服务响应较慢,请稍后重试;如长期失败,请检查 Gateway 日志', 'The service is responding slowly. Retry later; check Gateway logs if it persists', '服務回應較慢,請稍後重試;如長期失敗,請檢查 Gateway 紀錄檔', 'サービスの応答が遅いです。後で再試行し、続く場合は Gateway のログを確認してください', '서비스 응답이 느립니다. 나중에 다시 시도하고, 계속되면 Gateway 로그를 확인하세요', 'Dịch vụ phản hồi chậm, vui lòng thử lại sau; nếu vẫn lỗi, hãy kiểm tra nhật ký Gateway', 'El servicio responde lento, reintenta luego; si persiste, revisa los registros del Gateway', 'O serviço está lento, tente novamente; se persistir, verifique os logs do Gateway', 'Сервис отвечает медленно, повторите позже; если ошибка не уходит — посмотрите логи Gateway', 'Le service répond lentement, réessayez plus tard ; si l\u2019erreur persiste, consultez les journaux du Gateway', 'Der Dienst antwortet langsam, später erneut versuchen; falls anhaltend, prüfen Sie die Gateway-Protokolle'),
busy: _('服务正在处理其他请求,请稍后重试', 'The service is handling other requests. Retry shortly', '服務正在處理其他請求,請稍後重試', 'サービスが他のリクエストを処理中です。しばらくしてから再試行してください', '서비스가 다른 요청을 처리 중입니다. 잠시 후 다시 시도하세요', 'Dịch vụ đang xử lý yêu cầu khác, vui lòng thử lại sau', 'El servicio está atendiendo otras solicitudes, reintenta en breve', 'O serviço está processando outras solicitações, tente novamente em breve', 'Сервис обрабатывает другие запросы, повторите чуть позже', 'Le service traite d\u2019autres requêtes, réessayez sous peu', 'Der Dienst bearbeitet andere Anfragen, kurz später erneut versuchen'),
notFound: _('请确认目标资源是否仍存在', 'Confirm that the target resource still exists', '請確認目標資源是否仍存在', '対象リソースがまだ存在するか確認してください', '대상 리소스가 여전히 존재하는지 확인하세요', 'Hãy xác nhận tài nguyên đích vẫn tồn tại', 'Confirma que el recurso aún exista', 'Confirme se o recurso ainda existe', 'Убедитесь, что целевой ресурс ещё существует', 'Vérifiez que la ressource cible existe toujours', 'Bestätigen Sie, dass die Zielressource noch existiert'),
auth: _('请检查 API key/账号信息是否正确,或重新登录', 'Check that the API key / account info is correct, or sign in again', '請檢查 API key/帳號資訊是否正確,或重新登入', 'API キー/アカウント情報が正しいか確認するか、再度ログインしてください', 'API 키/계정 정보가 올바른지 확인하거나 다시 로그인하세요', 'Hãy kiểm tra API key/thông tin tài khoản, hoặc đăng nhập lại', 'Verifica que la API key / cuenta sea correcta, o vuelve a iniciar sesión', 'Verifique se a API key / conta está correta, ou faça login novamente', 'Проверьте API-ключ / данные учётной записи или войдите снова', 'Vérifiez que la clé API / les identifiants sont corrects ou reconnectez-vous', 'Prüfen Sie API-Key / Anmeldedaten oder melden Sie sich erneut an'),
rateLimit: _('已触发限流,请稍后重试,或在模型/渠道配置中放宽频率', 'Rate limit reached. Retry later, or raise the limit in the model/channel settings', '已觸發限流,請稍後重試,或在模型/頻道設定中放寬頻率', 'レート制限に達しました。後で再試行するか、モデル/チャンネル設定で上限を緩めてください', '속도 제한에 도달했습니다. 잠시 후 다시 시도하거나 모델/채널 설정에서 한도를 완화하세요', 'Đã đạt giới hạn tốc độ, thử lại sau hoặc nới lỏng giới hạn trong cài đặt mô hình/kênh', 'Se alcanzó el límite de tasa, reintenta luego o aumenta el límite en la configuración', 'Limite de taxa atingido, tente mais tarde ou aumente o limite nas configurações', 'Достигнут лимит запросов, повторите позже или увеличьте лимит в настройках', 'Limite de débit atteinte, réessayez plus tard ou augmentez la limite dans les paramètres', 'Ratenlimit erreicht, später erneut versuchen oder das Limit in den Einstellungen erhöhen'),
generic: _('请稍后重试;如问题持续,请到日志页查看详情', 'Try again later; if the issue persists, check the Logs page for details', '請稍後重試;如問題持續,請到日誌頁查看詳情', '後で再試行してください。問題が続く場合はログページで詳細を確認してください', '잠시 후 다시 시도하세요; 문제가 계속되면 로그 페이지에서 자세히 확인하세요', 'Vui lòng thử lại sau; nếu lỗi vẫn xảy ra, hãy kiểm tra trang Nhật ký', 'Reintenta más tarde; si persiste, revisa la página de Registros', 'Tente novamente mais tarde; se persistir, verifique a página de Logs', 'Повторите позже; если ошибка не уходит — посмотрите страницу Логи', 'Réessayez plus tard ; si le problème persiste, consultez la page Journaux', 'Versuchen Sie es später erneut; bei anhaltendem Problem die Protokoll-Seite prüfen'),
},
errorRawLabel: _('技术详情', 'Technical details', '技術詳情', '技術的詳細', '기술 정보', 'Chi tiết kỹ thuật', 'Detalles técnicos', 'Detalhes técnicos', 'Технические подробности', 'Détails techniques', 'Technische Details'),
// 空状态通用副本
emptyGetStartedHint: _('点下面的按钮开始第一步', 'Click the button below to get started', '點下面的按鈕開始第一步', '下のボタンから最初の一歩を', '아래 버튼을 눌러 시작하세요', 'Nhấn nút bên dưới để bắt đầu', 'Pulsa el botón de abajo para empezar', 'Clique no botão abaixo para começar', 'Нажмите кнопку ниже, чтобы начать', 'Cliquez sur le bouton ci-dessous pour commencer', 'Klicken Sie auf die Schaltfläche unten, um zu starten'),
}

View File

@@ -21,6 +21,11 @@ export default {
toggleEnabled: _('已启用', 'Enabled', '已啟用'),
toggleFailed: _('操作失败', 'Operation failed', '操作失敗', '状態切替失敗'),
confirmDelete: _('确定删除任务「{name}」?', 'Delete task "{name}"?', '確定刪除任務「{name}」?', 'タスク「{name}」を削除しますか?', '작업「{name}」을 삭제하시겠습니까?'),
deleteTitle: _('删除定时任务「{name}」', 'Delete cron task "{name}"', '刪除定時任務「{name}」', '定期タスク「{name}」を削除', '예약 작업「{name}」 삭제', 'Xoá tác vụ định kỳ "{name}"', 'Eliminar tarea programada "{name}"', 'Excluir tarefa agendada "{name}"', 'Удалить задачу "{name}"', 'Supprimer la tâche planifiée "{name}"', 'Geplante Aufgabe "{name}" löschen'),
deleteBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xoá', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
deleteCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
deleteImpactStop: _('任务将立即停止,不再按计划执行', 'Task will stop immediately and will no longer run on schedule', '任務將立即停止,不再按計畫執行', 'タスクは直ちに停止し、スケジュール実行されません', '작업이 즉시 중지되고 더 이상 예약대로 실행되지 않습니다', 'Tác vụ sẽ dừng ngay và không chạy theo lịch nữa', 'La tarea se detendrá de inmediato y dejará de ejecutarse según el calendario', 'A tarefa será interrompida imediatamente e deixará de rodar no agendamento', 'Задача немедленно остановится и больше не будет выполняться по расписанию', 'La tâche s\u2019arrêtera immédiatement et ne s\u2019exécutera plus selon le planning', 'Die Aufgabe wird sofort gestoppt und nicht mehr nach Zeitplan ausgeführt'),
deleteImpactHistory: _('已执行的历史记录会被保留', 'Past execution history will be kept', '已執行的歷史紀錄會被保留', '実行履歴は保持されます', '실행 이력은 유지됩니다', 'Lịch sử thực thi sẽ được giữ lại', 'El historial de ejecuciones se conservará', 'O histórico de execuções será mantido', 'История выполнения будет сохранена', 'L\u2019historique d\u2019exécution sera conservé', 'Der Ausführungsverlauf bleibt erhalten'),
deleted: _('已删除', 'Deleted', '已刪除', 'タスク削除済み', '작업 삭제됨', 'Tác vụ đã xóa', 'Tarea eliminada', 'Tarefa excluída', 'Задача удалена', 'Tâche supprimée', 'Aufgabe gelöscht'),
deleteFailed: _('删除失败', 'Delete failed', '刪除失敗', 'タスク削除失敗', '작업 삭제 실패'),
fetchFailed: _('获取任务列表失败', 'Failed to fetch task list', '取得任務列表失敗'),

View File

@@ -3,6 +3,25 @@ import { _ } from '../helper.js'
export default {
title: _('仪表盘', 'Dashboard', '儀表盤', 'ダッシュボード', '대시보드', 'Bảng điều khiển', 'Panel', 'Painel', 'Панель', 'Tableau de bord'),
desc: _('OpenClaw 运行状态概览', 'OpenClaw runtime status overview', 'OpenClaw 執行狀態概覽', 'OpenClaw 実行状況の概要', 'OpenClaw 실행 상태 개요', 'Tổng quan trạng thái OpenClaw', 'Resumen del estado de OpenClaw', 'Visão geral do estado do OpenClaw', 'Обзор состояния OpenClaw', 'Vue d\'ensemble de l\'état OpenClaw', 'OpenClaw-Statusübersicht'),
// ── 新手任务卡片 ──
onboardingTitle: _('🎯 新手上路', '🎯 Get Started', '🎯 新手上路', '🎯 はじめに', '🎯 시작하기', '🎯 Bắt đầu', '🎯 Empezar', '🎯 Comece aqui', '🎯 Начало работы', '🎯 Démarrer', '🎯 Erste Schritte'),
onboardingDesc: _('完成下面几步,就能开始用面板了', 'Finish these steps to start using ClawPanel', '完成下面几步,就能開始用面板了', '以下のステップを完了するとパネルが使えます', '아래 단계를 완료하면 패널을 사용할 수 있습니다', 'Hoàn tất các bước dưới để bắt đầu sử dụng', 'Completa estos pasos para empezar a usar el panel', 'Conclua estes passos para começar a usar o painel', 'Выполните эти шаги, чтобы начать пользоваться панелью', 'Effectuez ces étapes pour commencer à utiliser le panneau', 'Schließen Sie diese Schritte ab, um mit dem Panel zu beginnen'),
onboardingClose: _('我已经会用了,关闭引导', 'I know how to use this, close', '我已經會用了,關閉引導', '使い方を理解しています、閉じる', '사용법을 이미 알고 있습니다, 닫기', 'Tôi đã biết cách dùng, đóng', 'Ya sé cómo usarlo, cerrar', 'Já sei usar, fechar', 'Я уже знаю, закрыть', 'Je sais déjà l\u2019utiliser, fermer', 'Ich kenne mich aus, schließen'),
onboardingDone: _('完成', 'Done', '完成', '完了', '완료', 'Hoàn tất', 'Hecho', 'Concluído', 'Готово', 'Terminé', 'Erledigt'),
onboardingTodo: _('待完成', 'To do', '待完成', '未完了', '미완료', 'Chưa hoàn tất', 'Pendiente', 'Pendente', 'В процессе', 'À faire', 'Offen'),
onboardingStep1Title: _('启动 Gateway', 'Start Gateway', '啟動 Gateway', 'Gateway を起動', 'Gateway 시작', 'Khởi động Gateway', 'Iniciar Gateway', 'Iniciar Gateway', 'Запустить Gateway', 'Démarrer Gateway', 'Gateway starten'),
onboardingStep1Desc: _('Gateway 是面板和模型对话的「发动机」,先启动它', 'Gateway is the engine that connects the panel to your AI models. Start it first', 'Gateway 是面板和模型對話的「發動機」,先啟動它', 'Gateway はパネルと AI モデルをつなぐ「エンジン」。最初に起動してください', 'Gateway는 패널과 AI 모델을 연결하는 엔진입니다. 먼저 시작하세요', 'Gateway là "động cơ" kết nối panel với mô hình AI. Hãy khởi động trước', 'Gateway es el "motor" que conecta el panel con tus modelos de IA. Inícialo primero', 'O Gateway é o "motor" que conecta o painel aos seus modelos de IA. Inicie-o primeiro', 'Gateway — это «двигатель», соединяющий панель с моделями ИИ. Запустите его первым', 'Gateway est le « moteur » qui relie le panneau à vos modèles IA. Démarrez-le d\u2019abord', 'Gateway ist der „Motor", der das Panel mit Ihren KI-Modellen verbindet. Starten Sie ihn zuerst'),
onboardingStep1Cta: _('去启动', 'Start it', '去啟動', '起動する', '시작하기', 'Khởi động', 'Iniciar', 'Iniciar', 'Запустить', 'Démarrer', 'Starten'),
onboardingStep2Title: _('添加你的第一个模型', 'Add your first model', '新增你的第一個模型', '最初のモデルを追加', '첫 번째 모델 추가', 'Thêm mô hình đầu tiên', 'Añade tu primer modelo', 'Adicione seu primeiro modelo', 'Добавьте первую модель', 'Ajoutez votre premier modèle', 'Fügen Sie Ihr erstes Modell hinzu'),
onboardingStep2Desc: _('配上 OpenAI / Claude / DeepSeek 等任意服务商和 API keyAI 才有「大脑」', 'Add a provider like OpenAI / Claude / DeepSeek with an API key — that gives the AI its "brain"', '配上 OpenAI / Claude / DeepSeek 等任意服務商和 API keyAI 才有「大腦」', 'OpenAI / Claude / DeepSeek などのプロバイダーと API キーを設定すると AI に「脳」が宿ります', 'OpenAI / Claude / DeepSeek 같은 프로바이더와 API key를 설정해 AI에 "두뇌"를 부여하세요', 'Thêm nhà cung cấp (OpenAI / Claude / DeepSeek) cùng API key để AI có "bộ não"', 'Añade un proveedor como OpenAI / Claude / DeepSeek con su API key — eso le da el "cerebro" a la IA', 'Adicione um provedor como OpenAI / Claude / DeepSeek com a chave da API — isso dá um "cérebro" à IA', 'Добавьте провайдера (OpenAI / Claude / DeepSeek) с API-ключом — это даст ИИ «мозг»', 'Ajoutez un fournisseur comme OpenAI / Claude / DeepSeek avec une clé API — cela donne un « cerveau » à l\u2019IA', 'Fügen Sie einen Anbieter wie OpenAI / Claude / DeepSeek mit API-Key hinzu — damit hat die KI ein „Gehirn"'),
onboardingStep2Cta: _('去添加模型', 'Add a model', '去新增模型', 'モデルを追加', '모델 추가', 'Thêm mô hình', 'Añadir modelo', 'Adicionar modelo', 'Добавить модель', 'Ajouter un modèle', 'Modell hinzufügen'),
onboardingStep3Title: _('创建第一个 Agent', 'Create your first Agent', '建立第一個 Agent', '最初の Agent を作成', '첫 Agent 생성', 'Tạo Agent đầu tiên', 'Crea tu primer Agent', 'Crie seu primeiro Agent', 'Создайте первого агента', 'Créez votre premier Agent', 'Erstellen Sie Ihren ersten Agent'),
onboardingStep3Desc: _('Agent 是「分身」—— 给它身份、技能、记忆,它就能代你聊天和办事', 'Agents are your "alter egos" — give them an identity, skills, and memory so they can chat and act on your behalf', 'Agent 是「分身」—— 給它身份、技能、記憶,它就能代你聊天和辦事', 'Agent はあなたの「分身」。アイデンティティ・スキル・記憶を与えると、あなたの代わりに会話したり仕事をしたりします', 'Agent는 당신의 "분신"입니다. 정체성·스킬·기억을 부여하면 당신을 대신해 대화하고 일을 처리합니다', 'Agent là "phân thân" — đặt danh tính, kỹ năng, ký ức cho nó để nó trò chuyện và làm việc thay bạn', 'Los Agents son tus "alter egos" — dales una identidad, habilidades y memoria para que conversen y actúen por ti', 'Os Agents são seus "alter egos" — dê-lhes identidade, habilidades e memória para que conversem e ajam em seu nome', 'Agent — это «второе я». Дайте ему имя, навыки и память, и он будет говорить и действовать от вашего имени', 'Les Agents sont vos « alter egos ». Donnez-leur une identité, des compétences et une mémoire pour qu\u2019ils discutent et agissent pour vous', 'Agents sind Ihre „alter egos". Geben Sie ihnen Identität, Fähigkeiten und Gedächtnis, damit sie für Sie chatten und handeln'),
onboardingStep3Cta: _('去创建 Agent', 'Create an Agent', '去建立 Agent', 'Agent を作成', 'Agent 만들기', 'Tạo Agent', 'Crear Agent', 'Criar Agent', 'Создать агента', 'Créer un Agent', 'Agent erstellen'),
onboardingStep4Title: _('开始第一次对话', 'Start your first chat', '開始第一次對話', '最初のチャットを始める', '첫 대화 시작', 'Bắt đầu cuộc trò chuyện đầu tiên', 'Inicia tu primera conversación', 'Inicie sua primeira conversa', 'Начните первый чат', 'Démarrez votre première conversation', 'Starten Sie Ihren ersten Chat'),
onboardingStep4Desc: _('一切就绪 —— 现在打开聊天页,跟你的 Agent 说句话试试', 'All set — open the chat page and say hi to your Agent', '一切就緒 —— 現在打開聊天頁,跟你的 Agent 說句話試試', '準備完了。チャットページを開いて Agent に話しかけてみましょう', '준비 완료. 채팅 페이지를 열고 Agent에게 말을 걸어보세요', 'Tất cả sẵn sàng — mở trang trò chuyện và chào Agent của bạn', 'Listo — abre la página de chat y saluda a tu Agent', 'Tudo pronto — abra a página de chat e diga "oi" para o seu Agent', 'Готово — откройте страницу чата и поздоровайтесь со своим агентом', 'Tout est prêt — ouvrez la page de chat et dites bonjour à votre Agent', 'Alles bereit — öffnen Sie die Chat-Seite und begrüßen Sie Ihren Agent'),
onboardingStep4Cta: _('去聊天', 'Open chat', '去聊天', 'チャットを開く', '채팅 열기', 'Mở trò chuyện', 'Abrir chat', 'Abrir chat', 'Открыть чат', 'Ouvrir le chat', 'Chat öffnen'),
onboardingAllDone: _('🎉 全部搞定!享受你的 ClawPanel 之旅', '🎉 All done! Enjoy your ClawPanel journey', '🎉 全部搞定!享受你的 ClawPanel 之旅', '🎉 すべて完了ClawPanel をお楽しみください', '🎉 모두 완료! ClawPanel을 즐기세요', '🎉 Hoàn tất tất cả! Chúc bạn dùng ClawPanel vui vẻ', '🎉 ¡Todo listo! Disfruta tu experiencia con ClawPanel', '🎉 Tudo pronto! Aproveite sua experiência com o ClawPanel', '🎉 Всё готово! Наслаждайтесь работой с ClawPanel', '🎉 Tout est prêt ! Profitez de ClawPanel', '🎉 Alles erledigt! Viel Spaß mit ClawPanel'),
gateway: _('Gateway', 'Gateway'),
portDetect: _('端口检测', 'Port detection', '連接埠檢測', 'ポート検出', '포트 감지'),
notStarted: _('未启动', 'Not started', '未啟動', '未起動', '미시작', 'Chưa khởi động', 'No iniciado', 'Não iniciado', 'Не запущен', 'Non démarré', 'Nicht gestartet'),

View File

@@ -23,7 +23,17 @@ export default {
resetDiaryDone: _('梦境日记已重置', 'Dream diary reset'),
clearGroundedDone: _('grounded 短期记忆已清空', 'Grounded short-term memory cleared'),
confirmResetDiary: _('确定要重置梦境日记吗?这会清空现有 DREAMS.md 内容。', 'Reset the dream diary? This will clear existing DREAMS.md content.'),
resetDiaryTitle: _('重置梦境日记', 'Reset dream diary', '重置夢境日記', '夢日記をリセット', '꿈 일기 초기화', 'Khôi phục nhật ký giấc mơ', 'Restablecer diario de sueños', 'Redefinir diário de sonhos', 'Сбросить дневник снов', 'Réinitialiser le journal de rêves', 'Traumtagebuch zurücksetzen'),
resetDiaryBtn: _('重置', 'Reset', '重置', 'リセット', '초기화', 'Khôi phục', 'Restablecer', 'Redefinir', 'Сбросить', 'Réinitialiser', 'Zurücksetzen'),
resetDiaryCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
resetDiaryImpactContent: _('DREAMS.md 内容将清空,所有梦境记录将丢失', 'DREAMS.md will be emptied and all dream records will be lost', 'DREAMS.md 內容將清空,所有夢境紀錄將遺失', 'DREAMS.md が空になり、すべての夢の記録が失われます', 'DREAMS.md가 비워지고 모든 꿈 기록이 손실됩니다', 'DREAMS.md sẽ bị xoá và mọi ghi chép giấc mơ sẽ mất', 'DREAMS.md se vaciará y se perderán todos los registros de sueños', 'DREAMS.md será esvaziado e todos os registros de sonhos serão perdidos', 'Файл DREAMS.md будет очищен и все записи о снах будут утеряны', 'DREAMS.md sera vidé et tous les enregistrements de rêves seront perdus', 'DREAMS.md wird geleert und alle Traumaufzeichnungen gehen verloren'),
resetDiaryImpactReplay: _('下次 Dreaming 周期会重新生成新的梦境', 'The next Dreaming cycle will regenerate new dreams from scratch', '下次 Dreaming 週期會重新產生新的夢境', '次回の Dreaming サイクルで新しい夢が一から生成されます', '다음 Dreaming 사이클에서 새 꿈이 처음부터 다시 생성됩니다', 'Chu kỳ Dreaming tiếp theo sẽ sinh lại giấc mơ mới từ đầu', 'El próximo ciclo de Dreaming regenerará nuevos sueños desde cero', 'O próximo ciclo de Dreaming gerará novos sonhos do zero', 'Следующий цикл Dreaming сгенерирует новые сны с нуля', 'Le prochain cycle Dreaming générera de nouveaux rêves à partir de zéro', 'Der nächste Dreaming-Zyklus erzeugt neue Träume von Grund auf'),
confirmClearGrounded: _('确定要清空 grounded 短期记忆吗?', 'Clear grounded short-term memory?'),
clearGroundedTitle: _('清空短期记忆', 'Clear short-term memory', '清空短期記憶', '短期記憶をクリア', '단기 기억 비우기', 'Xoá ký ức ngắn hạn', 'Borrar memoria a corto plazo', 'Limpar memória de curto prazo', 'Очистить кратковременную память', 'Effacer la mémoire à court terme', 'Kurzzeitgedächtnis leeren'),
clearGroundedBtn: _('清空', 'Clear', '清空', 'クリア', '비우기', 'Xoá', 'Borrar', 'Limpar', 'Очистить', 'Effacer', 'Leeren'),
clearGroundedCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
clearGroundedImpact: _('grounded 短期记忆将立刻清空,最近一段对话上下文会丢失', 'Grounded short-term memory will be cleared immediately and recent conversation context will be lost', 'grounded 短期記憶將立即清空,最近一段對話上下文會遺失', 'grounded の短期記憶が直ちにクリアされ、直近の会話コンテキストが失われます', 'grounded 단기 기억이 즉시 비워지고 최근 대화 맥락이 사라집니다', 'Ký ức ngắn hạn grounded sẽ bị xoá ngay, ngữ cảnh trò chuyện gần đây sẽ mất', 'La memoria a corto plazo de grounded se borrará de inmediato y se perderá el contexto de las conversaciones recientes', 'A memória de curto prazo do grounded será limpa imediatamente e o contexto recente das conversas será perdido', 'Кратковременная память grounded будет немедленно очищена, и контекст недавних бесед будет утерян', 'La mémoire à court terme grounded sera effacée immédiatement et le contexte des conversations récentes sera perdu', 'Das grounded-Kurzzeitgedächtnis wird sofort geleert und der jüngste Gesprächskontext geht verloren'),
clearGroundedImpactNext: _('下一次对话开始时上下文将重建', 'Context will be rebuilt at the start of the next conversation', '下一次對話開始時上下文將重建', '次の会話開始時にコンテキストが再構築されます', '다음 대화 시작 시 컨텍스트가 다시 구축됩니다', 'Ngữ cảnh sẽ được xây dựng lại khi bắt đầu cuộc trò chuyện tiếp theo', 'El contexto se reconstruirá al iniciar la próxima conversación', 'O contexto será reconstruído no início da próxima conversa', 'Контекст будет восстановлен при начале следующей беседы', 'Le contexte sera reconstruit au début de la prochaine conversation', 'Der Kontext wird zu Beginn des nächsten Gesprächs neu aufgebaut'),
openMemory: _('打开记忆文件页', 'Open Memory Page'),
heroActive: _('正在整理上下文、筛选线索、沉淀长期记忆', 'Consolidating context, filtering signals, and promoting long-term memory'),
heroIdle: _('Dreaming 当前处于空闲状态,等待下一轮整理', 'Dreaming is idle and waiting for the next sweep'),

View File

@@ -477,6 +477,11 @@ export default {
cronCustomExpr: _('自定义 cron 表达式', 'Custom cron expression', '自訂 cron 表達式'),
cronPreview: _('预览', 'Preview', '預覽'),
cronConfirmDelete: _('确定要删除任务「{name}」吗?', 'Delete job "{name}"?', '確定要刪除任務「{name}」嗎?'),
cronDeleteTitle: _('删除任务「{name}」', 'Delete job "{name}"', '刪除任務「{name}」', 'ジョブ「{name}」を削除', '작업「{name}」 삭제', 'Xoá tác vụ "{name}"', 'Eliminar tarea "{name}"', 'Excluir tarefa "{name}"', 'Удалить задачу "{name}"', 'Supprimer la tâche "{name}"', 'Aufgabe "{name}" löschen'),
cronDeleteBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xoá', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
cronDeleteCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
cronDeleteImpactStop: _('任务将停止,不再按计划执行', 'Job will stop and will no longer run on schedule', '任務將停止,不再按計畫執行', 'ジョブは停止し、スケジュール実行されなくなります', '작업이 중지되고 더 이상 예약대로 실행되지 않습니다', 'Tác vụ sẽ dừng và không chạy theo lịch nữa', 'La tarea se detendrá y dejará de ejecutarse según el calendario', 'A tarefa será interrompida e deixará de rodar no agendamento', 'Задача остановится и не будет выполняться по расписанию', 'La tâche s\u2019arrêtera et ne s\u2019exécutera plus selon le planning', 'Die Aufgabe stoppt und wird nicht mehr nach Zeitplan ausgeführt'),
cronDeleteImpactHistory: _('已执行的历史记录会被保留', 'Past execution history will be kept', '已執行的歷史紀錄會被保留', '実行履歴は保持されます', '실행 이력은 유지됩니다', 'Lịch sử thực thi sẽ được giữ lại', 'El historial de ejecuciones se conservará', 'O histórico de execuções será mantido', 'История выполнения будет сохранена', 'L\u2019historique d\u2019exécution sera conservé', 'Der Ausführungsverlauf bleibt erhalten'),
cronSaving: _('保存中...', 'Saving...', '儲存中...'),
cronNameRequired: _('请输入任务名称', 'Job name is required', '請輸入任務名稱'),
cronPromptRequired: _('请输入 AI 指令', 'AI prompt is required', '請輸入 AI 指令'),

View File

@@ -0,0 +1,9 @@
import { _ } from '../helper.js'
export default {
title: _('面板术语表', 'ClawPanel Glossary', '面板術語表', '用語集', '용어집', 'Bảng thuật ngữ', 'Glosario', 'Glossário', 'Глоссарий', 'Glossaire', 'Glossar'),
desc: _('面板里出现的技术词,用大白话解释一遍。点击右上角「打开页面」直达对应配置。', 'Plain-language explanations of every tech term in the panel. Click "Open page" to jump to the matching settings.', '面板裡出現的技術詞,用大白話解釋一遍。點擊右上角「打開頁面」直達對應設定。', 'パネル内の専門用語をやさしい言葉で解説。「ページを開く」をクリックで対応設定へ', '패널의 기술 용어를 쉬운 말로 설명. "페이지 열기"를 클릭하면 해당 설정으로 이동', 'Giải thích các thuật ngữ kỹ thuật trong panel bằng ngôn ngữ đơn giản. Bấm "Mở trang" để tới phần cấu hình tương ứng', 'Términos técnicos del panel explicados con palabras sencillas. Pulsa "Abrir página" para ir a la configuración correspondiente', 'Termos técnicos do painel explicados em linguagem simples. Clique em "Abrir página" para ir à configuração correspondente', 'Технические термины панели простыми словами. Нажмите «Открыть страницу», чтобы перейти к настройкам', 'Termes techniques du panneau expliqués simplement. Cliquez sur « Ouvrir la page » pour accéder aux réglages', 'Technische Begriffe des Panels in einfachen Worten erklärt. Klicken Sie auf „Seite öffnen" um zur Einstellung zu gelangen'),
searchPlaceholder: _('搜索术语,比如 Gateway、Agent、Token...', 'Search terms like Gateway, Agent, Token...', '搜尋術語,例如 Gateway、Agent、Token...', '用語を検索: Gateway、Agent、Token...', '용어 검색: Gateway, Agent, Token...', 'Tìm thuật ngữ: Gateway, Agent, Token...', 'Buscar términos: Gateway, Agent, Token...', 'Buscar termos: Gateway, Agent, Token...', 'Поиск терминов: Gateway, Agent, Token...', 'Rechercher: Gateway, Agent, Token...', 'Begriffe suchen: Gateway, Agent, Token...'),
noMatch: _('没有匹配的术语', 'No matching terms', '沒有符合的術語', '一致する用語なし', '일치하는 용어 없음', 'Không có thuật ngữ phù hợp', 'Sin coincidencias', 'Sem correspondências', 'Совпадений не найдено', 'Aucune correspondance', 'Keine Übereinstimmung'),
openPage: _('打开页面', 'Open page', '打開頁面', 'ページを開く', '페이지 열기', 'Mở trang', 'Abrir página', 'Abrir página', 'Открыть страницу', 'Ouvrir la page', 'Seite öffnen'),
}

View File

@@ -25,6 +25,11 @@ export default {
created: _('已创建 {name}', 'Created {name}', '已建立 {name}'),
createFailed: _('创建失败', 'Failed to create', '建立失敗'),
confirmDelete: _('确定删除 {name}', 'Delete {name}?', '確定刪除 {name}', 'メモリファイル「{name}」を削除しますか?', '메모리 파일「{name}」을 삭제하시겠습니까?'),
deleteConfirmTitle: _('删除记忆文件「{name}」', 'Delete memory file "{name}"', '刪除記憶檔案「{name}」', 'メモリファイル「{name}」を削除', '메모리 파일「{name}」 삭제', 'Xoá tệp ký ức "{name}"', 'Eliminar archivo de memoria "{name}"', 'Excluir arquivo de memória "{name}"', 'Удалить файл памяти "{name}"', 'Supprimer le fichier de mémoire "{name}"', 'Speicherdatei "{name}" löschen'),
deleteConfirmBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xoá', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
deleteCancelBtn: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
deleteImpactPermanent: _('文件将从 Hermes 记忆库永久删除,无法恢复', 'File will be permanently removed from the Hermes memory store and cannot be recovered', '檔案將從 Hermes 記憶庫永久刪除,無法復原', 'Hermes メモリストアから永久に削除され、復元できません', 'Hermes 메모리에서 영구 삭제되며 복구할 수 없습니다', 'Tệp sẽ bị xoá vĩnh viễn khỏi kho ký ức của Hermes, không thể khôi phục', 'El archivo se eliminará permanentemente del almacén de memoria de Hermes y no se podrá recuperar', 'O arquivo será removido permanentemente do armazenamento de memória do Hermes e não poderá ser recuperado', 'Файл будет навсегда удалён из хранилища памяти Hermes и не подлежит восстановлению', 'Le fichier sera supprimé définitivement de la mémoire de Hermes et ne pourra pas être restauré', 'Die Datei wird dauerhaft aus dem Hermes-Speicher entfernt und kann nicht wiederhergestellt werden'),
deleteImpactAgent: _('该 Agent 在新对话中将不再使用此记忆', 'The Agent will no longer use this memory in new conversations', '該 Agent 在新對話中將不再使用此記憶', 'Agent は新しい会話でこのメモリを使用しなくなります', 'Agent는 새 대화에서 이 메모리를 더 이상 사용하지 않습니다', 'Agent sẽ không dùng ký ức này trong các cuộc trò chuyện mới', 'El Agent dejará de usar esta memoria en nuevas conversaciones', 'O Agent deixará de usar esta memória em novas conversas', 'Agent больше не будет использовать эту память в новых беседах', 'L\u2019Agent n\u2019utilisera plus cette mémoire dans les nouvelles conversations', 'Der Agent verwendet diesen Speicher in neuen Gesprächen nicht mehr'),
deleted: _('已删除 {name}', 'Deleted {name}', '已刪除 {name}', '削除済み', '삭제됨', 'Đã xóa', 'Eliminado', 'Excluído', 'Удалено', 'Supprimé', 'Gelöscht'),
deleteFailed: _('删除失败', 'Failed to delete', '刪除失敗', '削除失敗', '삭제 실패', 'Xóa thất bại', 'Error al eliminar', 'Falha ao excluir', 'Ошибка удаления', 'Échec de la suppression', 'Löschen fehlgeschlagen'),
noFiles: _('暂无文件', 'No files', '暫無檔案', 'メモリファイルなし', '메모리 파일 없음', 'Không có tệp', 'Sin archivos', 'Sem arquivos', 'Нет файлов', 'Aucun fichier', 'Keine Dateien'),

View File

@@ -82,6 +82,11 @@ export default {
confirmDeleteModel: _('确定删除模型「{name}」?', 'Delete model "{name}"?', '確定刪除模型「{name}」?'),
modelDeleted: _('已删除 {name}', 'Deleted {name}', '已刪除 {name}'),
confirmBatchDelete: _('确定删除选中的 {count} 个模型?\n{ids}', 'Delete {count} selected models?\n{ids}', '確定刪除選中的 {count} 個模型?\n{ids}'),
batchDeleteTitle: _('删除 {count} 个模型', 'Delete {count} models', '刪除 {count} 個模型', '{count} 件のモデルを削除', '{count}개 모델 삭제', 'Xoá {count} mô hình', 'Eliminar {count} modelos', 'Excluir {count} modelos', 'Удалить {count} моделей', 'Supprimer {count} modèles', '{count} Modelle löschen'),
batchDeleteBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xoá', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
batchDeleteCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
batchDeleteImpact: _('使用了这些模型的 Agent 会回退到该服务商的其他模型,或者主模型', 'Agents using these models will fall back to other models in this provider or the primary model', '使用了這些模型的 Agent 會回退到該服務商的其他模型,或主模型', 'これらのモデルを使用していた Agent はプロバイダー内の他のモデルまたはメインモデルにフォールバックします', '이 모델을 사용하던 Agent는 동일 프로바이더의 다른 모델 또는 주 모델로 폴백합니다', 'Agent đang dùng các mô hình này sẽ chuyển về mô hình khác của nhà cung cấp hoặc mô hình chính', 'Los Agents que usaban estos modelos recurrirán a otros modelos del proveedor o al modelo principal', 'Os Agents que utilizavam esses modelos voltarão para outros modelos do provedor ou para o modelo principal', 'Агенты, использовавшие эти модели, переключатся на другие модели поставщика или основную модель', 'Les Agents qui utilisaient ces modèles se rabattront sur d\u2019autres modèles du fournisseur ou sur le modèle principal', 'Agents, die diese Modelle verwendeten, weichen auf andere Modelle des Anbieters oder auf das Hauptmodell aus'),
batchDeleteImpactConfig: _('配置会立即保存并安排 Gateway 重启', 'Configuration is saved immediately and a Gateway restart is queued', '設定會立即儲存並安排 Gateway 重啟', '設定はすぐに保存され、Gateway の再起動が予約されます', '설정이 즉시 저장되고 Gateway 재시작이 예약됩니다', 'Cấu hình sẽ được lưu ngay và lên lịch khởi động lại Gateway', 'La configuración se guardará de inmediato y se programará un reinicio del Gateway', 'A configuração será salva imediatamente e o reinício do Gateway será agendado', 'Конфигурация будет сохранена немедленно и поставлен в очередь перезапуск Gateway', 'La configuration sera enregistrée immédiatement et un redémarrage du Gateway sera planifié', 'Die Konfiguration wird sofort gespeichert und ein Gateway-Neustart wird eingeplant'),
batchDeleted: _('已删除 {count} 个模型', 'Deleted {count} models', '已刪除 {count} 個模型'),
batchSelectHint: _('请先勾选要删除的模型', 'Please select models to delete first', '請先勾選要刪除的模型'),
addProviderTitle: _('添加服务商', 'Add Provider', '新增服務商'),

View File

@@ -51,6 +51,16 @@ export default {
installFailed: _('安装失败', 'Install failed', '安裝失敗'),
uninstallFailed: _('卸载失败', 'Uninstall failed', '卸載失敗'),
uninstallConfirm: _('确定要卸载 Gateway 服务吗?\n这会停止服务并移除 LaunchAgent。', 'Uninstall Gateway service?\nThis will stop the service and remove the LaunchAgent.', '確定要卸載 Gateway 服務吗?\n这會停止服務並移除 LaunchAgent。'),
uninstallTitle: _('卸载 Gateway 服务', 'Uninstall Gateway service', '卸載 Gateway 服務', 'Gateway サービスをアンインストール', 'Gateway 서비스 제거', 'Gỡ dịch vụ Gateway', 'Desinstalar servicio Gateway', 'Desinstalar serviço Gateway', 'Удалить службу Gateway', 'Désinstaller le service Gateway', 'Gateway-Dienst deinstallieren'),
uninstallBtn: _('卸载', 'Uninstall', '卸載', 'アンインストール', '제거', 'Gỡ cài đặt', 'Desinstalar', 'Desinstalar', 'Удалить', 'Désinstaller', 'Deinstallieren'),
uninstallCancelBtn: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
uninstallImpactStop: _('Gateway 将立即停止,所有连接到 ClawPanel 的渠道会下线', 'Gateway will stop immediately and all channels connected to ClawPanel will go offline', 'Gateway 將立即停止,所有連接到 ClawPanel 的頻道會下線', 'Gateway は直ちに停止し、ClawPanel に接続中のチャンネルがオフラインになります', 'Gateway가 즉시 중지되고 ClawPanel에 연결된 모든 채널이 오프라인 상태가 됩니다', 'Gateway sẽ dừng ngay lập tức và tất cả kênh kết nối tới ClawPanel sẽ ngoại tuyến', 'El Gateway se detendrá de inmediato y todos los canales conectados a ClawPanel quedarán desconectados', 'O Gateway será interrompido imediatamente e todos os canais conectados ao ClawPanel ficarão offline', 'Gateway будет немедленно остановлен, и все каналы, подключённые к ClawPanel, перейдут в офлайн', 'Le Gateway s\u2019arrêtera immédiatement et tous les canaux liés à ClawPanel passeront hors ligne', 'Gateway wird sofort gestoppt und alle mit ClawPanel verbundenen Kanäle gehen offline'),
uninstallImpactAutostart: _('系统开机自启动项会被移除,需要时可在「服务」页重新安装', 'Auto-start entries will be removed. You can reinstall from the Services page when needed', '系統開機自啟動項會被移除,需要時可在「服務」頁重新安裝', '自動起動設定が削除されます。必要時に「サービス」ページから再インストールできます', '시스템 자동 시작 항목이 제거됩니다. 필요할 때 서비스 페이지에서 다시 설치할 수 있습니다', 'Mục tự khởi động sẽ bị xoá, có thể cài lại từ trang Dịch vụ khi cần', 'Las entradas de inicio automático se eliminarán; podrás reinstalarlas desde la página Servicios', 'As entradas de inicialização automática serão removidas; reinstale a partir da página Serviços quando necessário', 'Записи автозапуска будут удалены. При необходимости можно переустановить со страницы Сервисы', 'Les entrées de démarrage automatique seront supprimées ; vous pourrez réinstaller depuis la page Services', 'Autostart-Einträge werden entfernt; bei Bedarf können Sie über die Seite Dienste neu installieren'),
uninstallImpactConfig: _('配置文件和聊天数据会被保留', 'Configuration files and chat data will be kept', '設定檔案和聊天數據會被保留', '設定ファイルとチャット履歴は保持されます', '설정 파일과 채팅 데이터는 유지됩니다', 'Tệp cấu hình và dữ liệu trò chuyện sẽ được giữ lại', 'Los archivos de configuración y datos de chat se conservarán', 'Os arquivos de configuração e dados de chat serão mantidos', 'Файлы конфигурации и данные чатов будут сохранены', 'Les fichiers de configuration et les données de chat seront conservés', 'Konfigurationsdateien und Chatdaten bleiben erhalten'),
deleteBackupTitle: _('删除备份「{name}」', 'Delete backup "{name}"', '刪除備份「{name}」', 'バックアップ「{name}」を削除', '백업「{name}」 삭제', 'Xoá bản sao lưu "{name}"', 'Eliminar copia "{name}"', 'Excluir backup "{name}"', 'Удалить резервную копию "{name}"', 'Supprimer la sauvegarde "{name}"', 'Sicherung "{name}" löschen'),
deleteBackupBtn: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xoá', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
deleteBackupCancel: _('保留', 'Keep', '保留', '保持', '유지', 'Giữ lại', 'Conservar', 'Manter', 'Оставить', 'Conserver', 'Behalten'),
deleteBackupImpact: _('备份文件将永久删除,无法用它恢复以前的配置', 'Backup file will be permanently deleted and cannot be restored', '備份檔案將永久刪除,無法用它復原以前的設定', 'バックアップが永久に削除され、以前の設定に戻せなくなります', '백업이 영구 삭제되어 이전 설정으로 복원할 수 없게 됩니다', 'Tệp sao lưu sẽ bị xoá vĩnh viễn, không thể dùng để khôi phục cấu hình cũ', 'El archivo de copia se eliminará permanentemente y no podrá restaurar configuraciones anteriores', 'O arquivo de backup será excluído permanentemente e não poderá restaurar configurações anteriores', 'Файл резервной копии будет удалён навсегда и не сможет восстановить прежнюю конфигурацию', 'Le fichier de sauvegarde sera supprimé définitivement et ne pourra plus restaurer la configuration', 'Die Sicherungsdatei wird dauerhaft gelöscht und kann nicht zur Wiederherstellung früherer Konfigurationen verwendet werden'),
actionProgress: _('正在{action}...', '{action} in progress...'),
actionProgressSec: _('正在{action}... {sec}s', '{action} in progress... {sec}s'),
actionTimeout: _('{action}超时Gateway 可能仍在启动中', '{action} timed out, Gateway may still be starting', '{action}逾時Gateway 可能仍在啟動中'),

View File

@@ -4,6 +4,7 @@
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showConfirm } from '../components/modal.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
@@ -276,7 +277,7 @@ async function saveOverview(container, state) {
toast(t('agentDetail.saveSuccess'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agentDetail.saveFailed')), 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveOverview')
@@ -350,7 +351,7 @@ async function saveTools(container, state) {
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.toolsSaved'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agentDetail.saveFailed')), 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveTools')
@@ -413,7 +414,7 @@ async function saveSkills(container, state) {
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.skillsSaved'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agentDetail.saveFailed')), 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveSkills')
@@ -512,7 +513,7 @@ async function openFileEditor(container, state, name, isNew = false) {
const res = await api.readAgentFile(state.agentId, name)
content = res.content || ''
} catch (e) {
toast(t('agentDetail.loadFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agentDetail.loadFailed')), 'error')
return
}
}
@@ -558,7 +559,7 @@ async function openFileEditor(container, state, name, isNew = false) {
// 刷新文件列表
renderFiles(container, state)
} catch (e) {
toast(t('agentDetail.fileSaveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agentDetail.fileSaveFailed')), 'error')
}
}
@@ -627,7 +628,16 @@ function renderBindingsList(container, state, bindings) {
const channel = btn.dataset.channel
const account = btn.dataset.account || null
const binding = bindings[Number(btn.dataset.index)]
const yes = await showConfirm(t('agentDetail.removeBindingConfirm', { channel: CHANNEL_LABELS[channel] || channel }))
const yes = await showConfirm({
title: t('agentDetail.removeBindingTitle'),
message: t('agentDetail.removeBindingConfirm', { channel: CHANNEL_LABELS[channel] || channel }),
impact: [
t('agentDetail.removeBindingImpactAgent'),
t('agentDetail.removeBindingImpactChannel'),
],
confirmText: t('agentDetail.removeBindingBtn'),
cancelText: t('agentDetail.removeBindingCancel'),
})
if (!yes) return
try {
await api.deleteAgentBinding(state.agentId, channel, account, binding?.match || null)
@@ -636,7 +646,7 @@ function renderBindingsList(container, state, bindings) {
state.detail = await api.getAgentDetail(state.agentId)
renderBindingsList(container, state, state.detail.bindings || [])
} catch (e) {
toast(t('agentDetail.bindingFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agentDetail.bindingFailed')), 'error')
}
})
}

View File

@@ -4,6 +4,7 @@
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showModal, showConfirm } from '../components/modal.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
@@ -72,7 +73,7 @@ async function loadAgents(page, state) {
}
} catch (e) {
container.innerHTML = '<div style="color:var(--error);padding:20px">' + t('agents.loadFailed') + ': ' + String(e).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>'
toast(t('agents.loadListFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agents.loadListFailed')), 'error')
}
}
@@ -115,7 +116,17 @@ function _renderRuntimeBadge(runtime) {
function renderAgents(page, state) {
const container = page.querySelector('#agents-list')
if (!state.agents.length) {
container.innerHTML = `<div style="color:var(--text-tertiary);padding:20px;text-align:center">${t('agents.noAgents')}</div>`
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🤖</div>
<div class="empty-title">${t('agents.noAgents')}</div>
<div class="empty-desc">${t('common.emptyGetStartedHint')}</div>
<div class="empty-cta"><button class="btn btn-primary" data-empty-cta="add-agent">${t('agents.addAgent')}</button></div>
</div>
`
container.querySelector('[data-empty-cta="add-agent"]')?.addEventListener('click', () => {
page.querySelector('#btn-add-agent')?.click()
})
return
}
@@ -241,7 +252,7 @@ async function showAddAgentDialog(page, state) {
invalidate('list_agents')
await loadAgents(page, state)
} catch (e) {
toast(t('agents.createFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agents.createFailed')), 'error')
}
}
})
@@ -322,14 +333,26 @@ async function showEditAgentDialog(page, state, id) {
toast(t('agents.updated'), 'success')
} catch (e) {
console.error('[Agent编辑] 保存失败:', e)
toast(t('agents.updateFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agents.updateFailed')), 'error')
}
}
})
}
async function deleteAgent(page, state, id) {
const yes = await showConfirm(t('agents.confirmDelete', { id }))
// 计算关联渠道绑定数(小白看清楚删了会丢什么)
const linkedBindings = (state.bindings || []).filter(b => (b.agentId || 'main') === id).length
const impact = [t('agents.deleteImpactConfig')]
if (linkedBindings > 0) {
impact.unshift(t('agents.deleteImpactBindings', { n: linkedBindings }))
}
const yes = await showConfirm({
title: t('agents.deleteConfirmTitle'),
message: t('agents.confirmDelete', { id }),
impact,
confirmText: t('agents.deleteConfirmBtn'),
cancelText: t('agents.deleteCancelBtn'),
})
if (!yes) return
try {
@@ -337,7 +360,7 @@ async function deleteAgent(page, state, id) {
toast(t('agents.deleted'), 'success')
await loadAgents(page, state)
} catch (e) {
toast(t('agents.deleteFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agents.deleteFailed')), 'error')
}
}
@@ -352,6 +375,6 @@ async function backupAgent(id) {
} catch { /* fallback */ }
toast(t('agents.backupDone', { file: zipPath.split('/').pop() }), 'success')
} catch (e) {
toast(t('agents.backupFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('agents.backupFailed')), 'error')
}
}

View File

@@ -5,6 +5,7 @@
*/
import { renderMarkdown } from '../lib/markdown.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showConfirm } from '../components/modal.js'
import { api } from '../lib/tauri-api.js'
import { OPENCLAW_KB } from '../lib/openclaw-kb.js'
@@ -3822,10 +3823,10 @@ function showSettings() {
toast(t('assistant.qtcoolMainSwitched', { model: selectedModel }), 'success')
qtcoolStatus.innerHTML = `<span style="color:#34d399">${statusIcon('ok', 14)} ${t('assistant.qtcoolAllDone', { model: selectedModel })}</span>`
} catch (e) {
toast(t('assistant.qtcoolGatewayFail') + ': ' + e.message, 'warning')
toast(humanizeError(e, t('assistant.qtcoolGatewayFail')), 'warning')
}
} catch (e) {
toast(t('assistant.qtcoolWriteFail') + ': ' + e, 'error')
toast(humanizeError(e, t('assistant.qtcoolWriteFail')), 'error')
}
}
}
@@ -3864,7 +3865,7 @@ function showSettings() {
toast(t('assistant.qtcoolSyncToDone', { model }), 'success')
try { await api.restartGateway() } catch {}
} catch (e) {
toast(t('assistant.qtcoolSyncFail') + ': ' + e, 'error')
toast(humanizeError(e, t('assistant.qtcoolSyncFail')), 'error')
}
})
@@ -3896,7 +3897,7 @@ function showSettings() {
if (modelId) overlay.querySelector('#ast-model').value = modelId
toast(t('assistant.qtcoolSyncFromDone'), 'success')
} catch (e) {
toast(t('assistant.qtcoolReadFail') + ': ' + e, 'error')
toast(humanizeError(e, t('assistant.qtcoolReadFail')), 'error')
}
})

View File

@@ -4,10 +4,12 @@
*/
import { api, invalidate, safeTauriListen } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showContentModal, showConfirm } from '../components/modal.js'
import { icon } from '../lib/icons.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js'
import { wsClient } from '../lib/ws-client.js'
// ── 渠道注册表:面板内置向导,覆盖 OpenClaw 官方渠道 + 国内扩展渠道 ──
@@ -325,7 +327,7 @@ async function loadPlatforms(page, state) {
const list = await api.listConfiguredPlatforms()
state.configured = Array.isArray(list) ? list : []
} catch (e) {
toast(t('channels.loadFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.loadFailed')), 'error')
state.configured = []
}
try {
@@ -670,7 +672,7 @@ function renderConfigured(page, state) {
modal.close?.() || modal.remove?.()
await loadPlatforms(page, state)
} catch (e) {
toast(t('channels.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.saveFailed')), 'error')
} finally {
modal.querySelector('#btn-quick-bind-save').disabled = false
modal.querySelector('#btn-quick-bind-save').textContent = t('channels.saveBinding')
@@ -706,7 +708,7 @@ function renderConfigured(page, state) {
await api.removeMessagingPlatform(pid, accountId || null)
toast(t('channels.removed'), 'info')
await loadPlatforms(page, state)
} catch (e) { toast(t('channels.removeFailed') + ': ' + e, 'error') }
} catch (e) { toast(humanizeError(e, t('channels.removeFailed')), 'error') }
})
})
@@ -717,16 +719,31 @@ function renderConfigured(page, state) {
await api.toggleMessagingPlatform(pid, !cur.enabled)
toast(`${platformLabel(pid)} ${cur.enabled ? t('channels.disabled') : t('channels.enabled')}`, 'success')
await loadPlatforms(page, state)
} catch (e) { toast(t('channels.operationFailed') + ': ' + e, 'error') }
} catch (e) { toast(humanizeError(e, t('channels.operationFailed')), 'error') }
})
card.querySelector('[data-action="remove"]')?.addEventListener('click', async () => {
const yes = await showConfirm(t('channels.confirmRemovePlatform', { name: platformLabel(pid) }))
const channelKey = getChannelBindingKey(pid)
const linkedBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey).length
const impact = [
t('channels.removePlatformImpactConfig'),
t('channels.removePlatformImpactStop'),
]
if (linkedBindings > 0) {
impact.unshift(t('channels.removePlatformImpactBindings', { n: linkedBindings }))
}
const yes = await showConfirm({
title: t('channels.removePlatformTitle', { name: platformLabel(pid) }),
message: t('channels.confirmRemovePlatform', { name: platformLabel(pid) }),
impact,
confirmText: t('channels.removePlatformBtn'),
cancelText: t('channels.removePlatformCancel'),
})
if (!yes) return
try {
await api.removeMessagingPlatform(pid)
toast(t('channels.removed'), 'info')
await loadPlatforms(page, state)
} catch (e) { toast(t('channels.removeFailed') + ': ' + e, 'error') }
} catch (e) { toast(humanizeError(e, t('channels.removeFailed')), 'error') }
})
})
}
@@ -841,7 +858,7 @@ function renderAgentBindings(page, state) {
</div>
</div>`
}).join('')
: `<div class="form-hint" style="padding:8px 0">${t('channels.noBindings')}</div>`
: `<div class="empty-state empty-compact" style="padding:14px 8px"><div class="empty-icon" style="font-size:28px">💬</div><div class="empty-desc">${t('channels.noBindings')}</div></div>`
const addDisabled = !canBind.length ? 'disabled' : ''
return `
@@ -891,14 +908,23 @@ function renderAgentBindings(page, state) {
const match = binding.match || {}
const ch = match.channel
const acct = match.accountId || null
const yes = await showConfirm(t('channels.confirmRemoveBinding', { agent: aid, summary: formatBindingMatchSummary(binding) }))
const yes = await showConfirm({
title: t('channels.removeBindingTitle'),
message: t('channels.confirmRemoveBinding', { agent: aid, summary: formatBindingMatchSummary(binding) }),
impact: [
t('channels.removeBindingImpactAgent'),
t('channels.removeBindingImpactConfig'),
],
confirmText: t('channels.removeBindingBtn'),
cancelText: t('channels.removeBindingCancel'),
})
if (!yes) return
try {
await api.deleteAgentBinding(aid, ch, acct, match)
toast(t('channels.bindingRemoved'), 'success')
await loadPlatforms(page, state)
} catch (e) {
toast(t('channels.removeFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.removeFailed')), 'error')
}
})
})
@@ -1078,7 +1104,7 @@ async function openAddAgentBindingModal(agentId, page, state) {
}
await loadPlatforms(page, state)
} catch (e) {
toast(t('channels.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.saveFailed')), 'error')
} finally {
btnSave.disabled = false
btnSave.textContent = t('channels.saveBinding')
@@ -1183,7 +1209,7 @@ function bindManualCommandCopy(root, commandSpecs) {
btn.textContent = prev
}, 1200)
} catch (e) {
toast(t('channels.copyCommandFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.copyCommandFailed')), 'error')
}
})
})
@@ -1245,7 +1271,7 @@ function showQqDiagnoseModal(result, options = {}) {
diagModal.remove()
showQqDiagnoseModal(fresh, { accountId })
} catch (e) {
toast(t('channels.repairFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.repairFailed')), 'error')
} finally {
repairBtn.disabled = false
repairBtn.innerHTML = prev
@@ -1289,7 +1315,7 @@ async function runChannelTestForBinding(binding, btnEl) {
toast(t('channels.testFailed') + ': ' + errs, 'error')
}
} catch (e) {
toast((channel === 'qqbot' ? t('channels.diagFailed') : t('channels.testFailed')) + ': ' + e, 'error')
toast(humanizeError(e, channel === 'qqbot' ? t('channels.diagFailed') : t('channels.testFailed')), 'error')
} finally {
if (btnEl) {
btnEl.disabled = false
@@ -1667,7 +1693,7 @@ async function openConfigDialog(pid, page, state, accountId) {
}
} catch (e) {
_flushQr()
toast(t('channels.executionFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.executionFailed')), 'error')
if (logBox) {
const div = document.createElement('div')
div.style.color = 'var(--error)'
@@ -1739,12 +1765,21 @@ async function openConfigDialog(pid, page, state, accountId) {
return Object.entries(field.requiredWhen).every(([k, expected]) => (form[k] || '') === expected)
}
// 字段 label 智能匹配术语 → 自动追加 ⓘ 按钮
const labelWithHelp = (label) => {
const l = String(label || '').toLowerCase()
if (l.includes('bot token')) return label + termHelpHtml('bottoken')
if (l.includes('webhook')) return label + termHelpHtml('webhook')
if (l.includes('signing secret') || l.includes('client secret') || l.includes('app secret') || l.includes('api key')) return label + termHelpHtml('apikey')
return label
}
const fieldsHtml = reg.fields.map((f, i) => {
const val = existing[f.key] || ''
if (f.type === 'select' && f.options) {
return `
<div class="form-group">
<label class="form-label">${f.label}${f.required ? ' *' : ''}</label>
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<select class="form-input" name="${f.key}" data-name="${f.key}">
${f.options.map(o => `<option value="${o.value}" ${val === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select>
@@ -1754,7 +1789,7 @@ async function openConfigDialog(pid, page, state, accountId) {
}
return `
<div class="form-group">
<label class="form-label">${f.label}${f.required ? ' *' : ''}</label>
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<div style="display:flex;gap:8px">
<input class="form-input" name="${f.key}" type="${f.secret ? 'password' : 'text'}"
value="${escapeAttr(val)}" placeholder="${f.placeholder || ''}"
@@ -1834,6 +1869,7 @@ async function openConfigDialog(pid, page, state, accountId) {
width: 520,
})
bindManualCommandCopy(modal, manualCommandSpecs)
attachTermTooltips(modal)
// 外部链接用系统浏览器打开
modal.addEventListener('click', (e) => {
@@ -1856,7 +1892,7 @@ async function openConfigDialog(pid, page, state, accountId) {
const result = await api.diagnoseChannel('qqbot', accountId || null)
showQqDiagnoseModal(result, { accountId: accountId || null })
} catch (e) {
toast(t('channels.diagFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.diagFailed')), 'error')
} finally {
diagBtn.disabled = false
diagBtn.innerHTML = prev
@@ -1971,7 +2007,7 @@ async function openConfigDialog(pid, page, state, accountId) {
logBox.textContent += (logBox.textContent ? '\n' : '') + String(output)
}
} catch (e) {
toast(t('channels.actionFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.actionFailed')), 'error')
} finally {
cleanup()
btn.disabled = false
@@ -2138,7 +2174,7 @@ async function openConfigDialog(pid, page, state, accountId) {
await api.installChannelPlugin(pluginPackage, pluginId, pluginVersion)
}
} catch (e) {
toast(t('channels.pluginInstallFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.pluginInstallFailed')), 'error')
btnSave.disabled = false
btnVerify.disabled = false
btnSave.textContent = isEdit ? t('channels.save') : t('channels.connectAndSave')
@@ -2170,7 +2206,7 @@ async function openConfigDialog(pid, page, state, accountId) {
modal.close?.() || modal.remove?.()
await loadPlatforms(page, state)
} catch (e) {
toast(t('channels.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('channels.saveFailed')), 'error')
} finally {
btnSave.disabled = false
btnVerify.disabled = false

View File

@@ -1427,7 +1427,16 @@ async function deleteSession(key) {
const mainKey = wsClient.snapshot?.sessionDefaults?.mainSessionKey || 'agent:main:main'
if (key === mainKey) { toast(t('chat.cannotDeleteMain'), 'warning'); return }
const label = parseSessionLabel(key)
const yes = await showConfirm(t('chat.confirmDeleteSession', { label }))
const yes = await showConfirm({
title: t('chat.deleteSessionTitle', { label }),
message: t('chat.confirmDeleteSession', { label }),
impact: [
t('chat.deleteSessionImpactHistory'),
t('chat.deleteSessionImpactCannotUndo'),
],
confirmText: t('chat.deleteSessionBtn'),
cancelText: t('chat.deleteSessionCancel'),
})
if (!yes) return
try {
await wsClient.sessionsDelete(key)
@@ -1532,7 +1541,16 @@ async function showCompactionHistory(key) {
async function resetCurrentSession() {
if (!_sessionKey) return
const label = getDisplayLabel(_sessionKey)
const yes = await showConfirm(t('chat.confirmResetSession', { label }))
const yes = await showConfirm({
title: t('chat.resetSessionTitle', { label }),
message: t('chat.confirmResetSession', { label }),
impact: [
t('chat.resetSessionImpactHistory'),
t('chat.resetSessionImpactContext'),
],
confirmText: t('chat.resetSessionBtn'),
cancelText: t('chat.resetSessionCancel'),
})
if (!yes) return
try {
await wsClient.sessionsReset(_sessionKey)

View File

@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { icon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
import { wsClient } from '../lib/ws-client.js'
@@ -80,7 +81,7 @@ async function saveConfig() {
toast(t('communication.configSaved'), 'info')
try { await api.reloadGateway(); toast(t('communication.gwReloaded'), 'success') } catch {}
} catch (e) {
toast(t('communication.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('communication.saveFailed')), 'error')
} finally {
if (btn) { btn.disabled = !_dirty; btn.innerHTML = `${icon('save', 14)} ${t('communication.save')}` }
}

View File

@@ -4,6 +4,7 @@
* 注意openclaw.json 不支持 cron.jobs 字段,定时任务只能通过 Gateway 在线管理
*/
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showContentModal, showConfirm } from '../components/modal.js'
import { icon } from '../lib/icons.js'
import { onGatewayChange } from '../lib/app-state.js'
@@ -138,7 +139,7 @@ async function fetchJobs(page, state) {
lastError: j.state?.lastError || null,
}))
} catch (e) {
toast(t('cron.fetchFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('cron.fetchFailed')), 'error')
state.jobs = []
}
@@ -191,12 +192,16 @@ function renderList(page, state) {
if (!state.jobs.length) {
el.innerHTML = `
<div style="text-align:center;padding:40px 0;color:var(--text-tertiary)">
<div style="margin-bottom:12px;color:var(--text-tertiary)">${icon('clock', 48)}</div>
<div style="font-size:var(--font-size-md);margin-bottom:6px">${t('cron.noTasks')}</div>
<div style="font-size:var(--font-size-sm)">${t('cron.noTasksHint')}</div>
<div class="empty-state">
<div class="empty-icon">⏰</div>
<div class="empty-title">${t('cron.noTasks')}</div>
<div class="empty-desc">${t('cron.noTasksHint')}</div>
<div class="empty-cta"><button class="btn btn-primary" data-empty-cta="new-task">${t('cron.newTask')}</button></div>
</div>
`
el.querySelector('[data-empty-cta="new-task"]')?.addEventListener('click', () => {
page.querySelector('#btn-new-task')?.click()
})
return
}
@@ -273,7 +278,16 @@ function renderList(page, state) {
card.querySelector('[data-action="delete"]').onclick = async function() {
const btn = this
const yes = await showConfirm(t('cron.confirmDelete', { name: job.name }))
const yes = await showConfirm({
title: t('cron.deleteTitle', { name: job.name }),
message: t('cron.confirmDelete', { name: job.name }),
impact: [
t('cron.deleteImpactStop'),
t('cron.deleteImpactHistory'),
],
confirmText: t('cron.deleteBtn'),
cancelText: t('cron.deleteCancel'),
})
if (!yes) return
if (btn) btn.disabled = true
try {
@@ -450,7 +464,7 @@ async function openTaskDialog(job, page, state) {
modal.close?.() || modal.remove?.()
await fetchJobs(page, state)
} catch (e) {
toast(t('cron.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('cron.saveFailed')), 'error')
saveBtn.disabled = false
saveBtn.textContent = isEdit ? t('cron.saveEdit') : t('cron.saveCreate')
}

View File

@@ -3,6 +3,7 @@
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { getActiveInstance, onGatewayChange } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance, showInstallationCleanup } from '../lib/gateway-ownership.js'
import { navigate } from '../router.js'
@@ -25,6 +26,7 @@ export async function render() {
<p class="page-desc">${t('dashboard.desc')}</p>
</div>
<div id="cli-conflict-mount"></div>
<div id="onboarding-mount"></div>
<div class="stat-cards" id="stat-cards">
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
@@ -38,6 +40,7 @@ export async function render() {
<button class="btn btn-secondary" id="btn-restart-gw">${t('dashboard.restartGw')}</button>
<button class="btn btn-secondary" id="btn-check-update">${t('dashboard.checkUpdate')}</button>
<button class="btn btn-secondary" id="btn-create-backup">${t('dashboard.createBackup')}</button>
<button class="btn btn-ghost" id="btn-open-glossary">📖 ${t('glossary.title')}</button>
</div>
<div class="config-section">
<div class="config-section-title">${t('dashboard.recentLogs')}</div>
@@ -230,6 +233,7 @@ async function _loadDashboardDataInner(page, fullRefresh) {
renderStatCards(page, services, version, agents, config, panelConfig)
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary, channels)
renderOnboarding(page, { gw, config, agents, channels })
// 第三波:日志(最低优先级)
const logs = await logsP
@@ -583,6 +587,7 @@ function bindActions(page) {
const btnRestart = page.querySelector('#btn-restart-gw')
const btnUpdate = page.querySelector('#btn-check-update')
const btnCreateBackup = page.querySelector('#btn-create-backup')
page.querySelector('#btn-open-glossary')?.addEventListener('click', () => navigate('/glossary'))
// Control UI 卡片点击 → 打开 OpenClaw 原生面板(用事件委托,因为卡片是动态渲染的)
page.addEventListener('click', async (e) => {
@@ -687,7 +692,7 @@ function bindActions(page) {
await api.restartService('ai.openclaw.gateway')
} catch (e) {
if (isForeignGatewayError(e)) await openGatewayConflict(page, e)
else toast(t('dashboard.restartFail') + ': ' + e, 'error')
else toast(humanizeError(e, t('dashboard.restartFail')), 'error')
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = t('dashboard.restartGw')
@@ -735,7 +740,7 @@ function bindActions(page) {
toast(t('dashboard.upToDate'), 'success')
}
} catch (e) {
toast(t('dashboard.checkUpdateFail') + ': ' + e, 'error')
toast(humanizeError(e, t('dashboard.checkUpdateFail')), 'error')
} finally {
btnUpdate.disabled = false
btnUpdate.textContent = t('dashboard.checkUpdate')
@@ -750,7 +755,7 @@ function bindActions(page) {
toast(t('dashboard.backupDone', { name: res.name }), 'success')
setTimeout(() => loadDashboardData(page), 500)
} catch (e) {
toast(t('dashboard.backupFail') + ': ' + e, 'error')
toast(humanizeError(e, t('dashboard.backupFail')), 'error')
} finally {
btnCreateBackup.disabled = false
btnCreateBackup.textContent = t('dashboard.createBackup')
@@ -758,6 +763,120 @@ function bindActions(page) {
})
}
// ── 新手引导卡片 ──
// 4 步任务:启动 Gateway / 加模型 / 创建 Agent / 第一次聊天。
// 全部完成或用户主动关闭后localStorage 标记隐藏dashboard 不再渲染。
const ONBOARDING_HIDDEN_KEY = 'clawpanel_onboarding_hidden'
function isOnboardingHidden() {
try { return localStorage.getItem(ONBOARDING_HIDDEN_KEY) === '1' } catch { return false }
}
function hideOnboarding() {
try { localStorage.setItem(ONBOARDING_HIDDEN_KEY, '1') } catch {}
}
function getOnboardingSteps({ gw, config, agents, channels }) {
// 步骤 1Gateway 启动
const gwRunning = !!gw?.running
// 步骤 2至少配了一个 provider 且非空
const providers = config?.models?.providers || {}
const hasModel = Object.keys(providers).length > 0
// 步骤 3自定义 Agent默认 main 不算)
const agentList = Array.isArray(agents) ? agents : []
const hasCustomAgent = agentList.some(a => a && a.id && a.id !== 'main')
// 步骤 4渠道接入不是必须但作为「已开始用」的标志
// 实际上更好的判定是「点过聊天页 / 发过一条消息」,但目前没记录,先用 channels 数量作为可选完成判据
// 改为:把第 4 步定义为「尝试聊天」—— 不强校验CTA 触发跳转即可(用户点了就当完成)
const hasChatTried = (() => {
try { return localStorage.getItem('clawpanel_onboarding_chat_clicked') === '1' } catch { return false }
})()
return [
{ id: 'gateway', titleKey: 'onboardingStep1Title', descKey: 'onboardingStep1Desc', ctaKey: 'onboardingStep1Cta', route: '/services', done: gwRunning },
{ id: 'model', titleKey: 'onboardingStep2Title', descKey: 'onboardingStep2Desc', ctaKey: 'onboardingStep2Cta', route: '/models', done: hasModel },
{ id: 'agent', titleKey: 'onboardingStep3Title', descKey: 'onboardingStep3Desc', ctaKey: 'onboardingStep3Cta', route: '/agents', done: hasCustomAgent },
{ id: 'chat', titleKey: 'onboardingStep4Title', descKey: 'onboardingStep4Desc', ctaKey: 'onboardingStep4Cta', route: '/chat', done: hasChatTried, markOnClick: 'clawpanel_onboarding_chat_clicked' },
]
}
function renderOnboarding(page, ctx) {
const mount = page.querySelector('#onboarding-mount')
if (!mount) return
if (isOnboardingHidden()) { mount.innerHTML = ''; return }
const steps = getOnboardingSteps(ctx)
const allDone = steps.every(s => s.done)
// 全部完成时显示一条庆祝条 + 关闭按钮
if (allDone) {
mount.innerHTML = `
<div class="onboarding-card onboarding-done-card">
<div class="onboarding-done-text">${escapeHtml(t('dashboard.onboardingAllDone'))}</div>
<button class="btn btn-sm btn-secondary" data-onboarding-action="close">${escapeHtml(t('dashboard.onboardingClose'))}</button>
</div>
`
mount.querySelector('[data-onboarding-action="close"]')?.addEventListener('click', () => {
hideOnboarding()
mount.innerHTML = ''
})
return
}
// 渲染 4 步进度卡片
const stepsHtml = steps.map((s, idx) => {
const num = idx + 1
const cls = s.done ? 'onboarding-step done' : 'onboarding-step'
const badge = s.done
? `<span class="onboarding-step-badge done">✓ ${escapeHtml(t('dashboard.onboardingDone'))}</span>`
: `<span class="onboarding-step-badge todo">${num}</span>`
const cta = s.done
? ''
: `<button class="btn btn-sm btn-primary" data-onboarding-step="${s.id}">${escapeHtml(t(`dashboard.${s.ctaKey}`))} →</button>`
return `
<div class="${cls}">
${badge}
<div class="onboarding-step-body">
<div class="onboarding-step-title">${escapeHtml(t(`dashboard.${s.titleKey}`))}</div>
<div class="onboarding-step-desc">${escapeHtml(t(`dashboard.${s.descKey}`))}</div>
</div>
<div class="onboarding-step-action">${cta}</div>
</div>
`
}).join('')
mount.innerHTML = `
<div class="onboarding-card">
<div class="onboarding-header">
<div>
<div class="onboarding-title">${escapeHtml(t('dashboard.onboardingTitle'))}</div>
<div class="onboarding-desc">${escapeHtml(t('dashboard.onboardingDesc'))}</div>
</div>
<button class="btn btn-xs btn-ghost" data-onboarding-action="close" title="${escapeHtml(t('dashboard.onboardingClose'))}">×</button>
</div>
<div class="onboarding-steps">
${stepsHtml}
</div>
</div>
`
mount.querySelector('[data-onboarding-action="close"]')?.addEventListener('click', () => {
hideOnboarding()
mount.innerHTML = ''
})
steps.forEach(s => {
if (s.done) return
const btn = mount.querySelector(`[data-onboarding-step="${s.id}"]`)
if (!btn) return
btn.addEventListener('click', () => {
if (s.markOnClick) {
try { localStorage.setItem(s.markOnClick, '1') } catch {}
}
navigate(s.route)
})
})
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -654,12 +654,30 @@ function bindEvents(page) {
page.querySelector('#btn-dreaming-toggle')?.addEventListener('click', () => toggleDreaming())
page.querySelector('#btn-dreaming-backfill')?.addEventListener('click', () => runAction('doctor.memory.backfillDreamDiary', t('dreaming.backfillDone')))
page.querySelector('#btn-dreaming-reset-diary')?.addEventListener('click', async () => {
const yes = await showConfirm(t('dreaming.confirmResetDiary'))
const yes = await showConfirm({
title: t('dreaming.resetDiaryTitle'),
message: t('dreaming.confirmResetDiary'),
impact: [
t('dreaming.resetDiaryImpactContent'),
t('dreaming.resetDiaryImpactReplay'),
],
confirmText: t('dreaming.resetDiaryBtn'),
cancelText: t('dreaming.resetDiaryCancel'),
})
if (!yes) return
runAction('doctor.memory.resetDreamDiary', t('dreaming.resetDiaryDone'))
})
page.querySelector('#btn-dreaming-clear-grounded')?.addEventListener('click', async () => {
const yes = await showConfirm(t('dreaming.confirmClearGrounded'))
const yes = await showConfirm({
title: t('dreaming.clearGroundedTitle'),
message: t('dreaming.confirmClearGrounded'),
impact: [
t('dreaming.clearGroundedImpact'),
t('dreaming.clearGroundedImpactNext'),
],
confirmText: t('dreaming.clearGroundedBtn'),
cancelText: t('dreaming.clearGroundedCancel'),
})
if (!yes) return
runAction('doctor.memory.resetGroundedShortTerm', t('dreaming.clearGroundedDone'))
})

View File

@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
@@ -236,7 +237,7 @@ async function handleCftunnelAction(page, action) {
toast(t('ext.tunnelActionDone', { action: label }), 'success')
await loadCftunnel(page)
} catch (e) {
toast(t('ext.tunnelActionFail', { action: label }) + ': ' + e, 'error')
toast(humanizeError(e, t('ext.tunnelActionFail', { action: label })), 'error')
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = label }
}
}
@@ -317,7 +318,7 @@ async function handleInstallCftunnel(page) {
progressFill.classList.add('error')
progressText.innerHTML = `${statusIcon('err', 14)} ${t('ext.installFailed')}`
logBox.textContent += '\n' + t('ext.error') + ': ' + e
toast(t('ext.installFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('ext.installFailed')), 'error')
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: t('ext.installFailedTitle', { name: 'cftunnel' }),
@@ -382,7 +383,7 @@ async function handleInstallClawapp(page) {
progressFill.classList.add('error')
progressText.innerHTML = `${statusIcon('err', 14)} ${t('ext.installFailed')}`
logBox.textContent += '\n' + t('ext.error') + ': ' + e
toast(t('ext.installFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('ext.installFailed')), 'error')
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: t('ext.installFailedTitle', { name: 'ClawApp' }),

View File

@@ -3,6 +3,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { tryShowEngagement } from '../components/engagement.js'
import { t } from '../lib/i18n.js'
@@ -71,7 +72,7 @@ async function loadConfig(page, state) {
renderConfig(page, state)
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:20px">' + t('gateway.loadFailed') + ': ' + e + '</div>'
toast(t('gateway.loadFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('gateway.loadFailed')), 'error')
}
}
@@ -337,9 +338,9 @@ async function saveConfig(page, state) {
toast(t('gateway.reloaded'), 'success')
setTimeout(tryShowEngagement, 3000)
} catch (e) {
toast(t('gateway.savedButReloadFailed') + ': ' + e, 'warning')
toast(humanizeError(e, t('gateway.savedButReloadFailed')), 'warning')
}
} catch (e) {
toast(t('gateway.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('gateway.saveFailed')), 'error')
}
}

272
src/pages/glossary.js Normal file
View File

@@ -0,0 +1,272 @@
/**
* 术语表 — 让小白看懂面板里的技术词
*
* 设计原则:
* - 术语用「比喻 + 一句话」解释,避免循环引用其他术语
* - 每条术语标注「相关页面」,点击直达对应配置入口
* - 数据直接内嵌(非走 locales 模块),后期需要更多语言时再迁
*/
import { t, getLang } from '../lib/i18n.js'
import { navigate } from '../router.js'
// ── 25 个核心术语zh-CN / en / zh-TW其他语言后续补 ──
const GLOSSARY = [
// 核心概念
{
id: 'agent', term: 'Agent', cat: 'core', route: '/agents',
zhCN: { name: 'Agent智能体', desc: '面板里的「分身」—— 拥有独立身份、技能、记忆,能代你聊天和办事。每个 Agent 类似一个独立的 AI 角色,互不干扰。' },
en: { name: 'Agent', desc: 'Your "alter ego" inside the panel — has its own identity, skills, and memory, and can chat or take actions on your behalf. Each Agent is an independent AI persona that does not interfere with others.' },
zhTW: { name: 'Agent智能體', desc: '面板裡的「分身」—— 擁有獨立身分、技能、記憶,能代你聊天和辦事。每個 Agent 類似一個獨立的 AI 角色,互不干擾。' },
},
{
id: 'gateway', term: 'Gateway', cat: 'core', route: '/services',
zhCN: { name: 'Gateway网关', desc: '面板与 AI 模型之间的「翻译官」+「调度员」。所有对话、技能调用、消息收发都从它经过。Gateway 没启动 = 面板用不了。' },
en: { name: 'Gateway', desc: 'The "translator" + "dispatcher" between the panel and your AI models. All chat, skill calls, and message routing pass through it. If Gateway is down, the panel can\'t do anything.' },
zhTW: { name: 'Gateway網關', desc: '面板與 AI 模型之間的「翻譯官」+「調度員」。所有對話、技能呼叫、訊息收發都從它經過。Gateway 沒啟動 = 面板用不了。' },
},
{
id: 'channel', term: 'Channel', cat: 'core', route: '/channels',
zhCN: { name: 'Channel消息渠道', desc: '把 Agent 接入到外部的「窗口」—— 比如 Telegram、Discord、QQ、飞书等。配好渠道后AI 就能通过这些应用收发消息。' },
en: { name: 'Channel', desc: 'The "window" that connects an Agent to external apps — Telegram, Discord, QQ, Feishu, etc. Once configured, the AI can send/receive messages through these apps.' },
zhTW: { name: 'Channel訊息頻道', desc: '把 Agent 接入到外部的「窗口」—— 例如 Telegram、Discord、QQ、飛書等。配好頻道後AI 就能透過這些應用收發訊息。' },
},
{
id: 'skill', term: 'Skill', cat: 'core', route: '/skills',
zhCN: { name: 'Skill技能', desc: 'Agent 的「特长包」—— 给它装上「天气查询」「日历管理」「文件操作」等能力,它就能在对话中自动调用这些工具。' },
en: { name: 'Skill', desc: 'A "talent pack" for the Agent — install abilities like "weather lookup", "calendar management", or "file ops", and the Agent will use them automatically during chat.' },
zhTW: { name: 'Skill技能', desc: 'Agent 的「特長包」—— 給它裝上「天氣查詢」「行事曆管理」「檔案操作」等能力,它就能在對話中自動使用這些工具。' },
},
{
id: 'memory', term: 'Memory', cat: 'core', route: '/memory',
zhCN: { name: 'Memory记忆', desc: 'Agent 的「日记本」—— 把重要对话、用户偏好、长期信息存下来,下次聊天时它能记住你说过什么。' },
en: { name: 'Memory', desc: 'The Agent\'s "journal" — saves important conversations, user preferences, and long-term info so it remembers what you said in past sessions.' },
zhTW: { name: 'Memory記憶', desc: 'Agent 的「日記本」—— 把重要對話、使用者偏好、長期資訊存下來,下次聊天時它能記住你說過什麼。' },
},
{
id: 'session', term: 'Session', cat: 'core', route: '/chat',
zhCN: { name: 'Session会话', desc: '一次连续的对话上下文。同一个 Session 里 AI 记得你说过的所有话;切换或重置 Session 就相当于「翻篇」重新开始。' },
en: { name: 'Session', desc: 'A continuous chat context. Within the same Session the AI remembers everything you said; switching or resetting starts fresh from a clean slate.' },
zhTW: { name: 'Session對話', desc: '一次連續的對話上下文。同一個 Session 裡 AI 記得你說過的所有話;切換或重置就相當於「翻篇」重新開始。' },
},
{
id: 'workspace', term: 'Workspace', cat: 'core', route: '/agents',
zhCN: { name: 'Workspace工作目录', desc: 'Agent 用来读写文件的「文件夹」。每个 Agent 可以指定独立的工作目录,互不干扰,避免误删别的 Agent 的资料。' },
en: { name: 'Workspace', desc: 'The "folder" the Agent reads/writes files in. Each Agent can have its own workspace so they don\'t accidentally touch each other\'s files.' },
zhTW: { name: 'Workspace工作目錄', desc: 'Agent 用來讀寫檔案的「資料夾」。每個 Agent 可以指定獨立的工作目錄,互不干擾,避免誤刪別的 Agent 的資料。' },
},
{
id: 'pairing', term: 'Pairing', cat: 'core', route: '/security',
zhCN: { name: 'Pairing设备配对', desc: '把面板和你的手机/平板「认亲」的过程。配对成功后,移动端 App 就能直接连到面板,不需要每次输密码。' },
en: { name: 'Pairing', desc: 'The process of "linking" the panel with your phone/tablet. Once paired, the mobile app connects directly without needing to log in each time.' },
zhTW: { name: 'Pairing裝置配對', desc: '把面板和你的手機/平板「認親」的過程。配對成功後,行動裝置 App 就能直接連到面板,不需要每次輸密碼。' },
},
// 模型与服务
{
id: 'provider', term: 'Provider', cat: 'model', route: '/models',
zhCN: { name: 'Provider服务商', desc: '给你提供 AI 模型的厂商 —— 比如 OpenAIChatGPT、AnthropicClaude、DeepSeek、Qwen 等。每个 Provider 通常对应一个 API key。' },
en: { name: 'Provider', desc: 'A company that supplies AI models — OpenAI (ChatGPT), Anthropic (Claude), DeepSeek, Qwen, etc. Each provider usually maps to one API key.' },
zhTW: { name: 'Provider服務商', desc: '給你提供 AI 模型的廠商 —— 例如 OpenAIChatGPT、AnthropicClaude、DeepSeek、Qwen 等。每個 Provider 通常對應一個 API key。' },
},
{
id: 'apikey', term: 'API Key', cat: 'model', route: '/models',
zhCN: { name: 'API KeyAPI 密钥)', desc: '类似服务商发的「会员卡密码」。AI 调用要扣费,凭这把钥匙服务商才知道是你在用,并按使用量计费。' },
en: { name: 'API Key', desc: 'Like a "member-card password" issued by the provider. The AI calls cost money — this key tells the provider it\'s you so they can bill correctly.' },
zhTW: { name: 'API KeyAPI 密鑰)', desc: '類似服務商發的「會員卡密碼」。AI 呼叫要扣費,憑這把鑰匙服務商才知道是你在用,並按使用量計費。' },
},
{
id: 'token', term: 'Token', cat: 'model', route: '/models',
zhCN: { name: 'Token计费单位', desc: 'AI 模型按「Token」收费 —— 大致相当于一个汉字、一个英文单词或半个标点。一次对话用 1000 Token = 大概 700 字。' },
en: { name: 'Token', desc: 'The unit AI models bill in. Roughly equals one character (Chinese), one English word, or half a punctuation mark. 1000 tokens ≈ 750 English words.' },
zhTW: { name: 'Token計費單位', desc: 'AI 模型按「Token」收費 —— 大致相當於一個中文字、一個英文單字或半個標點。一次對話用 1000 Token = 大概 700 字。' },
},
{
id: 'streaming', term: 'Streaming', cat: 'model',
zhCN: { name: 'Streaming流式响应', desc: 'AI 一边生成一边显示给你看(打字机效果),不用等它写完再显示。等待感好很多,但稍微费点带宽。' },
en: { name: 'Streaming', desc: 'The AI shows tokens to you as it generates them (typewriter effect) instead of waiting for the whole response. Better UX, slightly more bandwidth.' },
zhTW: { name: 'Streaming串流回應', desc: 'AI 一邊生成一邊顯示給你看(打字機效果),不用等它寫完再顯示。等待感好很多,但稍微費點頻寬。' },
},
{
id: 'context', term: 'Context Window', cat: 'model',
zhCN: { name: 'Context Window上下文窗口', desc: 'AI 一次对话能「记住」多少字。比如 32K = 大概 2 万汉字。超出窗口的早期对话 AI 会忘掉。' },
en: { name: 'Context Window', desc: 'How much text the AI can "remember" in one chat. e.g. 32K ≈ 24K English words. Anything older than the window gets forgotten by the AI.' },
zhTW: { name: 'Context Window上下文視窗', desc: 'AI 一次對話能「記住」多少字。例如 32K = 大概 2 萬中文字。超出視窗的早期對話 AI 會忘掉。' },
},
{
id: 'profile', term: 'Profile', cat: 'model',
zhCN: { name: 'Profile配置档案', desc: '一组配置的「快照」—— 比如「白天用 GPT-4晚上用 Claude」可以存成两个 Profile一键切换。' },
en: { name: 'Profile', desc: 'A "snapshot" of settings — e.g. "GPT-4 by day, Claude by night" can be saved as two profiles and switched with one click.' },
zhTW: { name: 'Profile設定檔', desc: '一組設定的「快照」—— 例如「白天用 GPT-4晚上用 Claude」可以存成兩個 Profile一鍵切換。' },
},
// 接入与协议
{
id: 'webhook', term: 'Webhook', cat: 'integration', route: '/channels',
zhCN: { name: 'Webhook回调地址', desc: '一个外部应用「打你电话」的号码。比如 Discord 收到消息后就请求这个地址通知 ClawPanel触发 AI 回复。' },
en: { name: 'Webhook', desc: 'A URL external apps "call back" to. e.g. when Discord receives a message it pings this URL so ClawPanel knows and triggers the AI to respond.' },
zhTW: { name: 'Webhook回呼網址', desc: '一個外部應用「打你電話」的號碼。例如 Discord 收到訊息後就請求這個位址通知 ClawPanel觸發 AI 回覆。' },
},
{
id: 'oauth', term: 'OAuth', cat: 'integration', route: '/channels',
zhCN: { name: 'OAuth第三方授权', desc: '一种「不用给密码也能让别人代你登录」的协议。授权 ClawPanel 接入 Discord 时走的就是 OAuth比直接给 token 更安全。' },
en: { name: 'OAuth', desc: 'A protocol that lets services log in on your behalf without you sharing your password. ClawPanel uses OAuth when connecting Discord — safer than handing over a raw token.' },
zhTW: { name: 'OAuth第三方授權', desc: '一種「不用給密碼也能讓別人代你登入」的協定。授權 ClawPanel 接入 Discord 時走的就是 OAuth比直接給 token 更安全。' },
},
{
id: 'bottoken', term: 'Bot Token', cat: 'integration', route: '/channels',
zhCN: { name: 'Bot Token机器人令牌', desc: 'Telegram/Discord 等平台给你的机器人发的「身份卡」。把它配到 ClawPanelAI 就能以这个机器人的身份说话。' },
en: { name: 'Bot Token', desc: 'The "ID card" issued by Telegram/Discord/etc. to your bot. Once you put it in ClawPanel, the AI can speak as this bot identity.' },
zhTW: { name: 'Bot Token機器人權杖', desc: 'Telegram/Discord 等平台給你的機器人發的「身分卡」。把它配到 ClawPanelAI 就能以這個機器人的身分說話。' },
},
{
id: 'binding', term: 'Binding', cat: 'integration', route: '/channels',
zhCN: { name: 'Binding绑定关系', desc: '把「哪个 Agent」和「哪个渠道」配对的规则。比如「营销 Agent 接 Discord技术 Agent 接 Slack」就是两条 Binding。' },
en: { name: 'Binding', desc: 'A rule that pairs "which Agent" with "which Channel". e.g. "marketing Agent to Discord, technical Agent to Slack" is two bindings.' },
zhTW: { name: 'Binding綁定關係', desc: '把「哪個 Agent」和「哪個頻道」配對的規則。例如「行銷 Agent 接 Discord技術 Agent 接 Slack」就是兩條 Binding。' },
},
{
id: 'mcp', term: 'MCP', cat: 'integration',
zhCN: { name: 'MCP模型上下文协议', desc: '一种让 AI 模型能用「外部工具」的标准。配上 MCP server 后AI 可以读你的数据库、调你的 API、操作你的应用。' },
en: { name: 'MCP (Model Context Protocol)', desc: 'A standard that lets AI models call "external tools". Once you add MCP servers, the AI can query your databases, call your APIs, and operate your apps.' },
zhTW: { name: 'MCP模型上下文協定', desc: '一種讓 AI 模型能用「外部工具」的標準。配上 MCP server 後AI 可以讀你的資料庫、呼叫你的 API、操作你的應用。' },
},
// 进阶
{
id: 'cron', term: 'Cron', cat: 'advanced', route: '/cron',
zhCN: { name: 'Cron定时任务', desc: '让 AI 在固定时间自动做事 —— 比如「每天早上 9 点推送当天会议安排」「每周一总结一次工作进度」。' },
en: { name: 'Cron', desc: 'Schedule the AI to act on a fixed timetable — e.g. "every day at 9am push today\'s meetings", "every Monday summarize work progress".' },
zhTW: { name: 'Cron定時任務', desc: '讓 AI 在固定時間自動做事 —— 例如「每天早上 9 點推送當天會議安排」「每週一總結一次工作進度」。' },
},
{
id: 'dreaming', term: 'Dreaming', cat: 'advanced', route: '/dreaming',
zhCN: { name: 'Dreaming梦境模式', desc: 'AI 在你不用它的时候自动「整理记忆」—— 把短期对话沉淀成长期记忆,类似人睡觉时大脑做梦消化白天的事。' },
en: { name: 'Dreaming', desc: 'When you\'re not using the AI, it auto-consolidates memory — promoting short-term chat into long-term memory, like the brain processing the day during sleep.' },
zhTW: { name: 'Dreaming夢境模式', desc: 'AI 在你不用它的時候自動「整理記憶」—— 把短期對話沉澱成長期記憶,類似人睡覺時大腦做夢消化白天的事。' },
},
{
id: 'backup', term: 'Backup', cat: 'advanced', route: '/services',
zhCN: { name: 'Backup备份', desc: '把当前面板配置模型、Agent、渠道、技能等保存一份「快照」。改坏了能一键还原不会一夜回到解放前。' },
en: { name: 'Backup', desc: 'A "snapshot" of your current panel config (models, agents, channels, skills, etc.). If you mess something up, restore in one click and you\'re safe.' },
zhTW: { name: 'Backup備份', desc: '把目前面板設定模型、Agent、頻道、技能等儲存一份「快照」。改壞了能一鍵還原不會一夜回到解放前。' },
},
{
id: 'compaction', term: 'Compaction', cat: 'advanced',
zhCN: { name: 'Compaction会话压缩', desc: '对话太长 Token 用太多时AI 自动「总结早期内容」—— 把开头一长段聊天压成一句话,省 Token 也保留要点。' },
en: { name: 'Compaction', desc: 'When a chat gets too long and uses too many tokens, the AI auto-summarizes the earlier parts — collapsing chunks into a sentence to save tokens while keeping the gist.' },
zhTW: { name: 'Compaction對話壓縮', desc: '對話太長 Token 用太多時AI 自動「總結早期內容」—— 把開頭一長段聊天壓成一句話,省 Token 也保留要點。' },
},
{
id: 'sandbox', term: 'Sandbox', cat: 'advanced',
zhCN: { name: 'Sandbox沙箱', desc: 'AI 执行命令的「安全屋」—— 在隔离环境里跑代码、装依赖,出问题不会影响你的主系统。' },
en: { name: 'Sandbox', desc: 'A "safe room" for the AI to run commands — isolates code execution and dependency installs so problems can\'t harm your main system.' },
zhTW: { name: 'Sandbox沙箱', desc: 'AI 執行命令的「安全屋」—— 在隔離環境裡跑程式碼、裝依賴,出問題不會影響你的主系統。' },
},
{
id: 'plugin', term: 'Plugin', cat: 'advanced', route: '/plugin-hub',
zhCN: { name: 'Plugin插件', desc: '给面板「装新功能」的扩展包 —— 第三方写好的渠道适配、特殊技能、UI 组件等都能通过插件加进来。' },
en: { name: 'Plugin', desc: 'Add-ons that give the panel new abilities — third-party channel adapters, special skills, UI components, etc. can all be added via plugins.' },
zhTW: { name: 'Plugin外掛', desc: '給面板「裝新功能」的擴充包 —— 第三方寫好的頻道介接、特殊技能、UI 元件等都能透過外掛加進來。' },
},
]
const CATEGORIES = [
{ id: 'all', zhCN: '全部', en: 'All', zhTW: '全部' },
{ id: 'core', zhCN: '核心概念', en: 'Core Concepts', zhTW: '核心概念' },
{ id: 'model', zhCN: '模型与服务', en: 'Models & Providers', zhTW: '模型與服務' },
{ id: 'integration', zhCN: '接入与协议', en: 'Integration & Protocols', zhTW: '接入與協定' },
{ id: 'advanced', zhCN: '进阶概念', en: 'Advanced', zhTW: '進階概念' },
]
function pickLang(item) {
const lang = getLang()
if (lang.startsWith('zh-CN')) return item.zhCN
if (lang.startsWith('zh-TW')) return item.zhTW || item.zhCN
return item.en
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
page.className = 'page glossary-page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">${esc(t('glossary.title'))}</h1>
<p class="page-desc">${esc(t('glossary.desc'))}</p>
</div>
<div class="glossary-toolbar">
<input type="search" id="glossary-search" class="form-input" placeholder="${esc(t('glossary.searchPlaceholder'))}">
<div class="glossary-tabs" id="glossary-tabs">
${CATEGORIES.map(c => `<button class="tab" data-cat="${c.id}">${esc(pickLang(c))}</button>`).join('')}
</div>
</div>
<div id="glossary-list" class="glossary-list"></div>
`
const state = { cat: 'all', query: '' }
function rerender() {
const listEl = page.querySelector('#glossary-list')
const q = state.query.trim().toLowerCase()
const items = GLOSSARY.filter(item => {
if (state.cat !== 'all' && item.cat !== state.cat) return false
if (!q) return true
const txt = pickLang(item)
return item.term.toLowerCase().includes(q)
|| (txt?.name || '').toLowerCase().includes(q)
|| (txt?.desc || '').toLowerCase().includes(q)
})
if (!items.length) {
listEl.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🔍</div>
<div class="empty-desc">${esc(t('glossary.noMatch'))}</div>
</div>
`
return
}
listEl.innerHTML = items.map(item => {
const txt = pickLang(item)
const cta = item.route
? `<button class="btn btn-xs btn-secondary" data-glossary-route="${esc(item.route)}">${esc(t('glossary.openPage'))} →</button>`
: ''
return `
<div class="glossary-card">
<div class="glossary-card-head">
<div class="glossary-term">${esc(txt.name)}</div>
${cta}
</div>
<div class="glossary-desc">${esc(txt.desc)}</div>
</div>
`
}).join('')
listEl.querySelectorAll('[data-glossary-route]').forEach(btn => {
btn.addEventListener('click', () => navigate(btn.dataset.glossaryRoute))
})
}
// Tab 切换
page.querySelectorAll('#glossary-tabs .tab').forEach(tab => {
if (tab.dataset.cat === 'all') tab.classList.add('active')
tab.addEventListener('click', () => {
page.querySelectorAll('#glossary-tabs .tab').forEach(x => x.classList.remove('active'))
tab.classList.add('active')
state.cat = tab.dataset.cat
rerender()
})
})
// 搜索
const searchInput = page.querySelector('#glossary-search')
searchInput.addEventListener('input', () => {
state.query = searchInput.value
rerender()
})
rerender()
return page
}

View File

@@ -3,6 +3,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { t } from '../lib/i18n.js'
const LOG_TABS = [
@@ -93,7 +94,7 @@ async function loadLog(page, logName) {
}
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:12px">' + t('logs.loadFailed') + ': ' + e + '</div>'
toast(t('logs.loadFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('logs.loadFailed')), 'error')
} finally {
if (refreshBtn) { refreshBtn.classList.remove('btn-loading'); refreshBtn.disabled = false }
}
@@ -110,7 +111,7 @@ async function searchLog(page, logName, query) {
el.innerHTML = results.map(l => `<div class="log-line">${highlightMatch(escapeHtml(l), query)}</div>`).join('')
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:12px">' + t('logs.searchFailed') + ': ' + e + '</div>'
toast(t('logs.searchFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('logs.searchFailed')), 'error')
}
}

View File

@@ -3,6 +3,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showModal } from '../components/modal.js'
import { t } from '../lib/i18n.js'
@@ -117,7 +118,7 @@ export async function render() {
toast(t('memory.created', { name: filename }), 'success')
loadFiles(page, state)
} catch (e) {
toast(t('memory.createFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.createFailed')), 'error')
}
},
})
@@ -128,7 +129,16 @@ export async function render() {
if (!state.currentPath) return
const name = state.currentPath.split('/').pop()
const { showConfirm } = await import('../components/modal.js')
const yes = await showConfirm(t('memory.confirmDelete', { name }))
const yes = await showConfirm({
title: t('memory.deleteConfirmTitle', { name }),
message: t('memory.confirmDelete', { name }),
impact: [
t('memory.deleteImpactPermanent'),
t('memory.deleteImpactAgent'),
],
confirmText: t('memory.deleteConfirmBtn'),
cancelText: t('memory.deleteCancelBtn'),
})
if (!yes) return
try {
await api.deleteMemoryFile(state.currentPath, state.agentId)
@@ -137,7 +147,7 @@ export async function render() {
resetEditor(page)
loadFiles(page, state)
} catch (e) {
toast(t('memory.deleteFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.deleteFailed')), 'error')
}
}
@@ -157,13 +167,22 @@ async function loadFiles(page, state) {
try {
const files = await api.listMemoryFiles(state.category, state.agentId)
if (!files || !files.length) {
tree.innerHTML = `<div style="color:var(--text-tertiary);padding:12px">${t('memory.noFiles')}</div>`
tree.innerHTML = `
<div class="empty-state empty-compact">
<div class="empty-icon">🧠</div>
<div class="empty-desc">${t('memory.noFiles')}</div>
<div class="empty-cta"><button class="btn btn-primary btn-sm" data-empty-cta="new-file">${t('memory.newFile')}</button></div>
</div>
`
tree.querySelector('[data-empty-cta="new-file"]')?.addEventListener('click', () => {
page.querySelector('#btn-new-file')?.click()
})
return
}
renderFileTree(page, state, files)
} catch (e) {
tree.innerHTML = `<div style="color:var(--error);padding:12px">${t('memory.loadFailed')}: ${e}</div>`
toast(t('memory.loadListFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.loadListFailed')), 'error')
}
}
@@ -213,7 +232,7 @@ async function loadFileContent(page, state) {
btnDl.disabled = false
} catch (e) {
editor.value = t('memory.readFailed') + ': ' + e
toast(t('memory.readFileFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.readFileFailed')), 'error')
}
}
@@ -239,7 +258,7 @@ async function saveFile(page, state) {
await api.writeMemoryFile(state.currentPath, content, state.category, state.agentId)
toast(t('memory.fileSaved'), 'success')
} catch (e) {
toast(t('memory.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.saveFailed')), 'error')
}
}
@@ -301,7 +320,7 @@ async function downloadCurrentFile(page, state) {
triggerDownload(filename, content)
toast(t('memory.downloaded', { name: filename }), 'success')
} catch (e) {
toast(t('memory.downloadFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.downloadFailed')), 'error')
}
}
@@ -320,6 +339,6 @@ async function exportZip(state) {
toast(t('memory.exported', { label, path: zipPath }), 'success')
}
} catch (e) {
toast(t('memory.exportFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('memory.exportFailed')), 'error')
}
}

View File

@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showModal, showConfirm } from '../components/modal.js'
import { icon, statusIcon } from '../lib/icons.js'
import { API_TYPES, PROVIDER_PRESETS, QTCOOL, MODEL_PRESETS, fetchQtcoolModels } from '../lib/model-presets.js'
@@ -704,7 +705,7 @@ async function saveConfigOnly(state) {
normalizeProviderUrls(state.config)
await api.writeOpenclawConfig(state.config, { noReload: true })
} catch (e) {
toast(t('models.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('models.saveFailed')), 'error')
}
}
@@ -730,7 +731,7 @@ async function doAutoSave(state) {
toast(t('models.configSavedGwNotRunning'), 'info', { duration: 4000 })
}
} catch (e) {
toast(t('models.autoSaveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('models.autoSaveFailed')), 'error')
}
}
@@ -1257,6 +1258,7 @@ function addProvider(page, state) {
`
document.body.appendChild(overlay)
attachTermTooltips(overlay)
// 预设按钮点击自动填充
overlay.querySelectorAll('.preset-btn').forEach(btn => {
@@ -1315,11 +1317,13 @@ function addProvider(page, state) {
// 编辑服务商
function editProvider(page, state, providerKey) {
const p = state.config.models.providers[providerKey]
// showModal 不返回 overlay需要异步扫 document.body 给 ⓘ 按钮绑定 clickattachTermTooltips 内部已去重)
setTimeout(() => attachTermTooltips(document.body), 0)
showModal({
title: t('models.editProviderTitle', { name: providerKey }),
fields: [
{ name: 'baseUrl', label: t('models.baseUrl'), value: p.baseUrl || '', hint: t('models.baseUrlHint') },
{ name: 'apiKey', label: t('models.apiKey'), value: p.apiKey || '', hint: t('models.apiKeyEditHint') },
{ name: 'apiKey', label: t('models.apiKey') + termHelpHtml('apikey'), value: p.apiKey || '', hint: t('models.apiKeyEditHint') },
{
name: 'api', label: t('models.apiType'), type: 'select', value: p.api || 'openai-completions',
options: API_TYPES,
@@ -1519,7 +1523,16 @@ async function handleBatchDelete(section, page, state, providerKey) {
const checked = [...section.querySelectorAll('.model-checkbox:checked')]
if (!checked.length) { toast(t('models.batchSelectHint'), 'warning'); return }
const ids = checked.map(cb => cb.dataset.modelId)
const yes = await showConfirm(t('models.confirmBatchDelete', { count: ids.length, ids: ids.join(', ') }))
const yes = await showConfirm({
title: t('models.batchDeleteTitle', { count: ids.length }),
message: t('models.confirmBatchDelete', { count: ids.length, ids: ids.join(', ') }),
impact: [
t('models.batchDeleteImpact'),
t('models.batchDeleteImpactConfig'),
],
confirmText: t('models.batchDeleteBtn'),
cancelText: t('models.batchDeleteCancel'),
})
if (!yes) return
pushUndo(state)
const provider = state.config.models.providers[providerKey]

View File

@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showConfirm, showModal, showUpgradeModal } from '../components/modal.js'
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
@@ -762,7 +763,13 @@ async function handleRestoreBackup(name, page) {
}
async function handleDeleteBackup(name, page) {
const yes = await showConfirm(t('services.deleteConfirm', { name }))
const yes = await showConfirm({
title: t('services.deleteBackupTitle', { name }),
message: t('services.deleteConfirm', { name }),
impact: [t('services.deleteBackupImpact')],
confirmText: t('services.deleteBackupBtn'),
cancelText: t('services.deleteBackupCancel'),
})
if (!yes) return
await api.deleteBackup(name)
toast(t('services.backupDeleted'), 'success')
@@ -885,14 +892,14 @@ async function handleSaveConfig(page, restart) {
await api.restartGateway()
toast(t('services.gwRestarted'), 'success')
} catch (e) {
toast(t('services.configSavedGwFailed') + ': ' + e, 'warning')
toast(humanizeError(e, t('services.configSavedGwFailed')), 'warning')
}
await loadServices(page)
}
await loadBackups(page)
} catch (e) {
toast(t('common.saveFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('common.saveFailed')), 'error')
status.innerHTML = `<span style="color:var(--error)">${t('common.saveFailed')}: ${e}</span>`
}
}
@@ -994,7 +1001,7 @@ async function handleClaimGateway(btn, page) {
await refreshGatewayStatus()
await loadServices(page)
} catch (e) {
toast(t('services.claimFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('services.claimFailed')), 'error')
btn.classList.remove('btn-loading')
btn.textContent = t('services.claimGateway')
}
@@ -1010,14 +1017,24 @@ async function handleInstallGateway(btn, page) {
toast(t('services.gwInstalled'), 'success')
await loadServices(page)
} catch (e) {
toast(t('services.installFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('services.installFailed')), 'error')
btn.classList.remove('btn-loading')
btn.textContent = t('services.install')
}
}
async function handleUninstallGateway(btn, page) {
const yes = await showConfirm(t('services.uninstallConfirm'))
const yes = await showConfirm({
title: t('services.uninstallTitle'),
message: t('services.uninstallConfirm'),
impact: [
t('services.uninstallImpactStop'),
t('services.uninstallImpactAutostart'),
t('services.uninstallImpactConfig'),
],
confirmText: t('services.uninstallBtn'),
cancelText: t('services.uninstallCancelBtn'),
})
if (!yes) return
btn.classList.add('btn-loading')
btn.textContent = t('services.uninstalling')
@@ -1026,7 +1043,7 @@ async function handleUninstallGateway(btn, page) {
toast(t('services.gwUninstalled'), 'success')
await loadServices(page)
} catch (e) {
toast(t('services.uninstallFailed') + ': ' + e, 'error')
toast(humanizeError(e, t('services.uninstallFailed')), 'error')
btn.classList.remove('btn-loading')
btn.textContent = t('services.uninstall')
}

View File

@@ -4,6 +4,8 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showConfirm } from '../components/modal.js'
import { t } from '../lib/i18n.js'
import { wsClient } from '../lib/ws-client.js'
@@ -165,9 +167,11 @@ function renderSkills(el, data) {
${!skills.length ? `
<div class="clawhub-panel">
<div class="clawhub-empty" style="text-align:center;padding:var(--space-xl)">
<div style="margin-bottom:var(--space-sm)">${t('skills.noSkills')}</div>
<div class="form-hint">${t('skills.noSkillsHint')}</div>
<div class="empty-state">
<div class="empty-icon">🛠️</div>
<div class="empty-title">${t('skills.noSkills')}</div>
<div class="empty-desc">${t('skills.noSkillsHint')}</div>
<div class="empty-cta"><button class="btn btn-primary" data-empty-cta="go-store">${t('skills.tabStore')}</button></div>
</div>
</div>` : ''}
@@ -186,6 +190,11 @@ function renderSkills(el, data) {
})
})
}
// 空状态 CTA切到「技能商店」主 Tab
el.querySelector('[data-empty-cta="go-store"]')?.addEventListener('click', () => {
const page = el.closest('.page')
page?.querySelector('#skills-main-tabs .tab[data-main-tab="store"]')?.click()
})
}
function renderSkillCard(skill, status) {
@@ -297,7 +306,7 @@ async function handleInstallDep(page, btn) {
toast(t('skills.depInstalled', { name: skillName }), 'success')
await loadSkills(page)
} catch (e) {
toast(`${t('skills.installFailed')}: ${e?.message || e}`, 'error')
toast(humanizeError(e, t('skills.installFailed')), 'error')
btn.disabled = false
btn.textContent = spec.label || t('skills.retry')
}
@@ -407,7 +416,7 @@ async function handleStoreInstall(page, btn) {
_installedNames.add(slug)
loadSkills(page).catch(() => {})
} catch (e) {
toast(`${t('skills.installFailed')}: ${e?.message || e}`, 'error')
toast(humanizeError(e, t('skills.installFailed')), 'error')
btn.disabled = false
btn.textContent = t('skills.install')
}
@@ -416,7 +425,8 @@ async function handleStoreInstall(page, btn) {
async function handleSkillUninstall(page, btn) {
const name = btn.dataset.name
if (!name) return
if (!confirm(t('skills.confirmUninstall', { name }))) return
const ok = await showConfirm(t('skills.confirmUninstall', { name }))
if (!ok) return
btn.disabled = true
btn.textContent = t('skills.uninstalling')
try {
@@ -424,7 +434,7 @@ async function handleSkillUninstall(page, btn) {
toast(t('skills.uninstalled', { name }), 'success')
await loadSkills(page)
} catch (e) {
toast(`${t('skills.uninstallFailed')}: ${e?.message || e}`, 'error')
toast(humanizeError(e, t('skills.uninstallFailed')), 'error')
btn.disabled = false
btn.textContent = t('skills.uninstall')
}

View File

@@ -209,6 +209,73 @@
background: var(--bg-secondary);
}
/* 结构化错误 toast: 主行 + hint + 可折叠技术详情 */
.toast-structured {
max-width: 420px;
}
.toast-structured .toast-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.toast-structured .toast-main {
font-weight: 500;
line-height: 1.4;
}
.toast-structured .toast-hint {
font-size: var(--font-size-xs);
color: var(--text-secondary);
line-height: 1.4;
font-weight: 400;
}
.toast-structured .toast-raw {
margin-top: 6px;
}
.toast-structured .toast-raw summary {
cursor: pointer;
font-size: var(--font-size-xs);
color: var(--text-tertiary);
user-select: none;
list-style: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.toast-structured .toast-raw summary::-webkit-details-marker {
display: none;
}
.toast-structured .toast-raw summary::before {
content: '▸';
display: inline-block;
font-size: 9px;
transition: transform 150ms ease;
}
.toast-structured .toast-raw[open] summary::before {
transform: rotate(90deg);
}
.toast-structured .toast-raw pre {
margin: 6px 0 0;
padding: 6px 8px;
background: var(--bg-tertiary, rgba(0, 0, 0, 0.05));
border-radius: var(--radius-sm);
font-size: 11px;
font-family: var(--font-mono, monospace);
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
@@ -320,6 +387,26 @@ mark {
margin-bottom: var(--space-lg);
}
/* 致命操作确认弹窗 — 影响列表 */
.modal-impact-list {
margin: var(--space-md) 0 0;
padding: 12px 14px 12px 28px;
background: rgba(var(--error-rgb, 239, 68, 68), 0.06);
border-left: 3px solid var(--error);
border-radius: var(--radius-sm);
list-style-type: disc;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.modal-impact-list li {
line-height: 1.6;
}
.modal-impact-list li + li {
margin-top: 4px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
@@ -327,6 +414,293 @@ mark {
margin-top: var(--space-xl);
}
/* 空状态通用组件 — 大图标 + 标题 + 副本 + CTA 按钮 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
color: var(--text-secondary);
}
.empty-state .empty-icon {
font-size: 56px;
line-height: 1;
margin-bottom: var(--space-md);
opacity: 0.85;
user-select: none;
}
.empty-state .empty-title {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--text-primary);
margin-bottom: 6px;
}
.empty-state .empty-desc {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
line-height: 1.6;
max-width: 360px;
margin-bottom: var(--space-lg);
}
.empty-state .empty-cta {
margin-top: var(--space-sm);
}
/* 紧凑版(适合 sidebar 内嵌空状态) */
.empty-state.empty-compact {
padding: 20px 12px;
}
.empty-state.empty-compact .empty-icon {
font-size: 36px;
margin-bottom: var(--space-sm);
}
.empty-state.empty-compact .empty-desc {
margin-bottom: var(--space-md);
}
/* ── 新手引导卡片 ── */
.onboarding-card {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(168, 85, 247, 0.05));
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
}
[data-theme="dark"] .onboarding-card {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(168, 85, 247, 0.08));
border-color: rgba(99, 102, 241, 0.3);
}
.onboarding-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.onboarding-title {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.onboarding-desc {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.onboarding-steps {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.onboarding-step {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px 14px;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
transition: border-color 200ms ease, opacity 200ms ease;
}
.onboarding-step.done {
opacity: 0.6;
border-color: rgba(34, 197, 94, 0.3);
}
.onboarding-step-badge {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.onboarding-step-badge.todo {
background: rgba(99, 102, 241, 0.15);
color: rgb(99, 102, 241);
}
.onboarding-step-badge.done {
background: rgba(34, 197, 94, 0.15);
color: rgb(34, 197, 94);
width: auto;
height: auto;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.onboarding-step-body {
flex: 1;
min-width: 0;
}
.onboarding-step-title {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.onboarding-step-desc {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
line-height: 1.5;
}
.onboarding-step.done .onboarding-step-title {
text-decoration: line-through;
color: var(--text-tertiary);
}
.onboarding-step-action {
flex-shrink: 0;
}
.onboarding-done-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.onboarding-done-text {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--text-primary);
}
/* ── 术语表 ── */
.glossary-page .glossary-toolbar {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.glossary-page #glossary-search {
max-width: 520px;
}
.glossary-tabs {
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
border-bottom: 1px solid var(--border-primary);
padding-bottom: 0;
}
.glossary-tabs .tab {
padding: 8px 16px;
font-size: var(--font-size-sm);
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border-bottom: 2px solid transparent;
transition: color 150ms ease, border-color 150ms ease;
}
.glossary-tabs .tab:hover {
color: var(--text-primary);
}
.glossary-tabs .tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 500;
}
.glossary-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: var(--space-md);
}
.glossary-card {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: var(--space-md);
transition: border-color 200ms ease, transform 200ms ease;
}
.glossary-card:hover {
border-color: rgba(99, 102, 241, 0.4);
transform: translateY(-1px);
}
.glossary-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.glossary-term {
font-weight: 600;
font-size: var(--font-size-md);
color: var(--text-primary);
line-height: 1.4;
}
.glossary-desc {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
}
/* ── 术语 ⓘ 按钮(嵌入页面中的 inline 帮助) ── */
.term-help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin-left: 4px;
border: none;
background: transparent;
color: var(--text-tertiary);
font-size: 13px;
line-height: 1;
cursor: help;
border-radius: 50%;
transition: color 150ms ease, background 150ms ease;
vertical-align: middle;
}
.term-help:hover {
color: var(--primary, rgb(99, 102, 241));
background: rgba(99, 102, 241, 0.1);
}
.term-help:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.5);
outline-offset: 1px;
}
@keyframes fadeInModal {
from { opacity: 0; }
to { opacity: 1; }