/** * 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')}

${renderModelSelect('ov-primary-model', primaryModel, state.models)}
${renderFallbackList(fallbacks, state.models)}
` // 添加备选模型 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')}

${t('agentDetail.toolAllowHint')}
${t('agentDetail.toolAlsoAllowHint')}
${t('agentDetail.toolDenyHint')}
` 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.name)} ${statusText}
${actionBtn}
${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 = ` ` 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 = `

${t('agentDetail.channelsTitle')}

${t('agentDetail.channelsDesc')}

` 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 ? `` : ''} ${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') } }) }