nexus-agentd 0.2.3 → 0.2.6

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.
package/dist/types.d.ts CHANGED
@@ -157,15 +157,29 @@ export interface AgentdApiKeyPrincipal {
157
157
  id: string;
158
158
  scope: AgentdApiKeyScope;
159
159
  }
160
+ export type AgentdPendingInputType = 'text' | 'choice' | 'confirmation' | 'payment' | 'unknown';
160
161
  export interface AgentdPendingRequest {
161
162
  id: string;
162
163
  kind: 'permission' | 'input';
163
164
  prompt: string;
165
+ /** Short, machine-readable step id derived from the elicitation source. */
166
+ step?: string;
167
+ /**
168
+ * Semantic classification of the pending prompt. Clients may specialise
169
+ * their UX for `payment`, `confirmation`, `choice`, etc.
170
+ */
171
+ inputType?: AgentdPendingInputType;
164
172
  options?: Array<{
165
173
  id: string;
166
174
  name: string;
167
175
  kind?: string;
168
176
  }>;
177
+ /**
178
+ * Extra fields extracted from the underlying elicitation (e.g. paymentUrl,
179
+ * confirmation flags, or ACP form defaults). Kept as a plain record so we
180
+ * do not couple the wire type to specific business schemas.
181
+ */
182
+ metadata?: Record<string, unknown>;
169
183
  }
