/**
* Agent 详情页
* 概览 / 文件 / 渠道 三个 Tab
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
import { navigate } from '../router.js'
function esc(str) {
if (!str) return ''
return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
}
function openChannelsBindingPage(agentId) {
const params = new URLSearchParams()
params.set('tab', 'agents')
params.set('agent', agentId || 'main')
params.set('action', 'bind')
navigate(`/channels?${params.toString()}`)
}
export async function render() {
const params = new URLSearchParams(location.hash.split('?')[1] || '')
const agentId = params.get('id') || 'main'
const page = document.createElement('div')
page.className = 'page agent-detail-page'
page.innerHTML = `
${t('agentDetail.tabOverview')}
${t('agentDetail.tabFiles')}
${t('agentDetail.tabChannels')}
${t('agentDetail.tabTools')}
${t('agentDetail.tabSkills')}
`
const state = { agentId, detail: null, files: null, models: [], skillsCatalog: [] }
// Tab 切换
page.querySelector('#agent-tabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab')
if (!tab) return
page.querySelectorAll('#agent-tabs .tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
switchTab(page, state, tab.dataset.tab)
})
// 首次加载
loadDetail(page, state)
return page
}
async function loadDetail(page, state) {
const content = page.querySelector('#agent-tab-content')
content.innerHTML = ''
try {
const [detail, config, skillsResp] = await Promise.all([
api.getAgentDetail(state.agentId),
api.readOpenclawConfig().catch(() => null),
api.skillsList().catch(() => ({ skills: [] })),
])
state.detail = detail
// 解析可用模型
state.models = parseModelList(config)
state.skillsCatalog = Array.isArray(skillsResp?.skills) ? skillsResp.skills : []
// 更新标题
const title = page.querySelector('#agent-detail-title')
const name = detail.identity?.name || detail.name || detail.id
const emoji = detail.identity?.emoji || ''
title.textContent = `${emoji} ${name}`.trim()
if (detail.isDefault) {
title.insertAdjacentHTML('beforeend', ` ${t('agentDetail.defaultAgent')}`)
}
switchTab(page, state, 'overview')
} catch (e) {
content.innerHTML = `${t('agentDetail.loadFailed')}: ${esc(String(e))}
`
}
}
function parseModelList(config) {
const models = []
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(`${pk}/${id}`)
}
}
return models
}
function switchTab(page, state, tab) {
const content = page.querySelector('#agent-tab-content')
if (tab === 'overview') renderOverview(content, state)
else if (tab === 'files') renderFiles(content, state)
else if (tab === 'channels') renderChannels(content, state)
else if (tab === 'tools') renderTools(content, state)
else if (tab === 'skills') renderSkills(content, state)
}
// ==================== 概览 Tab ====================
function renderOverview(container, state) {
const d = state.detail
if (!d) { container.innerHTML = ''; return }
// 解析模型配置
let primaryModel = ''
let fallbacks = []
if (d.model) {
if (typeof d.model === 'string') {
primaryModel = d.model
} else if (typeof d.model === 'object') {
primaryModel = d.model.primary || ''
fallbacks = Array.isArray(d.model.fallbacks) ? [...d.model.fallbacks] : []
}
}
const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'adaptive']
container.innerHTML = `
${t('agentDetail.basicInfo')}
${t('agentDetail.modelConfig')}
`
// 添加备选模型
container.querySelector('#btn-add-fallback').addEventListener('click', () => {
const list = container.querySelector('#ov-fallbacks')
const idx = list.querySelectorAll('.fallback-row').length
list.insertAdjacentHTML('beforeend', renderFallbackRow('', state.models, idx))
})
// 移除备选模型(事件代理)
container.querySelector('#ov-fallbacks').addEventListener('click', (e) => {
if (e.target.closest('.btn-remove-fallback')) {
e.target.closest('.fallback-row').remove()
}
})
// 保存
container.querySelector('#btn-save-overview').addEventListener('click', () => saveOverview(container, state))
}
function renderModelSelect(id, selected, models) {
if (!models.length) {
return ``
}
// 如果当前值不在列表中,添加到选项
const opts = [...models]
if (selected && !opts.includes(selected)) opts.unshift(selected)
return `
`
}
function renderFallbackList(fallbacks, models) {
if (!fallbacks.length) {
return `${t('agentDetail.noFallback')}
`
}
return fallbacks.map((fb, i) => renderFallbackRow(fb, models, i)).join('')
}
function renderFallbackRow(value, models, idx) {
const opts = [...models]
if (value && !opts.includes(value)) opts.unshift(value)
return `
`
}
async function saveOverview(container, state) {
const btn = container.querySelector('#btn-save-overview')
btn.disabled = true
btn.textContent = t('agentDetail.saving')
try {
const name = container.querySelector('#ov-name')?.value?.trim() || ''
const emoji = container.querySelector('#ov-emoji')?.value?.trim() || ''
const primaryEl = container.querySelector('#ov-primary-model')
const primary = primaryEl?.value?.trim() || ''
const thinkingDefault = container.querySelector('#ov-thinking')?.value || ''
// 收集备选模型
const fallbacks = []
container.querySelectorAll('.fallback-select').forEach(sel => {
const v = sel.value.trim()
if (v) fallbacks.push(v)
})
// 构建模型配置
let model = primary || undefined
if (primary && fallbacks.length > 0) {
model = { primary, fallbacks }
}
await api.updateAgentConfig(state.agentId, {
identity: { name: name || undefined, emoji: emoji || undefined },
model,
thinkingDefault: thinkingDefault || undefined,
})
// 更新本地缓存
invalidate('list_agents', 'get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.saveSuccess'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveOverview')
}
}
// ==================== 工具 Tab ====================
function renderTools(container, state) {
const tools = state.detail?.tools || {}
const profile = tools.profile || ''
const allow = Array.isArray(tools.allow) ? tools.allow.join(', ') : ''
const alsoAllow = Array.isArray(tools.alsoAllow) ? tools.alsoAllow.join(', ') : ''
const deny = Array.isArray(tools.deny) ? tools.deny.join(', ') : ''
container.innerHTML = `
${t('agentDetail.toolsTitle')}
${t('agentDetail.toolsDesc')}
`
container.querySelector('#btn-save-tools').addEventListener('click', () => saveTools(container, state))
}
async function saveTools(container, state) {
const btn = container.querySelector('#btn-save-tools')
btn.disabled = true
btn.textContent = t('agentDetail.saving')
try {
const tools = {
profile: container.querySelector('#tools-profile')?.value || undefined,
allow: splitCsv(container.querySelector('#tools-allow')?.value),
alsoAllow: splitCsv(container.querySelector('#tools-also-allow')?.value),
deny: splitCsv(container.querySelector('#tools-deny')?.value),
}
await api.updateAgentConfig(state.agentId, { tools: compactObject(tools) })
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.toolsSaved'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveTools')
}
}
// ==================== 技能 Tab ====================
function renderSkills(container, state) {
const selected = new Set(Array.isArray(state.detail?.skills) ? state.detail.skills : [])
const skills = state.skillsCatalog || []
container.innerHTML = `
${t('agentDetail.skillsTitle')}
${t('agentDetail.skillsDesc')}
${skills.length ? skills.map(skill => renderSkillCard(skill, selected.has(skill.name))).join('') : `
${t('agentDetail.noSkills')}
`}
`
container.querySelector('#btn-save-skills').addEventListener('click', () => saveSkills(container, state))
}
function renderSkillCard(skill, checked) {
const emoji = skill.emoji || '🧩'
const desc = skill.description || ''
const eligible = skill.eligible !== false
const disabled = skill.disabled === true
return `
`
}
async function saveSkills(container, state) {
const btn = container.querySelector('#btn-save-skills')
btn.disabled = true
btn.textContent = t('agentDetail.saving')
try {
const selected = []
container.querySelectorAll('.agent-skill-checkbox:checked').forEach((el) => selected.push(el.dataset.skillName))
await api.updateAgentConfig(state.agentId, { skills: selected })
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.skillsSaved'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveSkills')
}
}
function splitCsv(raw) {
if (!raw) return undefined
const values = String(raw)
.split(/[\n,]/)
.map(item => item.trim())
.filter(Boolean)
return values.length ? values : undefined
}
function compactObject(obj) {
const next = {}
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined && value !== null && value !== '') next[key] = value
}
return Object.keys(next).length ? next : undefined
}
// ==================== 文件 Tab ====================
async function renderFiles(container, state) {
container.innerHTML = `
${t('agentDetail.filesTitle')}
${t('agentDetail.filesDesc')}
`
try {
const files = await api.listAgentFiles(state.agentId)
state.files = files
renderFileList(container, state)
} catch (e) {
container.querySelector('#agent-files-list').innerHTML =
`${t('agentDetail.loadFailed')}: ${esc(String(e))}
`
}
}
function renderFileList(container, state) {
const list = container.querySelector('#agent-files-list')
const files = state.files || []
if (!files.length) {
list.innerHTML = `${t('agentDetail.noFiles')}
`
return
}
list.innerHTML = files.map(f => {
const statusClass = f.exists ? 'file-exists' : 'file-missing'
const statusText = f.exists ? t('agentDetail.fileExists') : t('agentDetail.fileMissing')
const sizeText = f.exists ? formatSize(f.size) : '-'
const timeText = f.exists && f.mtime ? new Date(f.mtime).toLocaleString('zh-CN') : '-'
const actionBtn = f.exists
? ``
: ``
return `
${esc(f.desc)}
${f.exists ? `
${t('agentDetail.fileSize')}: ${sizeText} · ${t('agentDetail.fileUpdated')}: ${timeText}
` : ''}
`
}).join('')
// 事件代理
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const name = btn.dataset.name
if (btn.dataset.action === 'edit-file') openFileEditor(container, state, name)
else if (btn.dataset.action === 'create-file') openFileEditor(container, state, name, true)
})
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
async function openFileEditor(container, state, name, isNew = false) {
let content = ''
if (!isNew) {
try {
const res = await api.readAgentFile(state.agentId, name)
content = res.content || ''
} catch (e) {
toast(t('agentDetail.loadFailed') + ': ' + e, 'error')
return
}
}
// 用弹窗编辑器
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
${t('agentDetail.editFileTitle', { name })}
`
document.body.appendChild(overlay)
const textarea = overlay.querySelector('#file-editor-textarea')
textarea.focus()
// Tab 键支持
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end)
textarea.selectionStart = textarea.selectionEnd = start + 2
}
})
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove()
})
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
overlay.querySelector('[data-action="save"]').onclick = async () => {
try {
await api.writeAgentFile(state.agentId, name, textarea.value)
toast(isNew ? t('agentDetail.fileCreated') : t('agentDetail.fileSaved'), 'success')
overlay.remove()
// 刷新文件列表
renderFiles(container, state)
} catch (e) {
toast(t('agentDetail.fileSaveFailed') + ': ' + e, 'error')
}
}
// Ctrl+S 快捷保存
overlay.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
overlay.querySelector('[data-action="save"]').click()
}
if (e.key === 'Escape') overlay.remove()
})
}
// ==================== 渠道 Tab ====================
async function renderChannels(container, state) {
const bindings = state.detail?.bindings || []
container.innerHTML = `
`
renderBindingsList(container, state, bindings)
container.querySelector('#btn-add-binding').addEventListener('click', () => {
openChannelsBindingPage(state.agentId)
})
}
function renderBindingsList(container, state, bindings) {
const list = container.querySelector('#agent-bindings-list')
if (!bindings.length) {
list.innerHTML = `${t('agentDetail.noBindings')}
`
return
}
list.innerHTML = bindings.map((b, i) => {
const channel = b.match?.channel || ''
const label = CHANNEL_LABELS[channel] || channel
const accountId = b.match?.accountId || ''
const typeLabel = b.type === 'acp' ? 'ACP' : 'Route'
return `
${esc(label)}
${accountId ? `${esc(accountId)}` : ''}
${typeLabel}
`
}).join('')
list.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="remove-binding"]')
if (!btn) return
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 }))
if (!yes) return
try {
await api.deleteAgentBinding(state.agentId, channel, account, binding?.match || null)
toast(t('agentDetail.bindingRemoved'), 'success')
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
renderBindingsList(container, state, state.detail.bindings || [])
} catch (e) {
toast(t('agentDetail.bindingFailed') + ': ' + e, 'error')
}
})
}