mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 12:50:14 +08:00
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:
@@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>') + '</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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')}` }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
// 步骤 1:Gateway 启动
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
272
src/pages/glossary.js
Normal 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 模型的厂商 —— 比如 OpenAI(ChatGPT)、Anthropic(Claude)、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 模型的廠商 —— 例如 OpenAI(ChatGPT)、Anthropic(Claude)、DeepSeek、Qwen 等。每個 Provider 通常對應一個 API key。' },
|
||||
},
|
||||
{
|
||||
id: 'apikey', term: 'API Key', cat: 'model', route: '/models',
|
||||
zhCN: { name: 'API Key(API 密钥)', 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 Key(API 密鑰)', 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 等平台给你的机器人发的「身份卡」。把它配到 ClawPanel,AI 就能以这个机器人的身份说话。' },
|
||||
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 等平台給你的機器人發的「身分卡」。把它配到 ClawPanel,AI 就能以這個機器人的身分說話。' },
|
||||
},
|
||||
{
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 给 ⓘ 按钮绑定 click(attachTermTooltips 内部已去重)
|
||||
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]
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user