170
184
  export interface AgentdPendingResponse {
171
185
  requestId: string;
@@ -0,0 +1 @@
1
+ export declare const sources: Record<string, string>;
@@ -0,0 +1,17 @@
1
+ // This file is generated by scripts/generate-webui-sources.mjs — do not edit by hand.
2
+ // It embeds the browser-side ES modules so the Node server can serve them
3
+ // without shipping raw .js assets on disk.
4
+ export const sources = {
5
+ "api": "import { showLogin } from './screens.js'\n\nexport async function api(path, options) {\n const settings = Object.assign({ credentials: 'same-origin' }, options || {})\n settings.headers = Object.assign({}, settings.headers || {})\n if (settings.body && typeof settings.body !== 'string') {\n settings.headers['Content-Type'] = 'application/json'\n settings.body = JSON.stringify(settings.body)\n }\n const response = await fetch(path, settings)\n let value = null\n try { value = await response.json() } catch {}\n if (!response.ok) {\n if (response.status === 401 && path.indexOf('/v1/admin/') === 0) showLogin()\n const message = value && value.error ? value.error : '请求失败(HTTP ' + response.status + ')'\n const error = new Error(localizeError(message))\n error.status = response.status\n throw error\n }\n return value\n}\n\nexport function localizeError(message) {\n const messages = {\n 'Invalid Console Password': '控制台密码不正确',\n 'Console setup is required': '请先完成控制台初始化',\n 'Console setup is already complete': '控制台已完成初始化',\n 'Console Password confirmation does not match': '两次输入的控制台密码不一致',\n 'Console Password must contain at least 12 characters': '控制台密码至少需要 12 个字符',\n 'Console Password must not exceed 1024 bytes': '控制台密码不能超过 1024 字节',\n 'Console Password must not contain NUL bytes': '控制台密码包含不支持的字符',\n 'Current Console Password is incorrect': '当前控制台密码不正确',\n 'Gateway setup is required': '请先完成网关初始化',\n 'Admin session is required': '管理员会话已失效,请重新登录',\n 'Bearer API Key is required': '缺少 Bearer API 密钥',\n 'Invalid API Key': 'API 密钥无效',\n 'API Key is not authorized for this agent': '该 API 密钥无权访问此智能体',\n 'API Key already exists': 'API 密钥已存在',\n 'API Key not found': '未找到 API 密钥',\n 'API Key name is required': '请输入 API 密钥名称',\n 'API Key name is too long': 'API 密钥名称过长',\n 'Custom API Key must contain at least 16 characters': '自定义 API 密钥至少需要 16 个字符',\n 'Custom API Key must not exceed 512 bytes': '自定义 API 密钥不能超过 512 字节',\n 'Custom API Key contains an unsupported value': '自定义 API 密钥包含不支持的内容',\n 'workspaceRoots must contain at least one path': '至少需要保留一个工作区根目录',\n 'A2A authentication value is required': '请输入 A2A 认证凭据',\n 'A2A authentication value is invalid': 'A2A 认证凭据无效',\n 'A2A Agent Card URL is required': '请输入 Agent Card URL',\n 'A2A Agent Card URL is invalid': 'Agent Card URL 无效',\n 'A2A Agent Card URL must use http(s) without credentials or a fragment': 'Agent Card URL 必须使用 HTTP(S),且不能包含凭据或片段',\n 'preferredTransport must be auto, jsonrpc, or http-json': '首选传输必须是自动、JSON-RPC 或 HTTP+JSON',\n 'A valid A2A header name is required': '请输入有效的 A2A 请求头名称',\n 'Too many authentication attempts': '认证失败次数过多,请稍后再试',\n 'Request body is too large': '请求内容过大',\n 'Request requires application/json': '请求必须使用 application/json',\n 'Same-origin request is required': '此操作必须从当前控制台页面发起',\n 'Request origin is invalid': '请求来源无效',\n 'Cross-origin admin request is not allowed': '不允许跨域管理请求',\n 'Control plane is unavailable': '管理控制面当前不可用',\n 'Agent Nexus run not found': '未找到运行记录',\n 'Internal server error': '服务器内部错误'\n }\n if (messages[message]) return messages[message]\n const workspaceConflict = /^Workspace change would exclude agent: (.+)$/.exec(message)\n if (workspaceConflict) return '请先修改智能体「' + workspaceConflict[1] + '」的工作区,再删除或编辑此工作区'\n const missingAgent = /^Configured agent not found: (.+)$/.exec(message)\n if (missingAgent) return '未找到已配置的智能体:' + missingAgent[1]\n const timeout = /^timeout must be between (\\d+) and (\\d+)$/.exec(message)\n if (timeout) return '超时时间必须介于 ' + Math.round(Number(timeout[1]) / 1000) + ' 和 ' + Math.round(Number(timeout[2]) / 1000) + ' 秒之间'\n const runtimeRange = /^(sessionTtlMs|promptTimeoutMs|cleanupIntervalMs) must be between (\\d+) and (\\d+)$/.exec(message)\n if (runtimeRange) {\n const units = {\n sessionTtlMs: ['会话空闲有效期', '小时', 3_600_000],\n promptTimeoutMs: ['ACP 任务超时', '分钟', 60_000],\n cleanupIntervalMs: ['清理任务周期', '秒', 1000]\n }\n const [label, unit, divisor] = units[runtimeRange[1]]\n return label + '必须介于 ' + Math.round(Number(runtimeRange[2]) / divisor) + ' 和 ' + Math.round(Number(runtimeRange[3]) / divisor) + ' ' + unit + '之间'\n }\n return message\n}\n",
6
+ "data": "import { state } from './state.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\n\nexport async function loadAll(force) {\n const suffix = force ? '?refresh=1' : ''\n const values = await Promise.all([\n api('/v1/admin/config'),\n api('/v1/admin/api-keys'),\n api('/v1/admin/overview' + suffix),\n api('/v1/admin/runs?limit=200')\n ])\n state.config = values[0]\n state.apiKeys = values[1].apiKeys || []\n state.readiness = values[2].agents || []\n state.sessions = values[2].sessions || 0\n state.runs = values[3].runs || []\n state.runTotal = values[3].total || 0\n state.runStats = values[3].stats || { active: 0, completed: 0, failed: 0 }\n}\n\nexport async function refreshRuns(showNotice) {\n const { renderRuns } = await import('./render.js')\n try {\n const value = await api('/v1/admin/runs?limit=200')\n state.runs = value.runs || []\n state.runTotal = value.total || 0\n state.runStats = value.stats || { active: 0, completed: 0, failed: 0 }\n if (state.page === 'runs') renderRuns()\n } catch (error) {\n if (showNotice) toast(error.message, true)\n }\n}\n\nexport async function refreshReadiness(showNotice) {\n const { render } = await import('./render.js')\n try {\n const overview = await api('/v1/admin/overview?refresh=1')\n state.readiness = overview.agents || []\n state.sessions = overview.sessions || 0\n if (state.page === 'overview' || state.page === 'agents') render()\n } catch (error) {\n if (showNotice) toast(error.message, true)\n }\n}\n",
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
+ "drawer": "import { byId, drawer, drawerBackdrop, drawerForm, drawerFooter, keyActionMenu, escapeHtml } from './dom.js'\nimport { runAction } from './toast.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 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 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
+ "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",
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 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
+ "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 { loadAll, refreshRuns, refreshReadiness } from './data.js'\nimport { closeDrawer, getDrawerSubmit, getDrawerVersion, openConfirmDrawer, handleDrawerKeydown } from './drawer.js'\nimport {\n render,\n openRunDrawer,\n openAgentDrawer,\n openWorkspaceDrawer,\n workspaceDependents,\n openKeyDrawer,\n openKeyScopeDrawer,\n openRenameKeyDrawer,\n showSecret,\n copySecret,\n reloadKeys,\n toggleKeyActionMenu,\n handleKeyMenuKeydown,\n closeKeyActionMenu\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 密钥', '当前密钥会立即失效,使用它的客户端必须改用新密钥。', '重新生成', async (isCurrent) => {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/regenerate', { method: 'POST' })\n await reloadKeys()\n if (isCurrent()) showSecret('API 密钥已重新生成', value.secret)\n else toast('API 密钥已重新生成,可在密钥列表中显示或复制')\n })\n return\n }\n if (action === 'delete') {\n openConfirmDrawer('删除 API 密钥', '使用该密钥的客户端会立即失去访问权限,此操作无法撤销。', '删除密钥', async () => {\n await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' })\n await reloadKeys()\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 await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { enabled: !key.enabled } })\n await reloadKeys()\n toast(key.enabled ? 'API 密钥已禁用' : 'API 密钥已启用')\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.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.agentDelete) {\n const id = button.dataset.agentDelete\n openConfirmDrawer('删除智能体', '删除后该智能体不再接受新任务,已有会话不受影响。', '删除智能体', async () => {\n state.config = await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'DELETE' })\n await loadAll(true)\n render()\n toast('智能体已删除')\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 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: { 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 render()\n if (state.page === 'runs') void refreshRuns(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)\n\n/* ── Pollers ──────────────────────────────────────────────────────── */\n\nsetInterval(() => {\n if (state.authenticated) void refreshReadiness(false)\n}, 20_000)\n\nsetInterval(() => {\n if (state.authenticated && state.page === 'runs') void refreshRuns(false)\n}, 5_000)\n\nvoid boot()\n",
12
+ "render": "import { state, pageMeta } from './state.js'\nimport { byId, pageStats, pageToolbar, pageResults, pageStatus, actions, keyActionMenu, drawerForm, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api, localizeError } from './api.js'\nimport { toast, runAction, withBusy } from './toast.js'\nimport { openDrawer, closeDrawer, getDrawerVersion } from './drawer.js'\nimport { showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { loadAll, refreshRuns, refreshReadiness } from './data.js'\nimport { shortId, formatDate, formatDuration, runStatusLabel, permissionLabel } from './format.js'\n\nlet actionsPage = null\nlet toolbarSignature = null\nlet resultsHtml = null\n\nfunction 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\nfunction setActions(html) {\n if (actionsPage === state.page) return\n actionsPage = state.page\n actions.innerHTML = html\n}\n\nfunction setToolbar(signature, html = '') {\n if (toolbarSignature === signature) return\n toolbarSignature = signature\n pageToolbar.innerHTML = html\n}\n\nfunction announce(message) {\n const summary = pageMeta[state.page] + ':' + message\n if (pageStatus.textContent !== summary) pageStatus.textContent = summary\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 === 'agents') renderAgents()\n if (state.page === 'workspaces') renderWorkspaces()\n if (state.page === 'keys') renderApiKeys()\n if (state.page === 'settings') renderSettings()\n}\n\n/* ── Overview ─────────────────────────────────────────────────────── */\n\nfunction stat(label, value) {\n return '<div class=\"stat\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n}\n\nfunction 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\">就绪</span>' : '<span class=\"status failed\">不可用</span>'\n}\n\nfunction readinessById() {\n return new Map(state.readiness.map((agent) => [agent.id, agent]))\n}\n\n/* 就绪结果里没有的 Agent 视为「尚未探测」,不能当成不可用 */\nfunction agentReadiness(agent, lookup) {\n return lookup.get(agent.id) || {\n id: agent.id,\n name: agent.name,\n protocol: agent.protocol,\n driver: agent.driver,\n enabled: agent.enabled,\n ready: false,\n checking: true\n }\n}\n\nexport function readinessRows() {\n const lookup = readinessById()\n return state.config.agents.map((agent) => agentReadiness(agent, lookup))\n}\n\nfunction 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>' + statusMarkup(agent) + '</div>'\n}\n\nfunction renderOverview() {\n const agents = readinessRows()\n const ready = agents.filter((agent) => agent.ready).length\n const checking = agents.filter((agent) => agent.enabled && agent.checking).length\n const attention = agents.filter((agent) => agent.enabled && !agent.checking && (!agent.ready || agent.error))\n setActions('<button id=\"refresh-overview\" class=\"button\">' + icons.refresh + '刷新</button>')\n const body = agents.length === 0\n ? emptyState(icons.robot, '尚未配置智能体', '', { action: 'add-agent', label: '添加智能体' })\n : attention.length\n ? '<div class=\"panel-body overview-list\">' + attention.map(overviewAgent).join('') + '</div>'\n : '<p class=\"panel-note\">' + (checking ? '正在检查 ' + checking + ' 个智能体。' : '全部就绪。') + '</p>'\n setResults('<div class=\"stats\">' +\n stat('智能体', agents.length) +\n stat('就绪', ready) +\n stat('会话', state.sessions) +\n '</div>' +\n '<div class=\"panel\"><div class=\"panel-header\"><h2>需要注意</h2><span class=\"muted\">' + attention.length + ' 个</span></div>' +\n body +\n '</div>')\n byId('refresh-overview').onclick = (event) => withBusy(event.currentTarget, () => refreshReadiness(true))\n announce(agents.length + ' 个智能体,' + ready + ' 个就绪,' + attention.length + ' 个需要注意')\n}\n\n/* ── Runs ─────────────────────────────────────────────────────────── */\n\nfunction 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\nfunction 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(run.task) + '</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\nfunction runDetailButton(run) {\n return '<button type=\"button\" class=\"button small run-detail-button\" data-run-detail=\"' + escapeHtml(run.id) + '\" aria-label=\"查看运行详情:' + escapeHtml(run.task) + '\">查看详情</button>'\n}\n\nfunction 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(run.task) + '</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 const activeStates = new Set(['running', 'input_required', 'permission_required'])\n const filtered = state.runs.filter((run) => {\n const haystack = (run.task + ' ' + run.agentName + ' ' + run.id).toLowerCase()\n return haystack.indexOf(state.runSearch.toLowerCase()) >= 0 &&\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) {\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 setToolbar('runs|' + 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></div>')\n setResults((state.runTotal > state.runs.length ? '<p class=\"muted\">已载入最近 ' + state.runs.length + ' 条记录,筛选仅作用于这些记录。</p>' : '') +\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 bindSearch('run-search', (value) => { state.runSearch = value; renderRuns() })\n byId('run-agent-filter').onchange = (event) => { state.runAgent = event.target.value; renderRuns() }\n byId('run-status-filter').onchange = (event) => { state.runStatus = event.target.value; renderRuns() }\n announce(active.length + ' 项当前运行,' + history.length + ' 项历史记录')\n}\n\nfunction 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 drawerForm.setAttribute('aria-busy', 'true')\n const version = getDrawerVersion()\n let run\n try {\n run = await api('/v1/admin/runs/' + encodeURIComponent(id))\n } catch (error) {\n if (version === getDrawerVersion()) {\n drawerForm.removeAttribute('aria-busy')\n drawerForm.innerHTML = '<p class=\"form-error\" role=\"alert\">' + escapeHtml(error.message) + '</p>'\n }\n return\n }\n if (version !== getDrawerVersion()) return\n const artifacts = run.artifacts && run.artifacts.length\n ? '<div class=\"run-detail-block\"><h3>产物</h3><div class=\"overview-list\">' + run.artifacts.map((artifact) => '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(artifact.name || artifact.filename || artifact.id || '未命名产物') + '</strong><small>' + escapeHtml(artifact.mediaType || artifact.url || '') + '</small></div></div>').join('') + '</div></div>'\n : ''\n const body = '<div class=\"run-detail-grid\">' +\n detailItem('智能体', run.agentName) + detailItem('状态', runStatusLabel(run.state)) +\n detailItem('开始时间', formatDate(run.startedAt)) + detailItem('耗时', formatDuration(run)) +\n detailItem('协议', run.protocol.toUpperCase()) + detailItem('运行 ID', run.id) +\n detailItem('当前阶段', run.progress && run.progress.phase || '—') + detailItem('输入附件', run.inputAttachmentCount || 0) + detailItem('产物', run.artifactCount || 0) +\n '</div><div class=\"run-detail-block\"><h3>用户任务</h3><pre class=\"run-detail-code\">' + escapeHtml(run.task) + (run.taskTruncated ? '\\n\\n[记录已截断]' : '') + '</pre></div>' +\n (run.progress && run.progress.message ? '<div class=\"run-detail-block\"><h3>最近进度</h3><pre class=\"run-detail-code\">' + escapeHtml(run.progress.message) + '</pre></div>' : '') +\n (run.output ? '<div class=\"run-detail-block\"><h3>智能体结果</h3><pre class=\"run-detail-code\">' + escapeHtml(run.output) + '</pre></div>' : '') +\n (run.error ? '<div class=\"run-detail-block\"><h3>错误</h3><pre class=\"run-detail-code\">' + escapeHtml(run.error) + '</pre></div>' : '') + artifacts\n byId('drawer-title').textContent = '运行详情 · ' + run.agentName\n drawerForm.innerHTML = body\n drawerForm.removeAttribute('aria-busy')\n}\n\n/* ── Agents ───────────────────────────────────────────────────────── */\n\nfunction agentRow(agent, live) {\n const statusAgent = live || { ready: false, enabled: agent.enabled, checking: true }\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 danger\" data-agent-delete=\"' + escapeHtml(agent.id) + '\">删除</button></div></td></tr>'\n}\n\nfunction renderAgents() {\n setActions('<button id=\"refresh-agents\" class=\"button\">' + icons.refresh + '刷新</button><button id=\"add-agent\" class=\"button primary\">' + icons.plus + '添加智能体</button>')\n byId('refresh-agents').onclick = (event) => withBusy(event.currentTarget, () => refreshReadiness(true))\n byId('add-agent').onclick = () => openAgentDrawer()\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 = !lookup.has(agent.id) ? 'checking' : live.ready ? 'ready' : agent.enabled ? 'failed' : 'disabled'\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\n/* ── Workspaces ───────────────────────────────────────────────────── */\n\nfunction withinRoot(root, workspace) {\n const base = root.replace(/\\/+$/, '')\n return workspace === base || workspace.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\nfunction 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\nfunction renderWorkspaces() {\n setActions('<button id=\"add-workspace\" class=\"button primary\">' + icons.plus + '添加工作区</button>')\n byId('add-workspace').onclick = () => openWorkspaceDrawer()\n const roots = state.config.workspaceRoots || []\n setResults('<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\n/* ── API keys ─────────────────────────────────────────────────────── */\n\nfunction 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\nfunction renderApiKeys() {\n closeKeyActionMenu()\n setActions('<button id=\"create-key\" class=\"button primary\">' + icons.plus + '创建 API 密钥</button>')\n setResults(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 announce(state.apiKeys.length + ' 个 API 密钥')\n}\n\nfunction 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\nfunction 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\n/* ── Settings ─────────────────────────────────────────────────────── */\n\n/* 三节的划分依据是「改它会不会影响其他客户端」:运行参数写进配置文件、对所有客户端生效;\n 外观只写 localStorage;账户只关乎当前管理员。两句节说明因此必须保留。 */\nfunction renderSettings() {\n const sessionTtlHours = Math.round((state.config.sessionTtlMs || 24 * 60 * 60 * 1000) / 3_600_000)\n const promptTimeoutMinutes = Math.round((state.config.promptTimeoutMs || 30 * 60 * 1000) / 60_000)\n const cleanupIntervalSeconds = Math.round((state.config.cleanupIntervalMs || 60_000) / 1000)\n const theme = localStorage.getItem('agent-nexus-theme') || 'system'\n setActions('<button type=\"submit\" form=\"settings-form\" class=\"button primary\">保存更改</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\">写入网关配置文件,对所有客户端生效。</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=\"1\" max=\"720\" step=\"1\" value=\"' + escapeHtml(sessionTtlHours) + '\"><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=\"1\" max=\"1440\" step=\"1\" value=\"' + escapeHtml(promptTimeoutMinutes) + '\"><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=\"1\" value=\"' + escapeHtml(cleanupIntervalSeconds) + '\"><small class=\"field-help\">扫描空闲会话与过期记录的间隔。默认 60 秒。</small></div>' +\n '</div>' +\n '</section>' +\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 '<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-form-error\" data-form-error role=\"alert\"></p>' +\n '</form>')\n const form = byId('settings-form')\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 const currentPassword = String(data.get('currentPassword') || '')\n const newPassword = String(data.get('newPassword') || '')\n const confirmPassword = String(data.get('confirmPassword') || '')\n const changingPassword = Boolean(currentPassword || newPassword || confirmPassword)\n const values = {\n sessionTtlMs: Number(data.get('sessionTtlHours')) * 3_600_000,\n promptTimeoutMs: Number(data.get('promptTimeoutMinutes')) * 60_000,\n cleanupIntervalMs: Number(data.get('cleanupIntervalSeconds')) * 1000\n }\n try {\n state.config = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })\n if (changingPassword) {\n if (!currentPassword || !newPassword || !confirmPassword) throw new Error('请完整填写密码修改信息')\n if (newPassword !== confirmPassword) throw new Error('两次输入的控制台密码不一致')\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword, newPassword, confirmPassword } })\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n return\n }\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\n/* ── Drawers: agent / workspace / key / password ──────────────────── */\n\nexport function openAgentDrawer(agent) {\n const editing = Boolean(agent)\n const current = agent || { protocol: 'acp', enabled: true, permissionPolicy: 'ask', permissionTimeoutMs: 900000, timeoutMs: 60000 }\n const permissionTimeoutSeconds = Math.max(1, Math.round(Number(current.permissionTimeoutMs || 900000) / 1000))\n const requestTimeoutSeconds = Math.max(1, Math.round(Number(current.timeoutMs || 60000) / 1000))\n const drivers = state.config.driverKinds.map((driver) => '<option value=\"' + escapeHtml(driver) + '\"' + selected(current.driver || state.config.driverKinds[0], driver) + '>' + escapeHtml(driver) + '</option>').join('')\n const roots = state.config.workspaceRoots.map((root) => '<option value=\"' + escapeHtml(root) + '\"></option>').join('')\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 + '</select></div><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=\"1\" value=\"' + escapeHtml(permissionTimeoutSeconds) + '\"><small class=\"field-help\">默认 900 秒,最长 24 小时;选择「始终允许」时不生效。</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=\"1\" value=\"' + escapeHtml(requestTimeoutSeconds) + '\"><small class=\"field-help\">单次请求上限。默认 60 秒,最大 1800 秒。</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 = 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 = Number(data.get('timeoutMs')) * 1000\n }\n await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'PUT', body: payload })\n if (isCurrent()) closeDrawer()\n await loadAll(true)\n render()\n toast(editing ? '智能体已更新' : '智能体已添加')\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 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 sync()\n}\n\nfunction drawerFormElements() {\n return byId('drawer-form').elements\n}\n\nexport function openWorkspaceDrawer(index) {\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 if (isCurrent()) closeDrawer()\n render()\n toast(editing ? '工作区已更新' : '工作区已添加')\n })\n}\n\nfunction 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 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 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 = '<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 await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })\n if (isCurrent()) closeDrawer()\n await reloadKeys()\n toast('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 await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { name } })\n if (isCurrent()) closeDrawer()\n await reloadKeys()\n toast('API 密钥已重命名')\n })\n const input = byId('f-key-name')\n if (input) setTimeout(() => input.select(), 0)\n}\n\nexport async function reloadKeys() {\n const value = await api('/v1/admin/api-keys')\n state.apiKeys = value.apiKeys || []\n render()\n}\n\n/* ── Key action menu ──────────────────────────────────────────────── */\n\nlet menuAnchor = null\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",
13
+ "screens": "import { state } from './state.js'\nimport { setupScreen, loginScreen, appRoot, byId } from './dom.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { loadAll } from './data.js'\nimport { render } from './render.js'\nimport { applyTheme } from './theme.js'\nimport { closeDrawer } from './drawer.js'\n\nfunction showOnly(element) {\n byId('boot-screen').classList.add('hidden')\n setupScreen.classList.add('hidden')\n loginScreen.classList.add('hidden')\n appRoot.classList.add('hidden')\n element.classList.remove('hidden')\n}\n\nexport function showLogin() {\n state.authenticated = false\n closeDrawer()\n showOnly(loginScreen)\n byId('login-form').reset()\n byId('login-password').focus()\n}\n\nexport async function boot() {\n applyTheme(localStorage.getItem('agent-nexus-theme') || 'system')\n try {\n const bootstrap = await api('/v1/bootstrap/status')\n if (bootstrap.adminSetupRequired) {\n showOnly(setupScreen)\n byId('setup-password').focus()\n return\n }\n const auth = await api('/v1/admin/auth/status')\n if (!auth.authenticated) {\n showLogin()\n return\n }\n await enterApp()\n } catch (error) {\n showLogin()\n toast(error.message, true)\n }\n}\n\nexport async function enterApp() {\n showOnly(byId('boot-screen'))\n try {\n await loadAll()\n state.authenticated = true\n showOnly(appRoot)\n render()\n } catch (error) {\n showLogin()\n throw error\n }\n}\n",
14
+ "state": "export const state = {\n page: 'overview',\n config: { workspaceRoots: [], driverKinds: [], agents: [] },\n readiness: [],\n apiKeys: [],\n runs: [],\n runTotal: 0,\n runStats: { active: 0, completed: 0, failed: 0 },\n sessions: 0,\n search: '',\n protocol: 'all',\n status: 'all',\n runSearch: '',\n runAgent: 'all',\n runStatus: 'all',\n authenticated: false\n}\n\nexport const pageMeta = {\n overview: '总览',\n runs: '运行记录',\n agents: '智能体',\n workspaces: '工作区',\n keys: 'API 密钥',\n settings: '设置'\n}\n",
15
+ "theme": "import { byId } from './dom.js'\n\nexport function applyTheme(preference) {\n const theme = preference === 'system'\n ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')\n : preference\n document.documentElement.dataset.theme = theme\n const select = byId('theme-select')\n if (select) select.value = preference\n}\n\n",
16
+ "toast": "import { byId } from './dom.js'\n\nexport function toast(message, error) {\n const item = document.createElement('div')\n item.className = 'toast' + (error ? ' error' : '')\n item.textContent = message\n byId(error ? 'toast-alert' : 'toast-status').appendChild(item)\n const region = byId('notifications')\n if (region.closest('#drawer')) region.scrollTop = region.scrollHeight\n setTimeout(() => item.remove(), 4200)\n}\n\nexport async function runAction(task) {\n try { await task() } catch (error) { toast(error.message, true) }\n}\n\nexport async function withBusy(button, task) {\n if (!button) return task()\n if (button.getAttribute('aria-busy') === 'true') return\n const disabled = button.disabled\n button.disabled = true\n button.setAttribute('aria-busy', 'true')\n try { return await task() } finally {\n button.disabled = disabled\n button.removeAttribute('aria-busy')\n }\n}\n"
17
+ };
@@ -5,4 +5,21 @@ export declare const icons: {
5
5
  workspaces: string;
6
6
  keys: string;
7
7
  settings: string;
8
+ sessions: string;
9
+ gateway: string;
10
+ refresh: string;
11
+ plus: string;
12
+ sun: string;
13
+ moon: string;
14
+ monitor: string;
15
+ menu: string;
16
+ chevronRight: string;
17
+ close: string;
18
+ logout: string;
19
+ lock: string;
20
+ inbox: string;
21
+ shield: string;
22
+ check: string;
23
+ alert: string;
24
+ search: string;
8
25
  };
@@ -1,9 +1,26 @@
1
1
  const base = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`;
2
2
  export const icons = {
3
- overview: base('<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>'),
4
- runs: base('<path d="M4 4v16h16"/><path d="m7 15 3-4 3 2 4-6"/><circle cx="7" cy="15" r="1"/><circle cx="10" cy="11" r="1"/><circle cx="13" cy="13" r="1"/><circle cx="17" cy="7" r="1"/>'),
5
- agents: base('<path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M9 13h.01M15 13h.01M9 17h6"/>'),
6
- workspaces: base('<path d="M3 7.5h7l2 2h9v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7.5V5a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v2.5"/>'),
7
- keys: base('<circle cx="8" cy="15" r="4"/><path d="m11 12 8-8M15 8l3 3M17 6l3 3"/>'),
8
- settings: base('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21H9.6v-.09A1.7 1.7 0 0 0 8.5 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3V9.6h.09A1.7 1.7 0 0 0 4.6 8.5a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.09A1.7 1.7 0 0 0 15.5 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.15.38.38.72.7 1 .3.27.7.4 1.1.4h.09v4h-.09c-.4 0-.8.14-1.1.4-.32.28-.55.62-.7 1Z"/>')
3
+ overview: base('<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>'),
4
+ runs: base('<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"/>'),
5
+ agents: base('<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"/>'),
6
+ workspaces: base('<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"/>'),
7
+ keys: base('<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"/>'),
8
+ settings: base('<path d="M21 5h-7"/><path d="M10 5H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 19h-5"/><path d="M12 19H3"/><path d="M12 3v4"/><path d="M8 10v4"/><path d="M16 17v4"/>'),
9
+ sessions: base('<path d="M12.8 2.2a2 2 0 0 0-1.6 0L2.6 6.1a1 1 0 0 0 0 1.8l8.6 3.9a2 2 0 0 0 1.6 0l8.6-3.9a1 1 0 0 0 0-1.8Z"/><path d="m6.1 10.4-3.5 1.6a1 1 0 0 0 0 1.8l8.6 3.9a2 2 0 0 0 1.6 0l8.6-3.9a1 1 0 0 0 0-1.8l-3.5-1.6"/><path d="m6.1 15.4-3.5 1.6a1 1 0 0 0 0 1.8l8.6 3.9a2 2 0 0 0 1.6 0l8.6-3.9a1 1 0 0 0 0-1.8l-3.5-1.6"/>'),
10
+ gateway: base('<rect x="2" y="3" width="20" height="7" rx="2"/><rect x="2" y="14" width="20" height="7" rx="2"/><path d="M6 6.5h.01"/><path d="M6 17.5h.01"/><path d="M10 6.5h5"/><path d="M10 17.5h5"/>'),
11
+ refresh: base('<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"/>'),
12
+ plus: base('<path d="M5 12h14"/><path d="M12 5v14"/>'),
13
+ sun: base('<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.9 4.9 1.4 1.4"/><path d="m17.7 17.7 1.4 1.4"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.3 17.7-1.4 1.4"/><path d="m19.1 4.9-1.4 1.4"/>'),
14
+ moon: base('<path d="M12 3a6.4 6.4 0 0 0 9 9 9 9 0 1 1-9-9Z"/>'),
15
+ monitor: base('<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>'),
16
+ menu: base('<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>'),
17
+ chevronRight: base('<path d="m9 18 6-6-6-6"/>'),
18
+ close: base('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
19
+ logout: base('<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>'),
20
+ lock: base('<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
21
+ inbox: base('<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.4 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.4-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.8 1.1Z"/>'),
22
+ shield: base('<path d="M20 13c0 5-3.5 7.5-7.7 9a1 1 0 0 1-.66 0C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.2-2.7a1.2 1.2 0 0 1 1.5 0C14.5 3.8 17 5 19 5a1 1 0 0 1 1 1Z"/><path d="m9 12 2 2 4-4"/>'),
23
+ check: base('<path d="M20 6 9 17l-5-5"/>'),
24
+ alert: base('<path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
25
+ search: base('<circle cx="11" cy="11" r="7.5"/><path d="m21 21-4.3-4.3"/>')
9
26
  };
@@ -1,3 +1,4 @@
1
1
  import type { ServerResponse } from 'node:http';
2
2
  export declare function redirectToAgentdWebUi(response: ServerResponse): void;
3
3
  export declare function writeAgentdWebUi(response: ServerResponse): void;
4
+ export declare function writeAgentdWebUiModule(response: ServerResponse, pathname: string): void;
@@ -1,17 +1,23 @@
1
- import { app } from './app.js';
2
1
  import { markup } from './markup.js';
3
2
  import { styles } from './styles.js';
4
- const page = `<!doctype html>
5
- <html lang="zh-CN">
6
- <head>
7
- <meta charset="utf-8">
8
- <meta name="viewport" content="width=device-width, initial-scale=1">
9
- <meta name="color-scheme" content="light dark">
10
- <title>Agent Nexus</title>
11
- <style>${styles}</style>
12
- </head>
13
- <body>${markup}<script>${app}</script></body>
3
+ import { sources } from './app/sources.js';
4
+ const page = `<!doctype html>
5
+ <html lang="zh-CN">
6
+ <head>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <meta name="color-scheme" content="light dark">
10
+ <title>Agent Nexus</title>
11
+ <style>${styles}</style>
12
+ <script type="module" defer src="/ui/app.js"></script>
13
+ </head>
14
+ <body>${markup}</body>
14
15
  </html>`;
16
+ const entryModule = `import './app/main.js'\n`;
17
+ const modules = { '/ui/app.js': entryModule };
18
+ for (const [name, source] of Object.entries(sources)) {
19
+ modules['/ui/app/' + name + '.js'] = source;
20
+ }
15
21
  export function redirectToAgentdWebUi(response) {
16
22
  response.writeHead(302, {
17
23
  Location: '/ui/',
@@ -26,7 +32,21 @@ export function writeAgentdWebUi(response) {
26
32
  'Cache-Control': 'no-store',
27
33
  'X-Content-Type-Options': 'nosniff',
28
34
  'Referrer-Policy': 'no-referrer',
29
- 'Content-Security-Policy': "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
35
+ 'Content-Security-Policy': "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
30
36
  });
31
37
  response.end(page);
32
38
  }
39
+ export function writeAgentdWebUiModule(response, pathname) {
40
+ const source = modules[pathname];
41
+ if (!source) {
42
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
43
+ response.end('Not found');
44
+ return;
45
+ }
46
+ response.writeHead(200, {
47
+ 'Content-Type': 'text/javascript; charset=utf-8',
48
+ 'Cache-Control': 'no-store',
49
+ 'X-Content-Type-Options': 'nosniff'
50
+ });
51
+ response.end(source);
52
+ }
@@ -1,67 +1,69 @@
1
1
  import { icons } from './icons.js';
2
- export const markup = String.raw `
3
- <div id="setup-screen" class="auth-screen hidden">
4
- <form id="setup-form" class="auth-card">
5
- <div class="brand-mark">N</div>
6
- <h1>初始化 Agent Nexus</h1>
7
- <p>创建仅用于后台管理的控制台密码。客户端 API 密钥将在登录后单独管理。</p>
8
- <label>控制台密码<input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
9
- <label>确认密码<input name="confirmPassword" type="password" minlength="12" autocomplete="new-password" required></label>
10
- <button class="button primary" type="submit">完成初始化</button>
11
- <p class="form-error" data-form-error></p>
12
- </form>
13
- </div>
14
-
15
- <div id="login-screen" class="auth-screen hidden">
16
- <form id="login-form" class="auth-card">
17
- <div class="brand-mark">N</div>
18
- <h1>Agent Nexus</h1>
19
- <p>登录后管理智能体、工作区和客户端 API 密钥。</p>
20
- <label>控制台密码<input name="password" type="password" autocomplete="current-password" required autofocus></label>
21
- <button class="button primary" type="submit">登录</button>
22
- <p class="form-error" data-form-error></p>
23
- </form>
24
- </div>
25
-
26
- <div id="app" class="app-shell hidden">
27
- <aside class="sidebar">
28
- <div class="brand"><span class="brand-mark small">N</span><span>Agent Nexus</span></div>
29
- <nav aria-label="主导航">
30
- <button class="nav-item active" data-page="overview">${icons.overview}<span>总览</span></button>
31
- <button class="nav-item" data-page="runs">${icons.runs}<span>运行记录</span></button>
32
- <button class="nav-item" data-page="agents">${icons.agents}<span>智能体</span></button>
33
- <button class="nav-item" data-page="workspaces">${icons.workspaces}<span>工作区</span></button>
34
- <button class="nav-item" data-page="keys">${icons.keys}<span>API 密钥</span></button>
35
- <button class="nav-item" data-page="settings">${icons.settings}<span>运行设置</span></button>
36
- </nav>
37
- <div class="admin-menu-wrap">
38
- <div id="admin-popover" class="admin-popover hidden">
39
- <label>界面主题
40
- <select id="theme-select">
41
- <option value="system">跟随系统</option>
42
- <option value="light">浅色</option>
43
- <option value="dark">深色</option>
44
- </select>
45
- </label>
46
- <button id="change-password" class="menu-button">修改密码</button>
47
- <button id="logout" class="menu-button danger-text">退出登录</button>
48
- </div>
49
- <button id="admin-menu" class="admin-button" aria-expanded="false">${icons.settings}<span><strong>管理员</strong><small>控制台设置</small></span></button>
50
- </div>
51
- </aside>
52
- <main class="main">
53
- <header class="page-header">
54
- <div><h1 id="page-title"></h1><p id="page-description"></p></div>
55
- <div id="page-actions" class="page-actions"></div>
56
- </header>
57
- <section id="page-content" class="page-content" aria-live="polite"></section>
58
- </main>
59
- </div>
60
-
61
- <div id="drawer-backdrop" class="drawer-backdrop hidden"></div>
62
- <aside id="drawer" class="drawer hidden" aria-modal="true" role="dialog">
63
- <div class="drawer-header"><h2 id="drawer-title"></h2><button id="drawer-close" class="icon-button" aria-label="关闭">×</button></div>
64
- <form id="drawer-form" class="drawer-body"></form>
65
- </aside>
66
- <div id="toast-region" class="toast-region" aria-live="assertive"></div>
2
+ export const markup = String.raw `
3
+ <div id="boot-screen" class="auth-screen"><p role="status">正在加载控制台…</p></div>
4
+ <div id="setup-screen" class="auth-screen hidden">
5
+ <form id="setup-form" class="auth-card">
6
+ <header><span class="brand-mark">N</span><h1>初始化 Agent Nexus</h1></header>
7
+ <p>此密码仅用于登录控制台;客户端 API 密钥在登录后单独创建。</p>
8
+ <div class="field"><label for="setup-password">控制台密码</label><input id="setup-password" name="password" type="password" minlength="12" autocomplete="new-password" required></div>
9
+ <div class="field"><label for="setup-confirm">确认密码</label><input id="setup-confirm" name="confirmPassword" type="password" minlength="12" autocomplete="new-password" required></div>
10
+ <button class="button primary auth-submit" type="submit">完成初始化</button>
11
+ <p class="form-error" data-form-error></p>
12
+ </form>
13
+ </div>
14
+
15
+ <div id="login-screen" class="auth-screen hidden">
16
+ <form id="login-form" class="auth-card">
17
+ <header><span class="brand-mark">N</span><h1>Agent Nexus</h1></header>
18
+ <div class="field"><label for="login-password">控制台密码</label><input id="login-password" name="password" type="password" autocomplete="current-password" required autofocus></div>
19
+ <button class="button primary auth-submit" type="submit">登录</button>
20
+ <p class="form-error" data-form-error></p>
21
+ </form>
22
+ </div>
23
+
24
+ <div id="app" class="app-shell hidden">
25
+ <aside class="sidebar">
26
+ <div class="brand"><span class="brand-mark">N</span><span>Agent Nexus</span></div>
27
+ <nav aria-label="主导航">
28
+ <div class="nav-group" role="group" aria-labelledby="nav-group-run">
29
+ <p class="nav-group-label" id="nav-group-run">运行</p>
30
+ <button class="nav-item active" data-page="overview">${icons.overview}<span>总览</span></button>
31
+ <button class="nav-item" data-page="runs">${icons.runs}<span>运行记录</span></button>
32
+ </div>
33
+ <div class="nav-group" role="group" aria-labelledby="nav-group-config">
34
+ <p class="nav-group-label" id="nav-group-config">网关配置</p>
35
+ <button class="nav-item" data-page="agents">${icons.agents}<span>智能体</span></button>
36
+ <button class="nav-item" data-page="workspaces">${icons.workspaces}<span>工作区</span></button>
37
+ <button class="nav-item" data-page="keys">${icons.keys}<span>API 密钥</span></button>
38
+ </div>
39
+ <div class="nav-group nav-group-footer">
40
+ <button class="nav-item" data-page="settings">${icons.settings}<span>设置</span></button>
41
+ <button class="nav-item nav-item-danger" data-action="logout">${icons.logout}<span>退出登录</span></button>
42
+ </div>
43
+ </nav>
44
+ </aside>
45
+ <main class="main">
46
+ <header class="topbar">
47
+ <h1 id="page-title"></h1>
48
+ <div id="page-actions" class="page-actions"></div>
49
+ </header>
50
+ <section id="page-content" class="page-content">
51
+ <div id="page-stats"></div>
52
+ <div id="page-toolbar"></div>
53
+ <div id="page-results"></div>
54
+ </section>
55
+ <p id="page-status" class="visually-hidden" role="status" aria-atomic="true"></p>
56
+ </main>
57
+ </div>
58
+
59
+ <div id="drawer-backdrop" class="drawer-backdrop hidden"></div>
60
+ <aside id="drawer" class="drawer hidden" aria-modal="true" role="dialog" aria-labelledby="drawer-title" tabindex="-1">
61
+ <div class="drawer-header"><h2 id="drawer-title"></h2><button id="drawer-close" class="icon-button" aria-label="关闭">${icons.close}</button></div>
62
+ <form id="drawer-form" class="drawer-body"></form>
63
+ <div id="drawer-footer" class="drawer-footer"></div>
64
+ </aside>
65
+ <div id="notifications" class="toast-region">
66
+ <div id="toast-status" role="status" aria-live="polite" aria-relevant="additions"></div>
67
+ <div id="toast-alert" role="alert" aria-live="assertive" aria-relevant="additions"></div>
68
+ </div>
67
69
  `;