mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
面向小白用户的产品定位重塑,从七大 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 全程通过
381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>') + '</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
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')
|
||
}
|
||
}
|