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

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

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

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

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

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

383 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'
import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.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
}
setTimeout(() => attachTermTooltips(document.body), 0)
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') + termHelpHtml('workspace'), 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')
}
}