nexus-agentd 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,15 +7,15 @@ export const sources = {
7
7
  "dom": "export const byId = (id) => document.getElementById(id)\n\nexport const setupScreen = byId('setup-screen')\nexport const loginScreen = byId('login-screen')\nexport const appRoot = byId('app')\nexport const content = byId('page-content')\nexport const pageStats = byId('page-stats')\nexport const pageToolbar = byId('page-toolbar')\nexport const pageResults = byId('page-results')\nexport const pageStatus = byId('page-status')\nexport const actions = byId('page-actions')\nexport const drawer = byId('drawer')\nexport const drawerBackdrop = byId('drawer-backdrop')\nexport const drawerForm = byId('drawer-form')\nexport const drawerFooter = byId('drawer-footer')\n\nexport const keyActionMenu = document.createElement('div')\nkeyActionMenu.className = 'key-action-menu hidden'\nkeyActionMenu.id = 'key-action-menu'\nkeyActionMenu.setAttribute('role', 'menu')\nkeyActionMenu.setAttribute('aria-label', 'API 密钥操作')\ndocument.body.appendChild(keyActionMenu)\n\nexport function escapeHtml(value) {\n return String(value == null ? '' : value)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;')\n}\n\nexport const selected = (value, expected) => (value === expected ? ' selected' : '')\nexport const checked = (value) => (value ? ' checked' : '')\n",
8
8
  "drawer": "import { byId, drawer, drawerBackdrop, drawerForm, drawerFooter, keyActionMenu, escapeHtml } from './dom.js'\nimport { runAction } from './toast.js'\nimport { state } from './state.js'\nimport { writeLocationState } from './location-state.js'\n\nlet drawerSubmit = null\nlet drawerVersion = 0\nlet returnFocus = null\nlet background = []\nlet previousOverflow = ''\n\nfunction focusable() {\n return Array.from(drawer.querySelectorAll('button, input, select, textarea, a[href], [tabindex]'))\n .filter((element) => !element.disabled && element.tabIndex >= 0 && element.getClientRects().length)\n}\n\nfunction restoreFocus() {\n const previous = returnFocus\n returnFocus = null\n let target = previous?.element\n if (!target?.isConnected && previous) {\n target = Array.from(document.querySelectorAll('button, input, select, textarea')).find((element) =>\n previous.id ? element.id === previous.id : Object.keys(previous.data).length &&\n Object.entries(previous.data).every(([key, value]) => element.dataset[key] === value))\n }\n if (!target?.isConnected || target.closest('.hidden') || target.disabled) target = document.querySelector('.nav-item.active')\n target?.focus({ preventScroll: true })\n}\n\nexport function getDrawerVersion() { return drawerVersion }\n\nexport function handleDrawerKeydown(event) {\n if (drawer.classList.contains('hidden') || event.isComposing) return\n if (event.key === 'Escape') {\n event.preventDefault()\n closeDrawer()\n }\n if (event.key === 'Tab') {\n const items = focusable()\n const first = items[0] || drawer\n const last = items[items.length - 1] || drawer\n if (!drawer.contains(document.activeElement) || (event.shiftKey ? document.activeElement === first : document.activeElement === last)) {\n event.preventDefault()\n ;(event.shiftKey ? last : first).focus()\n }\n }\n}\n\ndocument.addEventListener('focusin', (event) => {\n if (!drawer.classList.contains('hidden') && !drawer.contains(event.target)) (focusable()[0] || drawer).focus()\n})\n\nexport function openDrawer(title, body, submitLabel, onSubmit, danger) {\n if (state.selectedRunId) { state.selectedRunId = undefined; writeLocationState(state) }\n const version = ++drawerVersion\n if (drawer.classList.contains('hidden')) {\n const active = document.activeElement\n returnFocus = { element: active, id: active?.id, data: { ...active?.dataset } }\n keyActionMenu.classList.add('hidden')\n document.querySelectorAll('[data-key-menu]').forEach((button) => button.setAttribute('aria-expanded', 'false'))\n drawer.insertBefore(byId('notifications'), drawerFooter)\n background = Array.from(document.body.children).filter((element) => element !== drawer && element !== drawerBackdrop)\n .map((element) => [element, element.inert])\n background.forEach(([element]) => { element.inert = true })\n previousOverflow = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n }\n byId('drawer-title').textContent = title\n drawerForm.innerHTML = body + '<p class=\"form-error\" data-form-error role=\"alert\"></p>'\n drawerFooter.innerHTML = '<button type=\"button\" class=\"button\" data-close-drawer>取消</button>' +\n (submitLabel ? '<button type=\"submit\" form=\"drawer-form\" class=\"button ' + (danger ? 'solid-danger' : 'primary') + '\">' + escapeHtml(submitLabel) + '</button>' : '')\n drawerSubmit = onSubmit ? (form) => onSubmit(form, () => version === drawerVersion) : null\n drawer.classList.remove('hidden')\n drawerBackdrop.classList.remove('hidden')\n drawer.querySelector('.drawer-body').scrollTop = 0\n byId('notifications').scrollTop = byId('notifications').scrollHeight\n setTimeout(() => {\n if (version !== drawerVersion) return\n const items = focusable()\n const first = items.find((element) => element.matches('input, select, textarea')) || items[0] || drawer\n first.focus({ preventScroll: true })\n }, 0)\n}\n\nexport function closeDrawer() {\n if (drawer.classList.contains('hidden')) return\n if (state.selectedRunId) { state.selectedRunId = undefined; writeLocationState(state) }\n drawerVersion++\n drawer.classList.add('hidden')\n drawerBackdrop.classList.add('hidden')\n drawerForm.innerHTML = ''\n drawerFooter.innerHTML = ''\n drawerSubmit = null\n drawerForm.removeAttribute('aria-busy')\n document.body.appendChild(byId('notifications'))\n background.forEach(([element, inert]) => { element.inert = inert })\n background = []\n document.body.style.overflow = previousOverflow\n restoreFocus()\n}\n\nexport function openConfirmDrawer(title, message, confirmLabel, onConfirm) {\n openDrawer(title, '<p class=\"confirm-text\">' + escapeHtml(message) + '</p>', confirmLabel, async () => {\n closeDrawer()\n const version = drawerVersion\n await runAction(() => onConfirm(() => version === drawerVersion))\n }, true)\n const cancel = drawerFooter.querySelector('[data-close-drawer]')\n if (cancel) setTimeout(() => { if (cancel.isConnected) cancel.focus() }, 0)\n}\n\nexport function getDrawerSubmit() {\n return drawerSubmit\n}\n",
9
9
  "format": "const dateFormatter = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })\n\nexport function shortId(value) {\n return String(value || '').slice(0, 8)\n}\n\nexport function formatDate(timestamp) {\n if (!timestamp) return '—'\n return dateFormatter.format(new Date(timestamp))\n}\n\nexport function formatDuration(run) {\n const ms = run.durationMs != null ? run.durationMs : Math.max(0, Date.now() - run.startedAt)\n if (ms < 1000) return ms + ' 毫秒'\n if (ms < 60_000) return Math.round(ms / 1000) + ' 秒'\n if (ms < 3_600_000) return Math.floor(ms / 60_000) + ' 分 ' + Math.round(ms % 60_000 / 1000) + ' 秒'\n return Math.floor(ms / 3_600_000) + ' 小时 ' + Math.round(ms % 3_600_000 / 60_000) + ' 分'\n}\n\nexport const runStateLabels = {\n created: '已创建',\n running: '运行中',\n input_required: '等待输入',\n permission_required: '等待授权',\n completed: '已完成',\n failed: '失败',\n canceled: '已取消'\n}\n\nexport function runStatusLabel(value) {\n return runStateLabels[value] || value\n}\n\nexport function permissionLabel(value) {\n if (value === 'ask') return '询问'\n if (value === 'allow') return '自动允许单次'\n if (value === 'deny') return '拒绝'\n return value || '—'\n}\n\nexport function formatSize(bytes) {\n if (bytes == null) return '—'\n if (bytes < 1024) return bytes + ' B'\n if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'\n return (bytes / 1024 / 1024).toFixed(1) + ' MB'\n}\n\nexport const kindLabels = {\n image: '图片',\n video: '视频',\n audio: '音频',\n file: '文件'\n}\n\nexport function kindLabel(kind) {\n return kindLabels[kind] || kind || '文件'\n}\n",
10
- "icons": "const svg = (inner) => '<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">' + inner + '</svg>'\n\nexport const icons = {\n search: svg('<circle cx=\"11\" cy=\"11\" r=\"7.5\"/><path d=\"m21 21-4.3-4.3\"/>'),\n refresh: svg('<path d=\"M3 12a9 9 0 0 1 9-9 9.7 9.7 0 0 1 6.7 2.7L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.7 9.7 0 0 1-6.7-2.7L3 16\"/><path d=\"M8 16H3v5\"/>'),\n plus: svg('<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>'),\n copy: svg('<rect x=\"9\" y=\"9\" width=\"10\" height=\"10\" rx=\"2\"/><path d=\"M15 9V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/>'),\n disable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m6.3 6.3 11.4 11.4\"/>'),\n enable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m8.5 12 2.3 2.3 4.8-5\"/>'),\n edit: svg('<path d=\"M12 20h8\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z\"/>'),\n more: svg('<circle cx=\"5\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/>'),\n reveal: svg('<path d=\"M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.5\"/>'),\n scope: svg('<path d=\"M12 3 5 6v5c0 4.7 2.9 8 7 10 4.1-2 7-5.3 7-10V6Z\"/><path d=\"M9.5 12 11 13.5l3.5-4\"/>'),\n regenerate: svg('<path d=\"M20 7v5h-5\"/><path d=\"M19 12a7 7 0 1 0-2 5\"/>'),\n trash: svg('<path d=\"M4 7h16\"/><path d=\"M9 7V4h6v3\"/><path d=\"m6 7 1 13h10l1-13\"/><path d=\"M10 11v5M14 11v5\"/>'),\n folder: svg('<path d=\"M20 20a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7.1a2 2 0 0 1-1.66-.89l-.75-1.12A2 2 0 0 0 8.83 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z\"/>'),\n robot: svg('<path d=\"M12 8V4.5\"/><circle cx=\"12\" cy=\"3\" r=\"1.1\"/><rect x=\"4\" y=\"8\" width=\"16\" height=\"12\" rx=\"3\"/><path d=\"M2 14h2\"/><path d=\"M20 14h2\"/><path d=\"M9.5 13v1.6\"/><path d=\"M14.5 13v1.6\"/><path d=\"M9.5 17.2h5\"/>'),\n activity: svg('<path d=\"M22 12h-2.5a2 2 0 0 0-1.93 1.46l-2.1 7.4a.25.25 0 0 1-.48 0L9.24 3.14a.25.25 0 0 0-.48 0l-2.1 7.4A2 2 0 0 1 4.73 12H2\"/>'),\n artifacts: svg('<path d=\"M21 8v8a2 2 0 0 1-1 1.73l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 16V8a2 2 0 0 1 1-1.73l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>'),\n file: svg('<path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/><path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z\"/><path d=\"M9 13h6\"/><path d=\"M9 17h4\"/>'),\n video: svg('<rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"m16 10 5-3v10l-5-3\"/>'),\n audio: svg('<path d=\"M9 18V5l12-2v13\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><circle cx=\"18\" cy=\"16\" r=\"3\"/>'),\n key: svg('<circle cx=\"8\" cy=\"15\" r=\"4.2\"/><path d=\"m11.1 12 8-8\"/><path d=\"m15.4 7.7 2.9 2.9\"/><path d=\"m17.6 5.5 2.9 2.9\"/>')\n}\n\n",
10
+ "icons": "const svg = (inner, cls) => '<svg viewBox=\"0 0 24 24\"' + (cls ? ' class=\"' + cls + '\"' : '') + ' aria-hidden=\"true\" focusable=\"false\">' + inner + '</svg>'\n\nexport const icons = {\n search: svg('<circle cx=\"11\" cy=\"11\" r=\"7.5\"/><path d=\"m21 21-4.3-4.3\"/>'),\n refresh: svg('<path d=\"M3 12a9 9 0 0 1 9-9 9.7 9.7 0 0 1 6.7 2.7L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.7 9.7 0 0 1-6.7-2.7L3 16\"/><path d=\"M8 16H3v5\"/>'),\n plus: svg('<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>'),\n copy: svg('<rect x=\"9\" y=\"9\" width=\"10\" height=\"10\" rx=\"2\"/><path d=\"M15 9V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/>'),\n disable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m6.3 6.3 11.4 11.4\"/>'),\n enable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m8.5 12 2.3 2.3 4.8-5\"/>'),\n edit: svg('<path d=\"M12 20h8\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z\"/>'),\n more: svg('<circle cx=\"5\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/>'),\n reveal: svg('<path d=\"M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.5\"/>'),\n scope: svg('<path d=\"M12 3 5 6v5c0 4.7 2.9 8 7 10 4.1-2 7-5.3 7-10V6Z\"/><path d=\"M9.5 12 11 13.5l3.5-4\"/>'),\n regenerate: svg('<path d=\"M20 7v5h-5\"/><path d=\"M19 12a7 7 0 1 0-2 5\"/>'),\n trash: svg('<path d=\"M4 7h16\"/><path d=\"M9 7V4h6v3\"/><path d=\"m6 7 1 13h10l1-13\"/><path d=\"M10 11v5M14 11v5\"/>'),\n folder: svg('<path d=\"M20 20a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7.1a2 2 0 0 1-1.66-.89l-.75-1.12A2 2 0 0 0 8.83 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z\"/>'),\n robot: svg('<path d=\"M12 8V4.5\"/><circle cx=\"12\" cy=\"3\" r=\"1.1\"/><rect x=\"4\" y=\"8\" width=\"16\" height=\"12\" rx=\"3\"/><path d=\"M2 14h2\"/><path d=\"M20 14h2\"/><path d=\"M9.5 13v1.6\"/><path d=\"M14.5 13v1.6\"/><path d=\"M9.5 17.2h5\"/>'),\n activity: svg('<path d=\"M22 12h-2.5a2 2 0 0 0-1.93 1.46l-2.1 7.4a.25.25 0 0 1-.48 0L9.24 3.14a.25.25 0 0 0-.48 0l-2.1 7.4A2 2 0 0 1 4.73 12H2\"/>'),\n artifacts: svg('<path d=\"M21 8v8a2 2 0 0 1-1 1.73l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 16V8a2 2 0 0 1 1-1.73l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>'),\n file: svg('<path d=\"M15.75 13a.75.75 0 0 0-.75-.75H9a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 .75-.75m0 4a.75.75 0 0 0-.75-.75H9a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M7 2.25A2.75 2.75 0 0 0 4.25 5v14A2.75 2.75 0 0 0 7 21.75h10A2.75 2.75 0 0 0 19.75 19V7.968c0-.381-.124-.751-.354-1.055l-2.998-3.968a1.75 1.75 0 0 0-1.396-.695zM5.75 5c0-.69.56-1.25 1.25-1.25h7.25v4.397c0 .414.336.75.75.75h3.25V19c0 .69-.56 1.25-1.25 1.25H7c-.69 0-1.25-.56-1.25-1.25z\" clip-rule=\"evenodd\"/>', 'filled'),\n image: svg('<path d=\"M2 6a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v12a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"2.5\"/><path d=\"M14.526 12.621 6 22h12.133A3.867 3.867 0 0 0 22 18.133V18c0-.466-.175-.645-.49-.99l-4.03-4.395a2 2 0 0 0-2.954.006\"/>'),\n fileText: svg('<path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/><path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2\"/><path d=\"M9 9h1\"/><path d=\"M9 13h6\"/><path d=\"M9 17h6\"/>'),\n filePlay: svg('<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"4\"/><path d=\"m15 12-5-3v6z\"/>'),\n fileAudio: svg('<path d=\"M2 6a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v12a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z\"/><path d=\"M9 16.5V8.78a1 1 0 0 1 .757-.97l6-1.5A1 1 0 0 1 17 7.28V15\"/><path d=\"m9 11 8-2\"/><circle cx=\"7.5\" cy=\"16.5\" r=\"1.5\"/><circle cx=\"15.5\" cy=\"15.5\" r=\"1.5\"/>'),\n fileCode: svg('<path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/><path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2\"/><path d=\"m10 13-1 2 1 2\"/><path d=\"m14 13 1 2-1 2\"/>'),\n key: svg('<circle cx=\"8\" cy=\"15\" r=\"4.2\"/><path d=\"m11.1 12 8-8\"/><path d=\"m15.4 7.7 2.9 2.9\"/><path d=\"m17.6 5.5 2.9 2.9\"/>')\n}\n\n",
11
11
  "location-state": "// Navigation URLs contain only identifiers and filters, never task text or credentials.\nexport function readLocationState() {\n const [name, query] = location.hash.replace(/^#\\/?/, '').split('?')\n const page = ['overview','runs','artifacts','agents','workspaces','keys','settings'].includes(name) ? name : 'overview'\n const params = new URLSearchParams(query || '')\n const states = ['running','input_required','permission_required','completed','failed','canceled']\n const id = (value) => value && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) ? value : undefined\n return { page, runAgent: id(params.get('agent')) || 'all', runStatus: states.includes(params.get('state')) ? params.get('state') : 'all',\n runPageSize: [50,100,200].includes(Number(params.get('limit'))) ? Number(params.get('limit')) : 50,\n runOffset: Math.min(10_000_000, Math.max(0, Math.floor(Number(params.get('offset')) || 0))), selectedRunId: id(params.get('run')),\n protocol: ['acp','a2a'].includes(params.get('protocol')) ? params.get('protocol') : 'all',\n status: ['ready','failed','disabled'].includes(params.get('status')) ? params.get('status') : 'all' }\n}\n\nexport function writeLocationState(state) {\n const params = new URLSearchParams()\n if (state.page === 'runs') {\n if (state.runAgent !== 'all') params.set('agent', state.runAgent)\n if (state.runStatus !== 'all') params.set('state', state.runStatus)\n if (state.runPageSize !== 50) params.set('limit', String(state.runPageSize))\n if (state.runOffset) params.set('offset', String(state.runOffset))\n }\n if (state.page === 'agents') {\n if (state.protocol !== 'all') params.set('protocol', state.protocol)\n if (state.status !== 'all') params.set('status', state.status)\n }\n if (state.selectedRunId) params.set('run', state.selectedRunId)\n history.replaceState(null, '', '#/' + state.page + (params.size ? '?' + params : ''))\n}\n",
12
12
  "main": "import { state } from './state.js'\nimport { byId, content, drawerForm, drawerFooter, drawerBackdrop, keyActionMenu } from './dom.js'\nimport { api } from './api.js'\nimport { toast, runAction, withBusy } from './toast.js'\nimport { boot, enterApp, showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { readLocationState, writeLocationState } from './location-state.js'\nimport { loadAll, refreshRuns, refreshReadiness, markResourceSaved, retryResource } from './data.js'\nimport { closeDrawer, getDrawerSubmit, getDrawerVersion, openConfirmDrawer, handleDrawerKeydown } from './drawer.js'\nimport {\n render,\n openRunDrawer,\n refreshOpenRunDrawer,\n openAgentDrawer,\n openAgentDiagnostics,\n openWorkspaceDrawer,\n workspaceDependents,\n openKeyDrawer,\n openKeyScopeDrawer,\n openRenameKeyDrawer,\n showSecret,\n copySecret,\n applyApiKey,\n toggleKeyActionMenu,\n handleKeyMenuKeydown,\n closeKeyActionMenu,\n openArtifactDetail,\n confirmArtifactDelete,\n loadArtifacts\n} from './render.js'\n\nfunction handleKeyAction(action, id) {\n const key = state.apiKeys.find((item) => item.id === id)\n if (!key) return\n if (action === 'rename') {\n openRenameKeyDrawer(key)\n return\n }\n if (action === 'regenerate') {\n openConfirmDrawer('重新生成 API 密钥', '旧密钥会立即拒绝新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。原密钥 ID 和会话归属保留,客户端需改用新密钥。', '重新生成', async (isCurrent) => {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/regenerate', { method: 'POST' })\n applyApiKey(value.key)\n if (isCurrent()) showSecret('API 密钥已重新生成', value.secret)\n else toast('API 密钥已重新生成,可在密钥列表中显示或复制')\n })\n return\n }\n if (action === 'delete') {\n openConfirmDrawer('删除 API 密钥', '会立即拒绝使用该密钥的新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。删除后,原密钥 ID 的已有会话无法再访问。此操作无法撤销。', '删除密钥', async () => {\n await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' })\n state.apiKeys = state.apiKeys.filter((item) => item.id !== id)\n markResourceSaved('apiKeys')\n render()\n toast('API 密钥已删除')\n })\n return\n }\n void runAction(async () => {\n if (action === 'copy') {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })\n await copySecret(value.secret)\n toast('API 密钥已复制')\n }\n if (action === 'reveal') {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })\n showSecret('显示 API 密钥', value.secret)\n }\n if (action === 'scope') openKeyScopeDrawer(key)\n if (action === 'toggle') {\n const apply = async () => {\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { enabled: !key.enabled } })\n applyApiKey(updated)\n toast(key.enabled ? 'API 密钥已禁用;现有 SSE 已关闭,正在执行的任务会继续' : 'API 密钥已启用')\n }\n if (key.enabled) {\n openConfirmDrawer('禁用 API 密钥', '禁用会立即拒绝新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。已有会话在密钥重新启用后仍按原会话归属处理。', '禁用密钥', async () => { await apply() })\n } else await apply()\n }\n })\n}\n\nasync function handleContentClick(event) {\n const runTarget = event.target.closest('[data-run-detail]')\n if (runTarget) {\n await runAction(() => openRunDrawer(runTarget.dataset.runDetail))\n return\n }\n const button = event.target.closest('button')\n if (!button) return\n if (button.dataset.runQuickCancel) {\n const id = button.dataset.runQuickCancel\n openConfirmDrawer('取消任务', '取消当前任务并释放智能体会话。已经完成的文件或外部操作不会撤回。', '取消任务', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(id) + '/cancel', { method: 'POST', body: {} })\n toast('任务已取消')\n void retryResource('metrics')\n })\n return\n }\n if (button.dataset.runQuickDelete) {\n const id = button.dataset.runQuickDelete\n openConfirmDrawer('删除运行记录', '仅删除这条历史记录与已保存的产物文件,不影响智能体配置和客户端会话。此操作无法撤销。', '删除记录', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(id), { method: 'DELETE', body: {} })\n toast('运行记录已删除')\n void retryResource('metrics')\n })\n return\n }\n if (button.dataset.artifactDetail) {\n openArtifactDetail(button.dataset.artifactDetail, button.dataset.artifactRun)\n return\n }\n if (button.dataset.artifactDelete) {\n confirmArtifactDelete(button.dataset.artifactDelete, button.dataset.artifactRun)\n return\n }\n if (button.dataset.emptyAction) {\n if (button.dataset.emptyAction === 'add-agent') openAgentDrawer()\n if (button.dataset.emptyAction === 'create-key') openKeyDrawer()\n if (button.dataset.emptyAction === 'add-workspace') openWorkspaceDrawer()\n return\n }\n if (button.dataset.keyMenu) {\n toggleKeyActionMenu(button, button.dataset.keyMenu)\n return\n }\n if (button.dataset.agentEdit) {\n openAgentDrawer(state.config.agents.find((agent) => agent.id === button.dataset.agentEdit))\n return\n }\n if (button.dataset.agentDiagnostics) { await openAgentDiagnostics(button.dataset.agentDiagnostics); return }\n if (button.dataset.agentDelete) {\n const id = button.dataset.agentDelete\n openConfirmDrawer('删除智能体', '删除后该智能体不再接受新任务,已有会话不受影响。', '删除智能体', async () => {\n state.config = await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'DELETE' })\n render()\n toast('智能体已删除')\n void loadAll(true)\n })\n return\n }\n if (button.dataset.workspaceEdit !== undefined) {\n openWorkspaceDrawer(Number(button.dataset.workspaceEdit))\n return\n }\n if (button.dataset.workspaceDelete !== undefined) {\n const index = Number(button.dataset.workspaceDelete)\n const root = (state.config.workspaceRoots || [])[index] || ''\n if (workspaceDependents(index).length) {\n toast('请先修改使用此工作区的智能体配置', true)\n return\n }\n openConfirmDrawer('删除工作区', '删除后,新会话将无法再使用 ' + root + ';目录中的文件不会被删除。', '删除工作区', async () => {\n const roots = state.config.workspaceRoots.filter((_, itemIndex) => itemIndex !== index)\n state.config = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })\n markResourceSaved('config')\n render()\n toast('工作区已删除')\n })\n return\n }\n if (button.dataset.keyAction) handleKeyAction(button.dataset.keyAction, button.dataset.keyId)\n}\n\n/* ── Auth forms ───────────────────────────────────────────────────── */\n\nbyId('setup-form').onsubmit = async (event) => {\n event.preventDefault()\n const form = event.currentTarget\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n try {\n await api('/v1/bootstrap/initialize', { method: 'POST', body: { setupToken: String(data.get('setupToken') || '').trim(), password: String(data.get('password') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n form.reset()\n showLogin()\n toast('初始化完成,请使用控制台密码登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n}\n\nbyId('login-form').onsubmit = async (event) => {\n event.preventDefault()\n const form = event.currentTarget\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n try {\n const data = new FormData(form)\n await api('/v1/admin/auth/login', { method: 'POST', body: { password: String(data.get('password') || '') } })\n form.reset()\n await enterApp()\n } catch (reason) { error.textContent = reason.message }\n })\n}\n\n/* ── Navigation ───────────────────────────────────────────────────── */\n\ndocument.querySelectorAll('.nav-item').forEach((item) => {\n item.onclick = () => {\n if (item.dataset.action === 'logout') {\n void runAction(async () => {\n await api('/v1/admin/auth/logout', { method: 'POST' })\n showLogin()\n })\n return\n }\n state.page = item.dataset.page\n writeLocationState(state)\n render()\n if (state.page === 'runs') void refreshRuns(false)\n if (state.page === 'overview') void retryResource('metrics')\n if (state.page === 'artifacts') void loadArtifacts(false)\n }\n})\n\n/* ── Global click delegation ──────────────────────────────────────── */\n\ncontent.addEventListener('click', (event) => { void handleContentClick(event) })\ncontent.addEventListener('keydown', (event) => {\n const anchor = event.target.closest('[data-key-menu]')\n if (!anchor || !['ArrowDown', 'ArrowUp'].includes(event.key)) return\n event.preventDefault()\n toggleKeyActionMenu(anchor, anchor.dataset.keyMenu)\n if (event.key === 'ArrowUp') handleKeyMenuKeydown({ key: 'End', preventDefault() {} })\n})\nkeyActionMenu.addEventListener('keydown', handleKeyMenuKeydown)\n\nkeyActionMenu.addEventListener('click', (event) => {\n const button = event.target.closest('button[data-key-action]')\n if (!button) return\n closeKeyActionMenu(true)\n void handleKeyAction(button.dataset.keyAction, button.dataset.keyId)\n})\n\n/* ── Drawer events ────────────────────────────────────────────────── */\n\ndrawerForm.onsubmit = async (event) => {\n event.preventDefault()\n const submit = getDrawerSubmit()\n if (!submit) return\n const version = getDrawerVersion()\n await withBusy(drawerFooter.querySelector('[type=\"submit\"]'), async () => {\n const error = drawerForm.querySelector('[data-form-error]')\n error.textContent = ''\n try { await submit(drawerForm) } catch (reason) {\n if (version === getDrawerVersion()) {\n error.textContent = reason.message\n error.scrollIntoView({ block: 'nearest' })\n }\n else toast(reason.message, true)\n }\n })\n}\ndrawerForm.addEventListener('click', (event) => {\n if (event.target.closest('[data-close-drawer]')) closeDrawer()\n})\ndrawerFooter.addEventListener('click', (event) => {\n if (event.target.closest('[data-close-drawer]')) closeDrawer()\n})\nbyId('drawer-close').onclick = closeDrawer\ndrawerBackdrop.onclick = closeDrawer\n\n/* ── Global listeners ─────────────────────────────────────────────── */\n\ndocument.addEventListener('click', (event) => {\n if (!event.target.closest('.key-action-menu') && !event.target.closest('[data-key-menu]')) closeKeyActionMenu()\n})\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {\n if ((localStorage.getItem('agent-nexus-theme') || 'system') === 'system') applyTheme('system')\n})\ndocument.addEventListener('keydown', (event) => {\n if (event.isComposing) return\n handleDrawerKeydown(event)\n})\nwindow.addEventListener('resize', closeKeyActionMenu)\nwindow.addEventListener('scroll', closeKeyActionMenu, true)\nwindow.addEventListener('hashchange', () => {\n Object.assign(state, readLocationState())\n if (!state.authenticated) return\n render()\n if (state.page === 'runs') void refreshRuns(true)\n if (state.page === 'artifacts') void loadArtifacts(false)\n if (state.selectedRunId) void openRunDrawer(state.selectedRunId)\n})\n\n/* ── Pollers ──────────────────────────────────────────────────────── */\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden && ['overview', 'agents'].includes(state.page)) void refreshReadiness(false)\n}, 20_000)\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden && state.page === 'runs') void refreshRuns(false)\n}, 5_000)\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden) void refreshOpenRunDrawer()\n if (state.authenticated && !document.hidden && state.page === 'overview') void retryResource('metrics')\n}, 5_000)\n\nvoid boot()\n",
13
13
  "render-agents": "import { state } from './state.js'\nimport { byId, drawerForm, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api, localizeError } from './api.js'\nimport { toast, withBusy } from './toast.js'\nimport { openDrawer, closeDrawer, getDrawerVersion } from './drawer.js'\nimport { copySecret } from './render-keys.js'\n\nimport { loadAll, refreshReadiness } from './data.js'\nimport { permissionLabel } from './format.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, setToolbar, announce, emptyState, bindSearch, preciseUnitValue } from './render-shared.js'\n\nexport function statusMarkup(agent) {\n if (!agent.enabled) return '<span class=\"status\">已禁用</span>'\n if (agent.checking) return '<span class=\"status checking\">检查中</span>'\n return agent.ready ? '<span class=\"status ready\" title=\"' + (agent.protocol === 'acp' ? '命令探测通过;创建会话时才进行 ACP 握手' : 'Agent Card 探测通过;实际任务可能仍失败') + '\">' + (agent.protocol === 'acp' ? '命令可用' : 'Card 可用') + '</span>' : '<span class=\"status failed\">不可用</span>'\n}\n\nexport function readinessById() {\n return new Map(state.readiness.map((agent) => [agent.id, agent]))\n}\n\nexport function agentReadiness(agent, lookup) {\n return { ...(lookup.get(agent.id) || { ready: false, checking: true }),\n id: agent.id,\n name: agent.name,\n protocol: agent.protocol,\n driver: agent.driver,\n enabled: agent.enabled\n }\n}\n\nexport function readinessRows() {\n const lookup = readinessById()\n return state.config.agents.map((agent) => agentReadiness(agent, lookup))\n}\n\nexport function agentRow(agent, live) {\n const statusAgent = { ...(live || { ready: false, checking: true }), enabled: agent.enabled }\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(agent.name) + '</strong><small>' + escapeHtml(agent.id) + '</small>' +\n (statusAgent.error && agent.enabled ? '<span class=\"error-detail\">' + escapeHtml(localizeError(statusAgent.error)) + '</span>' : '') +\n '</div></td><td><span class=\"badge\">' + escapeHtml(agent.protocol.toUpperCase()) + '</span></td><td>' + escapeHtml(agent.driver || '—') + '</td><td>' + statusMarkup(statusAgent) + '</td><td>' + escapeHtml(agent.workspace || '—') + '</td><td>' + escapeHtml(permissionLabel(agent.permissionPolicy)) + '</td><td><div class=\"row-actions\"><button class=\"button small\" data-agent-edit=\"' + escapeHtml(agent.id) + '\">编辑</button><button class=\"button small\" data-agent-diagnostics=\"' + escapeHtml(agent.id) + '\"' + (agent.enabled ? '' : ' disabled') + '>诊断</button><button class=\"button small danger\" data-agent-delete=\"' + escapeHtml(agent.id) + '\">删除</button></div></td></tr>'\n}\n\nexport function renderAgents() {\n setActions('<button id=\"stdio-guide\" class=\"button\">接入自定义 ACP</button><button id=\"refresh-agents\" class=\"button\">' + icons.refresh + '刷新</button><button id=\"add-agent\" class=\"button primary\">' + icons.plus + '添加智能体</button>')\n byId('stdio-guide').onclick = openStdioGuide\n byId('refresh-agents').onclick = (event) => withBusy(event.currentTarget, () => refreshReadiness(true))\n byId('add-agent').onclick = () => openAgentDrawer()\n byId('add-agent').disabled = !state.resources.config.lastSuccessAt\n\n if (!state.config.agents.length && ['idle', 'loading'].includes(state.resources.config.status)) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载智能体配置…</p></div>')\n announce('正在加载智能体配置')\n return\n }\n if (!state.resources.config.lastSuccessAt) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">智能体配置读取失败,请重试后编辑。</p></div>')\n return\n }\n if (!state.config.agents.length) {\n setToolbar('')\n setResults('<div class=\"panel\">' +\n emptyState(icons.robot, '尚未配置智能体', '智能体是网关转发任务的执行端,支持本地 ACP 进程与远程 A2A 服务。', { action: 'add-agent', label: '添加智能体' }) +\n '</div>')\n announce('尚未配置智能体')\n return\n }\n\n const lookup = readinessById()\n const rows = state.config.agents.filter((agent) => {\n const live = agentReadiness(agent, lookup)\n const haystack = (agent.name + ' ' + agent.id + ' ' + (agent.description || '')).toLowerCase()\n const status = !agent.enabled ? 'disabled' : !lookup.has(agent.id) ? 'checking' : live.ready ? 'ready' : 'failed'\n return haystack.indexOf(state.search.toLowerCase()) >= 0 &&\n (state.protocol === 'all' || agent.protocol === state.protocol) &&\n (state.status === 'all' || status === state.status)\n })\n setToolbar('agents',\n '<div class=\"toolbar\"><div class=\"search-box\">' + icons.search + '<input id=\"agent-search\" type=\"search\" aria-label=\"搜索智能体\" placeholder=\"搜索智能体\" value=\"' + escapeHtml(state.search) + '\"></div>' +\n '<select id=\"protocol-filter\" aria-label=\"按协议筛选\"><option value=\"all\">全部协议</option><option value=\"acp\"' + selected(state.protocol, 'acp') + '>ACP</option><option value=\"a2a\"' + selected(state.protocol, 'a2a') + '>A2A</option></select>' +\n '<select id=\"status-filter\" aria-label=\"按就绪状态筛选\"><option value=\"all\">全部状态</option><option value=\"ready\"' + selected(state.status, 'ready') + '>就绪</option><option value=\"failed\"' + selected(state.status, 'failed') + '>不可用</option><option value=\"disabled\"' + selected(state.status, 'disabled') + '>已禁用</option></select></div>')\n setResults('<div class=\"table-wrap\"><table class=\"agent-table\"><thead><tr><th>智能体</th><th>协议</th><th>驱动</th><th>状态</th><th>工作区</th><th>权限策略</th><th>操作</th></tr></thead><tbody>' +\n (rows.length ? rows.map((agent) => agentRow(agent, lookup.get(agent.id))).join('') : '<tr><td colspan=\"7\" class=\"empty\">没有符合条件的智能体。</td></tr>') +\n '</tbody></table></div>')\n bindSearch('agent-search', (value) => { state.search = value; renderAgents() })\n byId('protocol-filter').onchange = (event) => { state.protocol = event.target.value; renderAgents() }\n byId('status-filter').onchange = (event) => { state.status = event.target.value; renderAgents() }\n announce(rows.length + ' 个智能体,' + rows.filter((agent) => agentReadiness(agent, lookup).ready).length + ' 个就绪')\n}\n\nexport function openAgentDrawer(agent) {\n if (!state.resources.config.lastSuccessAt) { toast('配置尚未成功读取,请重试后编辑', true); return }\n const editing = Boolean(agent)\n const current = agent || { protocol: 'acp', enabled: true, permissionPolicy: 'ask', permissionTimeoutMs: 900000, timeoutMs: 60000 }\n const permissionTimeoutSeconds = preciseUnitValue(current.permissionTimeoutMs, 1000, 900000)\n const requestTimeoutSeconds = preciseUnitValue(current.timeoutMs, 1000, 60000)\n const inheritedTaskTimeoutMs = Number(state.config.promptTimeoutMs || 30 * 60 * 1000)\n const inheritedIdleTimeoutMs = Number(current.timeoutMs || 60 * 1000)\n const taskTimeoutSeconds = current.taskTimeoutMs === undefined ? '' : preciseUnitValue(current.taskTimeoutMs, 1000, 30 * 60)\n const streamIdleTimeoutSeconds = current.streamIdleTimeoutMs === undefined ? '' : preciseUnitValue(current.streamIdleTimeoutMs, 1000, 60)\n const taskTimeoutInherited = current.taskTimeoutMs === undefined\n const streamIdleTimeoutInherited = current.streamIdleTimeoutMs === undefined\n const configuredDriver = current.driver || state.config.driverKinds[0]\n const driverKinds = [...new Set([...(state.config.driverKinds || []), ...(editing && current.driver ? [current.driver] : [])])]\n const visibleDriverKinds = editing && current.driver === 'stdio' ? driverKinds : driverKinds.filter((driver) => driver !== 'stdio')\n const drivers = visibleDriverKinds.map((driver) => '<option value=\"' + escapeHtml(driver) + '\"' + selected(configuredDriver, driver) + '>' + escapeHtml(driver) + '</option>').join('')\n const roots = state.config.workspaceRoots.map((root) => '<option value=\"' + escapeHtml(root) + '\"></option>').join('')\n const stdioNotice = '<div data-driver-section=\"stdio\" class=\"field' + (configuredDriver === 'stdio' ? '' : ' hidden') + '\"><small class=\"field-help\">stdio 的 command、参数与环境变量由本地配置提供;控制台仅支持编辑已有项的通用设置。</small></div>'\n const body =\n '<div class=\"field\"><label for=\"f-id\">智能体 ID</label><input id=\"f-id\" name=\"id\" value=\"' + escapeHtml(current.id || '') + '\" pattern=\"[a-z0-9][a-z0-9._\\\\-]{0,63}\" required' + (editing ? ' disabled' : '') + '></div>' +\n '<div class=\"field-row\"><div class=\"field\"><label for=\"f-protocol\">协议</label><select id=\"f-protocol\" name=\"protocol\"><option value=\"acp\"' + selected(current.protocol, 'acp') + '>ACP</option><option value=\"a2a\"' + selected(current.protocol, 'a2a') + '>A2A</option></select></div><div class=\"field\"><span class=\"field-spacer\" aria-hidden=\"true\">&nbsp;</span><label class=\"checkbox\"><input name=\"enabled\" type=\"checkbox\"' + checked(current.enabled) + '>启用</label></div></div>' +\n '<div class=\"field\"><label for=\"f-name\">名称</label><input id=\"f-name\" name=\"name\" value=\"' + escapeHtml(current.name || '') + '\" required></div>' +\n '<div class=\"field\"><label for=\"f-description\">描述</label><textarea id=\"f-description\" name=\"description\">' + escapeHtml(current.description || '') + '</textarea></div>' +\n '<div data-protocol-section=\"acp\"><div class=\"field\"><label for=\"f-driver\">驱动</label><select id=\"f-driver\" name=\"driver\">' + (drivers || '<option value=\"\" disabled selected>请先配置可用驱动</option>') + '</select></div>' + stdioNotice + '<div class=\"field\"><label for=\"f-workspace\">工作区</label><input id=\"f-workspace\" name=\"workspace\" list=\"workspace-roots\" value=\"' + escapeHtml(current.workspace || state.config.workspaceRoots[0] || '') + '\" required><datalist id=\"workspace-roots\">' + roots + '</datalist></div><div class=\"field-row\"><div class=\"field\"><label for=\"f-permissionPolicy\">权限策略</label><select id=\"f-permissionPolicy\" name=\"permissionPolicy\"><option value=\"ask\"' + selected(current.permissionPolicy, 'ask') + '>询问</option><option value=\"allow\"' + selected(current.permissionPolicy, 'allow') + '>自动允许单次</option><option value=\"deny\"' + selected(current.permissionPolicy, 'deny') + '>拒绝</option></select></div><div class=\"field\"><label for=\"f-permissionTimeoutMs\">权限确认超时(秒)</label><input id=\"f-permissionTimeoutMs\" name=\"permissionTimeoutMs\" type=\"number\" min=\"1\" max=\"86400\" step=\"0.001\" value=\"' + escapeHtml(permissionTimeoutSeconds) + '\"><small class=\"field-help\">默认 900 秒,最长 24 小时。自动策略只选 allow_once;只有永久选项时拒绝,请改为询问并显式授权。</small></div></div></div>' +\n '<div data-protocol-section=\"a2a\"><div class=\"field\"><label for=\"f-agentCardUrl\">Agent Card URL</label><input id=\"f-agentCardUrl\" name=\"agentCardUrl\" type=\"url\" value=\"' + escapeHtml(current.agentCardUrl || '') + '\" placeholder=\"http://agent.local:8080/.well-known/agent-card.json\" required><small class=\"field-help\">调用地址与能力从 Card 自动发现。</small></div><div class=\"field-row\"><div class=\"field\"><label for=\"f-preferredTransport\">首选传输</label><select id=\"f-preferredTransport\" name=\"preferredTransport\"><option value=\"auto\"' + selected(current.preferredTransport || 'auto', 'auto') + '>自动(按 Card)</option><option value=\"jsonrpc\"' + selected(current.preferredTransport, 'jsonrpc') + '>JSON-RPC</option><option value=\"http-json\"' + selected(current.preferredTransport, 'http-json') + '>HTTP+JSON</option></select></div><div class=\"field\"><label for=\"f-authType\">认证方式</label><select id=\"f-authType\" name=\"authType\"><option value=\"none\"' + selected(current.auth && current.auth.type || 'none', 'none') + '>无认证</option><option value=\"bearer\"' + selected(current.auth && current.auth.type, 'bearer') + '>Bearer Token</option><option value=\"header\"' + selected(current.auth && current.auth.type, 'header') + '>自定义请求头</option></select></div></div><div class=\"field\" data-auth-header><label for=\"f-authHeaderName\">请求头名称</label><input id=\"f-authHeaderName\" name=\"authHeaderName\" value=\"' + escapeHtml(current.auth && current.auth.headerName || '') + '\" placeholder=\"X-API-Key\"></div><div class=\"field\" data-auth-value><label data-auth-value-label for=\"f-authValue\">认证凭据</label><input id=\"f-authValue\" name=\"authValue\" type=\"password\" autocomplete=\"off\" placeholder=\"' + (editing && current.auth && current.auth.configured ? '留空以保留当前凭据' : '') + '\"></div><div class=\"field\"><label for=\"f-timeoutMs\">普通请求/建连超时(秒)</label><input id=\"f-timeoutMs\" name=\"timeoutMs\" type=\"number\" min=\"1\" max=\"1800\" step=\"any\" value=\"' + escapeHtml(requestTimeoutSeconds) + '\"><small class=\"field-help\">只限制单次请求和建连,不限制整轮任务。</small></div><div class=\"field\"><label for=\"f-taskTimeoutMs\">单轮任务期限(秒)</label><input id=\"f-taskTimeoutMs\" name=\"taskTimeoutMs\" type=\"number\" min=\"10\" max=\"86400\" step=\"any\" value=\"' + escapeHtml(taskTimeoutSeconds) + '\" placeholder=\"继承全局 ACP 任务超时(' + escapeHtml(preciseUnitValue(inheritedTaskTimeoutMs, 1000, 1800)) + ' 秒)\" data-inherited=\"' + (taskTimeoutInherited ? 'true' : 'false') + '\"><small class=\"field-help\">留空表示继承全局 ACP 任务超时(当前 ' + escapeHtml(preciseUnitValue(inheritedTaskTimeoutMs, 1000, 1800)) + ' 秒)。</small></div><div class=\"field\"><label for=\"f-streamIdleTimeoutMs\">流无进度超时(秒)</label><input id=\"f-streamIdleTimeoutMs\" name=\"streamIdleTimeoutMs\" type=\"number\" min=\"1\" max=\"1800\" step=\"any\" value=\"' + escapeHtml(streamIdleTimeoutSeconds) + '\" placeholder=\"继承普通请求/建连超时(' + escapeHtml(preciseUnitValue(inheritedIdleTimeoutMs, 1000, 60)) + ' 秒)\" data-inherited=\"' + (streamIdleTimeoutInherited ? 'true' : 'false') + '\"><small class=\"field-help\">留空表示继承普通请求/建连超时,收到进度会重新计时。</small></div></div>'\n openDrawer(editing ? '编辑智能体' : '添加智能体', body, editing ? '保存修改' : '添加智能体', async (form, isCurrent) => {\n const data = new FormData(form)\n const protocol = data.get('protocol')\n const id = editing ? current.id : String(data.get('id') || '').trim().toLowerCase()\n const payload = {\n protocol,\n name: String(data.get('name') || '').trim(),\n description: String(data.get('description') || '').trim(),\n enabled: data.get('enabled') === 'on'\n }\n if (protocol === 'acp') {\n payload.driver = data.get('driver')\n payload.workspace = String(data.get('workspace') || '').trim()\n payload.permissionPolicy = data.get('permissionPolicy')\n payload.permissionTimeoutMs = Math.round(Number(data.get('permissionTimeoutMs')) * 1000)\n } else {\n payload.agentCardUrl = String(data.get('agentCardUrl') || '').trim()\n payload.preferredTransport = data.get('preferredTransport')\n payload.authType = data.get('authType')\n payload.authHeaderName = String(data.get('authHeaderName') || '').trim()\n const authValue = String(data.get('authValue') || '')\n if (authValue) payload.authValue = authValue\n payload.timeoutMs = Math.round(Number(data.get('timeoutMs')) * 1000)\n const taskTimeoutInput = drawerFormElements().taskTimeoutMs\n const taskTimeoutValue = String(data.get('taskTimeoutMs') || '').trim()\n payload.taskTimeoutMs = taskTimeoutValue ? Math.round(Number(taskTimeoutValue) * 1000) : null\n if (!taskTimeoutValue && taskTimeoutInput.dataset.inherited === 'true' && current.taskTimeoutMs === undefined) delete payload.taskTimeoutMs\n const streamIdleInput = drawerFormElements().streamIdleTimeoutMs\n const streamIdleValue = String(data.get('streamIdleTimeoutMs') || '').trim()\n payload.streamIdleTimeoutMs = streamIdleValue ? Math.round(Number(streamIdleValue) * 1000) : null\n if (!streamIdleValue && streamIdleInput.dataset.inherited === 'true' && current.streamIdleTimeoutMs === undefined) delete payload.streamIdleTimeoutMs\n }\n await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'PUT', body: payload })\n if (isCurrent()) closeDrawer()\n render()\n toast(editing ? '智能体已更新' : '智能体已添加')\n void loadAll(true)\n })\n const protocolSelect = drawerFormElements().protocol\n const authSelect = drawerFormElements().authType\n const sync = () => {\n document.querySelectorAll('#drawer-form [data-protocol-section]').forEach((section) => {\n section.classList.toggle('hidden', section.dataset.protocolSection !== protocolSelect.value)\n section.querySelectorAll('input,select,textarea').forEach((field) => { field.disabled = section.classList.contains('hidden') })\n })\n const driver = drawerFormElements().driver?.value\n document.querySelectorAll('#drawer-form [data-driver-section]').forEach((section) => {\n const hidden = protocolSelect.value !== 'acp' || section.dataset.driverSection !== driver\n section.classList.toggle('hidden', hidden)\n section.querySelectorAll('input,textarea').forEach((field) => { field.disabled = hidden })\n })\n if (protocolSelect.value === 'a2a') {\n const auth = authSelect.value\n document.querySelector('#drawer-form [data-auth-header]').classList.toggle('hidden', auth !== 'header')\n document.querySelector('#drawer-form [data-auth-value]').classList.toggle('hidden', auth === 'none')\n document.querySelector('#drawer-form [data-auth-value-label]').textContent = auth === 'bearer' ? 'Bearer Token' : '认证凭据'\n }\n }\n protocolSelect.onchange = sync\n authSelect.onchange = sync\n byId('f-driver').onchange = sync\n byId('f-taskTimeoutMs').oninput = (event) => { event.currentTarget.dataset.inherited = 'false' }\n byId('f-streamIdleTimeoutMs').oninput = (event) => { event.currentTarget.dataset.inherited = 'false' }\n sync()\n}\n\nexport function drawerFormElements() {\n return byId('drawer-form').elements\n}\n\nexport async function openAgentDiagnostics(id) {\n openDrawer('智能体连接诊断', '<p role=\"status\">正在启动智能体并验证连接,完成后释放诊断会话;不会发送任务。</p>', '', null)\n const version = getDrawerVersion()\n try {\n const result = await api('/v1/admin/agents/' + encodeURIComponent(id) + '/diagnostics', { method: 'POST', body: {} })\n if (version !== getDrawerVersion()) return\n drawerForm.innerHTML = '<p>' + escapeHtml(result.ok ? '连接诊断通过' : '连接诊断未通过') + '</p><div class=\"overview-list\">' +\n (result.stages || []).map((stage) => '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(({ validate: '配置检查', start: '启动连接', initialize: '协议握手', createSession: '建立会话', connect: '连接与会话建立', handshake: '连接与会话建立', close: '清理会话' })[stage.name || stage.stage] || stage.name || stage.stage || '检查') + '</strong><small>' + escapeHtml(stage.message || stage.error || '') + '</small></div><span>' + escapeHtml(({ passed: '通过', failed: '失败', skipped: '跳过' })[stage.status] || stage.status || '') + '</span></div>').join('') +\n '</div><p class=\"field-help\">' + escapeHtml(result.message || '') + '</p>'\n } catch (error) {\n if (version === getDrawerVersion()) drawerForm.innerHTML = '<p class=\"form-error\" role=\"alert\">' + escapeHtml(error.message) + '</p>'\n }\n}\n\nexport function openStdioGuide() {\n const sample = JSON.stringify({ custom: { protocol: 'acp', driver: 'stdio', name: '自定义 ACP', command: '/absolute/path/to/your-agent', args: ['--acp'], probeArgs: ['--version'], workspace: state.config.workspaceRoots[0] || '/path/to/workspace', permissionPolicy: 'ask' } }, null, 2)\n openDrawer('接入自定义 ACP', '<p class=\"field-help\">将下方智能体项合并到本地配置的 agents 中,并按实际命令调整参数。重启网关后可在列表中执行连接诊断。command、args、probeArgs、inheritEnv 和 env 由本地配置管理。</p><pre class=\"run-detail-code\">' + escapeHtml(sample) + '</pre><button class=\"button\" type=\"button\" id=\"copy-stdio-config\">复制配置示例</button>', '', null)\n byId('copy-stdio-config').onclick = async () => {\n try { await copySecret(sample); toast('配置示例已复制') } catch (error) { toast(error.message, true) }\n }\n}\n",
14
- "render-artifacts": "import { state } from './state.js'\nimport { byId, drawerForm, escapeHtml, selected } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { withBusy, toast } from './toast.js'\nimport { openDrawer, openConfirmDrawer } from './drawer.js'\n\nimport { formatDate, formatSize, kindLabel, shortId } from './format.js'\n\nimport { setResults, setActions, setToolbar, announce, emptyState } from './render-shared.js'\n\nconst KINDS = ['image', 'video', 'audio', 'file']\n\nfunction artifactUrl(row) {\n return '/v1/admin/runs/' + encodeURIComponent(row.runId) + '/artifacts/' + encodeURIComponent(row.id)\n}\n\nexport function findArtifact(artifactId, runId) {\n return state.artifacts.find((row) => row.id === artifactId && row.runId === runId)\n}\n\nfunction previewCell(row) {\n if (row.kind === 'image' && row.downloadable) {\n return '<button type=\"button\" class=\"artifact-thumb-button\" aria-label=\"查看产物详情\" data-artifact-detail=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\"><img class=\"artifact-thumb\" loading=\"lazy\" src=\"' + artifactUrl(row) + '\" alt=\"\"></button>'\n }\n const icon = row.kind === 'video' ? icons.video : row.kind === 'audio' ? icons.audio : icons.file\n return '<button type=\"button\" class=\"artifact-tile\" aria-label=\"查看产物详情\" data-artifact-detail=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">' + icon + '</button>'\n}\n\nfunction artifactRow(row) {\n const title = row.name || row.filename || row.id\n return '<tr><td>' + previewCell(row) + '</td><td><div class=\"agent-name\"><strong>' + escapeHtml(title) + '</strong>' +\n (row.filename && row.filename !== title ? '<small>' + escapeHtml(row.filename) + '</small>' : '') +\n (row.mediaType ? '<small>' + escapeHtml(row.mediaType) + '</small>' : '') + '</div></td>' +\n '<td><div class=\"agent-name\"><strong>' + escapeHtml(row.agentName) + '</strong><small>' + escapeHtml(shortId(row.runId)) + '</small></div></td>' +\n '<td class=\"num\">' + escapeHtml(formatSize(row.size)) + '</td>' +\n '<td class=\"num\">' + escapeHtml(formatDate(row.createdAt || row.runStartedAt)) + '</td>' +\n '<td><div class=\"row-actions\">' +\n (row.downloadable ? '<a class=\"button small\" download href=\"' + artifactUrl(row) + '\">下载</a>' : '<span class=\"muted\">' + escapeHtml(row.storageStatus === 'metadata_only' ? '仅元数据' : '不可下载') + '</span>') +\n '<button type=\"button\" class=\"button small danger\" data-artifact-delete=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">删除</button>' +\n '</div></td></tr>'\n}\n\nfunction artifactsPath() {\n const query = new URLSearchParams({ limit: '50' })\n if (state.artifactOffset) query.set('offset', String(state.artifactOffset))\n if (state.artifactAgent !== 'all') query.set('agentId', state.artifactAgent)\n if (state.artifactKind !== 'all') query.set('kind', state.artifactKind)\n return '/v1/admin/artifacts?' + query\n}\n\nlet loadSeq = 0\n\nexport async function loadArtifacts(showError) {\n const path = artifactsPath()\n const seq = ++loadSeq\n try {\n const value = /** @type {import('./contracts.js').ArtifactListPayload} */ (await api(path))\n if (seq !== loadSeq) return\n state.artifacts = value.artifacts || []\n state.artifactTotal = value.total || 0\n renderArtifacts()\n } catch (error) {\n if (seq !== loadSeq) return\n if (showError) toast(error.message, true)\n }\n}\n\nexport function resetArtifactView() { loadSeq++ }\n\nfunction renderToolbar() {\n const agents = state.config.agents.map((agent) => '<option value=\"' + escapeHtml(agent.id) + '\"' + selected(state.artifactAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')\n const kinds = KINDS.map((kind) => '<option value=\"' + kind + '\"' + selected(state.artifactKind, kind) + '>' + escapeHtml(kindLabel(kind)) + '</option>').join('')\n setToolbar('artifacts|' + state.artifactAgent + '|' + state.artifactKind,\n '<div class=\"toolbar\"><select id=\"artifact-agent-filter\" aria-label=\"按智能体筛选\"><option value=\"all\">全部智能体</option>' + agents + '</select>' +\n '<select id=\"artifact-kind-filter\" aria-label=\"按类型筛选\"><option value=\"all\">全部类型</option>' + kinds + '</select>' +\n '<button class=\"button small\" id=\"clear-artifact-filters\"' + ((state.artifactAgent !== 'all' || state.artifactKind !== 'all') ? '' : ' hidden') + '>清除筛选</button></div>')\n byId('artifact-agent-filter').onchange = (event) => { state.artifactAgent = event.target.value; state.artifactOffset = 0; void loadArtifacts(false) }\n byId('artifact-kind-filter').onchange = (event) => { state.artifactKind = event.target.value; state.artifactOffset = 0; void loadArtifacts(false) }\n byId('clear-artifact-filters').onclick = () => { state.artifactAgent = 'all'; state.artifactKind = 'all'; state.artifactOffset = 0; void loadArtifacts(false) }\n}\n\nexport function renderArtifacts() {\n setActions('<button id=\"refresh-artifacts\" class=\"button\">' + icons.refresh + '刷新</button>')\n byId('refresh-artifacts').onclick = (event) => withBusy(event.currentTarget, () => loadArtifacts(true))\n if (!state.config.agents.length && ['idle', 'loading'].includes(state.resources.config.status)) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载网关配置…</p></div>')\n return\n }\n renderToolbar()\n const total = state.artifactTotal\n const offset = state.artifactOffset\n if (!total) {\n setResults('<div class=\"panel\">' + emptyState(icons.artifacts, '暂无产物', '智能体任务产出的文件、图片、视频会保存在这里,可下载或删除。') + '</div>')\n announce('暂无产物')\n return\n }\n const first = offset + 1\n const last = Math.min(offset + 50, total)\n setResults('<div class=\"run-pagination\"><span class=\"muted\">显示 ' + first + '–' + last + ',共 ' + total + ' 个产物</span><div class=\"row-actions\"><button class=\"button small\" id=\"artifacts-prev\"' + (offset ? '' : ' disabled') + '>上一页</button><button class=\"button small\" id=\"artifacts-next\"' + (offset + 50 < total ? '' : ' disabled') + '>下一页</button></div></div>' +\n '<div class=\"table-wrap\"><table class=\"artifact-table\"><thead><tr><th>预览</th><th>名称</th><th>来源</th><th class=\"num\">大小</th><th class=\"num\">保存时间</th><th>操作</th></tr></thead><tbody>' +\n (state.artifacts.length ? state.artifacts.map(artifactRow).join('') : '<tr><td colspan=\"6\" class=\"empty\">没有符合条件的产物。</td></tr>') +\n '</tbody></table></div>')\n byId('artifacts-prev').onclick = (event) => withBusy(event.currentTarget, async () => { state.artifactOffset = Math.max(0, state.artifactOffset - 50); await loadArtifacts(true) })\n byId('artifacts-next').onclick = (event) => withBusy(event.currentTarget, async () => { state.artifactOffset += 50; await loadArtifacts(true) })\n announce(total + ' 个产物')\n}\n\nfunction previewMarkup(row) {\n const url = artifactUrl(row)\n if (row.kind === 'image' && row.downloadable) return '<img class=\"artifact-preview\" src=\"' + url + '\" alt=\"' + escapeHtml(row.name || row.filename || '产物') + '\">'\n if (row.kind === 'video' && row.downloadable) return '<video class=\"artifact-preview\" controls preload=\"metadata\" src=\"' + url + '\"></video>'\n if (row.kind === 'audio' && row.downloadable) return '<audio controls preload=\"metadata\" src=\"' + url + '\"></audio>'\n return '<p class=\"field-help\">' + (row.downloadable ? '此类型不支持在线预览,可下载后查看。' : '该产物没有可下载的文件内容(' + escapeHtml(row.storageStatus || 'metadata_only') + ')。') + '</p>'\n}\n\nexport function openArtifactDetail(artifactId, runId) {\n const row = findArtifact(artifactId, runId)\n if (!row) { toast('产物不存在或已被删除', true); return }\n const title = row.name || row.filename || row.id\n const body =\n '<div class=\"artifact-preview-wrap\">' + previewMarkup(row) + '</div>' +\n '<div class=\"run-detail-grid\">' +\n [['name', '名称'], ['mediaType', '类型'], ['size', '大小'], ['createdAt', '保存时间'], ['agent', '来源智能体'], ['run', '运行 ID']]\n .map(([key, label]) => {\n const value = { name: title, mediaType: row.mediaType || '—', size: formatSize(row.size), createdAt: formatDate(row.createdAt || row.runStartedAt), agent: row.agentName, run: shortId(row.runId) }[key]\n return '<div class=\"run-detail-item\"><span>' + label + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n }).join('') +\n '</div>' +\n '<div class=\"run-detail-actions\">' +\n (row.downloadable ? '<a class=\"button small\" download href=\"' + artifactUrl(row) + '\">下载</a>' : '') +\n '<button type=\"button\" class=\"button small danger\" data-artifact-delete=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">删除产物</button>' +\n '</div>'\n openDrawer('产物详情', body, '', null)\n const remove = drawerForm.querySelector('[data-artifact-delete]')\n if (remove) remove.onclick = () => confirmArtifactDelete(remove.dataset.artifactDelete, remove.dataset.artifactRun)\n}\n\nexport function confirmArtifactDelete(artifactId, runId) {\n const row = findArtifact(artifactId, runId)\n const title = row ? (row.name || row.filename || row.id) : artifactId\n openConfirmDrawer('删除产物', '删除「' + title + '」的文件与记录,释放存储空间。运行记录本身保留。此操作无法撤销。', '删除产物', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(runId) + '/artifacts/' + encodeURIComponent(artifactId), { method: 'DELETE', body: {} })\n toast('产物已删除')\n if (state.page === 'artifacts') void loadArtifacts(false)\n })\n}\n",
15
- "render-keys": "import { state } from './state.js'\nimport { byId, keyActionMenu, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { openDrawer, closeDrawer } from './drawer.js'\nimport { showLogin } from './screens.js'\n\nimport { markResourceSaved } from './data.js'\nimport { formatDate } from './format.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, announce, emptyState } from './render-shared.js'\n\nlet menuAnchor = null\n\nexport function keyRow(key) {\n const scope = key.scope.allAgents ? '全部智能体' : (key.scope.agentIds.length ? key.scope.agentIds.join(', ') : '未授权智能体')\n const id = escapeHtml(key.id)\n const toggleLabel = key.enabled ? '禁用密钥' : '启用密钥'\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(key.name) + '</strong>' + (key.legacy ? '<span class=\"badge\">旧版</span>' : '') + '</div></td>' +\n '<td>' + (key.enabled ? '<span class=\"status ready\">已启用</span>' : '<span class=\"status\">已禁用</span>') + '</td>' +\n '<td data-label=\"API 密钥\"><div class=\"key-secret-cell\"><code>••••' + escapeHtml(key.suffix) + '</code><button class=\"key-copy-button\" data-key-action=\"copy\" data-key-id=\"' + id + '\" aria-label=\"复制完整密钥\" title=\"复制完整密钥\">' + icons.copy + '</button></div></td>' +\n '<td data-label=\"授权范围\"><span class=\"key-scope\">' + escapeHtml(scope) + '</span></td>' +\n '<td data-label=\"最后使用\"><span class=\"key-last-used\">' + escapeHtml(key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用') + '</span></td>' +\n '<td><div class=\"key-actions\"><button class=\"key-icon-button' + (key.enabled ? ' danger-action' : '') + '\" data-key-action=\"toggle\" data-key-id=\"' + id + '\" aria-label=\"' + toggleLabel + '\" title=\"' + toggleLabel + '\">' + (key.enabled ? icons.disable : icons.enable) + '</button><button class=\"key-icon-button\" data-key-action=\"rename\" data-key-id=\"' + id + '\" aria-label=\"重命名\" title=\"重命名\">' + icons.edit + '</button><button class=\"key-icon-button\" data-key-menu=\"' + id + '\" aria-label=\"更多操作\" title=\"更多操作\" aria-expanded=\"false\" aria-haspopup=\"menu\" aria-controls=\"key-action-menu\">' + icons.more + '</button></div></td></tr>'\n}\n\nexport function renderApiKeys() {\n closeKeyActionMenu()\n setActions('<button id=\"create-key\" class=\"button primary\">' + icons.plus + '创建 API 密钥</button>')\n const pending = state.resources.apiKeys.status === 'loading' && !state.apiKeys.length\n setResults(pending ? '<div class=\"panel\"><p class=\"panel-note\">正在加载 API 密钥…</p></div>' : state.apiKeys.length\n ? '<div class=\"table-wrap key-table-wrap\"><table class=\"key-table\"><thead><tr><th>名称</th><th>状态</th><th>API 密钥</th><th>授权范围</th><th>最后使用</th><th>操作</th></tr></thead><tbody>' + state.apiKeys.map(keyRow).join('') + '</tbody></table></div>'\n : '<div class=\"panel\">' + emptyState(icons.key, '尚未创建 API 密钥', '客户端调用网关数据接口时需要携带密钥。', { action: 'create-key', label: '创建 API 密钥' }) + '</div>')\n byId('create-key').onclick = () => openKeyDrawer()\n byId('create-key').disabled = !state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt\n announce(state.apiKeys.length + ' 个 API 密钥')\n}\n\nexport function agentScopeOption(agent, selected = false) {\n return '<label class=\"checkbox\"><input type=\"checkbox\" name=\"agentId\" value=\"' + escapeHtml(agent.id) + '\"' + checked(selected) + '><span class=\"scope-name\">' + escapeHtml(agent.name) + '</span><span class=\"muted\">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>'\n}\n\nexport function openKeyDrawer() {\n if (!state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt) { toast('配置与密钥列表尚未成功读取,请重试后创建', true); return }\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent)).join('')\n const body = '<div class=\"field\"><label for=\"f-name\">名称</label><input id=\"f-name\" name=\"name\" required autocomplete=\"off\" placeholder=\"例如:开发客户端\"></div><div class=\"field\"><label class=\"checkbox\"><input id=\"all-agents\" name=\"allAgents\" type=\"checkbox\" checked>允许访问全部智能体</label></div><div id=\"agent-scope\" class=\"agent-scope hidden\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div><div class=\"field\"><label for=\"f-customSecret\">自定义密钥(可选)</label><input id=\"f-customSecret\" name=\"customSecret\" minlength=\"16\" autocomplete=\"off\" placeholder=\"留空则自动生成 nx_sk_ 密钥\"></div>'\n openDrawer('创建 API 密钥', body, '创建 API 密钥', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const customSecret = String(data.get('customSecret') || '').trim()\n const result = await api('/v1/admin/api-keys', {\n method: 'POST',\n body: {\n name: String(data.get('name') || '').trim(),\n scope: { allAgents, agentIds },\n customSecret: customSecret || undefined\n }\n })\n state.apiKeys.unshift(result.key)\n markResourceSaved('apiKeys')\n if (isCurrent()) showSecret('API 密钥已创建', result.secret)\n else toast('API 密钥已创建,可在密钥列表中显示或复制')\n render()\n })\n const all = byId('all-agents')\n all.onchange = () => byId('agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function showSecret(title, secret) {\n openDrawer(title, '<div class=\"secret-box\"><span class=\"muted\">请立即复制;之后可在密钥列表中再次显示。</span><code class=\"secret-value\">' + escapeHtml(secret) + '</code><button class=\"button\" type=\"button\" id=\"copy-secret\">' + icons.copy + '复制</button></div>', '', null)\n byId('copy-secret').onclick = async () => {\n try {\n await copySecret(secret)\n toast('API 密钥已复制')\n } catch (error) { toast(error.message, true) }\n }\n}\n\nexport async function copySecret(secret) {\n if (navigator.clipboard && window.isSecureContext) {\n try {\n await navigator.clipboard.writeText(secret)\n return\n } catch {}\n }\n const temporary = document.createElement('textarea')\n temporary.value = secret\n temporary.setAttribute('readonly', '')\n temporary.style.position = 'fixed'\n temporary.style.opacity = '0'\n temporary.style.pointerEvents = 'none'\n const previousFocus = document.activeElement\n const dialog = byId('drawer')\n ;(dialog.classList.contains('hidden') ? document.body : dialog).appendChild(temporary)\n temporary.select()\n const copied = document.execCommand('copy')\n temporary.remove()\n previousFocus?.focus({ preventScroll: true })\n if (!copied) throw new Error('无法自动复制,请在“显示完整密钥”中手动复制。')\n}\n\nexport function openPasswordDrawer() {\n openDrawer('修改密码', '<div class=\"field\"><label for=\"f-currentPassword\">当前密码</label><input id=\"f-currentPassword\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\" required></div><div class=\"field\"><label for=\"f-newPassword\">新密码</label><input id=\"f-newPassword\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div><div class=\"field\"><label for=\"f-confirmPassword\">确认新密码</label><input id=\"f-confirmPassword\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div>', '修改密码', async (form) => {\n const data = new FormData(form)\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword: String(data.get('currentPassword') || ''), newPassword: String(data.get('newPassword') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n closeDrawer()\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n })\n}\n\nexport function openKeyScopeDrawer(key) {\n const selectedIds = new Set(key.scope.agentIds || [])\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent, selectedIds.has(agent.id))).join('')\n const body = '<p class=\"field-help\">缩小授权范围会立即拒绝受影响的新请求并关闭对应 SSE;正在执行的任务会继续。</p><div class=\"field\"><label class=\"checkbox\"><input id=\"edit-all-agents\" name=\"allAgents\" type=\"checkbox\"' + checked(key.scope.allAgents) + '>允许访问全部智能体</label></div><div id=\"edit-agent-scope\" class=\"agent-scope' + (key.scope.allAgents ? ' hidden' : '') + '\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div>'\n openDrawer('编辑 API 密钥授权范围', body, '保存授权范围', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const narrowed = key.scope.allAgents && !allAgents || !key.scope.allAgents && !allAgents && (key.scope.agentIds || []).some((agentId) => !agentIds.includes(agentId))\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast(narrowed ? 'API 密钥授权范围已更新;受影响 SSE 已关闭,正在执行的任务会继续' : 'API 密钥授权范围已更新')\n })\n const all = byId('edit-all-agents')\n all.onchange = () => byId('edit-agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function openRenameKeyDrawer(key) {\n openDrawer('重命名 API 密钥', '<div class=\"field\"><label for=\"f-key-name\">名称</label><input id=\"f-key-name\" name=\"name\" value=\"' + escapeHtml(key.name) + '\" required autocomplete=\"off\"></div>', '保存名称', async (form, isCurrent) => {\n const name = String(new FormData(form).get('name') || '').trim()\n if (name === key.name) { closeDrawer(); return }\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { name } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast('API 密钥已重命名')\n })\n const input = byId('f-key-name')\n if (input) setTimeout(() => input.select(), 0)\n}\n\nexport function applyApiKey(key) {\n state.apiKeys = state.apiKeys.map((current) => current.id === key.id ? key : current)\n markResourceSaved('apiKeys')\n render()\n}\n\nexport function handleKeyMenuKeydown(event) {\n if (keyActionMenu.classList.contains('hidden')) return\n if (event.key === 'Escape' || event.key === 'Tab') {\n if (event.key === 'Escape') event.preventDefault()\n closeKeyActionMenu(true)\n return\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return\n event.preventDefault()\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n const index = items.indexOf(document.activeElement)\n const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :\n (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length\n items.forEach((item, position) => { item.tabIndex = position === next ? 0 : -1 })\n items[next].focus({ preventScroll: true })\n}\n\nexport function toggleKeyActionMenu(anchor, id) {\n if (!keyActionMenu.classList.contains('hidden') && keyActionMenu.dataset.keyId === id) {\n closeKeyActionMenu(true)\n return\n }\n closeKeyActionMenu()\n menuAnchor = anchor\n keyActionMenu.dataset.keyId = id\n keyActionMenu.innerHTML =\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"reveal\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.reveal + '<span>显示完整密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"scope\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.scope + '<span>编辑授权范围</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"regenerate\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.regenerate + '<span>重新生成密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" class=\"danger-text\" data-key-action=\"delete\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.trash + '<span>删除密钥</span></button>'\n keyActionMenu.classList.remove('hidden')\n anchor.setAttribute('aria-expanded', 'true')\n const anchorRect = anchor.getBoundingClientRect()\n const menuRect = keyActionMenu.getBoundingClientRect()\n const left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, anchorRect.right - menuRect.width))\n const below = anchorRect.bottom + 6\n const top = below + menuRect.height <= window.innerHeight - 8\n ? below\n : Math.max(8, anchorRect.top - menuRect.height - 6)\n keyActionMenu.style.left = left + 'px'\n keyActionMenu.style.top = top + 'px'\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n items.forEach((item, index) => { item.tabIndex = index === 0 ? 0 : -1 })\n items[0]?.focus({ preventScroll: true })\n}\n\nexport function closeKeyActionMenu(restore = false) {\n keyActionMenu.classList.add('hidden')\n keyActionMenu.innerHTML = ''\n keyActionMenu.dataset.keyId = ''\n document.querySelectorAll('[data-key-menu][aria-expanded=\"true\"]').forEach((button) => button.setAttribute('aria-expanded', 'false'))\n if (restore === true && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true })\n menuAnchor = null\n}\n",
14
+ "render-artifacts": "import { state } from './state.js'\nimport { byId, drawerForm, escapeHtml, selected } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { withBusy, toast } from './toast.js'\nimport { openDrawer, openConfirmDrawer } from './drawer.js'\nimport { copySecret } from './render-keys.js'\n\nimport { formatDate, formatSize, kindLabel, shortId } from './format.js'\n\nimport { setResults, setActions, setToolbar, announce, emptyState } from './render-shared.js'\n\nconst KINDS = ['image', 'video', 'audio', 'file']\n\nconst CODE_EXTS = new Set(['js','mjs','cjs','ts','tsx','jsx','py','rb','go','rs','java','c','h','cpp','css','scss','html','htm','sql','sh','bash','ps1','yaml','yml','xml','toml','ini','json'])\nconst TEXT_EXTS = new Set(['txt','md','markdown','log','csv','tsv','conf','env','gitignore','dockerfile','json','yaml','yml','xml','toml','ini','sh','bash','ps1','js','mjs','cjs','ts','tsx','jsx','py','rb','go','rs','java','c','h','cpp','css','scss','html','htm','sql'])\n\nfunction artifactName(row) {\n return (row.filename || row.name || row.id).toLowerCase()\n}\n\nfunction isCodeArtifact(row) {\n const mt = (row.mediaType || '').toLowerCase()\n if (mt === 'application/json' || mt === 'application/xml' || mt.endsWith('+json') || mt.endsWith('+xml')) return true\n const ext = artifactName(row).split('.').pop() || ''\n return CODE_EXTS.has(ext)\n}\n\nfunction isTextArtifact(row) {\n const mt = (row.mediaType || '').toLowerCase()\n if (mt.startsWith('text/')) return true\n if (mt === 'application/json' || mt === 'application/xml' || mt.endsWith('+json') || mt.endsWith('+xml')) return true\n const ext = artifactName(row).split('.').pop() || ''\n return TEXT_EXTS.has(ext)\n}\n\nfunction artifactUrl(row) {\n return '/v1/admin/runs/' + encodeURIComponent(row.runId) + '/artifacts/' + encodeURIComponent(row.id)\n}\n\nexport function findArtifact(artifactId, runId) {\n return state.artifacts.find((row) => row.id === artifactId && row.runId === runId)\n}\n\nfunction previewIcon(row) {\n if (row.kind === 'image') return icons.image\n if (row.kind === 'video') return icons.filePlay\n if (row.kind === 'audio') return icons.fileAudio\n if (isCodeArtifact(row)) return icons.fileCode\n if (isTextArtifact(row)) return icons.fileText\n return icons.file\n}\n\nfunction previewCell(row) {\n return '<button type=\"button\" class=\"artifact-tile\" aria-label=\"查看产物详情\" data-artifact-detail=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">' + previewIcon(row) + '</button>'\n}\n\nfunction artifactRow(row) {\n const title = row.name || row.filename || row.id\n return '<tr><td>' + previewCell(row) + '</td><td><div class=\"agent-name\"><strong>' + escapeHtml(title) + '</strong>' +\n (row.filename && row.filename !== title ? '<small>' + escapeHtml(row.filename) + '</small>' : '') +\n (row.mediaType ? '<small>' + escapeHtml(row.mediaType) + '</small>' : '') + '</div></td>' +\n '<td><div class=\"agent-name\"><strong>' + escapeHtml(row.agentName) + '</strong><small>' + escapeHtml(shortId(row.runId)) + '</small></div></td>' +\n '<td class=\"num\">' + escapeHtml(formatSize(row.size)) + '</td>' +\n '<td class=\"num\">' + escapeHtml(formatDate(row.createdAt || row.runStartedAt)) + '</td>' +\n '<td><div class=\"row-actions\">' +\n (row.downloadable ? '<a class=\"button small\" download href=\"' + artifactUrl(row) + '\">下载</a>' : '<span class=\"muted\">' + escapeHtml(row.storageStatus === 'metadata_only' ? '仅元数据' : '不可下载') + '</span>') +\n '<button type=\"button\" class=\"button small danger\" data-artifact-delete=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">删除</button>' +\n '</div></td></tr>'\n}\n\nfunction artifactsPath() {\n const query = new URLSearchParams({ limit: '50' })\n if (state.artifactOffset) query.set('offset', String(state.artifactOffset))\n if (state.artifactAgent !== 'all') query.set('agentId', state.artifactAgent)\n if (state.artifactKind !== 'all') query.set('kind', state.artifactKind)\n return '/v1/admin/artifacts?' + query\n}\n\nlet loadSeq = 0\n\nexport async function loadArtifacts(showError) {\n const path = artifactsPath()\n const seq = ++loadSeq\n try {\n const value = /** @type {import('./contracts.js').ArtifactListPayload} */ (await api(path))\n if (seq !== loadSeq) return\n state.artifacts = value.artifacts || []\n state.artifactTotal = value.total || 0\n renderArtifacts()\n } catch (error) {\n if (seq !== loadSeq) return\n if (showError) toast(error.message, true)\n }\n}\n\nexport function resetArtifactView() { loadSeq++ }\n\nfunction renderToolbar() {\n const agents = state.config.agents.map((agent) => '<option value=\"' + escapeHtml(agent.id) + '\"' + selected(state.artifactAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')\n const kinds = KINDS.map((kind) => '<option value=\"' + kind + '\"' + selected(state.artifactKind, kind) + '>' + escapeHtml(kindLabel(kind)) + '</option>').join('')\n setToolbar('artifacts|' + state.artifactAgent + '|' + state.artifactKind,\n '<div class=\"toolbar\"><select id=\"artifact-agent-filter\" aria-label=\"按智能体筛选\"><option value=\"all\">全部智能体</option>' + agents + '</select>' +\n '<select id=\"artifact-kind-filter\" aria-label=\"按类型筛选\"><option value=\"all\">全部类型</option>' + kinds + '</select>' +\n '<button class=\"button small\" id=\"clear-artifact-filters\"' + ((state.artifactAgent !== 'all' || state.artifactKind !== 'all') ? '' : ' hidden') + '>清除筛选</button></div>')\n byId('artifact-agent-filter').onchange = (event) => { state.artifactAgent = event.target.value; state.artifactOffset = 0; void loadArtifacts(false) }\n byId('artifact-kind-filter').onchange = (event) => { state.artifactKind = event.target.value; state.artifactOffset = 0; void loadArtifacts(false) }\n byId('clear-artifact-filters').onclick = () => { state.artifactAgent = 'all'; state.artifactKind = 'all'; state.artifactOffset = 0; void loadArtifacts(false) }\n}\n\nexport function renderArtifacts() {\n setActions('<button id=\"refresh-artifacts\" class=\"button\">' + icons.refresh + '刷新</button>')\n byId('refresh-artifacts').onclick = (event) => withBusy(event.currentTarget, () => loadArtifacts(true))\n if (!state.config.agents.length && ['idle', 'loading'].includes(state.resources.config.status)) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载网关配置…</p></div>')\n return\n }\n renderToolbar()\n const total = state.artifactTotal\n const offset = state.artifactOffset\n if (!total) {\n setResults('<div class=\"panel\">' + emptyState(icons.artifacts, '暂无产物', '智能体任务产出的文件、图片、视频会保存在这里,可下载或删除。') + '</div>')\n announce('暂无产物')\n return\n }\n const first = offset + 1\n const last = Math.min(offset + 50, total)\n setResults('<div class=\"run-pagination\"><span class=\"muted\">显示 ' + first + '–' + last + ',共 ' + total + ' 个产物</span><div class=\"row-actions\"><button class=\"button small\" id=\"artifacts-prev\"' + (offset ? '' : ' disabled') + '>上一页</button><button class=\"button small\" id=\"artifacts-next\"' + (offset + 50 < total ? '' : ' disabled') + '>下一页</button></div></div>' +\n '<div class=\"table-wrap\"><table class=\"artifact-table\"><thead><tr><th>预览</th><th>名称</th><th>来源</th><th class=\"num\">大小</th><th class=\"num\">保存时间</th><th>操作</th></tr></thead><tbody>' +\n (state.artifacts.length ? state.artifacts.map(artifactRow).join('') : '<tr><td colspan=\"6\" class=\"empty\">没有符合条件的产物。</td></tr>') +\n '</tbody></table></div>')\n byId('artifacts-prev').onclick = (event) => withBusy(event.currentTarget, async () => { state.artifactOffset = Math.max(0, state.artifactOffset - 50); await loadArtifacts(true) })\n byId('artifacts-next').onclick = (event) => withBusy(event.currentTarget, async () => { state.artifactOffset += 50; await loadArtifacts(true) })\n announce(total + ' 个产物')\n}\n\nconst TEXT_PREVIEW_LIMIT = 512 * 1024\nconst TEXT_DISPLAY_LIMIT = 64 * 1024\n\nfunction previewMarkup(row) {\n const url = artifactUrl(row)\n if (row.kind === 'image' && row.downloadable) return '<img class=\"artifact-preview\" src=\"' + url + '\" alt=\"' + escapeHtml(row.name || row.filename || '产物') + '\">'\n if (row.kind === 'video' && row.downloadable) return '<video class=\"artifact-preview\" controls preload=\"metadata\" src=\"' + url + '\"></video>'\n if (row.kind === 'audio' && row.downloadable) return '<audio controls preload=\"metadata\" src=\"' + url + '\"></audio>'\n if (row.kind === 'file' && row.downloadable && isTextArtifact(row)) {\n return '<pre class=\"run-detail-code artifact-text-preview\" data-text-preview>正在加载预览…</pre>'\n }\n return '<p class=\"field-help\">' + (row.downloadable ? '此类型不支持在线预览,可下载后查看。' : '该产物没有可下载的文件内容(' + escapeHtml(row.storageStatus || 'metadata_only') + ')。') + '</p>'\n}\n\nfunction loadTextPreview(row) {\n const preview = drawerForm.querySelector('[data-text-preview]')\n if (!preview) return\n if (row.size != null && row.size > TEXT_PREVIEW_LIMIT) {\n preview.textContent = '文件较大(' + formatSize(row.size) + '),未自动加载预览,可下载后查看。'\n return\n }\n fetch(artifactUrl(row)).then((response) => {\n if (!response.ok) throw new Error('HTTP ' + response.status)\n return response.text()\n }).then((text) => {\n if (!preview.isConnected) return\n preview.textContent = text.length > TEXT_DISPLAY_LIMIT\n ? text.slice(0, TEXT_DISPLAY_LIMIT) + '\\n\\n…[预览已截断,请下载查看完整内容]'\n : text\n }).catch((error) => {\n if (!preview.isConnected) return\n preview.textContent = '预览加载失败:' + (error instanceof Error ? error.message : String(error))\n })\n}\n\nexport function openArtifactDetail(artifactId, runId) {\n const row = findArtifact(artifactId, runId)\n if (!row) { toast('产物不存在或已被删除', true); return }\n const title = row.name || row.filename || row.id\n // 以当前访问地址为基准拼出完整文件链接,同源部署(127.0.0.1 / 服务器 IP / 反向代理域名)都自动正确\n const fileUrl = row.downloadable ? new URL(artifactUrl(row), window.location.href).href : ''\n const body =\n '<div class=\"run-detail-grid\">' +\n [['name', '名称'], ['mediaType', '类型'], ['size', '大小'], ['createdAt', '保存时间'], ['agent', '来源智能体'], ['run', '运行 ID']]\n .map(([key, label]) => {\n const value = { name: title, mediaType: row.mediaType || '—', size: formatSize(row.size), createdAt: formatDate(row.createdAt || row.runStartedAt), agent: row.agentName, run: shortId(row.runId) }[key]\n return '<div class=\"run-detail-item\"><span>' + label + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n }).join('') +\n '</div>' +\n (fileUrl ?\n '<div class=\"artifact-link-row\"><span class=\"muted\">文件链接</span><div class=\"artifact-link-line\"><code><a href=\"' + escapeHtml(fileUrl) + '\" target=\"_blank\" rel=\"noopener\">' + escapeHtml(fileUrl) + '</a></code>' +\n '<button type=\"button\" class=\"button small\" data-artifact-copy-link>' + icons.copy + '复制</button></div></div>' : '') +\n '<div class=\"artifact-preview-wrap\">' + previewMarkup(row) + '</div>' +\n '<div class=\"run-detail-actions\">' +\n (row.downloadable ? '<a class=\"button small\" download href=\"' + artifactUrl(row) + '\">下载</a>' : '') +\n '<button type=\"button\" class=\"button small danger\" data-artifact-delete=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">删除产物</button>' +\n '</div>'\n openDrawer('产物详情', body, '', null)\n const remove = drawerForm.querySelector('[data-artifact-delete]')\n if (remove) remove.onclick = () => confirmArtifactDelete(remove.dataset.artifactDelete, remove.dataset.artifactRun)\n const copyLink = drawerForm.querySelector('[data-artifact-copy-link]')\n if (copyLink) copyLink.onclick = () => withBusy(copyLink, async () => { await copySecret(fileUrl, '请手动复制上方链接'); toast('文件链接已复制') })\n loadTextPreview(row)\n}\n\nexport function confirmArtifactDelete(artifactId, runId) {\n const row = findArtifact(artifactId, runId)\n const title = row ? (row.name || row.filename || row.id) : artifactId\n openConfirmDrawer('删除产物', '删除「' + title + '」的文件与记录,释放存储空间。运行记录本身保留。此操作无法撤销。', '删除产物', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(runId) + '/artifacts/' + encodeURIComponent(artifactId), { method: 'DELETE', body: {} })\n toast('产物已删除')\n if (state.page === 'artifacts') void loadArtifacts(false)\n })\n}\n",
15
+ "render-keys": "import { state } from './state.js'\nimport { byId, keyActionMenu, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { openDrawer, closeDrawer } from './drawer.js'\nimport { showLogin } from './screens.js'\n\nimport { markResourceSaved } from './data.js'\nimport { formatDate } from './format.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, announce, emptyState } from './render-shared.js'\n\nlet menuAnchor = null\n\nexport function keyRow(key) {\n const scope = key.scope.allAgents ? '全部智能体' : (key.scope.agentIds.length ? key.scope.agentIds.join(', ') : '未授权智能体')\n const id = escapeHtml(key.id)\n const toggleLabel = key.enabled ? '禁用密钥' : '启用密钥'\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(key.name) + '</strong>' + (key.legacy ? '<span class=\"badge\">旧版</span>' : '') + '</div></td>' +\n '<td>' + (key.enabled ? '<span class=\"status ready\">已启用</span>' : '<span class=\"status\">已禁用</span>') + '</td>' +\n '<td data-label=\"API 密钥\"><div class=\"key-secret-cell\"><code>••••' + escapeHtml(key.suffix) + '</code><button class=\"key-copy-button\" data-key-action=\"copy\" data-key-id=\"' + id + '\" aria-label=\"复制完整密钥\" title=\"复制完整密钥\">' + icons.copy + '</button></div></td>' +\n '<td data-label=\"授权范围\"><span class=\"key-scope\">' + escapeHtml(scope) + '</span></td>' +\n '<td data-label=\"最后使用\"><span class=\"key-last-used\">' + escapeHtml(key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用') + '</span></td>' +\n '<td><div class=\"key-actions\"><button class=\"key-icon-button' + (key.enabled ? ' danger-action' : '') + '\" data-key-action=\"toggle\" data-key-id=\"' + id + '\" aria-label=\"' + toggleLabel + '\" title=\"' + toggleLabel + '\">' + (key.enabled ? icons.disable : icons.enable) + '</button><button class=\"key-icon-button\" data-key-action=\"rename\" data-key-id=\"' + id + '\" aria-label=\"重命名\" title=\"重命名\">' + icons.edit + '</button><button class=\"key-icon-button\" data-key-menu=\"' + id + '\" aria-label=\"更多操作\" title=\"更多操作\" aria-expanded=\"false\" aria-haspopup=\"menu\" aria-controls=\"key-action-menu\">' + icons.more + '</button></div></td></tr>'\n}\n\nexport function renderApiKeys() {\n closeKeyActionMenu()\n setActions('<button id=\"create-key\" class=\"button primary\">' + icons.plus + '创建 API 密钥</button>')\n const pending = state.resources.apiKeys.status === 'loading' && !state.apiKeys.length\n setResults(pending ? '<div class=\"panel\"><p class=\"panel-note\">正在加载 API 密钥…</p></div>' : state.apiKeys.length\n ? '<div class=\"table-wrap key-table-wrap\"><table class=\"key-table\"><thead><tr><th>名称</th><th>状态</th><th>API 密钥</th><th>授权范围</th><th>最后使用</th><th>操作</th></tr></thead><tbody>' + state.apiKeys.map(keyRow).join('') + '</tbody></table></div>'\n : '<div class=\"panel\">' + emptyState(icons.key, '尚未创建 API 密钥', '客户端调用网关数据接口时需要携带密钥。', { action: 'create-key', label: '创建 API 密钥' }) + '</div>')\n byId('create-key').onclick = () => openKeyDrawer()\n byId('create-key').disabled = !state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt\n announce(state.apiKeys.length + ' 个 API 密钥')\n}\n\nexport function agentScopeOption(agent, selected = false) {\n return '<label class=\"checkbox\"><input type=\"checkbox\" name=\"agentId\" value=\"' + escapeHtml(agent.id) + '\"' + checked(selected) + '><span class=\"scope-name\">' + escapeHtml(agent.name) + '</span><span class=\"muted\">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>'\n}\n\nexport function openKeyDrawer() {\n if (!state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt) { toast('配置与密钥列表尚未成功读取,请重试后创建', true); return }\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent)).join('')\n const body = '<div class=\"field\"><label for=\"f-name\">名称</label><input id=\"f-name\" name=\"name\" required autocomplete=\"off\" placeholder=\"例如:开发客户端\"></div><div class=\"field\"><label class=\"checkbox\"><input id=\"all-agents\" name=\"allAgents\" type=\"checkbox\" checked>允许访问全部智能体</label></div><div id=\"agent-scope\" class=\"agent-scope hidden\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div><div class=\"field\"><label for=\"f-customSecret\">自定义密钥(可选)</label><input id=\"f-customSecret\" name=\"customSecret\" minlength=\"16\" autocomplete=\"off\" placeholder=\"留空则自动生成 nx_sk_ 密钥\"></div>'\n openDrawer('创建 API 密钥', body, '创建 API 密钥', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const customSecret = String(data.get('customSecret') || '').trim()\n const result = await api('/v1/admin/api-keys', {\n method: 'POST',\n body: {\n name: String(data.get('name') || '').trim(),\n scope: { allAgents, agentIds },\n customSecret: customSecret || undefined\n }\n })\n state.apiKeys.unshift(result.key)\n markResourceSaved('apiKeys')\n if (isCurrent()) showSecret('API 密钥已创建', result.secret)\n else toast('API 密钥已创建,可在密钥列表中显示或复制')\n render()\n })\n const all = byId('all-agents')\n all.onchange = () => byId('agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function showSecret(title, secret) {\n openDrawer(title, '<div class=\"secret-box\"><span class=\"muted\">请立即复制;之后可在密钥列表中再次显示。</span><code class=\"secret-value\">' + escapeHtml(secret) + '</code><button class=\"button\" type=\"button\" id=\"copy-secret\">' + icons.copy + '复制</button></div>', '', null)\n byId('copy-secret').onclick = async () => {\n try {\n await copySecret(secret)\n toast('API 密钥已复制')\n } catch (error) { toast(error.message, true) }\n }\n}\n\nexport async function copySecret(secret, fallbackHint = '请在“显示完整密钥”中手动复制') {\n if (navigator.clipboard && window.isSecureContext) {\n try {\n await navigator.clipboard.writeText(secret)\n return\n } catch {}\n }\n const temporary = document.createElement('textarea')\n temporary.value = secret\n temporary.setAttribute('readonly', '')\n temporary.style.position = 'fixed'\n temporary.style.opacity = '0'\n temporary.style.pointerEvents = 'none'\n const previousFocus = document.activeElement\n const dialog = byId('drawer')\n ;(dialog.classList.contains('hidden') ? document.body : dialog).appendChild(temporary)\n temporary.select()\n const copied = document.execCommand('copy')\n temporary.remove()\n previousFocus?.focus({ preventScroll: true })\n if (!copied) throw new Error(`无法自动复制,${fallbackHint}。`)\n}\n\nexport function openPasswordDrawer() {\n openDrawer('修改密码', '<div class=\"field\"><label for=\"f-currentPassword\">当前密码</label><input id=\"f-currentPassword\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\" required></div><div class=\"field\"><label for=\"f-newPassword\">新密码</label><input id=\"f-newPassword\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div><div class=\"field\"><label for=\"f-confirmPassword\">确认新密码</label><input id=\"f-confirmPassword\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div>', '修改密码', async (form) => {\n const data = new FormData(form)\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword: String(data.get('currentPassword') || ''), newPassword: String(data.get('newPassword') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n closeDrawer()\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n })\n}\n\nexport function openKeyScopeDrawer(key) {\n const selectedIds = new Set(key.scope.agentIds || [])\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent, selectedIds.has(agent.id))).join('')\n const body = '<p class=\"field-help\">缩小授权范围会立即拒绝受影响的新请求并关闭对应 SSE;正在执行的任务会继续。</p><div class=\"field\"><label class=\"checkbox\"><input id=\"edit-all-agents\" name=\"allAgents\" type=\"checkbox\"' + checked(key.scope.allAgents) + '>允许访问全部智能体</label></div><div id=\"edit-agent-scope\" class=\"agent-scope' + (key.scope.allAgents ? ' hidden' : '') + '\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div>'\n openDrawer('编辑 API 密钥授权范围', body, '保存授权范围', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const narrowed = key.scope.allAgents && !allAgents || !key.scope.allAgents && !allAgents && (key.scope.agentIds || []).some((agentId) => !agentIds.includes(agentId))\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast(narrowed ? 'API 密钥授权范围已更新;受影响 SSE 已关闭,正在执行的任务会继续' : 'API 密钥授权范围已更新')\n })\n const all = byId('edit-all-agents')\n all.onchange = () => byId('edit-agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function openRenameKeyDrawer(key) {\n openDrawer('重命名 API 密钥', '<div class=\"field\"><label for=\"f-key-name\">名称</label><input id=\"f-key-name\" name=\"name\" value=\"' + escapeHtml(key.name) + '\" required autocomplete=\"off\"></div>', '保存名称', async (form, isCurrent) => {\n const name = String(new FormData(form).get('name') || '').trim()\n if (name === key.name) { closeDrawer(); return }\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { name } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast('API 密钥已重命名')\n })\n const input = byId('f-key-name')\n if (input) setTimeout(() => input.select(), 0)\n}\n\nexport function applyApiKey(key) {\n state.apiKeys = state.apiKeys.map((current) => current.id === key.id ? key : current)\n markResourceSaved('apiKeys')\n render()\n}\n\nexport function handleKeyMenuKeydown(event) {\n if (keyActionMenu.classList.contains('hidden')) return\n if (event.key === 'Escape' || event.key === 'Tab') {\n if (event.key === 'Escape') event.preventDefault()\n closeKeyActionMenu(true)\n return\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return\n event.preventDefault()\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n const index = items.indexOf(document.activeElement)\n const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :\n (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length\n items.forEach((item, position) => { item.tabIndex = position === next ? 0 : -1 })\n items[next].focus({ preventScroll: true })\n}\n\nexport function toggleKeyActionMenu(anchor, id) {\n if (!keyActionMenu.classList.contains('hidden') && keyActionMenu.dataset.keyId === id) {\n closeKeyActionMenu(true)\n return\n }\n closeKeyActionMenu()\n menuAnchor = anchor\n keyActionMenu.dataset.keyId = id\n keyActionMenu.innerHTML =\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"reveal\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.reveal + '<span>显示完整密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"scope\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.scope + '<span>编辑授权范围</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"regenerate\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.regenerate + '<span>重新生成密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" class=\"danger-text\" data-key-action=\"delete\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.trash + '<span>删除密钥</span></button>'\n keyActionMenu.classList.remove('hidden')\n anchor.setAttribute('aria-expanded', 'true')\n const anchorRect = anchor.getBoundingClientRect()\n const menuRect = keyActionMenu.getBoundingClientRect()\n const left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, anchorRect.right - menuRect.width))\n const below = anchorRect.bottom + 6\n const top = below + menuRect.height <= window.innerHeight - 8\n ? below\n : Math.max(8, anchorRect.top - menuRect.height - 6)\n keyActionMenu.style.left = left + 'px'\n keyActionMenu.style.top = top + 'px'\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n items.forEach((item, index) => { item.tabIndex = index === 0 ? 0 : -1 })\n items[0]?.focus({ preventScroll: true })\n}\n\nexport function closeKeyActionMenu(restore = false) {\n keyActionMenu.classList.add('hidden')\n keyActionMenu.innerHTML = ''\n keyActionMenu.dataset.keyId = ''\n document.querySelectorAll('[data-key-menu][aria-expanded=\"true\"]').forEach((button) => button.setAttribute('aria-expanded', 'false'))\n if (restore === true && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true })\n menuAnchor = null\n}\n",
16
16
  "render-overview": "import { state } from './state.js'\nimport { byId, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\nimport { localizeError } from './api.js'\nimport { withBusy } from './toast.js'\n\nimport { refreshReadiness, retryResource } from './data.js'\nimport { runStatusLabel } from './format.js'\n\nimport { setResults, setActions, announce, stat, emptyState } from './render-shared.js'\nimport { readinessRows, statusMarkup } from './render-agents.js'\n\nexport function overviewAgent(agent) {\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(agent.name) + '</strong><small>' +\n escapeHtml(agent.protocol.toUpperCase() + (agent.driver ? ' · ' + agent.driver : '')) +\n (agent.error ? ' · ' + escapeHtml(localizeError(agent.error)) : '') + '</small></div><div class=\"row-actions\">' +\n '<button class=\"button small\" data-agent-edit=\"' + escapeHtml(agent.id) + '\">编辑</button><button class=\"button small\" data-agent-diagnostics=\"' + escapeHtml(agent.id) + '\">连接诊断</button>' + statusMarkup(agent) + '</div></div>'\n}\n\n/** 紧凑指标单元:标签 + 值,不单独成卡。 */\nfunction miniMetric(label, value) {\n return '<div class=\"run-detail-item\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(String(value)) + '</strong></div>'\n}\n\nconst WAITING_STATES = new Set(['input_required', 'permission_required'])\n\nfunction attentionRow(run) {\n const waiting = WAITING_STATES.has(run.state)\n const actions =\n (waiting ? '<button class=\"button small\" data-run-quick-cancel=\"' + escapeHtml(run.id) + '\">取消任务</button>' : '') +\n '<button class=\"button small\" data-run-detail=\"' + escapeHtml(run.id) + '\">查看详情</button>' +\n '<button class=\"button small danger\" data-run-quick-delete=\"' + escapeHtml(run.id) + '\">删除记录</button>'\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(run.agentName) + '</strong><small>' +\n escapeHtml(runStatusLabel(run.state) + ' · ' + (run.taskPreview || '')) + '</small></div><div class=\"row-actions\">' + actions + '</div></div>'\n}\n\nexport function renderOverview() {\n const agents = readinessRows()\n const enabled = agents.filter((agent) => agent.enabled)\n const ready = enabled.filter((agent) => agent.ready).length\n const attention = agents.filter((agent) => agent.enabled && !agent.checking && (!agent.ready || agent.error))\n const configPending = ['idle', 'loading'].includes(state.resources.config.status)\n const metrics = state.metrics || {}\n const storage = metrics.storageMetrics\n const recent = (metrics.recentRuns || []).filter((run) => WAITING_STATES.has(run.state) || run.state === 'failed').slice(0, 5)\n setActions('<button id=\"refresh-overview\" class=\"button\">' + icons.refresh + '刷新</button>')\n const configBroken = !state.resources.config.lastSuccessAt\n const body = configPending && agents.length === 0\n ? '<p class=\"panel-note\">正在加载网关配置…</p>'\n : configBroken\n ? '<p class=\"panel-note\">配置读取失败,请使用上方重试入口恢复。</p>'\n : agents.length === 0\n ? emptyState(icons.robot, '尚未配置智能体', '', { action: 'add-agent', label: '添加智能体' })\n : '<div class=\"panel-body overview-list\">' + attention.map(overviewAgent).join('') + '</div>'\n /* 没有异常、有配置、有智能体时整块隐藏:健康状态由顶部统计表达即可 */\n const agentPanel = (configPending && agents.length === 0) || configBroken || agents.length === 0 || attention.length\n ? '<div class=\"panel\"><div class=\"panel-header\"><h2>智能体状态</h2><span class=\"muted\">' + (attention.length ? attention.length + ' 个需要注意' : ready + ' 个可用') + '</span></div>' +\n body +\n '</div>'\n : ''\n const sysMetrics =\n miniMetric('现有会话', metrics.sessions ?? (state.resources.overview.lastSuccessAt ? state.sessions : '—')) +\n miniMetric('SSE 连接', metrics.activeSse ?? '—') +\n miniMetric('内存 RSS', metrics.rssBytes === undefined ? '—' : Math.round(metrics.rssBytes / 1024 / 1024) + ' MiB') +\n miniMetric('配额拒绝', metrics.quotaRejections ?? '—') +\n (storage ? miniMetric('历史文件', (storage.historyBytes / 1024 / 1024).toFixed(1) + ' MiB') +\n miniMetric('保留记录', storage.retainedRuns) +\n miniMetric('待写入', storage.pendingWrites) +\n miniMetric('历史写入失败', storage.writeFailures) : '')\n setResults('<div class=\"stats\">' +\n stat('智能体', agents.length, '#/agents') +\n stat('启用', enabled.length) +\n stat('异常', attention.length, attention.length ? '#/agents?status=failed' : undefined) +\n stat('活动任务', metrics.runs?.active ?? '—', '#/runs') +\n '</div>' +\n (agentPanel || '') +\n (sysMetrics ? '<div class=\"panel\"><div class=\"panel-header\"><h2>系统状态</h2></div>' +\n '<div class=\"run-detail-grid compact\">' + sysMetrics + '</div></div>' : '') +\n '<div class=\"panel\"><div class=\"panel-header\"><h2>失败与等待处理</h2>' +\n (recent.length >= 5 ? '<a class=\"button small\" href=\"#/runs?state=failed\">查看全部</a>' : '<span class=\"muted\">最近 5 条</span>') + '</div>' +\n (recent.length ? '<div class=\"panel-body\">' + recent.map(attentionRow).join('') + '</div>' : '<p class=\"panel-note\">' + (state.resources.metrics.lastSuccessAt ? '没有最近失败或等待处理的任务。' : '正在读取任务统计…') + '</p>') + '</div>')\n byId('refresh-overview').onclick = (event) => withBusy(event.currentTarget, async () => {\n await Promise.allSettled([refreshReadiness(true), retryResource('metrics')])\n })\n announce(agents.length + ' 个智能体,' + enabled.length + ' 个启用,' + attention.length + ' 个需要注意')\n}\n",
17
17
  "render-runs": "import { state } from './state.js'\nimport { byId, pageStats, drawerForm, escapeHtml, selected } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { withBusy, toast, runAction } from './toast.js'\nimport { openDrawer, getDrawerVersion, openConfirmDrawer, closeDrawer } from './drawer.js'\nimport { copySecret } from './render-keys.js'\nimport { writeLocationState } from './location-state.js'\n\nimport { refreshRuns } from './data.js'\nimport { shortId, formatDate, formatDuration, runStatusLabel } from './format.js'\n\nimport { setResults, setActions, setToolbar, announce, stat, emptyState, bindSearch } from './render-shared.js'\n\nlet runSearchTimer\nlet activeRunDrawer = null\nexport function resetRunView() { clearTimeout(runSearchTimer); activeRunDrawer = null }\n\nexport function runStatus(value) {\n const style = value === 'completed' ? ' completed' : value === 'failed' ? ' failed' : value === 'running' ? ' running' : (value === 'input_required' || value === 'permission_required') ? ' waiting' : ' canceled'\n return '<span class=\"status' + style + '\">' + escapeHtml(runStatusLabel(value)) + '</span>'\n}\n\nexport function runTaskPreview(run) {\n return run.taskPreview || run.task || '—'\n}\n\nexport function runCard(run) {\n const waiting = run.state === 'input_required' || run.state === 'permission_required'\n const progress = run.progress || {}\n return '<article class=\"run-card' + (waiting ? ' waiting' : '') + '\"><div class=\"run-card-head\"><div class=\"run-card-agent\"><span class=\"badge\">' + escapeHtml((run.protocol || '').toUpperCase()) + '</span><strong>' + escapeHtml(run.agentName) + '</strong></div>' + runStatus(run.state) + '</div><div class=\"run-task\">' + escapeHtml(runTaskPreview(run)) + '</div>' +\n (progress.phase || progress.message ? '<div class=\"run-progress\">' + (progress.phase ? '<strong>' + escapeHtml(progress.phase) + '</strong>' : '') + (progress.message ? '<small>' + escapeHtml(progress.message) + '</small>' : '') + '</div>' : '') +\n '<div class=\"run-meta\"><span>' + escapeHtml(shortId(run.id)) + '</span><span>' + escapeHtml(formatDuration(run)) + '</span></div>' + runDetailButton(run) + '</article>'\n}\n\nexport function runDetailButton(run) {\n return '<button type=\"button\" class=\"button small run-detail-button\" data-run-detail=\"' + escapeHtml(run.id) + '\" aria-label=\"查看运行详情:' + escapeHtml(runTaskPreview(run)) + '\">查看详情</button>'\n}\n\nexport function runRow(run) {\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(run.agentName) + '</strong><small>' + escapeHtml((run.protocol || '').toUpperCase()) + '</small></div></td><td class=\"run-task-cell\"><strong>' + escapeHtml(runTaskPreview(run)) + '</strong><small class=\"run-id\">' + escapeHtml(shortId(run.id)) + '</small></td><td class=\"run-task-cell\"><strong>' + escapeHtml(run.resultSummary || run.error || '—') + '</strong><small>' + escapeHtml(run.progress && run.progress.phase || '') + '</small></td><td>' + runStatus(run.state) + '</td><td class=\"num\">' + escapeHtml(formatDate(run.startedAt)) + '</td><td class=\"num\">' + escapeHtml(formatDuration(run)) + '</td><td>' + runDetailButton(run) + '</td></tr>'\n}\n\nexport function renderRuns() {\n writeLocationState(state)\n const pageSize = state.runPageSize || 50\n const activeStates = new Set(['running', 'input_required', 'permission_required'])\n const filtered = state.runs.filter((run) => {\n const haystack = (runTaskPreview(run) + ' ' + (run.agentName || '') + ' ' + run.id).toLowerCase()\n // The server searches the retained full task. A list response may only\n // contain taskPreview, so do not hide a server-matched row locally.\n const localSearchMatches = !state.runSearch || !run.task && run.taskPreview || haystack.indexOf(state.runSearch.toLowerCase()) >= 0\n return localSearchMatches &&\n (state.runAgent === 'all' || run.agentId === state.runAgent) &&\n (state.runStatus === 'all' || run.state === state.runStatus)\n })\n const active = filtered.filter((run) => activeStates.has(run.state))\n const history = filtered.filter((run) => !activeStates.has(run.state))\n const agentOptions = state.config.agents.map((agent) => '<option value=\"' + escapeHtml(agent.id) + '\"' + selected(state.runAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')\n setActions('<button id=\"refresh-runs\" class=\"button\">' + icons.refresh + '刷新</button>')\n byId('refresh-runs').onclick = (event) => withBusy(event.currentTarget, () => refreshRuns(true))\n\n if (!state.runs.length && state.resources.runs.status === 'loading') {\n pageStats.innerHTML = ''\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载运行记录…</p></div>')\n announce('正在加载运行记录')\n return\n }\n\n if (!state.runs.length && !state.runSearch && state.runAgent === 'all' && state.runStatus === 'all' && !state.runOffset) {\n pageStats.innerHTML = ''\n setToolbar('')\n setResults('<div class=\"panel\">' +\n emptyState(icons.activity, '暂无运行记录', '客户端通过网关数据接口发起任务后,进度与结果会出现在这里。') +\n '</div>')\n announce('暂无运行记录')\n return\n }\n\n pageStats.innerHTML =\n '<div class=\"run-summary\">' +\n stat('当前运行', state.runStats.active) +\n stat('已完成', state.runStats.completed) +\n stat('失败', state.runStats.failed) +\n stat('记录总数', state.runTotal) +\n '</div>'\n const hasFilters = Boolean(state.runSearch || state.runAgent !== 'all' || state.runStatus !== 'all')\n const pageSizes = [50, 100, 200].map((size) => '<option value=\"' + size + '\"' + selected(pageSize, size) + '>' + size + ' 条/页</option>').join('')\n setToolbar('runs|' + pageSize + '|' + JSON.stringify(state.config.agents.map((agent) => [agent.id, agent.name])),\n '<div class=\"toolbar\"><div class=\"search-box\">' + icons.search + '<input id=\"run-search\" type=\"search\" aria-label=\"搜索运行记录\" placeholder=\"搜索任务、智能体或运行 ID\" value=\"' + escapeHtml(state.runSearch) + '\"></div>' +\n '<select id=\"run-agent-filter\" aria-label=\"按智能体筛选\"><option value=\"all\">全部智能体</option>' + agentOptions + '</select>' +\n '<select id=\"run-status-filter\" aria-label=\"按运行状态筛选\"><option value=\"all\">全部状态</option><option value=\"running\"' + selected(state.runStatus, 'running') + '>运行中</option><option value=\"input_required\"' + selected(state.runStatus, 'input_required') + '>等待输入</option><option value=\"permission_required\"' + selected(state.runStatus, 'permission_required') + '>等待授权</option><option value=\"completed\"' + selected(state.runStatus, 'completed') + '>已完成</option><option value=\"failed\"' + selected(state.runStatus, 'failed') + '>失败</option><option value=\"canceled\"' + selected(state.runStatus, 'canceled') + '>已取消</option></select>' +\n '<select id=\"run-page-size\" aria-label=\"每页运行记录数\">' + pageSizes + '</select><button class=\"button small\" id=\"clear-run-filters\">清除筛选</button></div>')\n byId('clear-run-filters').hidden = !hasFilters\n const searchInput = byId('run-search')\n if (!searchInput.dataset.composing && searchInput.value !== state.runSearch) searchInput.value = state.runSearch\n byId('run-agent-filter').value = state.runAgent\n byId('run-status-filter').value = state.runStatus\n const first = state.runTotal ? state.runOffset + 1 : 0\n const last = Math.min(state.runOffset + pageSize, state.runTotal)\n setResults((state.runHasNew ? '<p class=\"panel-note\"><button class=\"button small\" id=\"runs-show-new\">有新任务,返回首页查看</button></p>' : '') + '<div class=\"run-pagination\"><span class=\"muted\">在已保留的全部记录中筛选 · 显示 ' + first + '–' + last + ',共 ' + state.runTotal + ' 条 · 第 ' + (Math.floor(state.runOffset / pageSize) + 1) + ' 页</span><div class=\"row-actions\"><button class=\"button small\" id=\"runs-prev\"' + (state.runOffset ? '' : ' disabled') + '>上一页</button><button class=\"button small\" id=\"runs-next\"' + (state.runOffset + pageSize < state.runTotal ? '' : ' disabled') + '>下一页</button></div></div>' +\n '<section class=\"run-section\"><div class=\"run-section-title\"><h2>当前运行</h2><span>' + active.length + ' 项</span></div>' +\n (active.length ? '<div class=\"run-live-grid\">' + active.map(runCard).join('') + '</div>' : '<div class=\"panel\">' + emptyState(icons.activity, '当前没有任务') + '</div>') +\n '</section>' +\n '<section class=\"run-section\"><div class=\"run-section-title\"><h2>历史记录</h2><span>' + history.length + ' 项</span></div>' +\n '<div class=\"table-wrap\"><table class=\"run-table\"><thead><tr><th>智能体</th><th>任务</th><th>结果</th><th>状态</th><th class=\"num\">开始时间</th><th class=\"num\">耗时</th><th>操作</th></tr></thead><tbody>' +\n (history.length ? history.map(runRow).join('') : '<tr><td colspan=\"7\" class=\"empty\">没有符合条件的历史记录。</td></tr>') +\n '</tbody></table></div></section>')\n const filterChanged = () => {\n state.runOffset = 0\n state.runHasNew = false\n clearTimeout(runSearchTimer)\n renderRuns()\n runSearchTimer = setTimeout(() => { if (state.authenticated) void refreshRuns(false) }, 250)\n }\n bindSearch('run-search', (value) => { state.runSearch = value; filterChanged() })\n byId('run-agent-filter').onchange = (event) => { state.runAgent = event.target.value; filterChanged() }\n byId('run-status-filter').onchange = (event) => { state.runStatus = event.target.value; filterChanged() }\n byId('run-page-size').onchange = (event) => { state.runPageSize = Math.min(200, Math.max(50, Number(event.target.value) || 50)); state.runOffset = 0; void refreshRuns(true) }\n byId('clear-run-filters').onclick = () => {\n clearTimeout(runSearchTimer)\n state.runSearch = ''\n state.runAgent = 'all'\n state.runStatus = 'all'\n state.runOffset = 0\n renderRuns()\n void refreshRuns(true)\n }\n byId('runs-prev').onclick = (event) => withBusy(event.currentTarget, async () => { state.runOffset = Math.max(0, state.runOffset - pageSize); await refreshRuns(true) })\n byId('runs-next').onclick = (event) => withBusy(event.currentTarget, async () => { state.runOffset += pageSize; await refreshRuns(true) })\n if (byId('runs-show-new')) byId('runs-show-new').onclick = () => { state.runOffset = 0; state.runHasNew = false; void refreshRuns(true) }\n announce(active.length + ' 项当前运行,' + history.length + ' 项历史记录')\n}\n\nexport function detailItem(label, value) {\n return '<div class=\"run-detail-item\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n}\n\nexport async function openRunDrawer(id) {\n openDrawer('运行详情', '<p role=\"status\">正在加载运行详情…</p>', '', null)\n state.selectedRunId = id\n writeLocationState(state)\n drawerForm.setAttribute('aria-busy', 'true')\n activeRunDrawer = { id, version: getDrawerVersion(), busy: false, html: '', revision: 0, actionBusy: false }\n await refreshOpenRunDrawer()\n}\n\nexport async function refreshOpenRunDrawer() {\n const target = activeRunDrawer\n if (!target || target.version !== getDrawerVersion() || target.busy || target.actionBusy) return\n const { id, version, revision } = target\n target.busy = true\n try {\n const run = await api('/v1/admin/runs/' + encodeURIComponent(id))\n if (version !== getDrawerVersion() || revision !== target.revision) return\n renderRunDetail(target, run)\n drawerForm.querySelector('[data-detail-error]').textContent = ''\n } catch (error) {\n if (version !== getDrawerVersion() || revision !== target.revision) return\n let warning = drawerForm.querySelector('[data-detail-error]')\n if (!warning) {\n warning = document.createElement('p')\n warning.dataset.detailError = ''\n warning.className = 'form-error'\n warning.setAttribute('role', 'status')\n drawerForm.prepend(warning)\n }\n warning.textContent = '详情刷新失败:' + error.message\n } finally {\n target.busy = false\n if (version === getDrawerVersion()) drawerForm.removeAttribute('aria-busy')\n }\n}\n\nfunction updateDetailText(element, value) {\n const text = String(value ?? '—')\n const old = element.textContent\n if (text === old) return\n if (element.childNodes.length !== 1 || element.firstChild.nodeType !== Node.TEXT_NODE) {\n element.replaceChildren(document.createTextNode(text))\n return\n }\n let prefix = 0\n while (prefix < old.length && prefix < text.length && old[prefix] === text[prefix]) prefix++\n element.firstChild.replaceData(prefix, old.length - prefix, text.slice(prefix))\n}\n\nfunction renderRunDetail(target, run) {\n if (!drawerForm.querySelector('[data-run-detail-body]')) {\n drawerForm.innerHTML = '<div data-run-detail-body>' +\n '<p class=\"field-help\">管理员可介入任意客户端的任务;操作会记录审计日志。重试使用新会话,旧记录保留。</p>' +\n '<div class=\"run-detail-actions\"><button type=\"button\" class=\"button small\" data-run-copy=\"id\">复制运行 ID</button><button type=\"button\" class=\"button small\" data-run-copy=\"output\">复制结果</button><button type=\"button\" class=\"button small\" data-run-copy=\"error\">复制错误</button><button type=\"button\" class=\"button small danger\" data-run-cancel>取消任务</button><button type=\"button\" class=\"button small\" data-run-retry>新会话重试</button><button type=\"button\" class=\"button small danger\" data-run-delete>删除记录</button><button type=\"button\" class=\"button small\" data-run-refresh>刷新</button></div>' +\n '<p class=\"field-help\" data-controls-reason></p><p class=\"form-error\" data-detail-error role=\"status\"></p>' +\n '<div class=\"run-detail-grid\">' +\n [['agent','智能体'],['state','状态'],['startedAt','开始时间'],['duration','耗时'],['protocol','协议'],['id','运行 ID'],['phase','当前阶段'],['attachments','输入附件'],['retry','重试来源']]\n .map(([field,label]) => '<div class=\"run-detail-item\"><span>' + label + '</span><strong data-detail-field=\"' + field + '\"></strong></div>').join('') +\n '</div><div data-pending-container></div>' +\n '<div class=\"run-detail-block\"><h3>用户任务</h3><pre class=\"run-detail-code\" data-detail-task></pre></div>' +\n '<div class=\"run-detail-block\" data-progress-block><h3>最近进度</h3><pre class=\"run-detail-code\" data-detail-progress></pre></div>' +\n '<div class=\"run-detail-block\" data-output-block><div class=\"run-output-heading\"><h3>智能体结果</h3><label class=\"checkbox\"><input type=\"checkbox\" data-follow-output>跟随最新</label></div><pre class=\"run-detail-code\" data-detail-output></pre></div>' +\n '<div class=\"run-detail-block\" data-error-block><h3>错误</h3><pre class=\"run-detail-code\" data-detail-failure></pre></div>' +\n '<div class=\"run-detail-block\"><h3>产物</h3><div data-detail-artifacts></div></div></div>'\n }\n byId('drawer-title').textContent = '运行详情 · ' + run.agentName\n const fields = { agent: run.agentName, state: runStatusLabel(run.state), startedAt: formatDate(run.startedAt), duration: formatDuration(run), protocol: run.protocol.toUpperCase(), id: run.id, phase: run.progress?.phase || '—', attachments: run.inputAttachmentCount || 0, retry: run.retryOfRunId || '—' }\n for (const [key,value] of Object.entries(fields)) updateDetailText(drawerForm.querySelector('[data-detail-field=\"' + key + '\"]'), value)\n updateDetailText(drawerForm.querySelector('[data-detail-task]'), (run.task || run.taskPreview || '—') + (run.taskTruncated ? '\\n\\n[记录已截断]' : ''))\n for (const [name,value] of [['progress',run.progress?.message],['output',run.output],['error',run.error]]) {\n drawerForm.querySelector('[data-' + name + '-block]').hidden = !value\n updateDetailText(drawerForm.querySelector(name === 'error' ? '[data-detail-failure]' : '[data-detail-' + name + ']'), value || '')\n }\n const output = drawerForm.querySelector('[data-detail-output]')\n if (drawerForm.querySelector('[data-follow-output]').checked) output.scrollTop = output.scrollHeight\n const artifactLabels = { pending: '保存中', metadata_only: '仅元数据', expired: '已过期', evicted: '已按容量清理', failed: '保存失败' }\n const artifactHtml = run.artifacts?.length ? run.artifacts.map((artifact) =>\n '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(artifact.name || artifact.filename || artifact.id || '未命名产物') + '</strong><small>' +\n escapeHtml([artifact.mediaType, artifact.size === undefined ? '' : artifact.size + ' 字节', artifact.downloadable ? '可下载' : artifactLabels[artifact.storageStatus] || '仅元数据'].filter(Boolean).join(' · ')) +\n '</small></div>' + (artifact.downloadable && artifact.id ? '<a class=\"button small\" download href=\"/v1/admin/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(artifact.id) + '\">下载</a>' : '') + '</div>'\n ).join('') : '<p class=\"field-help\">暂无产物</p>'\n const artifacts = drawerForm.querySelector('[data-detail-artifacts]')\n if (artifacts.dataset.signature !== artifactHtml) { artifacts.innerHTML = artifactHtml; artifacts.dataset.signature = artifactHtml }\n const controls = run.controls || {}\n const cancel = drawerForm.querySelector('[data-run-cancel]')\n const retry = drawerForm.querySelector('[data-run-retry]')\n const remove = drawerForm.querySelector('[data-run-delete]')\n cancel.disabled = !controls.canCancel\n retry.disabled = !controls.canRetry\n remove.disabled = !controls.canDelete\n updateDetailText(drawerForm.querySelector('[data-controls-reason]'), controls.unavailableReason || '')\n drawerForm.querySelector('[data-run-refresh]').onclick = () => { void refreshOpenRunDrawer() }\n cancel.onclick = () => confirmRunAction(run, 'cancel')\n retry.onclick = () => confirmRunAction(run, 'retry')\n remove.onclick = () => confirmRunAction(run, 'delete')\n for (const button of drawerForm.querySelectorAll('[data-run-copy]')) {\n const value = run[button.dataset.runCopy]\n button.disabled = !value\n button.onclick = () => runAction(async () => { await copySecret(String(value)); toast('已复制') })\n }\n renderPendingControls(target, controls.pendingRequest)\n}\n\nfunction confirmRunAction(run, action) {\n if (action === 'delete') {\n openConfirmDrawer('删除运行记录', '仅删除这条历史记录与已保存的产物文件,不影响智能体配置和客户端会话。此操作无法撤销。', '删除记录', async (isCurrent) => {\n await api('/v1/admin/runs/' + encodeURIComponent(run.id), { method: 'DELETE', body: {} })\n toast('运行记录已删除')\n void refreshRuns(false)\n if (isCurrent()) closeDrawer()\n })\n return\n }\n const retry = action === 'retry'\n openConfirmDrawer(retry ? '新会话重试' : '取消任务',\n retry ? '将用原任务文本创建新的会话,使用当前智能体配置。旧记录保留;任务已经产生的外部操作可能重复执行。' : '取消当前任务并释放智能体会话。已经完成的文件或外部操作不会撤回。',\n retry ? '新会话重试' : '取消任务', async (isCurrent) => {\n const result = await api('/v1/admin/runs/' + encodeURIComponent(run.id) + '/' + action, { method: 'POST', body: {} })\n toast(retry ? '已创建重试任务' : '任务已取消')\n void refreshRuns(false)\n if (isCurrent()) await openRunDrawer(retry ? result.runId : run.id)\n })\n}\n\nfunction renderPendingControls(target, pending) {\n const container = drawerForm.querySelector('[data-pending-container]')\n if (!pending) { container.replaceChildren(); delete container.dataset.requestId; return }\n if (container.dataset.requestId === pending.id) return\n container.dataset.requestId = pending.id\n const options = pending.options || []\n container.innerHTML = '<section class=\"run-pending\"><h3>' + (pending.kind === 'permission' ? '等待授权' : '等待补充输入') + '</h3><p class=\"run-pending-prompt\">' + escapeHtml(pending.prompt) + '</p>' +\n (options.length ? '<div class=\"field\"><label for=\"run-response-option\">选择操作</label><select id=\"run-response-option\"><option value=\"\">请选择,不会自动授权</option>' + options.map((option) => '<option value=\"' + escapeHtml(option.id) + '\">' + escapeHtml(option.name + (option.kind?.includes('always') ? '(永久)' : '')) + '</option>').join('') + '</select></div>' : '') +\n (pending.kind === 'input' ? '<div class=\"field\"><label for=\"run-response-message\">补充信息</label><textarea id=\"run-response-message\" rows=\"4\"></textarea></div>' : '') +\n '<div class=\"row-actions\"><button type=\"button\" class=\"button primary\" data-run-respond>提交' + (pending.kind === 'permission' ? '授权选择' : '补充信息') + '</button><button type=\"button\" class=\"button\" data-run-decline>拒绝</button></div><p class=\"form-error\" data-response-error role=\"alert\"></p></section>'\n const submit = async (decline) => {\n if (target.actionBusy) return\n const error = container.querySelector('[data-response-error]')\n error.textContent = ''\n const optionId = container.querySelector('#run-response-option')?.value || ''\n const message = container.querySelector('#run-response-message')?.value || ''\n if (!decline && pending.kind === 'permission' && !optionId) { error.textContent = '请选择要授权或拒绝的操作'; return }\n if (!decline && pending.kind === 'input' && !optionId && !message.trim()) { error.textContent = '请输入补充信息或选择操作'; return }\n target.actionBusy = true\n target.revision++\n container.querySelectorAll('button').forEach((button) => { button.disabled = true })\n try {\n await api('/v1/admin/runs/' + encodeURIComponent(target.id) + '/respond', {\n method: 'POST', body: { requestId: pending.id, ...(decline ? { action: 'decline' } : { action: 'accept', ...(optionId ? { optionId } : {}), ...(message ? { message } : {}) }) }\n })\n toast('已提交,等待智能体继续执行')\n if (target.version === getDrawerVersion()) { container.replaceChildren(); delete container.dataset.requestId }\n } catch (reason) {\n if (target.version === getDrawerVersion()) error.textContent = reason.message\n else toast(reason.message, true)\n } finally {\n target.actionBusy = false\n container.querySelectorAll('button').forEach((button) => { button.disabled = false })\n if (target.version === getDrawerVersion()) void refreshOpenRunDrawer()\n }\n }\n container.querySelector('[data-run-respond]').onclick = () => { void submit(false) }\n container.querySelector('[data-run-decline]').onclick = () => { void submit(true) }\n}\n",
18
- "render-settings": "import { state } from './state.js'\nimport { byId, escapeHtml, selected } from './dom.js'\n\nimport { api } from './api.js'\nimport { toast, withBusy } from './toast.js'\n\nimport { showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { markResourceSaved } from './data.js'\n\nimport { setResults, setActions, preciseUnitValue } from './render-shared.js'\n\nexport function runtimeValue(data, name, unit) {\n const value = Number(data.get(name))\n if (!Number.isFinite(value)) throw new Error('请填写有效的运行参数')\n return Math.round(value * unit)\n}\n\nexport function renderSettings() {\n const existingForm = byId('settings-form')\n const existingPasswordForm = byId('password-form')\n if (existingForm && existingPasswordForm) {\n existingForm.refreshRuntime?.()\n return\n }\n const initialRuntime = {\n sessionTtlMs: Number(state.config.sessionTtlMs || 24 * 60 * 60 * 1000),\n promptTimeoutMs: Number(state.config.promptTimeoutMs || 30 * 60 * 1000),\n cleanupIntervalMs: Number(state.config.cleanupIntervalMs || 60_000)\n }\n const runtimeUnavailable = !state.resources.config.lastSuccessAt\n const configLoading = runtimeUnavailable\n const sessionTtlHours = preciseUnitValue(initialRuntime.sessionTtlMs, 3_600_000, 24)\n const promptTimeoutMinutes = preciseUnitValue(initialRuntime.promptTimeoutMs, 60_000, 30)\n const cleanupIntervalSeconds = preciseUnitValue(initialRuntime.cleanupIntervalMs, 1000, 60)\n const initialRuntimeInput = { sessionTtlHours, promptTimeoutMinutes, cleanupIntervalSeconds }\n const theme = localStorage.getItem('agent-nexus-theme') || 'system'\n setActions('<button id=\"settings-save\" type=\"submit\" form=\"settings-form\" class=\"button primary\"' + (configLoading ? ' disabled' : '') + '>保存更改</button><button id=\"password-save\" type=\"submit\" form=\"password-form\" class=\"button\">保存密码</button>')\n setResults('<form id=\"settings-form\" class=\"settings-layout\">' +\n '<section class=\"settings-section settings-runtime\"><div class=\"settings-section-heading\"><h2>运行参数</h2>' +\n '<p class=\"settings-note\">写入网关配置文件,对所有客户端生效;会话 TTL 与清理周期立即由会话管理器采用,ACP 任务超时由新建 Session 采用。</p></div>' +\n '<div class=\"settings-fields\">' +\n '<div class=\"field\"><label for=\"session-ttl-hours\">会话空闲有效期(小时)</label><input id=\"session-ttl-hours\" name=\"sessionTtlHours\" type=\"number\" min=\"0.0166667\" max=\"720\" step=\"any\" value=\"' + escapeHtml(sessionTtlHours) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">无活动超过该时长后释放会话,支持小数小时以保留分钟级设置。默认 24 小时。</small></div>' +\n '<div class=\"field\"><label for=\"prompt-timeout-minutes\">单次 ACP 任务超时(分钟)</label><input id=\"prompt-timeout-minutes\" name=\"promptTimeoutMinutes\" type=\"number\" min=\"0.1666667\" max=\"1440\" step=\"any\" value=\"' + escapeHtml(promptTimeoutMinutes) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">超时后任务记为失败并释放资源,支持小数分钟以保留秒级设置。默认 30 分钟。</small></div>' +\n '<div class=\"field\"><label for=\"cleanup-interval-seconds\">清理任务周期(秒)</label><input id=\"cleanup-interval-seconds\" name=\"cleanupIntervalSeconds\" type=\"number\" min=\"5\" max=\"3600\" step=\"0.001\" value=\"' + escapeHtml(cleanupIntervalSeconds) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">扫描空闲会话与过期记录的间隔,支持毫秒级设置。默认 60 秒。</small></div>' +\n '</div>' +\n '<p class=\"settings-note\" data-runtime-loading role=\"status\"' + (configLoading ? '' : ' hidden') + '>运行参数尚未成功读取,暂不能保存默认值;可单独修改控制台密码。</p>' +\n '<p class=\"form-error settings-form-error\" data-form-error role=\"alert\"></p>' +\n '</section></form>' +\n '<section class=\"settings-section settings-appearance\"><div class=\"settings-section-heading\"><h2>外观</h2>' +\n '<p class=\"settings-note\">仅影响当前浏览器。</p></div>' +\n '<div class=\"settings-fields\"><div class=\"field\"><label for=\"theme-select\">界面主题</label>' +\n '<select id=\"theme-select\"><option value=\"system\"' + selected(theme, 'system') + '>跟随系统</option><option value=\"light\"' + selected(theme, 'light') + '>浅色</option><option value=\"dark\"' + selected(theme, 'dark') + '>深色</option></select>' +\n '</div></div></section>' +\n '<form id=\"password-form\" class=\"settings-layout settings-password-layout\">' +\n '<section class=\"settings-section settings-account\"><div class=\"settings-section-heading\"><h2>账户</h2></div>' +\n '<div class=\"settings-account-row\"><div class=\"settings-account-copy\"><strong>控制台密码</strong><p>用于登录当前管理控制台。</p></div>' +\n '<div class=\"settings-account-fields\"><div class=\"field\"><label for=\"settings-current-password\">当前密码</label><input id=\"settings-current-password\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-new-password\">新密码</label><input id=\"settings-new-password\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-confirm-password\">确认新密码</label><input id=\"settings-confirm-password\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div></div></div>' +\n '</section>' +\n '<p class=\"form-error settings-password-error\" data-password-error role=\"alert\"></p>' +\n '</form>')\n const form = byId('settings-form')\n const passwordForm = byId('password-form')\n // Update only the untouched runtime fields. Password entry, focus and IME\n // composition must survive a late configuration response or retry.\n form.refreshRuntime = () => {\n const save = byId('settings-save')\n if (save.getAttribute('aria-busy') === 'true') return\n const unavailable = !state.resources.config.lastSuccessAt\n save.disabled = unavailable\n if (form.dataset.dirty === 'true') return\n const fields = [\n ['sessionTtlHours', 'sessionTtlMs', 3_600_000, 86_400_000],\n ['promptTimeoutMinutes', 'promptTimeoutMs', 60_000, 1_800_000],\n ['cleanupIntervalSeconds', 'cleanupIntervalMs', 1000, 60_000]\n ]\n for (const [name, key, unit, fallback] of fields) {\n initialRuntime[key] = Number(state.config[key] ?? fallback)\n initialRuntimeInput[name] = preciseUnitValue(initialRuntime[key], unit, fallback)\n const input = form.elements.namedItem(name)\n if (input.value !== initialRuntimeInput[name]) input.value = initialRuntimeInput[name]\n input.disabled = unavailable\n }\n form.querySelector('[data-runtime-loading]').hidden = !unavailable\n }\n const markDirty = () => { form.dataset.dirty = 'true' }\n const markPasswordDirty = () => { passwordForm.dataset.dirty = 'true' }\n form.addEventListener('input', markDirty)\n form.addEventListener('change', markDirty)\n passwordForm.addEventListener('input', markPasswordDirty)\n passwordForm.addEventListener('change', markPasswordDirty)\n form.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n let values\n let runtimeChanged = false\n try {\n if (!state.resources.config.lastSuccessAt) throw new Error('运行参数尚未成功读取,请稍后重试')\n values = {\n sessionTtlMs: String(data.get('sessionTtlHours')) === initialRuntimeInput.sessionTtlHours ? initialRuntime.sessionTtlMs : runtimeValue(data, 'sessionTtlHours', 3_600_000),\n promptTimeoutMs: String(data.get('promptTimeoutMinutes')) === initialRuntimeInput.promptTimeoutMinutes ? initialRuntime.promptTimeoutMs : runtimeValue(data, 'promptTimeoutMinutes', 60_000),\n cleanupIntervalMs: String(data.get('cleanupIntervalSeconds')) === initialRuntimeInput.cleanupIntervalSeconds ? initialRuntime.cleanupIntervalMs : runtimeValue(data, 'cleanupIntervalSeconds', 1000)\n }\n runtimeChanged = String(data.get('sessionTtlHours')) !== initialRuntimeInput.sessionTtlHours ||\n String(data.get('promptTimeoutMinutes')) !== initialRuntimeInput.promptTimeoutMinutes ||\n String(data.get('cleanupIntervalSeconds')) !== initialRuntimeInput.cleanupIntervalSeconds\n } catch (reason) {\n error.textContent = reason.message\n return\n }\n let runtimeSaved = false\n let runtimeFailure\n if (runtimeChanged) {\n try {\n const result = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })\n state.config = { ...state.config, ...result }\n markResourceSaved('config')\n Object.assign(initialRuntime, values)\n for (const name of Object.keys(initialRuntimeInput)) initialRuntimeInput[name] = String(data.get(name))\n runtimeSaved = true\n } catch (reason) { runtimeFailure = reason }\n }\n if (runtimeFailure) {\n const messages = []\n if (runtimeFailure) messages.push('运行参数保存失败:' + runtimeFailure.message)\n error.textContent = messages.join(';')\n } else if (runtimeSaved) {\n const currentData = new FormData(form)\n form.dataset.dirty = String(Object.keys(initialRuntimeInput).some((name) => String(currentData.get(name)) !== initialRuntimeInput[name]))\n toast('运行参数已保存;会话 TTL 与清理周期立即生效,ACP 任务超时对新建 Session 生效')\n } else {\n form.dataset.dirty = 'false'\n toast('没有需要保存的运行参数更改')\n }\n })\n }\n passwordForm.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || byId('password-save'), async () => {\n const error = passwordForm.querySelector('[data-password-error]')\n error.textContent = ''\n const data = new FormData(passwordForm)\n const currentPassword = String(data.get('currentPassword') || '')\n const newPassword = String(data.get('newPassword') || '')\n const confirmPassword = String(data.get('confirmPassword') || '')\n try {\n if (!currentPassword || !newPassword || !confirmPassword) throw new Error('请完整填写密码修改信息')\n if (newPassword !== confirmPassword) throw new Error('两次输入的控制台密码不一致')\n if (newPassword.length < 12) throw new Error('控制台密码至少需要 12 个字符')\n if (new TextEncoder().encode(newPassword).length > 1024) throw new Error('控制台密码不能超过 1024 字节')\n if (newPassword.includes('\\u0000')) throw new Error('控制台密码包含不支持的字符')\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword, newPassword, confirmPassword } })\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n }\n byId('theme-select').onchange = (event) => {\n localStorage.setItem('agent-nexus-theme', event.target.value)\n applyTheme(event.target.value)\n }\n}\n",
18
+ "render-settings": "import { state } from './state.js'\nimport { byId, escapeHtml, selected } from './dom.js'\n\nimport { api } from './api.js'\nimport { toast, withBusy } from './toast.js'\n\nimport { showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { markResourceSaved } from './data.js'\n\nimport { setResults, setActions, preciseUnitValue } from './render-shared.js'\n\nexport function runtimeValue(data, name, unit) {\n const value = Number(data.get(name))\n if (!Number.isFinite(value)) throw new Error('请填写有效的运行参数')\n return Math.round(value * unit)\n}\n\nexport function renderSettings() {\n const existingForm = byId('settings-form')\n const existingPasswordForm = byId('password-form')\n if (existingForm && existingPasswordForm) {\n existingForm.refreshRuntime?.()\n return\n }\n const initialRuntime = {\n sessionTtlMs: Number(state.config.sessionTtlMs || 24 * 60 * 60 * 1000),\n promptTimeoutMs: Number(state.config.promptTimeoutMs || 30 * 60 * 1000),\n cleanupIntervalMs: Number(state.config.cleanupIntervalMs || 60_000)\n }\n const runtimeUnavailable = !state.resources.config.lastSuccessAt\n const configLoading = runtimeUnavailable\n const sessionTtlHours = preciseUnitValue(initialRuntime.sessionTtlMs, 3_600_000, 24)\n const promptTimeoutMinutes = preciseUnitValue(initialRuntime.promptTimeoutMs, 60_000, 30)\n const cleanupIntervalSeconds = preciseUnitValue(initialRuntime.cleanupIntervalMs, 1000, 60)\n const initialRuntimeInput = { sessionTtlHours, promptTimeoutMinutes, cleanupIntervalSeconds }\n const theme = localStorage.getItem('agent-nexus-theme') || 'system'\n setActions('<button id=\"settings-save\" type=\"submit\" form=\"settings-form\" class=\"button primary\"' + (configLoading ? ' disabled' : '') + '>保存更改</button><button id=\"password-save\" type=\"submit\" form=\"password-form\" class=\"button\">保存密码</button>')\n setResults('<form id=\"settings-form\" class=\"settings-layout\">' +\n '<section class=\"settings-section settings-runtime\"><div class=\"settings-section-heading\"><h2>运行参数</h2>' +\n '<p class=\"settings-note\">写入网关配置文件,对所有客户端生效;会话 TTL 与清理周期立即由会话管理器采用,ACP 任务超时由新建 Session 采用。</p></div>' +\n '<div class=\"settings-fields\">' +\n '<div class=\"field\"><label for=\"session-ttl-hours\">会话空闲有效期(小时)</label><input id=\"session-ttl-hours\" name=\"sessionTtlHours\" type=\"number\" min=\"0.0166667\" max=\"720\" step=\"any\" value=\"' + escapeHtml(sessionTtlHours) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">无活动超过该时长后释放会话,支持小数小时以保留分钟级设置。默认 24 小时。</small></div>' +\n '<div class=\"field\"><label for=\"prompt-timeout-minutes\">单次 ACP 任务超时(分钟)</label><input id=\"prompt-timeout-minutes\" name=\"promptTimeoutMinutes\" type=\"number\" min=\"0.1666667\" max=\"1440\" step=\"any\" value=\"' + escapeHtml(promptTimeoutMinutes) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">超时后任务记为失败并释放资源,支持小数分钟以保留秒级设置。默认 30 分钟。</small></div>' +\n '<div class=\"field\"><label for=\"cleanup-interval-seconds\">清理任务周期(秒)</label><input id=\"cleanup-interval-seconds\" name=\"cleanupIntervalSeconds\" type=\"number\" min=\"5\" max=\"3600\" step=\"0.001\" value=\"' + escapeHtml(cleanupIntervalSeconds) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">扫描空闲会话与过期记录的间隔,支持毫秒级设置。默认 60 秒。</small></div>' +\n '</div>' +\n '<p class=\"settings-note\" data-runtime-loading role=\"status\"' + (configLoading ? '' : ' hidden') + '>运行参数尚未成功读取,暂不能保存默认值;可单独修改控制台密码。</p>' +\n '<p class=\"form-error settings-form-error\" data-form-error role=\"alert\"></p>' +\n '</section></form>' +\n '<div class=\"settings-layout\"><section class=\"settings-section settings-appearance\"><div class=\"settings-section-heading\"><h2>外观</h2>' +\n '<p class=\"settings-note\">仅影响当前浏览器。</p></div>' +\n '<div class=\"settings-fields\"><div class=\"field\"><label for=\"theme-select\">界面主题</label>' +\n '<select id=\"theme-select\"><option value=\"system\"' + selected(theme, 'system') + '>跟随系统</option><option value=\"light\"' + selected(theme, 'light') + '>浅色</option><option value=\"dark\"' + selected(theme, 'dark') + '>深色</option></select>' +\n '</div></div></section></div>' +\n '<form id=\"password-form\" class=\"settings-layout settings-password-layout\">' +\n '<section class=\"settings-section settings-account\"><div class=\"settings-section-heading\"><h2>账户</h2>' +\n '<p class=\"settings-note\">控制台密码用于登录当前管理控制台。</p></div>' +\n '<div class=\"settings-fields\">' +\n '<div class=\"field\"><label for=\"settings-current-password\">当前密码</label><input id=\"settings-current-password\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-new-password\">新密码</label><input id=\"settings-new-password\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-confirm-password\">确认新密码</label><input id=\"settings-confirm-password\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div></div></section>' +\n '<p class=\"form-error settings-password-error\" data-password-error role=\"alert\"></p>' +\n '</form>')\n const form = byId('settings-form')\n const passwordForm = byId('password-form')\n // Update only the untouched runtime fields. Password entry, focus and IME\n // composition must survive a late configuration response or retry.\n form.refreshRuntime = () => {\n const save = byId('settings-save')\n if (save.getAttribute('aria-busy') === 'true') return\n const unavailable = !state.resources.config.lastSuccessAt\n save.disabled = unavailable\n if (form.dataset.dirty === 'true') return\n const fields = [\n ['sessionTtlHours', 'sessionTtlMs', 3_600_000, 86_400_000],\n ['promptTimeoutMinutes', 'promptTimeoutMs', 60_000, 1_800_000],\n ['cleanupIntervalSeconds', 'cleanupIntervalMs', 1000, 60_000]\n ]\n for (const [name, key, unit, fallback] of fields) {\n initialRuntime[key] = Number(state.config[key] ?? fallback)\n initialRuntimeInput[name] = preciseUnitValue(initialRuntime[key], unit, fallback)\n const input = form.elements.namedItem(name)\n if (input.value !== initialRuntimeInput[name]) input.value = initialRuntimeInput[name]\n input.disabled = unavailable\n }\n form.querySelector('[data-runtime-loading]').hidden = !unavailable\n }\n const markDirty = () => { form.dataset.dirty = 'true' }\n const markPasswordDirty = () => { passwordForm.dataset.dirty = 'true' }\n form.addEventListener('input', markDirty)\n form.addEventListener('change', markDirty)\n passwordForm.addEventListener('input', markPasswordDirty)\n passwordForm.addEventListener('change', markPasswordDirty)\n form.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n let values\n let runtimeChanged = false\n try {\n if (!state.resources.config.lastSuccessAt) throw new Error('运行参数尚未成功读取,请稍后重试')\n values = {\n sessionTtlMs: String(data.get('sessionTtlHours')) === initialRuntimeInput.sessionTtlHours ? initialRuntime.sessionTtlMs : runtimeValue(data, 'sessionTtlHours', 3_600_000),\n promptTimeoutMs: String(data.get('promptTimeoutMinutes')) === initialRuntimeInput.promptTimeoutMinutes ? initialRuntime.promptTimeoutMs : runtimeValue(data, 'promptTimeoutMinutes', 60_000),\n cleanupIntervalMs: String(data.get('cleanupIntervalSeconds')) === initialRuntimeInput.cleanupIntervalSeconds ? initialRuntime.cleanupIntervalMs : runtimeValue(data, 'cleanupIntervalSeconds', 1000)\n }\n runtimeChanged = String(data.get('sessionTtlHours')) !== initialRuntimeInput.sessionTtlHours ||\n String(data.get('promptTimeoutMinutes')) !== initialRuntimeInput.promptTimeoutMinutes ||\n String(data.get('cleanupIntervalSeconds')) !== initialRuntimeInput.cleanupIntervalSeconds\n } catch (reason) {\n error.textContent = reason.message\n return\n }\n let runtimeSaved = false\n let runtimeFailure\n if (runtimeChanged) {\n try {\n const result = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })\n state.config = { ...state.config, ...result }\n markResourceSaved('config')\n Object.assign(initialRuntime, values)\n for (const name of Object.keys(initialRuntimeInput)) initialRuntimeInput[name] = String(data.get(name))\n runtimeSaved = true\n } catch (reason) { runtimeFailure = reason }\n }\n if (runtimeFailure) {\n const messages = []\n if (runtimeFailure) messages.push('运行参数保存失败:' + runtimeFailure.message)\n error.textContent = messages.join(';')\n } else if (runtimeSaved) {\n const currentData = new FormData(form)\n form.dataset.dirty = String(Object.keys(initialRuntimeInput).some((name) => String(currentData.get(name)) !== initialRuntimeInput[name]))\n toast('运行参数已保存;会话 TTL 与清理周期立即生效,ACP 任务超时对新建 Session 生效')\n } else {\n form.dataset.dirty = 'false'\n toast('没有需要保存的运行参数更改')\n }\n })\n }\n passwordForm.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || byId('password-save'), async () => {\n const error = passwordForm.querySelector('[data-password-error]')\n error.textContent = ''\n const data = new FormData(passwordForm)\n const currentPassword = String(data.get('currentPassword') || '')\n const newPassword = String(data.get('newPassword') || '')\n const confirmPassword = String(data.get('confirmPassword') || '')\n try {\n if (!currentPassword || !newPassword || !confirmPassword) throw new Error('请完整填写密码修改信息')\n if (newPassword !== confirmPassword) throw new Error('两次输入的控制台密码不一致')\n if (newPassword.length < 12) throw new Error('控制台密码至少需要 12 个字符')\n if (new TextEncoder().encode(newPassword).length > 1024) throw new Error('控制台密码不能超过 1024 字节')\n if (newPassword.includes('\\u0000')) throw new Error('控制台密码包含不支持的字符')\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword, newPassword, confirmPassword } })\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n }\n byId('theme-select').onchange = (event) => {\n localStorage.setItem('agent-nexus-theme', event.target.value)\n applyTheme(event.target.value)\n }\n}\n",
19
19
  "render-shared": "import { state, pageMeta } from './state.js'\nimport { byId, pageStats, pageToolbar, pageResults, pageStatus, actions, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\n\nlet actionsPage = null\nlet actionsHtml = null\nlet toolbarSignature = null\nlet resultsHtml = null\nexport function resetSharedView() {\n actionsPage = actionsHtml = toolbarSignature = resultsHtml = null\n for (const element of [actions,pageStats,pageToolbar,pageResults,pageStatus]) element.replaceChildren()\n}\n\nexport function setResults(html) {\n if (resultsHtml === html) return\n resultsHtml = html\n const focused = pageResults.contains(document.activeElement) ? document.activeElement : null\n pageResults.innerHTML = html\n if (focused) {\n const data = Object.entries(focused.dataset)\n const replacement = Array.from(pageResults.querySelectorAll('button, input, select')).find((element) =>\n focused.id ? focused.id === element.id : data.length && data.every(([key, value]) => element.dataset[key] === value))\n ;(replacement || document.querySelector('.nav-item.active'))?.focus({ preventScroll: true })\n }\n}\n\nexport function setActions(html) {\n if (actionsPage === state.page && actionsHtml === html) return\n actionsPage = state.page\n actionsHtml = html\n actions.innerHTML = html\n}\n\nexport function setToolbar(signature, html = '') {\n if (toolbarSignature === signature) return\n toolbarSignature = signature\n pageToolbar.innerHTML = html\n}\n\nexport function announce(message) {\n const summary = pageMeta[state.page] + ':' + message\n if (pageStatus.textContent !== summary) pageStatus.textContent = summary\n}\n\nexport function stat(label, value, href) {\n const open = href ? '<a class=\"stat\" href=\"' + escapeHtml(href) + '\">' : '<div class=\"stat\">'\n const close = href ? '</a>' : '</div>'\n return open + '<span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong>' + close\n}\n\nexport function emptyState(icon, title, text, cta) {\n return '<div class=\"empty-state\">' + icon + '<strong>' + escapeHtml(title) + '</strong>' +\n (text ? '<span>' + escapeHtml(text) + '</span>' : '') +\n (cta ? '<button type=\"button\" class=\"button primary\" data-empty-action=\"' + escapeHtml(cta.action) + '\">' + icons.plus + escapeHtml(cta.label) + '</button>' : '') +\n '</div>'\n}\n\nexport function bindSearch(id, apply) {\n const input = byId(id)\n if (input.dataset.searchBound) return\n input.dataset.searchBound = 'true'\n input.addEventListener('compositionstart', () => { input.dataset.composing = 'true' })\n input.addEventListener('compositionend', () => { delete input.dataset.composing; apply(input.value) })\n input.addEventListener('input', (event) => {\n if (!event.isComposing && !input.dataset.composing) apply(input.value)\n })\n}\n\nexport function preciseUnitValue(value, unit, fallback) {\n const number = Number.isFinite(Number(value)) ? Number(value) : fallback\n const rendered = (number / unit).toFixed(7)\n return rendered.replace(/0+$/, '').replace(/\\.$/, '')\n}\n",
20
20
  "render-workspaces": "import { state } from './state.js'\nimport { byId, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { openDrawer, closeDrawer } from './drawer.js'\n\nimport { markResourceSaved } from './data.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, announce, emptyState } from './render-shared.js'\n\nexport function withinRoot(root, workspace) {\n const windows = /^[a-z]:[\\\\/]|^\\\\\\\\/i.test(root)\n const normalize = (value) => windows ? value.replace(/\\\\/g, '/').toLowerCase() : value\n const base = normalize(root).replace(/\\/+$/, '')\n const candidate = normalize(workspace)\n return candidate === base || candidate.startsWith(base + '/')\n}\n\nexport function workspaceDependents(index) {\n const roots = state.config.workspaceRoots\n return state.config.agents.filter((agent) => agent.protocol === 'acp' && agent.workspace &&\n withinRoot(roots[index], agent.workspace) &&\n !roots.some((root, other) => other !== index && withinRoot(root, agent.workspace)))\n}\n\nexport function workspaceRow(root, index, total) {\n const usedBy = state.config.agents.filter((agent) => agent.protocol === 'acp' && agent.workspace && withinRoot(root, agent.workspace)).map((agent) => escapeHtml(agent.name))\n const blocked = workspaceDependents(index).length > 0\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong class=\"path\">' + escapeHtml(root) + '</strong>' +\n '<small>' + (usedBy.length ? '使用中:' + usedBy.join('、') : '没有智能体使用') + '</small></div>' +\n '<div class=\"row-actions\"><button class=\"button small\" data-workspace-edit=\"' + index + '\">编辑</button>' +\n '<button class=\"button small danger\" data-workspace-delete=\"' + index + '\"' +\n (total === 1 ? ' disabled title=\"至少保留一个工作区\"' : blocked ? ' disabled title=\"请先修改使用此工作区的智能体配置\"' : '') + '>删除</button></div></div>'\n}\n\nexport function renderWorkspaces() {\n setActions('<button id=\"add-workspace\" class=\"button primary\">' + icons.plus + '添加工作区</button>')\n byId('add-workspace').onclick = () => openWorkspaceDrawer()\n byId('add-workspace').disabled = !state.resources.config.lastSuccessAt\n const roots = state.config.workspaceRoots || []\n const pending = !state.resources.config.lastSuccessAt && ['idle', 'loading'].includes(state.resources.config.status)\n setResults(pending ? '<div class=\"panel\"><p class=\"panel-note\">正在加载工作区配置…</p></div>' : !state.resources.config.lastSuccessAt ? '<div class=\"panel\"><p class=\"panel-note\">工作区配置读取失败,请重试后编辑。</p></div>' : '<div class=\"panel\"><div class=\"panel-header\"><h2>允许的根目录</h2><span class=\"muted\">' + roots.length + ' 个</span></div>' +\n (roots.length\n ? '<div class=\"panel-body workspace-list\">' + roots.map((root, index) => workspaceRow(root, index, roots.length)).join('') + '</div>'\n : emptyState(icons.folder, '尚未配置工作区', '', { action: 'add-workspace', label: '添加工作区' })) +\n '</div>')\n announce(roots.length + ' 个工作区')\n}\n\nexport function openWorkspaceDrawer(index) {\n if (!state.resources.config.lastSuccessAt) { toast('配置尚未成功读取,请重试后编辑', true); return }\n const editing = Number.isInteger(index)\n const current = editing ? state.config.workspaceRoots[index] : ''\n openDrawer(editing ? '编辑工作区' : '添加工作区', '<div class=\"field\"><label for=\"f-path\">允许访问的根目录</label><input id=\"f-path\" name=\"path\" value=\"' + escapeHtml(current) + '\" required></div>', editing ? '保存修改' : '添加工作区', async (form, isCurrent) => {\n const roots = state.config.workspaceRoots.slice()\n const value = String(new FormData(form).get('path') || '').trim()\n if (editing) roots[index] = value\n else roots.push(value)\n const result = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })\n state.config = result\n markResourceSaved('config')\n if (isCurrent()) closeDrawer()\n render()\n toast(editing ? '工作区已更新' : '工作区已添加')\n })\n}\n",
21
21
  "render": "// Page dispatch; rendering and write state live with each page.\nimport { state, pageMeta } from './state.js'\nimport { byId, pageStats } from './dom.js'\nimport { setToolbar, resetSharedView } from './render-shared.js'\nimport { renderOverview } from './render-overview.js'\nimport { renderRuns, resetRunView } from './render-runs.js'\nimport { renderAgents } from './render-agents.js'\nimport { renderWorkspaces } from './render-workspaces.js'\nimport { renderApiKeys, closeKeyActionMenu } from './render-keys.js'\nimport { renderSettings } from './render-settings.js'\nimport { renderArtifacts } from './render-artifacts.js'\nexport * from './render-runs.js'\nexport * from './render-agents.js'\nexport * from './render-workspaces.js'\nexport * from './render-keys.js'\nexport * from './render-artifacts.js'\nexport function resetView() {\n resetRunView()\n closeKeyActionMenu()\n resetSharedView()\n}\n\nexport function render() {\n byId('page-title').textContent = pageMeta[state.page]\n byId('page-content').closest('.main')?.classList.toggle('settings-page', state.page === 'settings')\n document.querySelectorAll('.nav-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.page === state.page)\n if (item.dataset.page === state.page) item.setAttribute('aria-current', 'page')\n else item.removeAttribute('aria-current')\n })\n if (state.page !== 'runs') pageStats.innerHTML = ''\n if (state.page !== 'runs' && state.page !== 'agents') setToolbar('')\n if (state.page === 'overview') renderOverview()\n if (state.page === 'runs') renderRuns()\n if (state.page === 'artifacts') renderArtifacts()\n if (state.page === 'agents') renderAgents()\n if (state.page === 'workspaces') renderWorkspaces()\n if (state.page === 'keys') renderApiKeys()\n if (state.page === 'settings') renderSettings()\n}\n",
@@ -611,13 +611,7 @@ tr[data-run-detail] { cursor: pointer; }
611
611
  .artifact-table th:nth-child(4), .artifact-table th:nth-child(5) { width: 120px; }
612
612
  .artifact-table th:nth-child(6) { width: 180px; }
613
613
  .artifact-table td { height: 56px; }
614
- .artifact-thumb-button {
615
- display: block; width: 44px; height: 44px;
616
- padding: 0; border-radius: var(--radius);
617
- overflow: hidden;
618
- }
619
- .artifact-thumb { display: block; width: 44px; height: 44px; object-fit: cover; border: 1px solid hsl(var(--border)); border-radius: var(--radius); background: hsl(var(--muted) / .4); }
620
- /* 非图片产物的类型图标瓦片 */
614
+ /* 产物类型图标瓦片(默认不加载内容,点击后进详情抽屉预览) */
621
615
  .artifact-tile {
622
616
  display: grid; place-items: center;
623
617
  width: 44px; height: 44px;
@@ -629,9 +623,17 @@ tr[data-run-detail] { cursor: pointer; }
629
623
  }
630
624
  .artifact-tile:hover { background: hsl(var(--accent)); color: hsl(var(--accent-foreground)); }
631
625
  .artifact-tile svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
626
+ .artifact-tile svg.filled { fill: currentColor; stroke: none; }
632
627
  .artifact-preview-wrap { display: grid; justify-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4); }
