Files
clawpanel/src/pages/agents.js
晴天 e710db6ffb 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 全程通过
2026-05-14 03:38:47 +08:00

381 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Agent 管理页面
* Agent 增删改查 + 身份编辑
*/
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'
import { listAgentsCompat } from '../lib/api-compat.js'
import { hasFeature } from '../lib/kernel.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<div>
<h1 class="page-title">${t('agents.title')}</h1>
<p class="page-desc">${t('agents.desc')}</p>
<p class="page-subhint">${t('agents.detailHint')}</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="btn-add-agent">${t('agents.addAgent')}</button>
</div>
</div>
<div class="page-content">
<div id="agents-list"></div>
</div>
`
const state = { agents: [], bindings: [] }
// 非阻塞:先返回 DOM后台加载数据
loadAgents(page, state)
page.querySelector('#btn-add-agent').addEventListener('click', () => showAddAgentDialog(page, state))
return page
}
function renderSkeleton(container) {
const item = () => `
<div class="agent-card" style="pointer-events:none">
<div class="agent-card-header">
<div class="skeleton" style="width:40px;height:40px;border-radius:50%"></div>
<div style="flex:1;display:flex;flex-direction:column;gap:6px">
<div class="skeleton" style="width:45%;height:16px;border-radius:4px"></div>
<div class="skeleton" style="width:60%;height:12px;border-radius:4px"></div>
</div>
</div>
</div>`
container.innerHTML = [item(), item(), item()].join('')
}
async function loadAgents(page, state) {
const container = page.querySelector('#agents-list')
renderSkeleton(container)
try {
const [agents, config] = await Promise.all([
listAgentsCompat(),
api.readOpenclawConfig().catch(() => null),
])
state.agents = agents
state.bindings = Array.isArray(config?.bindings) ? config.bindings : []
renderAgents(page, state)
// 只在第一次加载时绑定事件(避免重复绑定)
if (!state.eventsAttached) {
attachAgentEvents(page, state)
state.eventsAttached = true
}
} 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(humanizeError(e, t('agents.loadListFailed')), 'error')
}
}
/** 为指定 agent 生成绑定渠道的 badge HTML */
function renderBindingBadges(agentId, bindings) {
const matched = (bindings || []).filter(b => (b.agentId || 'main') === agentId)
if (!matched.length) {
return `<span style="color:var(--text-tertiary)">${t('agents.noBinding')}</span>`
}
return matched.map(b => {
const channel = b.match?.channel || ''
const label = CHANNEL_LABELS[channel] || channel
const accountId = b.match?.accountId
const text = accountId ? `${label} · ${accountId}` : label
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
return `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px;white-space:nowrap">${escaped}</span>`
}).join(' ')
}
/**
* 渲染 Agent Runtime 徽章。
*
* 上游 2026.5.2+ 会在 agents.list 返回 agentRuntime 元数据:
* - { id: 'pi' } → 默认 Pi runtime最常用蓝色徽章
* - { id: 'codex' } → OpenAI Codex CLI runtime紫色徽章
* - { id: '其他' } → 显示原始 id灰色徽章
*
* 老内核不会调用本函数(被 hasFeature('agents.runtime') 门控)。
*/
function _renderRuntimeBadge(runtime) {
const id = (runtime && typeof runtime === 'object' ? runtime.id : runtime) || 'pi'
const map = {
pi: { label: 'Pi', cls: 'badge-info' },
codex: { label: 'Codex CLI', cls: 'badge-purple' },
}
const meta = map[id] || { label: id, cls: 'badge-neutral' }
return `<span class="badge ${meta.cls}" title="agentRuntime.id = ${id}">${meta.label}</span>`
}
function renderAgents(page, state) {
const container = page.querySelector('#agents-list')
if (!state.agents.length) {
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
}
container.innerHTML = state.agents.map(a => {
const isDefault = a.isDefault || a.id === 'main'
const name = a.identityName ? a.identityName.split(',')[0].trim() : t('agents.noDesc')
return `
<div class="agent-card" data-id="${a.id}">
<div class="agent-card-header">
<div class="agent-card-title">
<span class="agent-id">${a.id}</span>
${isDefault ? `<span class="badge badge-success">${t('agents.default')}</span>` : ''}
</div>
<div class="agent-card-actions">
<button class="btn btn-sm btn-primary" data-action="detail" data-id="${a.id}">${t('agents.detail')}</button>
<button class="btn btn-sm btn-secondary" data-action="backup" data-id="${a.id}">${t('agents.backup')}</button>
<button class="btn btn-sm btn-secondary" data-action="edit" data-id="${a.id}">${t('agents.edit')}</button>
${!isDefault ? `<button class="btn btn-sm btn-danger" data-action="delete" data-id="${a.id}">${t('agents.delete')}</button>` : ''}
</div>
</div>
<div class="agent-card-body">
<div class="agent-info-row">
<span class="agent-info-label">${t('agents.labelName')}</span>
<span class="agent-info-value">${name}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">${t('agents.labelModel')}</span>
<span class="agent-info-value">${typeof a.model === 'object' ? (a.model?.primary || a.model?.id || JSON.stringify(a.model)) : (a.model || t('agents.notSet'))}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">${t('agents.labelWorkspace')}</span>
<span class="agent-info-value" style="font-family:var(--font-mono);font-size:var(--font-size-xs)">${a.workspace || t('agents.notSet')}</span>
</div>
${hasFeature('agents.runtime') ? `
<div class="agent-info-row">
<span class="agent-info-label">${t('agents.labelRuntime')}</span>
<span class="agent-info-value">${_renderRuntimeBadge(a.agentRuntime)}</span>
</div>` : ''}
<div class="agent-info-row">
<span class="agent-info-label">${t('agents.labelBindings')}</span>
<span class="agent-info-value">${renderBindingBadges(a.id, state.bindings)}</span>
</div>
</div>
</div>
`
}).join('')
}
function attachAgentEvents(page, state) {
const container = page.querySelector('#agents-list')
container.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (btn) {
const action = btn.dataset.action
const id = btn.dataset.id
if (action === 'detail') location.hash = `#/agent-detail?id=${encodeURIComponent(id)}`
else if (action === 'edit') showEditAgentDialog(page, state, id)
else if (action === 'delete') await deleteAgent(page, state, id)
else if (action === 'backup') await backupAgent(id)
return
}
// 点击卡片空白区域 → 进入详情页
const card = e.target.closest('.agent-card')
if (card) {
const id = card.dataset.id
if (id) location.hash = `#/agent-detail?id=${encodeURIComponent(id)}`
}
})
}
async function showAddAgentDialog(page, state) {
// 获取模型列表
let models = []
try {
const config = await api.readOpenclawConfig()
const providers = config?.models?.providers || {}
for (const [pk, pv] of Object.entries(providers)) {
for (const m of (pv.models || [])) {
const id = typeof m === 'string' ? m : m.id
if (id) models.push({ value: `${pk}/${id}`, label: `${pk}/${id}` })
}
}
} catch { models = [{ value: 'newapi/claude-opus-4-6', label: 'newapi/claude-opus-4-6' }] }
if (!models.length) {
toast(t('agents.addModelsFirst'), 'warning')
return
}
showModal({
title: t('agents.addTitle'),
fields: [
{ name: 'id', label: t('agents.agentId'), value: '', placeholder: t('agents.agentIdPlaceholder') },
{ name: 'name', label: t('agents.agentName'), value: '', placeholder: t('agents.agentNamePlaceholder') },
{ name: 'emoji', label: t('agents.agentEmoji'), value: '', placeholder: t('agents.agentEmojiPlaceholder') },
{ name: 'model', label: t('agents.agentModel'), type: 'select', value: models[0]?.value || '', options: models },
{ name: 'workspace', label: t('agents.agentWorkspace'), value: '', placeholder: t('agents.agentWorkspacePlaceholder') },
],
onConfirm: async (result) => {
const id = (result.id || '').trim()
if (!id) { toast(t('agents.idRequired'), 'warning'); return }
if (!/^[a-z0-9_-]+$/.test(id)) { toast(t('agents.idInvalid'), 'warning'); return }
const name = (result.name || '').trim()
const emoji = (result.emoji || '').trim()
const model = result.model || models[0]?.value || ''
const workspace = (result.workspace || '').trim()
try {
await api.addAgent(id, model, workspace || null)
// 身份信息更新(非关键,失败不阻塞)
if (name || emoji) {
try {
await api.updateAgentIdentity(id, name || null, emoji || null)
} catch (identityErr) {
console.warn('[Agent] 身份信息更新失败Agent 已创建):', identityErr)
toast(t('agents.createdNameFailed'), 'warning')
}
}
toast(t('agents.created'), 'success')
// 强制清除缓存并重新加载
invalidate('list_agents')
await loadAgents(page, state)
} catch (e) {
toast(humanizeError(e, t('agents.createFailed')), 'error')
}
}
})
}
async function showEditAgentDialog(page, state, id) {
const agent = state.agents.find(a => a.id === id)
if (!agent) return
const name = agent.identityName ? agent.identityName.split(',')[0].trim() : ''
// 获取模型列表
let models = []
try {
const config = await api.readOpenclawConfig()
const providers = config?.models?.providers || {}
for (const [pk, pv] of Object.entries(providers)) {
for (const m of (pv.models || [])) {
const mid = typeof m === 'string' ? m : m.id
if (mid) models.push({ value: `${pk}/${mid}`, label: `${pk}/${mid}` })
}
}
console.log('[Agent编辑] 获取到模型列表:', models.length, '个')
} catch (e) {
console.error('[Agent编辑] 获取模型列表失败:', e)
}
const fields = [
{ name: 'name', label: t('agents.agentName'), value: name, placeholder: t('agents.agentNamePlaceholder') },
{ name: 'emoji', label: t('agents.agentEmoji'), value: agent.identityEmoji || '', placeholder: t('agents.agentEmojiPlaceholder') },
]
if (models.length) {
const modelField = {
name: 'model', label: t('agents.agentModel'), type: 'select',
value: agent.model || models[0]?.value || '',
options: models,
}
fields.push(modelField)
console.log('[Agent编辑] 当前模型:', agent.model)
console.log('[Agent编辑] 模型选项:', models)
} else {
console.warn('[Agent编辑] 模型列表为空,不显示模型选择器')
}
fields.push({
name: 'workspace', label: t('agents.labelWorkspace').replace(':', ''),
value: agent.workspace || t('agents.notSet'),
placeholder: t('agents.workspaceReadonly'),
readonly: true,
})
showModal({
title: t('agents.editTitle', { id }),
fields,
onConfirm: async (result) => {
console.log('[Agent编辑] 保存数据:', result)
const newName = (result.name || '').trim()
const emoji = (result.emoji || '').trim()
const model = (result.model || '').trim()
try {
if (newName || emoji) {
console.log('[Agent编辑] 更新身份信息...')
await api.updateAgentIdentity(id, newName || null, emoji || null)
}
if (model && model !== agent.model) {
console.log('[Agent编辑] 更新模型:', agent.model, '->', model)
await api.updateAgentModel(id, model)
}
// 手动更新 state 并重新渲染,确保立即生效
if (newName) agent.identityName = newName
if (emoji) agent.identityEmoji = emoji
if (model) agent.model = model
renderAgents(page, state)
toast(t('agents.updated'), 'success')
} catch (e) {
console.error('[Agent编辑] 保存失败:', e)
toast(humanizeError(e, t('agents.updateFailed')), 'error')
}
}
})
}
async function deleteAgent(page, state, 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 {
await api.deleteAgent(id)
toast(t('agents.deleted'), 'success')
await loadAgents(page, state)
} catch (e) {
toast(humanizeError(e, t('agents.deleteFailed')), 'error')
}
}
async function backupAgent(id) {
toast(t('agents.backingUp', { id }), 'info')
try {
const zipPath = await api.backupAgent(id)
try {
const { open } = await import('@tauri-apps/plugin-shell')
const dir = zipPath.substring(0, zipPath.lastIndexOf('/')) || zipPath
await open(dir)
} catch { /* fallback */ }
toast(t('agents.backupDone', { file: zipPath.split('/').pop() }), 'success')
} catch (e) {
toast(humanizeError(e, t('agents.backupFailed')), 'error')
}
}