nexus-agentd 0.1.8 → 0.2.0

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/webui/app.js CHANGED
@@ -1,1075 +1,913 @@
1
- export const app = String.raw `
2
- (() => {
3
- 'use strict'
4
-
5
- const state = {
6
- page: 'overview',
7
- config: { workspaceRoots: [], driverKinds: [], agents: [] },
8
- readiness: [],
9
- apiKeys: [],
10
- runs: [],
11
- runTotal: 0,
12
- sessions: 0,
13
- search: '',
14
- protocol: 'all',
15
- status: 'all',
16
- runSearch: '',
17
- runAgent: 'all',
18
- runStatus: 'all',
19
- fileRoots: [],
20
- fileRoot: '',
21
- filePath: '',
22
- fileEntries: [],
23
- authenticated: false
24
- }
25
-
26
- const pageMeta = {
27
- overview: ['总览', '查看已配置智能体运行时的实时状态。'],
28
- runs: ['运行记录', '追踪当前任务进度,并查看已完成或失败的历史任务。'],
29
- agents: ['智能体', '配置本地 ACP 进程和远程 A2A 智能体。'],
30
- workspaces: ['工作区', '管理 ACP 智能体允许访问的路径。'],
31
- files: ['文件', '浏览工作区文件,并生成有时效的外部下载链接。'],
32
- keys: ['API 密钥', '管理 Agent Nexus 数据接口的客户端凭据。'],
33
- settings: ['运行设置', '调整会话生命周期、任务超时和后台清理周期。']
34
- }
35
-
36
- const actionIcons = {
37
- copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="10" height="10" rx="2"></rect><path d="M15 9V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path></svg>',
38
- disable: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"></circle><path d="m6.3 6.3 11.4 11.4"></path></svg>',
39
- enable: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"></circle><path d="m8.5 12 2.3 2.3 4.8-5"></path></svg>',
40
- edit: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h8"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"></path></svg>',
41
- more: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle></svg>',
42
- reveal: '<svg viewBox="0 0 24 24" aria-hidden="true"><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"></path><circle cx="12" cy="12" r="2.5"></circle></svg>',
43
- scope: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.7 2.9 8 7 10 4.1-2 7-5.3 7-10V6Z"></path><path d="M9.5 12 11 13.5l3.5-4"></path></svg>',
44
- regenerate: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5"></path><path d="M19 12a7 7 0 1 0-2 5"></path></svg>',
45
- delete: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"></path><path d="M9 7V4h6v3"></path><path d="m6 7 1 13h10l1-13"></path><path d="M10 11v5M14 11v5"></path></svg>'
46
- }
47
-
48
- const byId = (id) => document.getElementById(id)
49
- const setupScreen = byId('setup-screen')
50
- const loginScreen = byId('login-screen')
51
- const appRoot = byId('app')
52
- const content = byId('page-content')
53
- const actions = byId('page-actions')
54
- const drawer = byId('drawer')
55
- const drawerBackdrop = byId('drawer-backdrop')
56
- const drawerForm = byId('drawer-form')
57
- const keyActionMenu = document.createElement('div')
58
- keyActionMenu.className = 'key-action-menu hidden'
59
- keyActionMenu.setAttribute('role', 'menu')
60
- document.body.appendChild(keyActionMenu)
61
- let drawerSubmit = null
62
-
63
- function escapeHtml(value) {
64
- return String(value == null ? '' : value)
65
- .replace(/&/g, '&amp;')
66
- .replace(/</g, '&lt;')
67
- .replace(/>/g, '&gt;')
68
- .replace(/"/g, '&quot;')
69
- .replace(/'/g, '&#039;')
70
- }
71
-
72
- function selected(value, expected) {
73
- return value === expected ? ' selected' : ''
74
- }
75
-
76
- function checked(value) {
77
- return value ? ' checked' : ''
78
- }
79
-
80
- async function api(path, options) {
81
- const settings = Object.assign({ credentials: 'same-origin' }, options || {})
82
- settings.headers = Object.assign({}, settings.headers || {})
83
- if (settings.body && typeof settings.body !== 'string') {
84
- settings.headers['Content-Type'] = 'application/json'
85
- settings.body = JSON.stringify(settings.body)
86
- }
87
- const response = await fetch(path, settings)
88
- let value = null
89
- try { value = await response.json() } catch {}
90
- if (!response.ok) {
91
- if (response.status === 401 && path.indexOf('/v1/admin/') === 0) showLogin()
92
- const message = value && value.error ? value.error : '请求失败(HTTP ' + response.status + ')'
93
- const error = new Error(localizeError(message))
94
- error.status = response.status
95
- throw error
96
- }
97
- return value
98
- }
99
-
100
- function localizeError(message) {
101
- const messages = {
102
- 'Invalid Console Password': '控制台密码不正确',
103
- 'Console setup is required': '请先完成控制台初始化',
104
- 'Console setup is already complete': '控制台已完成初始化',
105
- 'Console Password confirmation does not match': '两次输入的控制台密码不一致',
106
- 'Console Password must contain at least 12 characters': '控制台密码至少需要 12 个字符',
107
- 'Console Password must not exceed 1024 bytes': '控制台密码不能超过 1024 字节',
108
- 'Console Password must not contain NUL bytes': '控制台密码包含不支持的字符',
109
- 'Current Console Password is incorrect': '当前控制台密码不正确',
110
- 'Gateway setup is required': '请先完成网关初始化',
111
- 'Admin session is required': '管理员会话已失效,请重新登录',
112
- 'Bearer API Key is required': '缺少 Bearer API 密钥',
113
- 'Invalid API Key': 'API 密钥无效',
114
- 'API Key is not authorized for this agent': '该 API 密钥无权访问此智能体',
115
- 'API Key already exists': 'API 密钥已存在',
116
- 'API Key not found': '未找到 API 密钥',
117
- 'API Key name is required': '请输入 API 密钥名称',
118
- 'API Key name is too long': 'API 密钥名称过长',
119
- 'Custom API Key must contain at least 16 characters': '自定义 API 密钥至少需要 16 个字符',
120
- 'Custom API Key must not exceed 512 bytes': '自定义 API 密钥不能超过 512 字节',
121
- 'Custom API Key contains an unsupported value': '自定义 API 密钥包含不支持的内容',
122
- 'workspaceRoots must contain at least one path': '至少需要保留一个工作区根目录',
123
- 'A2A authentication value is required': '请输入 A2A 认证凭据',
124
- 'A2A authentication value is invalid': 'A2A 认证凭据无效',
125
- 'A2A Agent Card URL is required': '请输入 Agent Card URL',
126
- 'A2A Agent Card URL is invalid': 'Agent Card URL 无效',
127
- 'A2A Agent Card URL must use http(s) without credentials or a fragment': 'Agent Card URL 必须使用 HTTP(S),且不能包含凭据或片段',
128
- 'preferredTransport must be auto, jsonrpc, or http-json': '首选传输必须是自动、JSON-RPC 或 HTTP+JSON',
129
- 'A valid A2A header name is required': '请输入有效的 A2A 请求头名称',
130
- 'Too many authentication attempts': '认证失败次数过多,请稍后再试',
131
- 'Request body is too large': '请求内容过大',
132
- 'Request requires application/json': '请求必须使用 application/json',
133
- 'Same-origin request is required': '此操作必须从当前控制台页面发起',
134
- 'Request origin is invalid': '请求来源无效',
135
- 'Cross-origin admin request is not allowed': '不允许跨域管理请求',
136
- 'Control plane is unavailable': '管理控制面当前不可用',
137
- 'Agent Nexus run not found': '未找到运行记录',
138
- 'Internal server error': '服务器内部错误'
139
- }
140
- if (messages[message]) return messages[message]
141
- const missingAgent = /^Configured agent not found: (.+)$/.exec(message)
142
- if (missingAgent) return '未找到已配置的智能体:' + missingAgent[1]
143
- const timeout = /^timeout must be between (\d+) and (\d+)$/.exec(message)
144
- if (timeout) return '超时时间必须介于 ' + Math.round(Number(timeout[1]) / 1000) + ' 和 ' + Math.round(Number(timeout[2]) / 1000) + ' 秒之间'
145
- const runtimeRange = /^(sessionTtlMs|promptTimeoutMs|cleanupIntervalMs) must be between (\d+) and (\d+)$/.exec(message)
146
- if (runtimeRange) {
147
- const units = {
148
- sessionTtlMs: ['Session 空闲有效期', '小时', 3_600_000],
149
- promptTimeoutMs: ['ACP 任务超时', '分钟', 60_000],
150
- cleanupIntervalMs: ['清理任务周期', '秒', 1000]
151
- }
152
- const [label, unit, divisor] = units[runtimeRange[1]]
153
- return label + '必须介于 ' + Math.round(Number(runtimeRange[2]) / divisor) + ' 和 ' + Math.round(Number(runtimeRange[3]) / divisor) + ' ' + unit + '之间'
154
- }
155
- return message
156
- }
157
-
158
- function showOnly(element) {
159
- setupScreen.classList.add('hidden')
160
- loginScreen.classList.add('hidden')
161
- appRoot.classList.add('hidden')
162
- element.classList.remove('hidden')
163
- }
164
-
165
- function showLogin() {
166
- state.authenticated = false
167
- showOnly(loginScreen)
168
- byId('login-form').reset()
169
- }
170
-
171
- async function boot() {
172
- applyTheme(localStorage.getItem('agent-nexus-theme') || 'system')
173
- try {
174
- const bootstrap = await api('/v1/bootstrap/status')
175
- if (bootstrap.adminSetupRequired) {
176
- showOnly(setupScreen)
177
- return
178
- }
179
- const auth = await api('/v1/admin/auth/status')
180
- if (!auth.authenticated) {
181
- showLogin()
182
- return
183
- }
184
- await enterApp()
185
- } catch (error) {
186
- showLogin()
187
- toast(error.message, true)
188
- }
189
- }
190
-
191
- async function enterApp() {
192
- state.authenticated = true
193
- showOnly(appRoot)
194
- await loadAll()
195
- render()
196
- }
197
-
198
- async function loadAll(force) {
199
- const suffix = force ? '?refresh=1' : ''
200
- const values = await Promise.all([
201
- api('/v1/admin/config'),
202
- api('/v1/admin/api-keys'),
203
- api('/v1/admin/overview' + suffix),
204
- api('/v1/admin/runs?limit=200'),
205
- api('/v1/admin/files/roots')
206
- ])
207
- state.config = values[0]
208
- state.apiKeys = values[1].apiKeys || []
209
- state.readiness = values[2].agents || []
210
- state.sessions = values[2].sessions || 0
211
- state.runs = values[3].runs || []
212
- state.runTotal = values[3].total || 0
213
- state.fileRoots = values[4].roots || []
214
- if (!state.fileRoots.some((root) => root.id === state.fileRoot)) {
215
- state.fileRoot = state.fileRoots[0] ? state.fileRoots[0].id : ''
216
- state.filePath = ''
217
- state.fileEntries = []
218
- }
219
- }
220
-
221
- async function refreshRuns(showNotice) {
222
- try {
223
- const value = await api('/v1/admin/runs?limit=200')
224
- state.runs = value.runs || []
225
- state.runTotal = value.total || 0
226
- if (state.page === 'runs') renderRuns()
227
- if (showNotice) toast('运行记录已刷新')
228
- } catch (error) {
229
- if (showNotice) toast(error.message, true)
230
- }
231
- }
232
-
233
- async function refreshReadiness(showNotice) {
234
- try {
235
- const overview = await api('/v1/admin/overview?refresh=1')
236
- state.readiness = overview.agents || []
237
- state.sessions = overview.sessions || 0
238
- render()
239
- if (showNotice) toast('运行状态已刷新')
240
- } catch (error) {
241
- if (showNotice) toast(error.message, true)
242
- }
243
- }
244
-
245
- function render() {
246
- const meta = pageMeta[state.page]
247
- byId('page-title').textContent = meta[0]
248
- byId('page-description').textContent = meta[1]
249
- document.querySelectorAll('.nav-item').forEach((item) => {
250
- item.classList.toggle('active', item.dataset.page === state.page)
251
- })
252
- if (state.page === 'overview') renderOverview()
253
- if (state.page === 'runs') renderRuns()
254
- if (state.page === 'agents') renderAgents()
255
- if (state.page === 'workspaces') renderWorkspaces()
256
- if (state.page === 'files') renderFiles()
257
- if (state.page === 'keys') renderApiKeys()
258
- if (state.page === 'settings') renderSettings()
259
- }
260
-
261
- function renderSettings() {
262
- const sessionTtlHours = Math.round((state.config.sessionTtlMs || 24 * 60 * 60 * 1000) / 3_600_000)
263
- const promptTimeoutMinutes = Math.round((state.config.promptTimeoutMs || 30 * 60 * 1000) / 60_000)
264
- const cleanupIntervalSeconds = Math.round((state.config.cleanupIntervalMs || 60_000) / 1000)
265
- actions.innerHTML = '<span class="muted">修改后立即生效,新任务按新参数执行</span>'
266
- content.innerHTML =
267
- '<div class="settings-layout"><section class="panel settings-form"><div class="panel-header"><div><h2>运行生命周期</h2><p class="settings-subtitle">这些参数由网关统一管理,重启后仍会保留。</p></div></div>' +
268
- '<div class="settings-fields">' +
269
- '<label>Session 空闲有效期(小时)<input id="session-ttl-hours" type="number" min="1" max="720" step="1" value="' + escapeHtml(sessionTtlHours) + '"><small class="field-help">Session 在没有新消息、授权或输入交互后,超过此时间会被释放。默认 24 小时。</small></label>' +
270
- '<label>单次 ACP 任务超时(分钟)<input id="prompt-timeout-minutes" type="number" min="1" max="1440" step="1" value="' + escapeHtml(promptTimeoutMinutes) + '"><small class="field-help">单次 ACP prompt 的最长运行时间。默认 30 分钟;超时后任务会记录为失败并释放运行资源。</small></label>' +
271
- '<label>清理任务周期(秒)<input id="cleanup-interval-seconds" type="number" min="5" max="3600" step="1" value="' + escapeHtml(cleanupIntervalSeconds) + '"><small class="field-help">网关扫描空闲 Session、过期运行记录和临时输入文件的间隔。默认每 60 秒执行一次。</small></label>' +
272
- '</div><div class="settings-divider"></div><div class="settings-note"><strong>A2A 请求超时</strong><span>在“智能体 → 编辑 → A2A → 请求超时”中单独配置。默认 60 秒,最大 30 分钟,不受 ACP 全局任务超时字段覆盖。</span></div><div class="settings-actions"><button id="save-runtime-settings" class="button primary">保存运行设置</button></div></section></div>'
273
- byId('save-runtime-settings').onclick = async () => {
274
- const values = {
275
- sessionTtlMs: Number(byId('session-ttl-hours').value) * 3_600_000,
276
- promptTimeoutMs: Number(byId('prompt-timeout-minutes').value) * 60_000,
277
- cleanupIntervalMs: Number(byId('cleanup-interval-seconds').value) * 1000
278
- }
279
- if (!Object.values(values).every((value) => Number.isInteger(value) && value > 0)) {
280
- toast('请输入有效的整数运行参数。', true)
281
- return
282
- }
283
- await runAction(async () => {
284
- state.config = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })
285
- render()
286
- toast('运行设置已保存并立即生效')
287
- })
288
- }
289
- }
290
-
291
- function renderRuns() {
292
- const activeStates = new Set(['running', 'input_required', 'permission_required'])
293
- const filtered = state.runs.filter((run) => {
294
- const haystack = (run.task + ' ' + run.agentName + ' ' + run.id).toLowerCase()
295
- return haystack.indexOf(state.runSearch.toLowerCase()) >= 0 &&
296
- (state.runAgent === 'all' || run.agentId === state.runAgent) &&
297
- (state.runStatus === 'all' || run.state === state.runStatus)
298
- })
299
- const active = filtered.filter((run) => activeStates.has(run.state))
300
- const history = filtered.filter((run) => !activeStates.has(run.state))
301
- const completed = state.runs.filter((run) => run.state === 'completed').length
302
- const failed = state.runs.filter((run) => run.state === 'failed').length
303
- const agentOptions = state.config.agents.map((agent) => '<option value="' + escapeHtml(agent.id) + '"' + selected(state.runAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')
304
- actions.innerHTML = '<span class="muted">每 5 秒自动刷新</span><button id="refresh-runs" class="button">立即刷新</button>'
305
- content.innerHTML =
306
- '<div class="run-summary">' +
307
- runSummary('当前运行', state.runs.filter((run) => activeStates.has(run.state)).length) +
308
- runSummary('已完成', completed) +
309
- runSummary('失败', failed) +
310
- runSummary('保留记录', state.runTotal) +
311
- '</div>' +
312
- '<div class="toolbar"><input id="run-search" type="search" placeholder="搜索任务、智能体或运行 ID" value="' + escapeHtml(state.runSearch) + '">' +
313
- '<select id="run-agent-filter"><option value="all">全部智能体</option>' + agentOptions + '</select>' +
314
- '<select id="run-status-filter"><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>' +
315
- '<section class="run-section"><div class="run-section-title"><h2>当前运行</h2><span>' + active.length + ' 项</span></div>' +
316
- (active.length ? '<div class="run-live-grid">' + active.map(runCard).join('') + '</div>' : '<div class="panel"><div class="empty">当前没有正在执行或等待处理的任务。</div></div>') +
317
- '</section>' +
318
- '<section class="run-section"><div class="run-section-title"><h2>历史记录</h2><span>' + history.length + ' 项</span></div>' +
319
- '<div class="table-wrap"><table class="run-table"><thead><tr><th>智能体</th><th>任务</th><th>结果</th><th>状态</th><th>开始时间</th><th>耗时</th></tr></thead><tbody>' +
320
- (history.length ? history.map(runRow).join('') : '<tr><td colspan="6" class="empty">没有符合条件的历史记录。</td></tr>') +
321
- '</tbody></table></div></section>'
322
- byId('refresh-runs').onclick = () => refreshRuns(true)
323
- byId('run-search').oninput = (event) => { state.runSearch = event.target.value; renderRuns() }
324
- byId('run-agent-filter').onchange = (event) => { state.runAgent = event.target.value; renderRuns() }
325
- byId('run-status-filter').onchange = (event) => { state.runStatus = event.target.value; renderRuns() }
326
- }
327
-
328
- function runSummary(label, value) {
329
- return '<div class="run-summary-item"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
330
- }
331
-
332
- function runCard(run) {
333
- const waiting = run.state === 'input_required' || run.state === 'permission_required'
334
- return '<article class="run-card' + (waiting ? ' waiting' : '') + '" data-run-detail="' + escapeHtml(run.id) + '" tabindex="0"><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><div class="run-progress"><strong>' + escapeHtml(run.progress && run.progress.phase || '运行中') + '</strong><small>' + escapeHtml(run.progress && run.progress.message || '等待最新进度') + '</small></div><div class="run-meta"><span>' + escapeHtml(shortId(run.id)) + '</span><span>' + escapeHtml(formatDuration(run)) + '</span></div></article>'
335
- }
336
-
337
- function runRow(run) {
338
- return '<tr data-run-detail="' + escapeHtml(run.id) + '"><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>' + escapeHtml(formatDate(run.startedAt)) + '</td><td>' + escapeHtml(formatDuration(run)) + '</td></tr>'
339
- }
340
-
341
- function runStatus(value) {
342
- const labels = { created: '已创建', running: '运行中', input_required: '等待输入', permission_required: '等待授权', completed: '已完成', failed: '失败', canceled: '已取消' }
343
- const style = value === 'completed' ? ' completed' : value === 'failed' ? ' failed' : value === 'running' ? ' running' : (value === 'input_required' || value === 'permission_required') ? ' waiting' : ' canceled'
344
- return '<span class="status' + style + '">' + escapeHtml(labels[value] || value) + '</span>'
345
- }
346
-
347
- function shortId(value) {
348
- return String(value || '').slice(0, 8)
349
- }
350
-
351
- function formatDuration(run) {
352
- const ms = run.durationMs != null ? run.durationMs : Math.max(0, Date.now() - run.startedAt)
353
- if (ms < 1000) return ms + ' 毫秒'
354
- if (ms < 60_000) return Math.round(ms / 1000) + ' 秒'
355
- if (ms < 3_600_000) return Math.floor(ms / 60_000) + ' 分 ' + Math.round(ms % 60_000 / 1000) + ' 秒'
356
- return Math.floor(ms / 3_600_000) + ' 小时 ' + Math.round(ms % 3_600_000 / 60_000) + ' 分'
357
- }
358
-
359
- async function openRunDrawer(id) {
360
- const run = await api('/v1/admin/runs/' + encodeURIComponent(id))
361
- const artifacts = run.artifacts && run.artifacts.length
362
- ? '<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>'
363
- : ''
364
- const body = '<div class="run-detail-grid">' +
365
- detailItem('智能体', run.agentName) + detailItem('状态', runStatusLabel(run.state)) +
366
- detailItem('开始时间', formatDate(run.startedAt)) + detailItem('耗时', formatDuration(run)) +
367
- detailItem('协议', run.protocol.toUpperCase()) + detailItem('运行 ID', run.id) +
368
- detailItem('当前阶段', run.progress && run.progress.phase || '—') + detailItem('输入附件', run.inputAttachmentCount || 0) + detailItem('产物', run.artifactCount || 0) +
369
- '</div><div class="run-detail-block"><h3>用户任务</h3><pre class="run-detail-code">' + escapeHtml(run.task) + (run.taskTruncated ? '\n\n[记录已截断]' : '') + '</pre></div>' +
370
- (run.progress && run.progress.message ? '<div class="run-detail-block"><h3>最近进度</h3><pre class="run-detail-code">' + escapeHtml(run.progress.message) + '</pre></div>' : '') +
371
- (run.output ? '<div class="run-detail-block"><h3>智能体结果</h3><pre class="run-detail-code">' + escapeHtml(run.output) + '</pre></div>' : '') +
372
- (run.error ? '<div class="run-detail-block"><h3>错误</h3><pre class="run-detail-code">' + escapeHtml(run.error) + '</pre></div>' : '') + artifacts
373
- openDrawer('运行详情 · ' + run.agentName, body, '', null)
374
- }
375
-
376
- function detailItem(label, value) {
377
- return '<div class="run-detail-item"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
378
- }
379
-
380
- function runStatusLabel(value) {
381
- return { created: '已创建', running: '运行中', input_required: '等待输入', permission_required: '等待授权', completed: '已完成', failed: '失败', canceled: '已取消' }[value] || value
382
- }
383
-
384
- function renderOverview() {
385
- const agents = readinessRows()
386
- const ready = agents.filter((agent) => agent.ready).length
387
- actions.innerHTML = '<button id="refresh-overview" class="button">刷新</button>'
388
- content.innerHTML =
389
- '<div class="stats">' +
390
- stat('智能体', agents.length) +
391
- stat('就绪', ready) +
392
- stat('会话', state.sessions) +
393
- '</div>' +
394
- '<div class="panel"><div class="panel-header"><h2>智能体</h2><span class="muted">每 20 秒自动检查</span></div>' +
395
- (agents.length
396
- ? '<div class="panel-body overview-list">' + agents.map(overviewAgent).join('') + '</div>'
397
- : '<div class="empty">尚未配置智能体。</div>') +
398
- '</div>'
399
- byId('refresh-overview').onclick = () => refreshReadiness(true)
400
- }
401
-
402
- function stat(label, value) {
403
- return '<div class="stat"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
404
- }
405
-
406
- function readinessRows() {
407
- const readiness = new Map(state.readiness.map((agent) => [agent.id, agent]))
408
- return state.config.agents.map((agent) => readiness.get(agent.id) || {
409
- id: agent.id,
410
- name: agent.name,
411
- protocol: agent.protocol,
412
- driver: agent.driver,
413
- enabled: agent.enabled,
414
- ready: false,
415
- checking: true
416
- })
417
- }
418
-
419
- function overviewAgent(agent) {
420
- return '<div class="list-row"><div class="list-row-main"><strong>' + escapeHtml(agent.name) + '</strong><small>' +
421
- escapeHtml(agent.protocol.toUpperCase() + (agent.driver ? ' · ' + agent.driver : '')) +
422
- (agent.error ? ' · ' + escapeHtml(localizeError(agent.error)) : '') + '</small></div>' + statusMarkup(agent) + '</div>'
423
- }
424
-
425
- function renderAgents() {
426
- actions.innerHTML = '<button id="refresh-agents" class="button">刷新</button><button id="add-agent" class="button primary">添加智能体</button>'
427
- const readiness = new Map(state.readiness.map((agent) => [agent.id, agent]))
428
- const rows = state.config.agents.filter((agent) => {
429
- const live = readiness.get(agent.id) || {}
430
- const haystack = (agent.name + ' ' + agent.id + ' ' + (agent.description || '')).toLowerCase()
431
- const status = !readiness.has(agent.id) ? 'checking' : live.ready ? 'ready' : agent.enabled ? 'failed' : 'disabled'
432
- return haystack.indexOf(state.search.toLowerCase()) >= 0 &&
433
- (state.protocol === 'all' || agent.protocol === state.protocol) &&
434
- (state.status === 'all' || status === state.status)
435
- })
436
- content.innerHTML =
437
- '<div class="toolbar"><input id="agent-search" type="search" placeholder="搜索智能体" value="' + escapeHtml(state.search) + '">' +
438
- '<select id="protocol-filter"><option value="all">全部协议</option><option value="acp"' + selected(state.protocol, 'acp') + '>ACP</option><option value="a2a"' + selected(state.protocol, 'a2a') + '>A2A</option></select>' +
439
- '<select id="status-filter"><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>' +
440
- '<div class="table-wrap"><table><thead><tr><th>智能体</th><th>协议</th><th>驱动</th><th>状态</th><th>工作区</th><th>权限策略</th><th></th></tr></thead><tbody>' +
441
- (rows.length ? rows.map((agent) => agentRow(agent, readiness.get(agent.id))).join('') : '<tr><td colspan="7" class="empty">没有符合条件的智能体。</td></tr>') +
442
- '</tbody></table></div>'
443
- byId('agent-search').oninput = (event) => { state.search = event.target.value; renderAgents() }
444
- byId('protocol-filter').onchange = (event) => { state.protocol = event.target.value; renderAgents() }
445
- byId('status-filter').onchange = (event) => { state.status = event.target.value; renderAgents() }
446
- byId('refresh-agents').onclick = () => refreshReadiness(true)
447
- byId('add-agent').onclick = () => openAgentDrawer()
448
- }
449
-
450
- function agentRow(agent, live) {
451
- const statusAgent = live || { ready: false, enabled: agent.enabled, checking: true }
452
- return '<tr><td><div class="agent-name"><strong>' + escapeHtml(agent.name) + '</strong><small>' + escapeHtml(agent.id) + '</small>' +
453
- (statusAgent.error && agent.enabled ? '<span class="error-detail">' + escapeHtml(localizeError(statusAgent.error)) + '</span>' : '') +
454
- '</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>'
455
- }
456
-
457
- function permissionLabel(value) {
458
- if (value === 'ask') return '询问'
459
- if (value === 'allow') return '始终允许'
460
- if (value === 'deny') return '拒绝'
461
- return value || '—'
462
- }
463
-
464
- function statusMarkup(agent) {
465
- if (!agent.enabled) return '<span class="status">已禁用</span>'
466
- if (agent.checking) return '<span class="status checking">检查中</span>'
467
- return agent.ready ? '<span class="status ready">就绪</span>' : '<span class="status failed">不可用</span>'
468
- }
469
-
470
- function renderWorkspaces() {
471
- actions.innerHTML = '<button id="add-workspace" class="button primary">添加工作区</button>'
472
- const roots = state.config.workspaceRoots || []
473
- content.innerHTML = '<div class="workspace-list">' +
474
- roots.map((root, index) => '<div class="list-row"><div class="list-row-main"><strong class="path">' + escapeHtml(root) + '</strong><small>ACP 会话允许访问的根目录</small></div><div class="row-actions"><button class="button small" data-workspace-edit="' + index + '">编辑</button><button class="button small danger" data-workspace-delete="' + index + '"' + (roots.length === 1 ? ' disabled' : '') + '>删除</button></div></div>').join('') +
475
- '</div><div class="tip">建议仅添加必要的最小目录范围。启动 ACP 会话前,Agent Nexus 会解析并校验真实路径。</div>'
476
- byId('add-workspace').onclick = () => openWorkspaceDrawer()
477
- }
478
-
479
- function renderFiles() {
480
- const rootOptions = state.fileRoots.map((root) => '<option value="' + escapeHtml(root.id) + '"' + selected(state.fileRoot, root.id) + '>' + escapeHtml(root.name + ' · ' + root.path) + '</option>').join('')
481
- actions.innerHTML = '<button id="refresh-files" class="button">刷新</button><button id="new-directory" class="button">新建目录</button><button id="upload-file" class="button primary">上传文件</button><input id="file-upload-input" class="hidden" type="file" multiple>'
482
- content.innerHTML =
483
- '<div class="file-toolbar"><select id="file-root-select" aria-label="工作区根目录">' + rootOptions + '</select><button class="button small" id="file-up"' + (state.filePath ? '' : ' disabled') + '>返回上级</button><code class="file-current-path">/' + escapeHtml(state.filePath.replace(/\\/g, '/')) + '</code></div>' +
484
- (state.fileRoots.length
485
- ? '<div class="table-wrap"><table class="file-table"><thead><tr><th>名称</th><th>类型</th><th>大小</th><th>修改时间</th><th></th></tr></thead><tbody>' +
486
- (state.fileEntries.length ? state.fileEntries.map(fileRow).join('') : '<tr><td colspan="5" class="empty">此目录为空。</td></tr>') +
487
- '</tbody></table></div><div class="tip">“发布链接”会把文件流式复制到 Gateway 的独立临时仓库;不会公开工作区目录。链接默认 24 小时后失效,可在配置中调整。</div>'
488
- : '<div class="panel"><div class="empty">没有可用的工作区根目录。</div></div>')
489
- byId('refresh-files').onclick = () => refreshFiles(true)
490
- byId('new-directory').onclick = () => createDirectory()
491
- byId('upload-file').onclick = () => byId('file-upload-input').click()
492
- byId('file-upload-input').onchange = (event) => uploadFiles(Array.from(event.target.files || []))
493
- byId('file-root-select').onchange = (event) => {
494
- state.fileRoot = event.target.value
495
- state.filePath = ''
496
- void refreshFiles(false)
497
- }
498
- byId('file-up').onclick = () => {
499
- state.filePath = parentFilePath(state.filePath)
500
- void refreshFiles(false)
501
- }
502
- }
503
-
504
- function fileRow(entry) {
505
- const name = escapeHtml(entry.name)
506
- const encodedRoot = encodeURIComponent(state.fileRoot)
507
- const encodedPath = encodeURIComponent(entry.path)
508
- const primary = entry.type === 'directory'
509
- ? '<button class="file-name-button" data-file-open="' + encodedPath + '">📁 <span>' + name + '</span></button>'
510
- : '<span class="file-name">📄 <span>' + name + '</span></span>'
511
- const download = entry.type === 'file'
512
- ? '<a class="button small" href="/v1/admin/files/content?root=' + encodedRoot + '&path=' + encodedPath + '" download>下载</a><button class="button small" data-file-publish="' + encodedPath + '">发布链接</button>'
513
- : ''
514
- return '<tr><td>' + primary + '</td><td>' + fileTypeLabel(entry.type) + '</td><td>' + (entry.size == null ? '—' : formatBytes(entry.size)) + '</td><td>' + formatDate(entry.modifiedAt) + '</td><td><div class="row-actions">' + download + '<button class="button small" data-file-rename="' + encodedPath + '">重命名</button><button class="button small danger" data-file-delete="' + encodedPath + '">删除</button></div></td></tr>'
515
- }
516
-
517
- async function refreshFiles(showNotice) {
518
- if (!state.fileRoot) { state.fileEntries = []; renderFiles(); return }
519
- await runAction(async () => {
520
- const value = await api('/v1/admin/files?root=' + encodeURIComponent(state.fileRoot) + '&path=' + encodeURIComponent(state.filePath))
521
- state.filePath = value.path || ''
522
- state.fileEntries = value.entries || []
523
- if (state.page === 'files') renderFiles()
524
- if (showNotice) toast('文件列表已刷新')
525
- })
526
- }
527
-
528
- async function uploadFiles(files) {
529
- if (!files.length || !state.fileRoot) return
530
- await runAction(async () => {
531
- for (const file of files) {
532
- const target = joinFilePath(state.filePath, file.name)
533
- const response = await fetch('/v1/admin/files/content?root=' + encodeURIComponent(state.fileRoot) + '&path=' + encodeURIComponent(target), {
534
- method: 'PUT',
535
- credentials: 'same-origin',
536
- headers: { 'Content-Type': file.type || 'application/octet-stream' },
537
- body: file
538
- })
539
- let value = null
540
- try { value = await response.json() } catch {}
541
- if (!response.ok) throw new Error(localizeError(value && value.error ? value.error : '上传失败(HTTP ' + response.status + ')'))
542
- }
543
- await refreshFiles(false)
544
- toast('已上传 ' + files.length + ' 个文件')
545
- })
546
- }
547
-
548
- async function createDirectory() {
549
- const name = prompt('新目录名称')
550
- if (!name) return
551
- await runAction(async () => {
552
- await api('/v1/admin/files/directory', { method: 'POST', body: { root: state.fileRoot, path: joinFilePath(state.filePath, name) } })
553
- await refreshFiles(false)
554
- toast('目录已创建')
555
- })
556
- }
557
-
558
- async function publishFile(filePath) {
559
- await runAction(async () => {
560
- const value = await api('/v1/admin/files/publish', { method: 'POST', body: { root: state.fileRoot, paths: [filePath] } })
561
- const files = value.files || []
562
- const body = files.map((file) => {
563
- const url = new URL(file.url, location.origin).toString()
564
- return '<div class="published-file"><strong>' + escapeHtml(file.name) + '</strong><a href="' + escapeHtml(url) + '" target="_blank" rel="noopener">' + escapeHtml(url) + '</a><small>有效期至 ' + escapeHtml(formatDate(file.expiresAt)) + ' · ' + escapeHtml(formatBytes(file.size)) + '</small></div>'
565
- }).join('')
566
- openDrawer('已发布临时链接', '<div class="published-files">' + body + '</div>', '', null)
567
- toast('临时下载链接已生成')
568
- })
569
- }
570
-
571
- function parentFilePath(value) {
572
- const parts = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean)
573
- parts.pop()
574
- return parts.join('/')
575
- }
576
-
577
- function joinFilePath(parent, name) {
578
- return [String(parent || '').replace(/\\/g, '/').replace(/\/$/, ''), String(name || '').replace(/^[/\\]+/, '')].filter(Boolean).join('/')
579
- }
580
-
581
- function fileTypeLabel(value) {
582
- return value === 'directory' ? '目录' : value === 'file' ? '文件' : value === 'symlink' ? '符号链接' : '其他'
583
- }
584
-
585
- function formatBytes(value) {
586
- const size = Number(value) || 0
587
- if (size < 1024) return size + ' B'
588
- if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
589
- if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB'
590
- return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB'
591
- }
592
-
593
- function renderApiKeys() {
594
- closeKeyActionMenu()
595
- actions.innerHTML = '<button id="create-key" class="button primary">创建 API 密钥</button>'
596
- content.innerHTML = state.apiKeys.length
597
- ? '<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 class="key-actions-heading">操作</th></tr></thead><tbody>' + state.apiKeys.map(keyRow).join('') + '</tbody></table></div>'
598
- : '<div class="panel"><div class="empty">尚未创建 API 密钥。客户端需要访问时再创建即可。</div></div>'
599
- byId('create-key').onclick = () => openKeyDrawer()
600
- }
601
-
602
- function keyRow(key) {
603
- const scope = key.scope.allAgents ? '全部智能体' : (key.scope.agentIds.length ? key.scope.agentIds.join(', ') : '未授权智能体')
604
- const name = key.legacy && key.name === 'Legacy Access Key' ? '旧版访问密钥' : key.name
605
- const id = escapeHtml(key.id)
606
- const toggleLabel = key.enabled ? '禁用密钥' : '启用密钥'
607
- return '<tr><td><div class="agent-name"><strong>' + escapeHtml(name) + '</strong><small>' + (key.legacy ? '从旧版配置迁移' : '创建于 ' + formatDate(key.createdAt)) + '</small></div></td>' +
608
- '<td>' + (key.enabled ? '<span class="status ready">已启用</span>' : '<span class="status">已禁用</span>') + '</td>' +
609
- '<td><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="复制完整密钥">' + actionIcons.copy + '</button></div></td>' +
610
- '<td><span class="key-scope">' + escapeHtml(scope) + '</span></td>' +
611
- '<td><span class="key-last-used">' + escapeHtml(key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用') + '</span></td>' +
612
- '<td class="key-actions-cell"><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 ? actionIcons.disable : actionIcons.enable) + '</button><button class="key-icon-button" data-key-action="rename" data-key-id="' + id + '" aria-label="重命名" title="重命名">' + actionIcons.edit + '</button><button class="key-icon-button" data-key-menu="' + id + '" aria-label="更多操作" title="更多操作" aria-expanded="false">' + actionIcons.more + '</button></div></td></tr>'
613
- }
614
-
615
- function toggleKeyActionMenu(anchor, id) {
616
- if (!keyActionMenu.classList.contains('hidden') && keyActionMenu.dataset.keyId === id) {
617
- closeKeyActionMenu()
618
- return
619
- }
620
- closeKeyActionMenu()
621
- keyActionMenu.dataset.keyId = id
622
- keyActionMenu.innerHTML =
623
- '<button type="button" role="menuitem" data-key-action="reveal" data-key-id="' + escapeHtml(id) + '">' + actionIcons.reveal + '<span>显示完整密钥</span></button>' +
624
- '<button type="button" role="menuitem" data-key-action="scope" data-key-id="' + escapeHtml(id) + '">' + actionIcons.scope + '<span>编辑授权范围</span></button>' +
625
- '<button type="button" role="menuitem" data-key-action="regenerate" data-key-id="' + escapeHtml(id) + '">' + actionIcons.regenerate + '<span>重新生成密钥</span></button>' +
626
- '<button type="button" role="menuitem" class="danger-text" data-key-action="delete" data-key-id="' + escapeHtml(id) + '">' + actionIcons.delete + '<span>删除密钥</span></button>'
627
- keyActionMenu.classList.remove('hidden')
628
- anchor.setAttribute('aria-expanded', 'true')
629
- const anchorRect = anchor.getBoundingClientRect()
630
- const menuRect = keyActionMenu.getBoundingClientRect()
631
- const left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, anchorRect.right - menuRect.width))
632
- const below = anchorRect.bottom + 6
633
- const top = below + menuRect.height <= window.innerHeight - 8
634
- ? below
635
- : Math.max(8, anchorRect.top - menuRect.height - 6)
636
- keyActionMenu.style.left = left + 'px'
637
- keyActionMenu.style.top = top + 'px'
638
- }
639
-
640
- function closeKeyActionMenu() {
641
- keyActionMenu.classList.add('hidden')
642
- keyActionMenu.innerHTML = ''
643
- keyActionMenu.dataset.keyId = ''
644
- document.querySelectorAll('[data-key-menu][aria-expanded="true"]').forEach((button) => button.setAttribute('aria-expanded', 'false'))
645
- }
646
-
647
- function openDrawer(title, body, submitLabel, onSubmit) {
648
- byId('drawer-title').textContent = title
649
- drawerForm.innerHTML = body + '<div class="drawer-footer"><button type="button" class="button" data-close-drawer>取消</button>' + (submitLabel ? '<button type="submit" class="button primary">' + escapeHtml(submitLabel) + '</button>' : '') + '</div><p class="form-error" data-form-error></p>'
650
- drawerSubmit = onSubmit || null
651
- drawer.classList.remove('hidden')
652
- drawerBackdrop.classList.remove('hidden')
653
- const first = drawerForm.querySelector('input, select, textarea')
654
- if (first) setTimeout(() => first.focus(), 0)
655
- }
656
-
657
- function closeDrawer() {
658
- drawer.classList.add('hidden')
659
- drawerBackdrop.classList.add('hidden')
660
- drawerForm.innerHTML = ''
661
- drawerSubmit = null
662
- }
663
-
664
- function openAgentDrawer(agent) {
665
- const editing = Boolean(agent)
666
- const current = agent || { protocol: 'acp', enabled: true, permissionPolicy: 'ask', permissionTimeoutMs: 900000, timeoutMs: 60000 }
667
- const permissionTimeoutSeconds = Math.max(1, Math.round(Number(current.permissionTimeoutMs || 900000) / 1000))
668
- const requestTimeoutSeconds = Math.max(1, Math.round(Number(current.timeoutMs || 60000) / 1000))
669
- const drivers = state.config.driverKinds.map((driver) => '<option value="' + escapeHtml(driver) + '"' + selected(current.driver || state.config.driverKinds[0], driver) + '>' + escapeHtml(driver) + '</option>').join('')
670
- const roots = state.config.workspaceRoots.map((root) => '<option value="' + escapeHtml(root) + '"></option>').join('')
671
- const body =
672
- '<label>智能体 ID<input name="id" value="' + escapeHtml(current.id || '') + '" pattern="[a-z0-9][a-z0-9._\\-]{0,63}" required' + (editing ? ' disabled' : '') + '></label>' +
673
- '<div class="field-row"><label>协议<select name="protocol"><option value="acp"' + selected(current.protocol, 'acp') + '>ACP</option><option value="a2a"' + selected(current.protocol, 'a2a') + '>A2A</option></select></label><label class="checkbox"><input name="enabled" type="checkbox"' + checked(current.enabled) + '>启用</label></div>' +
674
- '<label>名称<input name="name" value="' + escapeHtml(current.name || '') + '" required></label>' +
675
- '<label>描述<textarea name="description">' + escapeHtml(current.description || '') + '</textarea></label>' +
676
- '<label>Agent 交互指令<textarea name="instructions" rows="8" placeholder="可选:补充这个 Agent 的专属规则。Gateway 会自动注入多轮确认和用户输入规范。">' + escapeHtml(current.instructions || '') + '</textarea><small class="field-help">可选补充规则,最多 32768 个字符;不要填写密钥等敏感信息。</small></label>' +
677
- '<div data-protocol-section="acp"><label>驱动<select name="driver">' + drivers + '</select></label><label>工作区<input name="workspace" list="workspace-roots" value="' + escapeHtml(current.workspace || state.config.workspaceRoots[0] || '') + '" required><datalist id="workspace-roots">' + roots + '</datalist></label><div class="field-row"><label>权限策略<select 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></label><label>权限确认超时(秒)<input name="permissionTimeoutMs" type="number" min="1" max="86400" step="1" value="' + escapeHtml(permissionTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 900 秒,最长 24 小时;“始终允许”不会等待确认。</small></label></div></div>' +
678
- '<div data-protocol-section="a2a"><label>Agent Card URL<input name="agentCardUrl" type="url" value="' + escapeHtml(current.agentCardUrl || '') + '" placeholder="http://agent.local:8080/.well-known/agent-card.json" required><small class="field-help">填写完整的 Agent Card JSON 地址;调用地址和能力将从 Card 自动发现。</small></label><div class="field-row"><label>首选传输<select 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></label><label>认证方式<select 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></label></div><label data-auth-header>请求头名称<input name="authHeaderName" value="' + escapeHtml(current.auth && current.auth.headerName || '') + '" placeholder="X-API-Key"></label><label data-auth-value><span data-auth-value-label>认证凭据</span><input name="authValue" type="password" autocomplete="off" placeholder="' + (editing && current.auth && current.auth.configured ? '留空以保留当前凭据' : '') + '"></label><label>请求超时(秒)<input name="timeoutMs" type="number" min="1" max="1800" step="1" value="' + escapeHtml(requestTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 60 秒;用于限制这个 A2A Agent 的单次请求,最大 1800 秒(30 分钟)。</small></label></div>'
679
- openDrawer(editing ? '编辑智能体' : '添加智能体', body, editing ? '保存修改' : '添加智能体', async (form) => {
680
- const data = new FormData(form)
681
- const protocol = data.get('protocol')
682
- const id = editing ? current.id : String(data.get('id') || '').trim().toLowerCase()
683
- const payload = {
684
- protocol,
685
- name: String(data.get('name') || '').trim(),
686
- description: String(data.get('description') || '').trim(),
687
- instructions: String(data.get('instructions') || '').trim(),
688
- enabled: data.get('enabled') === 'on'
689
- }
690
- if (protocol === 'acp') {
691
- payload.driver = data.get('driver')
692
- payload.workspace = String(data.get('workspace') || '').trim()
693
- payload.permissionPolicy = data.get('permissionPolicy')
694
- payload.permissionTimeoutMs = Number(data.get('permissionTimeoutMs')) * 1000
695
- } else {
696
- payload.agentCardUrl = String(data.get('agentCardUrl') || '').trim()
697
- payload.preferredTransport = data.get('preferredTransport')
698
- payload.authType = data.get('authType')
699
- payload.authHeaderName = String(data.get('authHeaderName') || '').trim()
700
- const authValue = String(data.get('authValue') || '')
701
- if (authValue) payload.authValue = authValue
702
- payload.timeoutMs = Number(data.get('timeoutMs')) * 1000
703
- }
704
- await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'PUT', body: payload })
705
- closeDrawer()
706
- await loadAll(true)
707
- render()
708
- toast(editing ? '智能体已更新' : '智能体已添加')
709
- })
710
- const protocolSelect = drawerForm.elements.protocol
711
- const authSelect = drawerForm.elements.authType
712
- const sync = () => {
713
- drawerForm.querySelectorAll('[data-protocol-section]').forEach((section) => {
714
- section.classList.toggle('hidden', section.dataset.protocolSection !== protocolSelect.value)
715
- section.querySelectorAll('input,select,textarea').forEach((field) => { field.disabled = section.classList.contains('hidden') })
716
- })
717
- if (protocolSelect.value === 'a2a') {
718
- const auth = authSelect.value
719
- drawerForm.querySelector('[data-auth-header]').classList.toggle('hidden', auth !== 'header')
720
- drawerForm.querySelector('[data-auth-value]').classList.toggle('hidden', auth === 'none')
721
- drawerForm.querySelector('[data-auth-value-label]').textContent = auth === 'bearer' ? 'Bearer Token' : '认证凭据'
722
- }
723
- }
724
- protocolSelect.onchange = sync
725
- authSelect.onchange = sync
726
- sync()
727
- }
728
-
729
- function openWorkspaceDrawer(index) {
730
- const editing = Number.isInteger(index)
731
- const current = editing ? state.config.workspaceRoots[index] : ''
732
- openDrawer(editing ? '编辑工作区' : '添加工作区', '<label>允许访问的根目录<input name="path" value="' + escapeHtml(current) + '" required></label>', editing ? '保存修改' : '添加工作区', async (form) => {
733
- const roots = state.config.workspaceRoots.slice()
734
- const value = String(new FormData(form).get('path') || '').trim()
735
- if (editing) roots[index] = value
736
- else roots.push(value)
737
- const result = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })
738
- state.config = result
739
- closeDrawer()
740
- render()
741
- toast(editing ? '工作区已更新' : '工作区已添加')
742
- })
743
- }
744
-
745
- function openKeyDrawer() {
746
- const scopeRows = state.config.agents.map((agent) => '<label class="checkbox"><input type="checkbox" name="agentId" value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.name) + ' <span class="muted">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>').join('')
747
- const body = '<label>名称<input name="name" required placeholder="例如:开发客户端"></label><label class="checkbox"><input id="all-agents" name="allAgents" type="checkbox" checked>允许访问全部智能体</label><div id="agent-scope" class="agent-scope hidden">' + (scopeRows || '<span class="muted">尚未配置智能体。</span>') + '</div><label>自定义密钥(可选)<input name="customSecret" minlength="16" autocomplete="off" placeholder="留空则自动生成 nx_sk_ 密钥"></label>'
748
- openDrawer('创建 API 密钥', body, '创建 API 密钥', async (form) => {
749
- const data = new FormData(form)
750
- const allAgents = data.get('allAgents') === 'on'
751
- const agentIds = data.getAll('agentId').map(String)
752
- if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')
753
- const customSecret = String(data.get('customSecret') || '').trim()
754
- const result = await api('/v1/admin/api-keys', {
755
- method: 'POST',
756
- body: {
757
- name: String(data.get('name') || '').trim(),
758
- scope: { allAgents, agentIds },
759
- customSecret: customSecret || undefined
760
- }
761
- })
762
- state.apiKeys.unshift(result.key)
763
- showSecret('API 密钥已创建', result.secret)
764
- render()
765
- })
766
- const all = byId('all-agents')
767
- all.onchange = () => byId('agent-scope').classList.toggle('hidden', all.checked)
768
- }
769
-
770
- function showSecret(title, secret) {
771
- openDrawer(title, '<div class="secret-box"><span class="muted">请立即复制此密钥;之后也可在 API 密钥页面再次显示。</span><code class="secret-value" id="secret-value">' + escapeHtml(secret) + '</code><button class="button" type="button" id="copy-secret">复制</button></div>', '', null)
772
- byId('copy-secret').onclick = async () => {
773
- try {
774
- await copySecret(secret)
775
- toast('API 密钥已复制')
776
- } catch (error) { toast(error.message, true) }
777
- }
778
- }
779
-
780
- async function copySecret(secret) {
781
- if (navigator.clipboard && window.isSecureContext) {
782
- try {
783
- await navigator.clipboard.writeText(secret)
784
- return
785
- } catch {}
786
- }
787
- const temporary = document.createElement('textarea')
788
- temporary.value = secret
789
- temporary.setAttribute('readonly', '')
790
- temporary.style.position = 'fixed'
791
- temporary.style.opacity = '0'
792
- temporary.style.pointerEvents = 'none'
793
- document.body.appendChild(temporary)
794
- temporary.select()
795
- const copied = document.execCommand('copy')
796
- temporary.remove()
797
- if (!copied) throw new Error('无法自动复制,请在“显示完整密钥”中手动复制。')
798
- }
799
-
800
- function openPasswordDrawer() {
801
- openDrawer('修改控制台密码', '<label>当前密码<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>新密码<input name="newPassword" type="password" minlength="12" autocomplete="new-password" required></label><label>确认新密码<input name="confirmPassword" type="password" minlength="12" autocomplete="new-password" required></label>', '修改密码', async (form) => {
802
- const data = new FormData(form)
803
- await api('/v1/admin/password', { method: 'PUT', body: { currentPassword: String(data.get('currentPassword') || ''), newPassword: String(data.get('newPassword') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })
804
- closeDrawer()
805
- showLogin()
806
- toast('控制台密码已修改,请重新登录。')
807
- })
808
- }
809
-
810
- async function handleContentClick(event) {
811
- const runTarget = event.target.closest('[data-run-detail]')
812
- if (runTarget) {
813
- await runAction(() => openRunDrawer(runTarget.dataset.runDetail))
814
- return
815
- }
816
- const button = event.target.closest('button')
817
- if (!button) return
818
- if (button.dataset.keyMenu) {
819
- toggleKeyActionMenu(button, button.dataset.keyMenu)
820
- return
821
- }
822
- if (button.dataset.agentEdit) {
823
- openAgentDrawer(state.config.agents.find((agent) => agent.id === button.dataset.agentEdit))
824
- return
825
- }
826
- if (button.dataset.agentDelete) {
827
- if (!confirm('确定删除此智能体吗?已有会话不会被修改。')) return
828
- await runAction(async () => {
829
- state.config = await api('/v1/admin/agents/' + encodeURIComponent(button.dataset.agentDelete), { method: 'DELETE' })
830
- await loadAll(true)
831
- render()
832
- toast('智能体已删除')
833
- })
834
- return
835
- }
836
- if (button.dataset.workspaceEdit !== undefined) {
837
- openWorkspaceDrawer(Number(button.dataset.workspaceEdit))
838
- return
839
- }
840
- if (button.dataset.workspaceDelete !== undefined) {
841
- const index = Number(button.dataset.workspaceDelete)
842
- if (!confirm('确定移除此工作区根目录吗?')) return
843
- await runAction(async () => {
844
- const roots = state.config.workspaceRoots.filter((_, itemIndex) => itemIndex !== index)
845
- state.config = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })
846
- render()
847
- toast('工作区已移除')
848
- })
849
- return
850
- }
851
- if (button.dataset.fileOpen) {
852
- state.filePath = decodeURIComponent(button.dataset.fileOpen)
853
- await refreshFiles(false)
854
- return
855
- }
856
- if (button.dataset.filePublish) {
857
- await publishFile(decodeURIComponent(button.dataset.filePublish))
858
- return
859
- }
860
- if (button.dataset.fileRename) {
861
- const source = decodeURIComponent(button.dataset.fileRename)
862
- const currentName = source.replace(/\\/g, '/').split('/').pop()
863
- const name = prompt('新名称', currentName)
864
- if (!name || name === currentName) return
865
- await runAction(async () => {
866
- await api('/v1/admin/files/move', { method: 'POST', body: { root: state.fileRoot, path: source, destination: joinFilePath(parentFilePath(source), name) } })
867
- await refreshFiles(false)
868
- toast('文件已重命名')
869
- })
870
- return
871
- }
872
- if (button.dataset.fileDelete) {
873
- const target = decodeURIComponent(button.dataset.fileDelete)
874
- if (!confirm('确定删除“' + target + '”吗?目录会连同其内容一起删除。')) return
875
- await runAction(async () => {
876
- await api('/v1/admin/files/content?root=' + encodeURIComponent(state.fileRoot) + '&path=' + encodeURIComponent(target), { method: 'DELETE' })
877
- await refreshFiles(false)
878
- toast('已删除')
879
- })
880
- return
881
- }
882
- if (button.dataset.keyAction) await handleKeyAction(button.dataset.keyAction, button.dataset.keyId)
883
- }
884
-
885
- async function handleKeyAction(action, id) {
886
- const key = state.apiKeys.find((item) => item.id === id)
887
- if (!key) return
888
- await runAction(async () => {
889
- if (action === 'copy') {
890
- const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })
891
- await copySecret(value.secret)
892
- toast('API 密钥已复制')
893
- }
894
- if (action === 'reveal') {
895
- const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })
896
- showSecret('显示 API 密钥', value.secret)
897
- }
898
- if (action === 'rename') {
899
- const name = prompt('API 密钥名称', key.name)
900
- if (!name || name === key.name) return
901
- await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { name } })
902
- await reloadKeys()
903
- toast('API 密钥已重命名')
904
- }
905
- if (action === 'scope') {
906
- openKeyScopeDrawer(key)
907
- }
908
- if (action === 'toggle') {
909
- await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { enabled: !key.enabled } })
910
- await reloadKeys()
911
- toast(key.enabled ? 'API 密钥已禁用' : 'API 密钥已启用')
912
- }
913
- if (action === 'regenerate') {
914
- if (!confirm('确定重新生成此 API 密钥吗?当前密钥将立即失效。')) return
915
- const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/regenerate', { method: 'POST' })
916
- await reloadKeys()
917
- showSecret('API 密钥已重新生成', value.secret)
918
- }
919
- if (action === 'delete') {
920
- if (!confirm('确定删除此 API 密钥吗?此操作无法撤销。')) return
921
- await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' })
922
- await reloadKeys()
923
- toast('API 密钥已删除')
924
- }
925
- })
926
- }
927
-
928
- function openKeyScopeDrawer(key) {
929
- const selectedIds = new Set(key.scope.agentIds || [])
930
- const scopeRows = state.config.agents.map((agent) => '<label class="checkbox"><input type="checkbox" name="agentId" value="' + escapeHtml(agent.id) + '"' + checked(selectedIds.has(agent.id)) + '>' + escapeHtml(agent.name) + ' <span class="muted">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>').join('')
931
- const body = '<label class="checkbox"><input id="edit-all-agents" name="allAgents" type="checkbox"' + checked(key.scope.allAgents) + '>允许访问全部智能体</label><div id="edit-agent-scope" class="agent-scope' + (key.scope.allAgents ? ' hidden' : '') + '">' + (scopeRows || '<span class="muted">尚未配置智能体。</span>') + '</div>'
932
- openDrawer('编辑 API 密钥授权范围', body, '保存授权范围', async (form) => {
933
- const data = new FormData(form)
934
- const allAgents = data.get('allAgents') === 'on'
935
- const agentIds = data.getAll('agentId').map(String)
936
- if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')
937
- await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })
938
- closeDrawer()
939
- await reloadKeys()
940
- toast('API 密钥授权范围已更新')
941
- })
942
- const all = byId('edit-all-agents')
943
- all.onchange = () => byId('edit-agent-scope').classList.toggle('hidden', all.checked)
944
- }
945
-
946
- async function reloadKeys() {
947
- const value = await api('/v1/admin/api-keys')
948
- state.apiKeys = value.apiKeys || []
949
- render()
950
- }
951
-
952
- async function runAction(task) {
953
- try { await task() } catch (error) { toast(error.message, true) }
954
- }
955
-
956
- function formatDate(timestamp) {
957
- if (!timestamp) return '—'
958
- return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(timestamp))
959
- }
960
-
961
- function toast(message, error) {
962
- const item = document.createElement('div')
963
- item.className = 'toast' + (error ? ' error' : '')
964
- item.textContent = message
965
- byId('toast-region').appendChild(item)
966
- setTimeout(() => item.remove(), 4200)
967
- }
968
-
969
- function applyTheme(preference) {
970
- const theme = preference === 'system'
971
- ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
972
- : preference
973
- document.documentElement.dataset.theme = theme
974
- const select = byId('theme-select')
975
- if (select) select.value = preference
976
- }
977
-
978
- byId('setup-form').onsubmit = async (event) => {
979
- event.preventDefault()
980
- const form = event.currentTarget
981
- const error = form.querySelector('[data-form-error]')
982
- error.textContent = ''
983
- const data = new FormData(form)
984
- try {
985
- await api('/v1/bootstrap/initialize', { method: 'POST', body: { password: String(data.get('password') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })
986
- form.reset()
987
- showLogin()
988
- toast('初始化完成,请使用控制台密码登录。')
989
- } catch (reason) { error.textContent = reason.message }
990
- }
991
-
992
- byId('login-form').onsubmit = async (event) => {
993
- event.preventDefault()
994
- const form = event.currentTarget
995
- const error = form.querySelector('[data-form-error]')
996
- error.textContent = ''
997
- try {
998
- const data = new FormData(form)
999
- await api('/v1/admin/auth/login', { method: 'POST', body: { password: String(data.get('password') || '') } })
1000
- form.reset()
1001
- await enterApp()
1002
- } catch (reason) { error.textContent = reason.message }
1003
- }
1004
-
1005
- document.querySelectorAll('.nav-item').forEach((item) => {
1006
- item.onclick = () => {
1007
- state.page = item.dataset.page
1008
- render()
1009
- if (state.page === 'runs') void refreshRuns(false)
1010
- if (state.page === 'files') void refreshFiles(false)
1011
- }
1012
- })
1013
- content.addEventListener('click', (event) => { void handleContentClick(event) })
1014
- keyActionMenu.addEventListener('click', (event) => {
1015
- const button = event.target.closest('button[data-key-action]')
1016
- if (!button) return
1017
- closeKeyActionMenu()
1018
- void handleKeyAction(button.dataset.keyAction, button.dataset.keyId)
1019
- })
1020
- drawerForm.onsubmit = async (event) => {
1021
- event.preventDefault()
1022
- if (!drawerSubmit) return
1023
- const error = drawerForm.querySelector('[data-form-error]')
1024
- error.textContent = ''
1025
- try { await drawerSubmit(drawerForm) } catch (reason) { error.textContent = reason.message }
1026
- }
1027
- drawerForm.addEventListener('click', (event) => {
1028
- if (event.target.closest('[data-close-drawer]')) closeDrawer()
1029
- })
1030
- byId('drawer-close').onclick = closeDrawer
1031
- drawerBackdrop.onclick = closeDrawer
1032
-
1033
- byId('admin-menu').onclick = () => {
1034
- const popover = byId('admin-popover')
1035
- const hidden = popover.classList.toggle('hidden')
1036
- byId('admin-menu').setAttribute('aria-expanded', String(!hidden))
1037
- }
1038
- byId('theme-select').onchange = (event) => {
1039
- localStorage.setItem('agent-nexus-theme', event.target.value)
1040
- applyTheme(event.target.value)
1041
- }
1042
- byId('change-password').onclick = () => { byId('admin-popover').classList.add('hidden'); openPasswordDrawer() }
1043
- byId('logout').onclick = async () => {
1044
- await runAction(async () => {
1045
- await api('/v1/admin/auth/logout', { method: 'POST' })
1046
- showLogin()
1047
- })
1048
- }
1049
- document.addEventListener('click', (event) => {
1050
- if (!event.target.closest('.admin-menu-wrap')) byId('admin-popover').classList.add('hidden')
1051
- if (!event.target.closest('.key-action-menu') && !event.target.closest('[data-key-menu]')) closeKeyActionMenu()
1052
- })
1053
- matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
1054
- if ((localStorage.getItem('agent-nexus-theme') || 'system') === 'system') applyTheme('system')
1055
- })
1056
- document.addEventListener('keydown', (event) => {
1057
- if (event.key === 'Escape') {
1058
- closeKeyActionMenu()
1059
- closeDrawer()
1060
- }
1061
- })
1062
- window.addEventListener('resize', closeKeyActionMenu)
1063
- window.addEventListener('scroll', closeKeyActionMenu, true)
1064
-
1065
- setInterval(() => {
1066
- if (state.authenticated) void refreshReadiness(false)
1067
- }, 20_000)
1068
-
1069
- setInterval(() => {
1070
- if (state.authenticated && state.page === 'runs') void refreshRuns(false)
1071
- }, 5_000)
1072
-
1073
- void boot()
1074
- })()
1
+ export const app = String.raw `
2
+ (() => {
3
+ 'use strict'
4
+
5
+ const state = {
6
+ page: 'overview',
7
+ config: { workspaceRoots: [], driverKinds: [], agents: [] },
8
+ readiness: [],
9
+ apiKeys: [],
10
+ runs: [],
11
+ runTotal: 0,
12
+ sessions: 0,
13
+ search: '',
14
+ protocol: 'all',
15
+ status: 'all',
16
+ runSearch: '',
17
+ runAgent: 'all',
18
+ runStatus: 'all',
19
+ authenticated: false
20
+ }
21
+
22
+ const pageMeta = {
23
+ overview: ['总览', '查看已配置智能体运行时的实时状态。'],
24
+ runs: ['运行记录', '追踪当前任务进度,并查看已完成或失败的历史任务。'],
25
+ agents: ['智能体', '配置本地 ACP 进程和远程 A2A 智能体。'],
26
+ workspaces: ['工作区', '管理 ACP 智能体允许访问的路径。'],
27
+ keys: ['API 密钥', '管理 Agent Nexus 数据接口的客户端凭据。'],
28
+ settings: ['运行设置', '调整会话生命周期、任务超时和后台清理周期。']
29
+ }
30
+
31
+ const actionIcons = {
32
+ copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="10" height="10" rx="2"></rect><path d="M15 9V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path></svg>',
33
+ disable: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"></circle><path d="m6.3 6.3 11.4 11.4"></path></svg>',
34
+ enable: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"></circle><path d="m8.5 12 2.3 2.3 4.8-5"></path></svg>',
35
+ edit: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h8"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"></path></svg>',
36
+ more: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle></svg>',
37
+ reveal: '<svg viewBox="0 0 24 24" aria-hidden="true"><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"></path><circle cx="12" cy="12" r="2.5"></circle></svg>',
38
+ scope: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.7 2.9 8 7 10 4.1-2 7-5.3 7-10V6Z"></path><path d="M9.5 12 11 13.5l3.5-4"></path></svg>',
39
+ regenerate: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5"></path><path d="M19 12a7 7 0 1 0-2 5"></path></svg>',
40
+ delete: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"></path><path d="M9 7V4h6v3"></path><path d="m6 7 1 13h10l1-13"></path><path d="M10 11v5M14 11v5"></path></svg>'
41
+ }
42
+
43
+ const byId = (id) => document.getElementById(id)
44
+ const setupScreen = byId('setup-screen')
45
+ const loginScreen = byId('login-screen')
46
+ const appRoot = byId('app')
47
+ const content = byId('page-content')
48
+ const actions = byId('page-actions')
49
+ const drawer = byId('drawer')
50
+ const drawerBackdrop = byId('drawer-backdrop')
51
+ const drawerForm = byId('drawer-form')
52
+ const keyActionMenu = document.createElement('div')
53
+ keyActionMenu.className = 'key-action-menu hidden'
54
+ keyActionMenu.setAttribute('role', 'menu')
55
+ document.body.appendChild(keyActionMenu)
56
+ let drawerSubmit = null
57
+
58
+ function escapeHtml(value) {
59
+ return String(value == null ? '' : value)
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;')
63
+ .replace(/"/g, '&quot;')
64
+ .replace(/'/g, '&#039;')
65
+ }
66
+
67
+ function selected(value, expected) {
68
+ return value === expected ? ' selected' : ''
69
+ }
70
+
71
+ function checked(value) {
72
+ return value ? ' checked' : ''
73
+ }
74
+
75
+ async function api(path, options) {
76
+ const settings = Object.assign({ credentials: 'same-origin' }, options || {})
77
+ settings.headers = Object.assign({}, settings.headers || {})
78
+ if (settings.body && typeof settings.body !== 'string') {
79
+ settings.headers['Content-Type'] = 'application/json'
80
+ settings.body = JSON.stringify(settings.body)
81
+ }
82
+ const response = await fetch(path, settings)
83
+ let value = null
84
+ try { value = await response.json() } catch {}
85
+ if (!response.ok) {
86
+ if (response.status === 401 && path.indexOf('/v1/admin/') === 0) showLogin()
87
+ const message = value && value.error ? value.error : '请求失败(HTTP ' + response.status + ')'
88
+ const error = new Error(localizeError(message))
89
+ error.status = response.status
90
+ throw error
91
+ }
92
+ return value
93
+ }
94
+
95
+ function localizeError(message) {
96
+ const messages = {
97
+ 'Invalid Console Password': '控制台密码不正确',
98
+ 'Console setup is required': '请先完成控制台初始化',
99
+ 'Console setup is already complete': '控制台已完成初始化',
100
+ 'Console Password confirmation does not match': '两次输入的控制台密码不一致',
101
+ 'Console Password must contain at least 12 characters': '控制台密码至少需要 12 个字符',
102
+ 'Console Password must not exceed 1024 bytes': '控制台密码不能超过 1024 字节',
103
+ 'Console Password must not contain NUL bytes': '控制台密码包含不支持的字符',
104
+ 'Current Console Password is incorrect': '当前控制台密码不正确',
105
+ 'Gateway setup is required': '请先完成网关初始化',
106
+ 'Admin session is required': '管理员会话已失效,请重新登录',
107
+ 'Bearer API Key is required': '缺少 Bearer API 密钥',
108
+ 'Invalid API Key': 'API 密钥无效',
109
+ 'API Key is not authorized for this agent': '该 API 密钥无权访问此智能体',
110
+ 'API Key already exists': 'API 密钥已存在',
111
+ 'API Key not found': '未找到 API 密钥',
112
+ 'API Key name is required': '请输入 API 密钥名称',
113
+ 'API Key name is too long': 'API 密钥名称过长',
114
+ 'Custom API Key must contain at least 16 characters': '自定义 API 密钥至少需要 16 个字符',
115
+ 'Custom API Key must not exceed 512 bytes': '自定义 API 密钥不能超过 512 字节',
116
+ 'Custom API Key contains an unsupported value': '自定义 API 密钥包含不支持的内容',
117
+ 'workspaceRoots must contain at least one path': '至少需要保留一个工作区根目录',
118
+ 'A2A authentication value is required': '请输入 A2A 认证凭据',
119
+ 'A2A authentication value is invalid': 'A2A 认证凭据无效',
120
+ 'A2A Agent Card URL is required': '请输入 Agent Card URL',
121
+ 'A2A Agent Card URL is invalid': 'Agent Card URL 无效',
122
+ 'A2A Agent Card URL must use http(s) without credentials or a fragment': 'Agent Card URL 必须使用 HTTP(S),且不能包含凭据或片段',
123
+ 'preferredTransport must be auto, jsonrpc, or http-json': '首选传输必须是自动、JSON-RPC 或 HTTP+JSON',
124
+ 'A valid A2A header name is required': '请输入有效的 A2A 请求头名称',
125
+ 'Too many authentication attempts': '认证失败次数过多,请稍后再试',
126
+ 'Request body is too large': '请求内容过大',
127
+ 'Request requires application/json': '请求必须使用 application/json',
128
+ 'Same-origin request is required': '此操作必须从当前控制台页面发起',
129
+ 'Request origin is invalid': '请求来源无效',
130
+ 'Cross-origin admin request is not allowed': '不允许跨域管理请求',
131
+ 'Control plane is unavailable': '管理控制面当前不可用',
132
+ 'Agent Nexus run not found': '未找到运行记录',
133
+ 'Internal server error': '服务器内部错误'
134
+ }
135
+ if (messages[message]) return messages[message]
136
+ const missingAgent = /^Configured agent not found: (.+)$/.exec(message)
137
+ if (missingAgent) return '未找到已配置的智能体:' + missingAgent[1]
138
+ const timeout = /^timeout must be between (\d+) and (\d+)$/.exec(message)
139
+ if (timeout) return '超时时间必须介于 ' + Math.round(Number(timeout[1]) / 1000) + ' 和 ' + Math.round(Number(timeout[2]) / 1000) + ' 秒之间'
140
+ const runtimeRange = /^(sessionTtlMs|promptTimeoutMs|cleanupIntervalMs) must be between (\d+) and (\d+)$/.exec(message)
141
+ if (runtimeRange) {
142
+ const units = {
143
+ sessionTtlMs: ['Session 空闲有效期', '小时', 3_600_000],
144
+ promptTimeoutMs: ['ACP 任务超时', '分钟', 60_000],
145
+ cleanupIntervalMs: ['清理任务周期', '秒', 1000]
146
+ }
147
+ const [label, unit, divisor] = units[runtimeRange[1]]
148
+ return label + '必须介于 ' + Math.round(Number(runtimeRange[2]) / divisor) + ' 和 ' + Math.round(Number(runtimeRange[3]) / divisor) + ' ' + unit + '之间'
149
+ }
150
+ return message
151
+ }
152
+
153
+ function showOnly(element) {
154
+ setupScreen.classList.add('hidden')
155
+ loginScreen.classList.add('hidden')
156
+ appRoot.classList.add('hidden')
157
+ element.classList.remove('hidden')
158
+ }
159
+
160
+ function showLogin() {
161
+ state.authenticated = false
162
+ showOnly(loginScreen)
163
+ byId('login-form').reset()
164
+ }
165
+
166
+ async function boot() {
167
+ applyTheme(localStorage.getItem('agent-nexus-theme') || 'system')
168
+ try {
169
+ const bootstrap = await api('/v1/bootstrap/status')
170
+ if (bootstrap.adminSetupRequired) {
171
+ showOnly(setupScreen)
172
+ return
173
+ }
174
+ const auth = await api('/v1/admin/auth/status')
175
+ if (!auth.authenticated) {
176
+ showLogin()
177
+ return
178
+ }
179
+ await enterApp()
180
+ } catch (error) {
181
+ showLogin()
182
+ toast(error.message, true)
183
+ }
184
+ }
185
+
186
+ async function enterApp() {
187
+ state.authenticated = true
188
+ showOnly(appRoot)
189
+ await loadAll()
190
+ render()
191
+ }
192
+
193
+ async function loadAll(force) {
194
+ const suffix = force ? '?refresh=1' : ''
195
+ const values = await Promise.all([
196
+ api('/v1/admin/config'),
197
+ api('/v1/admin/api-keys'),
198
+ api('/v1/admin/overview' + suffix),
199
+ api('/v1/admin/runs?limit=200')
200
+ ])
201
+ state.config = values[0]
202
+ state.apiKeys = values[1].apiKeys || []
203
+ state.readiness = values[2].agents || []
204
+ state.sessions = values[2].sessions || 0
205
+ state.runs = values[3].runs || []
206
+ state.runTotal = values[3].total || 0
207
+ }
208
+
209
+ async function refreshRuns(showNotice) {
210
+ try {
211
+ const value = await api('/v1/admin/runs?limit=200')
212
+ state.runs = value.runs || []
213
+ state.runTotal = value.total || 0
214
+ if (state.page === 'runs') renderRuns()
215
+ if (showNotice) toast('运行记录已刷新')
216
+ } catch (error) {
217
+ if (showNotice) toast(error.message, true)
218
+ }
219
+ }
220
+
221
+ async function refreshReadiness(showNotice) {
222
+ try {
223
+ const overview = await api('/v1/admin/overview?refresh=1')
224
+ state.readiness = overview.agents || []
225
+ state.sessions = overview.sessions || 0
226
+ render()
227
+ if (showNotice) toast('运行状态已刷新')
228
+ } catch (error) {
229
+ if (showNotice) toast(error.message, true)
230
+ }
231
+ }
232
+
233
+ function render() {
234
+ const meta = pageMeta[state.page]
235
+ byId('page-title').textContent = meta[0]
236
+ byId('page-description').textContent = meta[1]
237
+ document.querySelectorAll('.nav-item').forEach((item) => {
238
+ item.classList.toggle('active', item.dataset.page === state.page)
239
+ })
240
+ if (state.page === 'overview') renderOverview()
241
+ if (state.page === 'runs') renderRuns()
242
+ if (state.page === 'agents') renderAgents()
243
+ if (state.page === 'workspaces') renderWorkspaces()
244
+ if (state.page === 'keys') renderApiKeys()
245
+ if (state.page === 'settings') renderSettings()
246
+ }
247
+
248
+ function renderSettings() {
249
+ const sessionTtlHours = Math.round((state.config.sessionTtlMs || 24 * 60 * 60 * 1000) / 3_600_000)
250
+ const promptTimeoutMinutes = Math.round((state.config.promptTimeoutMs || 30 * 60 * 1000) / 60_000)
251
+ const cleanupIntervalSeconds = Math.round((state.config.cleanupIntervalMs || 60_000) / 1000)
252
+ actions.innerHTML = '<span class="muted">修改后立即生效,新任务按新参数执行</span>'
253
+ content.innerHTML =
254
+ '<div class="settings-layout"><section class="panel settings-form"><div class="panel-header"><div><h2>运行生命周期</h2><p class="settings-subtitle">这些参数由网关统一管理,重启后仍会保留。</p></div></div>' +
255
+ '<div class="settings-fields">' +
256
+ '<label>Session 空闲有效期(小时)<input id="session-ttl-hours" type="number" min="1" max="720" step="1" value="' + escapeHtml(sessionTtlHours) + '"><small class="field-help">Session 在没有新消息、授权或输入交互后,超过此时间会被释放。默认 24 小时。</small></label>' +
257
+ '<label>单次 ACP 任务超时(分钟)<input id="prompt-timeout-minutes" type="number" min="1" max="1440" step="1" value="' + escapeHtml(promptTimeoutMinutes) + '"><small class="field-help">单次 ACP prompt 的最长运行时间。默认 30 分钟;超时后任务会记录为失败并释放运行资源。</small></label>' +
258
+ '<label>清理任务周期(秒)<input id="cleanup-interval-seconds" type="number" min="5" max="3600" step="1" value="' + escapeHtml(cleanupIntervalSeconds) + '"><small class="field-help">网关扫描空闲 Session、过期运行记录和临时输入文件的间隔。默认每 60 秒执行一次。</small></label>' +
259
+ '</div><div class="settings-divider"></div><div class="settings-note"><strong>A2A 请求超时</strong><span>在“智能体 → 编辑 → A2A → 请求超时”中单独配置。默认 60 秒,最大 30 分钟,不受 ACP 全局任务超时字段覆盖。</span></div><div class="settings-actions"><button id="save-runtime-settings" class="button primary">保存运行设置</button></div></section></div>'
260
+ byId('save-runtime-settings').onclick = async () => {
261
+ const values = {
262
+ sessionTtlMs: Number(byId('session-ttl-hours').value) * 3_600_000,
263
+ promptTimeoutMs: Number(byId('prompt-timeout-minutes').value) * 60_000,
264
+ cleanupIntervalMs: Number(byId('cleanup-interval-seconds').value) * 1000
265
+ }
266
+ if (!Object.values(values).every((value) => Number.isInteger(value) && value > 0)) {
267
+ toast('请输入有效的整数运行参数。', true)
268
+ return
269
+ }
270
+ await runAction(async () => {
271
+ state.config = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })
272
+ render()
273
+ toast('运行设置已保存并立即生效')
274
+ })
275
+ }
276
+ }
277
+
278
+ function renderRuns() {
279
+ const activeStates = new Set(['running', 'input_required', 'permission_required'])
280
+ const filtered = state.runs.filter((run) => {
281
+ const haystack = (run.task + ' ' + run.agentName + ' ' + run.id).toLowerCase()
282
+ return haystack.indexOf(state.runSearch.toLowerCase()) >= 0 &&
283
+ (state.runAgent === 'all' || run.agentId === state.runAgent) &&
284
+ (state.runStatus === 'all' || run.state === state.runStatus)
285
+ })
286
+ const active = filtered.filter((run) => activeStates.has(run.state))
287
+ const history = filtered.filter((run) => !activeStates.has(run.state))
288
+ const completed = state.runs.filter((run) => run.state === 'completed').length
289
+ const failed = state.runs.filter((run) => run.state === 'failed').length
290
+ const agentOptions = state.config.agents.map((agent) => '<option value="' + escapeHtml(agent.id) + '"' + selected(state.runAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')
291
+ actions.innerHTML = '<span class="muted">每 5 秒自动刷新</span><button id="refresh-runs" class="button">立即刷新</button>'
292
+ content.innerHTML =
293
+ '<div class="run-summary">' +
294
+ runSummary('当前运行', state.runs.filter((run) => activeStates.has(run.state)).length) +
295
+ runSummary('已完成', completed) +
296
+ runSummary('失败', failed) +
297
+ runSummary('保留记录', state.runTotal) +
298
+ '</div>' +
299
+ '<div class="toolbar"><input id="run-search" type="search" placeholder="搜索任务、智能体或运行 ID" value="' + escapeHtml(state.runSearch) + '">' +
300
+ '<select id="run-agent-filter"><option value="all">全部智能体</option>' + agentOptions + '</select>' +
301
+ '<select id="run-status-filter"><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>' +
302
+ '<section class="run-section"><div class="run-section-title"><h2>当前运行</h2><span>' + active.length + ' 项</span></div>' +
303
+ (active.length ? '<div class="run-live-grid">' + active.map(runCard).join('') + '</div>' : '<div class="panel"><div class="empty">当前没有正在执行或等待处理的任务。</div></div>') +
304
+ '</section>' +
305
+ '<section class="run-section"><div class="run-section-title"><h2>历史记录</h2><span>' + history.length + ' 项</span></div>' +
306
+ '<div class="table-wrap"><table class="run-table"><thead><tr><th>智能体</th><th>任务</th><th>结果</th><th>状态</th><th>开始时间</th><th>耗时</th></tr></thead><tbody>' +
307
+ (history.length ? history.map(runRow).join('') : '<tr><td colspan="6" class="empty">没有符合条件的历史记录。</td></tr>') +
308
+ '</tbody></table></div></section>'
309
+ byId('refresh-runs').onclick = () => refreshRuns(true)
310
+ byId('run-search').oninput = (event) => { state.runSearch = event.target.value; renderRuns() }
311
+ byId('run-agent-filter').onchange = (event) => { state.runAgent = event.target.value; renderRuns() }
312
+ byId('run-status-filter').onchange = (event) => { state.runStatus = event.target.value; renderRuns() }
313
+ }
314
+
315
+ function runSummary(label, value) {
316
+ return '<div class="run-summary-item"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
317
+ }
318
+
319
+ function runCard(run) {
320
+ const waiting = run.state === 'input_required' || run.state === 'permission_required'
321
+ return '<article class="run-card' + (waiting ? ' waiting' : '') + '" data-run-detail="' + escapeHtml(run.id) + '" tabindex="0"><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><div class="run-progress"><strong>' + escapeHtml(run.progress && run.progress.phase || '运行中') + '</strong><small>' + escapeHtml(run.progress && run.progress.message || '等待最新进度') + '</small></div><div class="run-meta"><span>' + escapeHtml(shortId(run.id)) + '</span><span>' + escapeHtml(formatDuration(run)) + '</span></div></article>'
322
+ }
323
+
324
+ function runRow(run) {
325
+ return '<tr data-run-detail="' + escapeHtml(run.id) + '"><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>' + escapeHtml(formatDate(run.startedAt)) + '</td><td>' + escapeHtml(formatDuration(run)) + '</td></tr>'
326
+ }
327
+
328
+ function runStatus(value) {
329
+ const labels = { created: '已创建', running: '运行中', input_required: '等待输入', permission_required: '等待授权', completed: '已完成', failed: '失败', canceled: '已取消' }
330
+ const style = value === 'completed' ? ' completed' : value === 'failed' ? ' failed' : value === 'running' ? ' running' : (value === 'input_required' || value === 'permission_required') ? ' waiting' : ' canceled'
331
+ return '<span class="status' + style + '">' + escapeHtml(labels[value] || value) + '</span>'
332
+ }
333
+
334
+ function shortId(value) {
335
+ return String(value || '').slice(0, 8)
336
+ }
337
+
338
+ function formatDuration(run) {
339
+ const ms = run.durationMs != null ? run.durationMs : Math.max(0, Date.now() - run.startedAt)
340
+ if (ms < 1000) return ms + ' 毫秒'
341
+ if (ms < 60_000) return Math.round(ms / 1000) + ' 秒'
342
+ if (ms < 3_600_000) return Math.floor(ms / 60_000) + ' 分 ' + Math.round(ms % 60_000 / 1000) + ' 秒'
343
+ return Math.floor(ms / 3_600_000) + ' 小时 ' + Math.round(ms % 3_600_000 / 60_000) + ' 分'
344
+ }
345
+
346
+ async function openRunDrawer(id) {
347
+ const run = await api('/v1/admin/runs/' + encodeURIComponent(id))
348
+ const artifacts = run.artifacts && run.artifacts.length
349
+ ? '<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>'
350
+ : ''
351
+ const body = '<div class="run-detail-grid">' +
352
+ detailItem('智能体', run.agentName) + detailItem('状态', runStatusLabel(run.state)) +
353
+ detailItem('开始时间', formatDate(run.startedAt)) + detailItem('耗时', formatDuration(run)) +
354
+ detailItem('协议', run.protocol.toUpperCase()) + detailItem('运行 ID', run.id) +
355
+ detailItem('当前阶段', run.progress && run.progress.phase || '—') + detailItem('输入附件', run.inputAttachmentCount || 0) + detailItem('产物', run.artifactCount || 0) +
356
+ '</div><div class="run-detail-block"><h3>用户任务</h3><pre class="run-detail-code">' + escapeHtml(run.task) + (run.taskTruncated ? '\n\n[记录已截断]' : '') + '</pre></div>' +
357
+ (run.progress && run.progress.message ? '<div class="run-detail-block"><h3>最近进度</h3><pre class="run-detail-code">' + escapeHtml(run.progress.message) + '</pre></div>' : '') +
358
+ (run.output ? '<div class="run-detail-block"><h3>智能体结果</h3><pre class="run-detail-code">' + escapeHtml(run.output) + '</pre></div>' : '') +
359
+ (run.error ? '<div class="run-detail-block"><h3>错误</h3><pre class="run-detail-code">' + escapeHtml(run.error) + '</pre></div>' : '') + artifacts
360
+ openDrawer('运行详情 · ' + run.agentName, body, '', null)
361
+ }
362
+
363
+ function detailItem(label, value) {
364
+ return '<div class="run-detail-item"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
365
+ }
366
+
367
+ function runStatusLabel(value) {
368
+ return { created: '已创建', running: '运行中', input_required: '等待输入', permission_required: '等待授权', completed: '已完成', failed: '失败', canceled: '已取消' }[value] || value
369
+ }
370
+
371
+ function renderOverview() {
372
+ const agents = readinessRows()
373
+ const ready = agents.filter((agent) => agent.ready).length
374
+ actions.innerHTML = '<button id="refresh-overview" class="button">刷新</button>'
375
+ content.innerHTML =
376
+ '<div class="stats">' +
377
+ stat('智能体', agents.length) +
378
+ stat('就绪', ready) +
379
+ stat('会话', state.sessions) +
380
+ '</div>' +
381
+ '<div class="panel"><div class="panel-header"><h2>智能体</h2><span class="muted">每 20 秒自动检查</span></div>' +
382
+ (agents.length
383
+ ? '<div class="panel-body overview-list">' + agents.map(overviewAgent).join('') + '</div>'
384
+ : '<div class="empty">尚未配置智能体。</div>') +
385
+ '</div>'
386
+ byId('refresh-overview').onclick = () => refreshReadiness(true)
387
+ }
388
+
389
+ function stat(label, value) {
390
+ return '<div class="stat"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
391
+ }
392
+
393
+ function readinessRows() {
394
+ const readiness = new Map(state.readiness.map((agent) => [agent.id, agent]))
395
+ return state.config.agents.map((agent) => readiness.get(agent.id) || {
396
+ id: agent.id,
397
+ name: agent.name,
398
+ protocol: agent.protocol,
399
+ driver: agent.driver,
400
+ enabled: agent.enabled,
401
+ ready: false,
402
+ checking: true
403
+ })
404
+ }
405
+
406
+ function overviewAgent(agent) {
407
+ return '<div class="list-row"><div class="list-row-main"><strong>' + escapeHtml(agent.name) + '</strong><small>' +
408
+ escapeHtml(agent.protocol.toUpperCase() + (agent.driver ? ' · ' + agent.driver : '')) +
409
+ (agent.error ? ' · ' + escapeHtml(localizeError(agent.error)) : '') + '</small></div>' + statusMarkup(agent) + '</div>'
410
+ }
411
+
412
+ function renderAgents() {
413
+ actions.innerHTML = '<button id="refresh-agents" class="button">刷新</button><button id="add-agent" class="button primary">添加智能体</button>'
414
+ const readiness = new Map(state.readiness.map((agent) => [agent.id, agent]))
415
+ const rows = state.config.agents.filter((agent) => {
416
+ const live = readiness.get(agent.id) || {}
417
+ const haystack = (agent.name + ' ' + agent.id + ' ' + (agent.description || '')).toLowerCase()
418
+ const status = !readiness.has(agent.id) ? 'checking' : live.ready ? 'ready' : agent.enabled ? 'failed' : 'disabled'
419
+ return haystack.indexOf(state.search.toLowerCase()) >= 0 &&
420
+ (state.protocol === 'all' || agent.protocol === state.protocol) &&
421
+ (state.status === 'all' || status === state.status)
422
+ })
423
+ content.innerHTML =
424
+ '<div class="toolbar"><input id="agent-search" type="search" placeholder="搜索智能体" value="' + escapeHtml(state.search) + '">' +
425
+ '<select id="protocol-filter"><option value="all">全部协议</option><option value="acp"' + selected(state.protocol, 'acp') + '>ACP</option><option value="a2a"' + selected(state.protocol, 'a2a') + '>A2A</option></select>' +
426
+ '<select id="status-filter"><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>' +
427
+ '<div class="table-wrap"><table><thead><tr><th>智能体</th><th>协议</th><th>驱动</th><th>状态</th><th>工作区</th><th>权限策略</th><th></th></tr></thead><tbody>' +
428
+ (rows.length ? rows.map((agent) => agentRow(agent, readiness.get(agent.id))).join('') : '<tr><td colspan="7" class="empty">没有符合条件的智能体。</td></tr>') +
429
+ '</tbody></table></div>'
430
+ byId('agent-search').oninput = (event) => { state.search = event.target.value; renderAgents() }
431
+ byId('protocol-filter').onchange = (event) => { state.protocol = event.target.value; renderAgents() }
432
+ byId('status-filter').onchange = (event) => { state.status = event.target.value; renderAgents() }
433
+ byId('refresh-agents').onclick = () => refreshReadiness(true)
434
+ byId('add-agent').onclick = () => openAgentDrawer()
435
+ }
436
+
437
+ function agentRow(agent, live) {
438
+ const statusAgent = live || { ready: false, enabled: agent.enabled, checking: true }
439
+ return '<tr><td><div class="agent-name"><strong>' + escapeHtml(agent.name) + '</strong><small>' + escapeHtml(agent.id) + '</small>' +
440
+ (statusAgent.error && agent.enabled ? '<span class="error-detail">' + escapeHtml(localizeError(statusAgent.error)) + '</span>' : '') +
441
+ '</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>'
442
+ }
443
+
444
+ function permissionLabel(value) {
445
+ if (value === 'ask') return '询问'
446
+ if (value === 'deny') return '拒绝'
447
+ return value || '—'
448
+ }
449
+
450
+ function statusMarkup(agent) {
451
+ if (!agent.enabled) return '<span class="status">已禁用</span>'
452
+ if (agent.checking) return '<span class="status checking">检查中</span>'
453
+ return agent.ready ? '<span class="status ready">就绪</span>' : '<span class="status failed">不可用</span>'
454
+ }
455
+
456
+ function renderWorkspaces() {
457
+ actions.innerHTML = '<button id="add-workspace" class="button primary">添加工作区</button>'
458
+ const roots = state.config.workspaceRoots || []
459
+ content.innerHTML = '<div class="workspace-list">' +
460
+ roots.map((root, index) => '<div class="list-row"><div class="list-row-main"><strong class="path">' + escapeHtml(root) + '</strong><small>ACP 会话允许访问的根目录</small></div><div class="row-actions"><button class="button small" data-workspace-edit="' + index + '">编辑</button><button class="button small danger" data-workspace-delete="' + index + '"' + (roots.length === 1 ? ' disabled' : '') + '>删除</button></div></div>').join('') +
461
+ '</div><div class="tip">建议仅添加必要的最小目录范围。启动 ACP 会话前,Agent Nexus 会解析并校验真实路径。</div>'
462
+ byId('add-workspace').onclick = () => openWorkspaceDrawer()
463
+ }
464
+
465
+ function renderApiKeys() {
466
+ closeKeyActionMenu()
467
+ actions.innerHTML = '<button id="create-key" class="button primary">创建 API 密钥</button>'
468
+ content.innerHTML = state.apiKeys.length
469
+ ? '<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 class="key-actions-heading">操作</th></tr></thead><tbody>' + state.apiKeys.map(keyRow).join('') + '</tbody></table></div>'
470
+ : '<div class="panel"><div class="empty">尚未创建 API 密钥。客户端需要访问时再创建即可。</div></div>'
471
+ byId('create-key').onclick = () => openKeyDrawer()
472
+ }
473
+
474
+ function keyRow(key) {
475
+ const scope = key.scope.allAgents ? '全部智能体' : (key.scope.agentIds.length ? key.scope.agentIds.join(', ') : '未授权智能体')
476
+ const name = key.legacy && key.name === 'Legacy Access Key' ? '旧版访问密钥' : key.name
477
+ const id = escapeHtml(key.id)
478
+ const toggleLabel = key.enabled ? '禁用密钥' : '启用密钥'
479
+ return '<tr><td><div class="agent-name"><strong>' + escapeHtml(name) + '</strong><small>' + (key.legacy ? '从旧版配置迁移' : '创建于 ' + formatDate(key.createdAt)) + '</small></div></td>' +
480
+ '<td>' + (key.enabled ? '<span class="status ready">已启用</span>' : '<span class="status">已禁用</span>') + '</td>' +
481
+ '<td><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="复制完整密钥">' + actionIcons.copy + '</button></div></td>' +
482
+ '<td><span class="key-scope">' + escapeHtml(scope) + '</span></td>' +
483
+ '<td><span class="key-last-used">' + escapeHtml(key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用') + '</span></td>' +
484
+ '<td class="key-actions-cell"><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 ? actionIcons.disable : actionIcons.enable) + '</button><button class="key-icon-button" data-key-action="rename" data-key-id="' + id + '" aria-label="重命名" title="重命名">' + actionIcons.edit + '</button><button class="key-icon-button" data-key-menu="' + id + '" aria-label="更多操作" title="更多操作" aria-expanded="false">' + actionIcons.more + '</button></div></td></tr>'
485
+ }
486
+
487
+ function toggleKeyActionMenu(anchor, id) {
488
+ if (!keyActionMenu.classList.contains('hidden') && keyActionMenu.dataset.keyId === id) {
489
+ closeKeyActionMenu()
490
+ return
491
+ }
492
+ closeKeyActionMenu()
493
+ keyActionMenu.dataset.keyId = id
494
+ keyActionMenu.innerHTML =
495
+ '<button type="button" role="menuitem" data-key-action="reveal" data-key-id="' + escapeHtml(id) + '">' + actionIcons.reveal + '<span>显示完整密钥</span></button>' +
496
+ '<button type="button" role="menuitem" data-key-action="scope" data-key-id="' + escapeHtml(id) + '">' + actionIcons.scope + '<span>编辑授权范围</span></button>' +
497
+ '<button type="button" role="menuitem" data-key-action="regenerate" data-key-id="' + escapeHtml(id) + '">' + actionIcons.regenerate + '<span>重新生成密钥</span></button>' +
498
+ '<button type="button" role="menuitem" class="danger-text" data-key-action="delete" data-key-id="' + escapeHtml(id) + '">' + actionIcons.delete + '<span>删除密钥</span></button>'
499
+ keyActionMenu.classList.remove('hidden')
500
+ anchor.setAttribute('aria-expanded', 'true')
501
+ const anchorRect = anchor.getBoundingClientRect()
502
+ const menuRect = keyActionMenu.getBoundingClientRect()
503
+ const left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, anchorRect.right - menuRect.width))
504
+ const below = anchorRect.bottom + 6
505
+ const top = below + menuRect.height <= window.innerHeight - 8
506
+ ? below
507
+ : Math.max(8, anchorRect.top - menuRect.height - 6)
508
+ keyActionMenu.style.left = left + 'px'
509
+ keyActionMenu.style.top = top + 'px'
510
+ }
511
+
512
+ function closeKeyActionMenu() {
513
+ keyActionMenu.classList.add('hidden')
514
+ keyActionMenu.innerHTML = ''
515
+ keyActionMenu.dataset.keyId = ''
516
+ document.querySelectorAll('[data-key-menu][aria-expanded="true"]').forEach((button) => button.setAttribute('aria-expanded', 'false'))
517
+ }
518
+
519
+ function openDrawer(title, body, submitLabel, onSubmit) {
520
+ byId('drawer-title').textContent = title
521
+ drawerForm.innerHTML = body + '<div class="drawer-footer"><button type="button" class="button" data-close-drawer>取消</button>' + (submitLabel ? '<button type="submit" class="button primary">' + escapeHtml(submitLabel) + '</button>' : '') + '</div><p class="form-error" data-form-error></p>'
522
+ drawerSubmit = onSubmit || null
523
+ drawer.classList.remove('hidden')
524
+ drawerBackdrop.classList.remove('hidden')
525
+ const first = drawerForm.querySelector('input, select, textarea')
526
+ if (first) setTimeout(() => first.focus(), 0)
527
+ }
528
+
529
+ function closeDrawer() {
530
+ drawer.classList.add('hidden')
531
+ drawerBackdrop.classList.add('hidden')
532
+ drawerForm.innerHTML = ''
533
+ drawerSubmit = null
534
+ }
535
+
536
+ function openAgentDrawer(agent) {
537
+ const editing = Boolean(agent)
538
+ const current = agent || { protocol: 'acp', enabled: true, permissionPolicy: 'ask', permissionTimeoutMs: 900000, timeoutMs: 60000 }
539
+ const permissionTimeoutSeconds = Math.max(1, Math.round(Number(current.permissionTimeoutMs || 900000) / 1000))
540
+ const requestTimeoutSeconds = Math.max(1, Math.round(Number(current.timeoutMs || 60000) / 1000))
541
+ const drivers = state.config.driverKinds.map((driver) => '<option value="' + escapeHtml(driver) + '"' + selected(current.driver || state.config.driverKinds[0], driver) + '>' + escapeHtml(driver) + '</option>').join('')
542
+ const roots = state.config.workspaceRoots.map((root) => '<option value="' + escapeHtml(root) + '"></option>').join('')
543
+ const body =
544
+ '<label>智能体 ID<input name="id" value="' + escapeHtml(current.id || '') + '" pattern="[a-z0-9][a-z0-9._\\-]{0,63}" required' + (editing ? ' disabled' : '') + '></label>' +
545
+ '<div class="field-row"><label>协议<select name="protocol"><option value="acp"' + selected(current.protocol, 'acp') + '>ACP</option><option value="a2a"' + selected(current.protocol, 'a2a') + '>A2A</option></select></label><label class="checkbox"><input name="enabled" type="checkbox"' + checked(current.enabled) + '>启用</label></div>' +
546
+ '<label>名称<input name="name" value="' + escapeHtml(current.name || '') + '" required></label>' +
547
+ '<label>描述<textarea name="description">' + escapeHtml(current.description || '') + '</textarea></label>' +
548
+ '<div data-protocol-section="acp"><label>驱动<select name="driver">' + drivers + '</select></label><label>工作区<input name="workspace" list="workspace-roots" value="' + escapeHtml(current.workspace || state.config.workspaceRoots[0] || '') + '" required><datalist id="workspace-roots">' + roots + '</datalist></label><div class="field-row"><label>权限策略<select name="permissionPolicy"><option value="ask"' + selected(current.permissionPolicy, 'ask') + '>询问</option><option value="deny"' + selected(current.permissionPolicy, 'deny') + '>拒绝</option></select></label><label>权限确认超时(秒)<input name="permissionTimeoutMs" type="number" min="1" max="86400" step="1" value="' + escapeHtml(permissionTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 900 秒,最长 24 小时。</small></label></div></div>' +
549
+ '<div data-protocol-section="a2a"><label>Agent Card URL<input name="agentCardUrl" type="url" value="' + escapeHtml(current.agentCardUrl || '') + '" placeholder="http://agent.local:8080/.well-known/agent-card.json" required><small class="field-help">填写完整的 Agent Card JSON 地址;调用地址和能力将从 Card 自动发现。</small></label><div class="field-row"><label>首选传输<select 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></label><label>认证方式<select 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></label></div><label data-auth-header>请求头名称<input name="authHeaderName" value="' + escapeHtml(current.auth && current.auth.headerName || '') + '" placeholder="X-API-Key"></label><label data-auth-value><span data-auth-value-label>认证凭据</span><input name="authValue" type="password" autocomplete="off" placeholder="' + (editing && current.auth && current.auth.configured ? '留空以保留当前凭据' : '') + '"></label><label>请求超时(秒)<input name="timeoutMs" type="number" min="1" max="1800" step="1" value="' + escapeHtml(requestTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 60 秒;用于限制这个 A2A Agent 的单次请求,最大 1800 秒(30 分钟)。</small></label></div>'
550
+ openDrawer(editing ? '编辑智能体' : '添加智能体', body, editing ? '保存修改' : '添加智能体', async (form) => {
551
+ const data = new FormData(form)
552
+ const protocol = data.get('protocol')
553
+ const id = editing ? current.id : String(data.get('id') || '').trim().toLowerCase()
554
+ const payload = {
555
+ protocol,
556
+ name: String(data.get('name') || '').trim(),
557
+ description: String(data.get('description') || '').trim(),
558
+ enabled: data.get('enabled') === 'on'
559
+ }
560
+ if (protocol === 'acp') {
561
+ payload.driver = data.get('driver')
562
+ payload.workspace = String(data.get('workspace') || '').trim()
563
+ payload.permissionPolicy = data.get('permissionPolicy')
564
+ payload.permissionTimeoutMs = Number(data.get('permissionTimeoutMs')) * 1000
565
+ } else {
566
+ payload.agentCardUrl = String(data.get('agentCardUrl') || '').trim()
567
+ payload.preferredTransport = data.get('preferredTransport')
568
+ payload.authType = data.get('authType')
569
+ payload.authHeaderName = String(data.get('authHeaderName') || '').trim()
570
+ const authValue = String(data.get('authValue') || '')
571
+ if (authValue) payload.authValue = authValue
572
+ payload.timeoutMs = Number(data.get('timeoutMs')) * 1000
573
+ }
574
+ await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'PUT', body: payload })
575
+ closeDrawer()
576
+ await loadAll(true)
577
+ render()
578
+ toast(editing ? '智能体已更新' : '智能体已添加')
579
+ })
580
+ const protocolSelect = drawerForm.elements.protocol
581
+ const authSelect = drawerForm.elements.authType
582
+ const sync = () => {
583
+ drawerForm.querySelectorAll('[data-protocol-section]').forEach((section) => {
584
+ section.classList.toggle('hidden', section.dataset.protocolSection !== protocolSelect.value)
585
+ section.querySelectorAll('input,select,textarea').forEach((field) => { field.disabled = section.classList.contains('hidden') })
586
+ })
587
+ if (protocolSelect.value === 'a2a') {
588
+ const auth = authSelect.value
589
+ drawerForm.querySelector('[data-auth-header]').classList.toggle('hidden', auth !== 'header')
590
+ drawerForm.querySelector('[data-auth-value]').classList.toggle('hidden', auth === 'none')
591
+ drawerForm.querySelector('[data-auth-value-label]').textContent = auth === 'bearer' ? 'Bearer Token' : '认证凭据'
592
+ }
593
+ }
594
+ protocolSelect.onchange = sync
595
+ authSelect.onchange = sync
596
+ sync()
597
+ }
598
+
599
+ function openWorkspaceDrawer(index) {
600
+ const editing = Number.isInteger(index)
601
+ const current = editing ? state.config.workspaceRoots[index] : ''
602
+ openDrawer(editing ? '编辑工作区' : '添加工作区', '<label>允许访问的根目录<input name="path" value="' + escapeHtml(current) + '" required></label>', editing ? '保存修改' : '添加工作区', async (form) => {
603
+ const roots = state.config.workspaceRoots.slice()
604
+ const value = String(new FormData(form).get('path') || '').trim()
605
+ if (editing) roots[index] = value
606
+ else roots.push(value)
607
+ const result = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })
608
+ state.config = result
609
+ closeDrawer()
610
+ render()
611
+ toast(editing ? '工作区已更新' : '工作区已添加')
612
+ })
613
+ }
614
+
615
+ function openKeyDrawer() {
616
+ const scopeRows = state.config.agents.map((agent) => '<label class="checkbox"><input type="checkbox" name="agentId" value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.name) + ' <span class="muted">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>').join('')
617
+ const body = '<label>名称<input name="name" required placeholder="例如:开发客户端"></label><label class="checkbox"><input id="all-agents" name="allAgents" type="checkbox" checked>允许访问全部智能体</label><div id="agent-scope" class="agent-scope hidden">' + (scopeRows || '<span class="muted">尚未配置智能体。</span>') + '</div><label>自定义密钥(可选)<input name="customSecret" minlength="16" autocomplete="off" placeholder="留空则自动生成 nx_sk_ 密钥"></label>'
618
+ openDrawer('创建 API 密钥', body, '创建 API 密钥', async (form) => {
619
+ const data = new FormData(form)
620
+ const allAgents = data.get('allAgents') === 'on'
621
+ const agentIds = data.getAll('agentId').map(String)
622
+ if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')
623
+ const customSecret = String(data.get('customSecret') || '').trim()
624
+ const result = await api('/v1/admin/api-keys', {
625
+ method: 'POST',
626
+ body: {
627
+ name: String(data.get('name') || '').trim(),
628
+ scope: { allAgents, agentIds },
629
+ customSecret: customSecret || undefined
630
+ }
631
+ })
632
+ state.apiKeys.unshift(result.key)
633
+ showSecret('API 密钥已创建', result.secret)
634
+ render()
635
+ })
636
+ const all = byId('all-agents')
637
+ all.onchange = () => byId('agent-scope').classList.toggle('hidden', all.checked)
638
+ }
639
+
640
+ function showSecret(title, secret) {
641
+ openDrawer(title, '<div class="secret-box"><span class="muted">请立即复制此密钥;之后也可在 API 密钥页面再次显示。</span><code class="secret-value" id="secret-value">' + escapeHtml(secret) + '</code><button class="button" type="button" id="copy-secret">复制</button></div>', '', null)
642
+ byId('copy-secret').onclick = async () => {
643
+ try {
644
+ await copySecret(secret)
645
+ toast('API 密钥已复制')
646
+ } catch (error) { toast(error.message, true) }
647
+ }
648
+ }
649
+
650
+ async function copySecret(secret) {
651
+ if (navigator.clipboard && window.isSecureContext) {
652
+ try {
653
+ await navigator.clipboard.writeText(secret)
654
+ return
655
+ } catch {}
656
+ }
657
+ const temporary = document.createElement('textarea')
658
+ temporary.value = secret
659
+ temporary.setAttribute('readonly', '')
660
+ temporary.style.position = 'fixed'
661
+ temporary.style.opacity = '0'
662
+ temporary.style.pointerEvents = 'none'
663
+ document.body.appendChild(temporary)
664
+ temporary.select()
665
+ const copied = document.execCommand('copy')
666
+ temporary.remove()
667
+ if (!copied) throw new Error('无法自动复制,请在“显示完整密钥”中手动复制。')
668
+ }
669
+
670
+ function openPasswordDrawer() {
671
+ openDrawer('修改控制台密码', '<label>当前密码<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>新密码<input name="newPassword" type="password" minlength="12" autocomplete="new-password" required></label><label>确认新密码<input name="confirmPassword" type="password" minlength="12" autocomplete="new-password" required></label>', '修改密码', async (form) => {
672
+ const data = new FormData(form)
673
+ await api('/v1/admin/password', { method: 'PUT', body: { currentPassword: String(data.get('currentPassword') || ''), newPassword: String(data.get('newPassword') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })
674
+ closeDrawer()
675
+ showLogin()
676
+ toast('控制台密码已修改,请重新登录。')
677
+ })
678
+ }
679
+
680
+ async function handleContentClick(event) {
681
+ const runTarget = event.target.closest('[data-run-detail]')
682
+ if (runTarget) {
683
+ await runAction(() => openRunDrawer(runTarget.dataset.runDetail))
684
+ return
685
+ }
686
+ const button = event.target.closest('button')
687
+ if (!button) return
688
+ if (button.dataset.keyMenu) {
689
+ toggleKeyActionMenu(button, button.dataset.keyMenu)
690
+ return
691
+ }
692
+ if (button.dataset.agentEdit) {
693
+ openAgentDrawer(state.config.agents.find((agent) => agent.id === button.dataset.agentEdit))
694
+ return
695
+ }
696
+ if (button.dataset.agentDelete) {
697
+ if (!confirm('确定删除此智能体吗?已有会话不会被修改。')) return
698
+ await runAction(async () => {
699
+ state.config = await api('/v1/admin/agents/' + encodeURIComponent(button.dataset.agentDelete), { method: 'DELETE' })
700
+ await loadAll(true)
701
+ render()
702
+ toast('智能体已删除')
703
+ })
704
+ return
705
+ }
706
+ if (button.dataset.workspaceEdit !== undefined) {
707
+ openWorkspaceDrawer(Number(button.dataset.workspaceEdit))
708
+ return
709
+ }
710
+ if (button.dataset.workspaceDelete !== undefined) {
711
+ const index = Number(button.dataset.workspaceDelete)
712
+ if (!confirm('确定移除此工作区根目录吗?')) return
713
+ await runAction(async () => {
714
+ const roots = state.config.workspaceRoots.filter((_, itemIndex) => itemIndex !== index)
715
+ state.config = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })
716
+ render()
717
+ toast('工作区已移除')
718
+ })
719
+ return
720
+ }
721
+ if (button.dataset.keyAction) await handleKeyAction(button.dataset.keyAction, button.dataset.keyId)
722
+ }
723
+
724
+ async function handleKeyAction(action, id) {
725
+ const key = state.apiKeys.find((item) => item.id === id)
726
+ if (!key) return
727
+ await runAction(async () => {
728
+ if (action === 'copy') {
729
+ const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })
730
+ await copySecret(value.secret)
731
+ toast('API 密钥已复制')
732
+ }
733
+ if (action === 'reveal') {
734
+ const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })
735
+ showSecret('显示 API 密钥', value.secret)
736
+ }
737
+ if (action === 'rename') {
738
+ const name = prompt('API 密钥名称', key.name)
739
+ if (!name || name === key.name) return
740
+ await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { name } })
741
+ await reloadKeys()
742
+ toast('API 密钥已重命名')
743
+ }
744
+ if (action === 'scope') {
745
+ openKeyScopeDrawer(key)
746
+ }
747
+ if (action === 'toggle') {
748
+ await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { enabled: !key.enabled } })
749
+ await reloadKeys()
750
+ toast(key.enabled ? 'API 密钥已禁用' : 'API 密钥已启用')
751
+ }
752
+ if (action === 'regenerate') {
753
+ if (!confirm('确定重新生成此 API 密钥吗?当前密钥将立即失效。')) return
754
+ const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/regenerate', { method: 'POST' })
755
+ await reloadKeys()
756
+ showSecret('API 密钥已重新生成', value.secret)
757
+ }
758
+ if (action === 'delete') {
759
+ if (!confirm('确定删除此 API 密钥吗?此操作无法撤销。')) return
760
+ await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' })
761
+ await reloadKeys()
762
+ toast('API 密钥已删除')
763
+ }
764
+ })
765
+ }
766
+
767
+ function openKeyScopeDrawer(key) {
768
+ const selectedIds = new Set(key.scope.agentIds || [])
769
+ const scopeRows = state.config.agents.map((agent) => '<label class="checkbox"><input type="checkbox" name="agentId" value="' + escapeHtml(agent.id) + '"' + checked(selectedIds.has(agent.id)) + '>' + escapeHtml(agent.name) + ' <span class="muted">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>').join('')
770
+ const body = '<label class="checkbox"><input id="edit-all-agents" name="allAgents" type="checkbox"' + checked(key.scope.allAgents) + '>允许访问全部智能体</label><div id="edit-agent-scope" class="agent-scope' + (key.scope.allAgents ? ' hidden' : '') + '">' + (scopeRows || '<span class="muted">尚未配置智能体。</span>') + '</div>'
771
+ openDrawer('编辑 API 密钥授权范围', body, '保存授权范围', async (form) => {
772
+ const data = new FormData(form)
773
+ const allAgents = data.get('allAgents') === 'on'
774
+ const agentIds = data.getAll('agentId').map(String)
775
+ if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')
776
+ await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })
777
+ closeDrawer()
778
+ await reloadKeys()
779
+ toast('API 密钥授权范围已更新')
780
+ })
781
+ const all = byId('edit-all-agents')
782
+ all.onchange = () => byId('edit-agent-scope').classList.toggle('hidden', all.checked)
783
+ }
784
+
785
+ async function reloadKeys() {
786
+ const value = await api('/v1/admin/api-keys')
787
+ state.apiKeys = value.apiKeys || []
788
+ render()
789
+ }
790
+
791
+ async function runAction(task) {
792
+ try { await task() } catch (error) { toast(error.message, true) }
793
+ }
794
+
795
+ function formatDate(timestamp) {
796
+ if (!timestamp) return '—'
797
+ return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(timestamp))
798
+ }
799
+
800
+ function toast(message, error) {
801
+ const item = document.createElement('div')
802
+ item.className = 'toast' + (error ? ' error' : '')
803
+ item.textContent = message
804
+ byId('toast-region').appendChild(item)
805
+ setTimeout(() => item.remove(), 4200)
806
+ }
807
+
808
+ function applyTheme(preference) {
809
+ const theme = preference === 'system'
810
+ ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
811
+ : preference
812
+ document.documentElement.dataset.theme = theme
813
+ const select = byId('theme-select')
814
+ if (select) select.value = preference
815
+ }
816
+
817
+ byId('setup-form').onsubmit = async (event) => {
818
+ event.preventDefault()
819
+ const form = event.currentTarget
820
+ const error = form.querySelector('[data-form-error]')
821
+ error.textContent = ''
822
+ const data = new FormData(form)
823
+ try {
824
+ await api('/v1/bootstrap/initialize', { method: 'POST', body: { password: String(data.get('password') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })
825
+ form.reset()
826
+ showLogin()
827
+ toast('初始化完成,请使用控制台密码登录。')
828
+ } catch (reason) { error.textContent = reason.message }
829
+ }
830
+
831
+ byId('login-form').onsubmit = async (event) => {
832
+ event.preventDefault()
833
+ const form = event.currentTarget
834
+ const error = form.querySelector('[data-form-error]')
835
+ error.textContent = ''
836
+ try {
837
+ const data = new FormData(form)
838
+ await api('/v1/admin/auth/login', { method: 'POST', body: { password: String(data.get('password') || '') } })
839
+ form.reset()
840
+ await enterApp()
841
+ } catch (reason) { error.textContent = reason.message }
842
+ }
843
+
844
+ document.querySelectorAll('.nav-item').forEach((item) => {
845
+ item.onclick = () => {
846
+ state.page = item.dataset.page
847
+ render()
848
+ if (state.page === 'runs') void refreshRuns(false)
849
+ }
850
+ })
851
+ content.addEventListener('click', (event) => { void handleContentClick(event) })
852
+ keyActionMenu.addEventListener('click', (event) => {
853
+ const button = event.target.closest('button[data-key-action]')
854
+ if (!button) return
855
+ closeKeyActionMenu()
856
+ void handleKeyAction(button.dataset.keyAction, button.dataset.keyId)
857
+ })
858
+ drawerForm.onsubmit = async (event) => {
859
+ event.preventDefault()
860
+ if (!drawerSubmit) return
861
+ const error = drawerForm.querySelector('[data-form-error]')
862
+ error.textContent = ''
863
+ try { await drawerSubmit(drawerForm) } catch (reason) { error.textContent = reason.message }
864
+ }
865
+ drawerForm.addEventListener('click', (event) => {
866
+ if (event.target.closest('[data-close-drawer]')) closeDrawer()
867
+ })
868
+ byId('drawer-close').onclick = closeDrawer
869
+ drawerBackdrop.onclick = closeDrawer
870
+
871
+ byId('admin-menu').onclick = () => {
872
+ const popover = byId('admin-popover')
873
+ const hidden = popover.classList.toggle('hidden')
874
+ byId('admin-menu').setAttribute('aria-expanded', String(!hidden))
875
+ }
876
+ byId('theme-select').onchange = (event) => {
877
+ localStorage.setItem('agent-nexus-theme', event.target.value)
878
+ applyTheme(event.target.value)
879
+ }
880
+ byId('change-password').onclick = () => { byId('admin-popover').classList.add('hidden'); openPasswordDrawer() }
881
+ byId('logout').onclick = async () => {
882
+ await runAction(async () => {
883
+ await api('/v1/admin/auth/logout', { method: 'POST' })
884
+ showLogin()
885
+ })
886
+ }
887
+ document.addEventListener('click', (event) => {
888
+ if (!event.target.closest('.admin-menu-wrap')) byId('admin-popover').classList.add('hidden')
889
+ if (!event.target.closest('.key-action-menu') && !event.target.closest('[data-key-menu]')) closeKeyActionMenu()
890
+ })
891
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
892
+ if ((localStorage.getItem('agent-nexus-theme') || 'system') === 'system') applyTheme('system')
893
+ })
894
+ document.addEventListener('keydown', (event) => {
895
+ if (event.key === 'Escape') {
896
+ closeKeyActionMenu()
897
+ closeDrawer()
898
+ }
899
+ })
900
+ window.addEventListener('resize', closeKeyActionMenu)
901
+ window.addEventListener('scroll', closeKeyActionMenu, true)
902
+
903
+ setInterval(() => {
904
+ if (state.authenticated) void refreshReadiness(false)
905
+ }, 20_000)
906
+
907
+ setInterval(() => {
908
+ if (state.authenticated && state.page === 'runs') void refreshRuns(false)
909
+ }, 5_000)
910
+
911
+ void boot()
912
+ })()
1075
913
  `;