628
+ .artifact-link-row { display: grid; gap: var(--sp-1); margin-bottom: var(--sp-4); }
629
+ .artifact-link-line { display: flex; align-items: center; gap: var(--sp-2); }
630
+ .artifact-link-line code { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--fs-xs); overflow-wrap: anywhere; }
631
+ .artifact-link-line code a { color: hsl(var(--foreground)); text-decoration: underline; text-underline-offset: 2px; }
632
+ .artifact-link-line code a:hover { color: hsl(var(--accent-foreground)); }
633
+ .artifact-link-line .button { flex-shrink: 0; }
633
634
  .artifact-preview { max-width: 100%; max-height: 320px; border: 1px solid hsl(var(--border)); border-radius: var(--radius); background: hsl(var(--muted) / .4); }
634
635
  .artifact-preview-wrap audio { width: 100%; }
636
+ .artifact-text-preview { width: 100%; max-height: 480px; }
635
637
 
636
638
  /* ── Drawer ───────────────────────────────────────────────────────── */
637
639
  .drawer-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .4); z-index: var(--z-backdrop); animation: fadeIn var(--duration-fade) var(--ease-out); }
@@ -674,12 +676,9 @@ tr[data-run-detail] { cursor: pointer; }
674
676
  .main.settings-page .page-actions { position: absolute; right: max(var(--gutter), calc((100% - 820px) / 2)); }
