mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
延续上一轮小白 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
383 lines
15 KiB
JavaScript
383 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'
|
||
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, '&').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
|
||
}
|
||
|
||
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')
|
||
}
|
||
}
|