675
677
  .main.settings-page .page-content { padding-top: var(--sp-8); }
676
678
  .settings-layout { width: min(100%, 820px); margin-inline: auto; }
679
+ /* 设置页顶层布局块之间用分隔线(每节各自包在 .settings-layout 里) */
680
+ .settings-layout + .settings-layout { margin-top: 48px; padding-top: 48px; border-top: 1px solid hsl(var(--border)); }
677
681
  .settings-section { width: 100%; }
678
- .settings-section + .settings-section {
679
- margin-top: 48px;
680
- padding-top: 48px;
681
- border-top: 1px solid hsl(var(--border));
682
- }
683
682
  .settings-section-heading { display: grid; gap: var(--sp-2); margin-bottom: 28px; }
684
683
  .settings-section-heading h2 { font-size: var(--fs-lg); font-weight: 600; letter-spacing: -.01em; }
685
684
  /* 节说明写的是生效范围——这是界面自身无法表达的信息,不要当复述删掉 */
@@ -689,16 +688,6 @@ tr[data-run-detail] { cursor: pointer; }
689
688
  .settings-fields .field input, .settings-fields .field select { max-width: none; }
690
689
  .settings-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: 28px; }
691
690
  .settings-form-error { width: min(100%, 520px); margin-top: 32px; }
692
- .settings-account-row {
693
- display: grid;
694
- grid-template-columns: minmax(160px, 200px) minmax(0, 1fr);
695
- align-items: start;
696
- gap: 32px;
697
- }
698
- .settings-account-copy { display: grid; gap: var(--sp-1); }
699
- .settings-account-copy strong { font-size: var(--fs-sm); font-weight: 600; }
700
- .settings-account-copy p { color: hsl(var(--muted-foreground)); font-size: var(--fs-sm); }
701
- .settings-account-fields { display: grid; gap: 24px; width: min(100%, 520px); }
702
691
 
703
692
  /* ── Toast ────────────────────────────────────────────────────────── */
704
693
  .toast-region {
@@ -772,9 +761,8 @@ tr[data-run-detail] { cursor: pointer; }
772
761
  @media (max-width: 640px) {
773
762
  .page-content { padding: var(--sp-4) var(--gutter) var(--sp-4); }
774
763
  .main.settings-page .page-content { padding-top: var(--sp-6); }
775
- .settings-section + .settings-section { margin-top: 40px; padding-top: 40px; }
764
+ .settings-layout + .settings-layout { margin-top: 40px; padding-top: 40px; }
776
765
  .settings-section-heading { margin-bottom: 24px; }
777
- .settings-account-row { grid-template-columns: 1fr; gap: var(--sp-4); }
778
766
  .search-box { max-width: none; }
779
767
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
780
768
  .run-detail-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); }