nexus-agentd 0.3.0 → 0.4.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/LICENSE +22 -22
- package/README.md +201 -143
- package/dist/a2a/runtime.js +9 -4
- package/dist/artifact-store.d.ts +15 -0
- package/dist/artifact-store.js +97 -5
- package/dist/process-tree.js +137 -10
- package/dist/run-store.d.ts +38 -0
- package/dist/run-store.js +94 -3
- package/dist/server.js +55 -1
- package/dist/session/admin-runs.js +1 -0
- package/dist/session.d.ts +7 -1
- package/dist/session.js +82 -2
- package/dist/types.d.ts +2 -0
- package/dist/webui/app/sources.js +13 -12
- package/dist/webui/icons.d.ts +1 -0
- package/dist/webui/icons.js +1 -0
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +1 -0
- package/dist/webui/styles.js +64 -31
- package/nexus-agentd.example.json +38 -38
- package/package.json +2 -2
|
@@ -3,23 +3,24 @@
|
|
|
3
3
|
// without shipping raw .js assets on disk.
|
|
4
4
|
export const sources = {
|
|
5
5
|
"api": "// @ts-check\nimport { showLogin } from './screens.js'\nimport { state } from './state.js'\n\nexport class ApiError extends Error {\n /** @param {string} message @param {number} status */\n constructor(message, status) { super(message); this.status = status }\n}\n\n/**\n * @template [T=unknown]\n * @param {string} path\n * @param {import('./contracts.js').ApiOptions} [options]\n * @returns {Promise<T>}\n */\nexport async function api(path, options) {\n const epoch = state.authEpoch\n const headers = new Headers(options?.headers)\n let body = options?.body\n if (body && typeof body !== 'string') { headers.set('Content-Type', 'application/json'); body = JSON.stringify(body) }\n /** @type {RequestInit} */\n const settings = { credentials: 'same-origin', ...options, headers, body }\n // Only read requests get an automatic timeout; a timed-out mutation has an\n // ambiguous outcome and must not encourage a blind retry.\n if ((!settings.method || settings.method === 'GET') && !settings.signal) settings.signal = AbortSignal.timeout(15_000)\n let response\n try { response = await fetch(path, settings) }\n catch (reason) {\n throw new Error(reason instanceof Error && reason.name === 'TimeoutError' ? '请求超时,显示的可能是旧数据' : '无法连接网关,请检查网络;写入结果请刷新核实')\n }\n /** @type {unknown} */\n let value = null\n try { value = await response.json() } catch {}\n if (epoch !== state.authEpoch) throw new Error('登录状态已变化,请重新登录后核实操作结果')\n if (!response.ok) {\n if (response.status === 401 && path.indexOf('/v1/admin/') === 0 && epoch === state.authEpoch) showLogin()\n const message = value && typeof value === 'object' && 'error' in value && typeof value.error === 'string' ? value.error : '请求失败(HTTP ' + response.status + ')'\n throw new ApiError(localizeError(message), response.status)\n }\n return /** @type {T} */ (value)\n}\n\n/** @param {string} message */\nexport function localizeError(message) {\n /** @type {Record<string, string>} */\n const messages = {\n 'Invalid setup token': '初始化令牌不正确,请从网关启动终端复制当前 Setup token',\n 'Console Host is not allowed': '此控制台地址未获允许,请在本地配置 publicOrigins 后重启网关',\n 'Invalid Console Password': '控制台密码不正确',\n 'Console setup is required': '请先完成控制台初始化',\n 'Console setup is already complete': '控制台已完成初始化',\n 'Console Password confirmation does not match': '两次输入的控制台密码不一致',\n 'Console Password must contain at least 12 characters': '控制台密码至少需要 12 个字符',\n 'Console Password must not exceed 1024 bytes': '控制台密码不能超过 1024 字节',\n 'Console Password must not contain NUL bytes': '控制台密码包含不支持的字符',\n 'Current Console Password is incorrect': '当前控制台密码不正确',\n 'Gateway setup is required': '请先完成网关初始化',\n 'Admin session is required': '管理员会话已失效,请重新登录',\n 'Bearer API Key is required': '缺少 Bearer API 密钥',\n 'Invalid API Key': 'API 密钥无效',\n 'API Key is not authorized for this agent': '该 API 密钥无权访问此智能体',\n 'API Key already exists': 'API 密钥已存在',\n 'API Key not found': '未找到 API 密钥',\n 'API Key name is required': '请输入 API 密钥名称',\n 'API Key name is too long': 'API 密钥名称过长',\n 'Custom API Key must contain at least 16 characters': '自定义 API 密钥至少需要 16 个字符',\n 'Custom API Key must not exceed 512 bytes': '自定义 API 密钥不能超过 512 字节',\n 'Custom API Key contains an unsupported value': '自定义 API 密钥包含不支持的内容',\n 'workspaceRoots must contain at least one path': '至少需要保留一个工作区根目录',\n 'A2A authentication value is required': '请输入 A2A 认证凭据',\n 'A2A authentication value is invalid': 'A2A 认证凭据无效',\n 'A2A Agent Card URL is required': '请输入 Agent Card URL',\n 'A2A Agent Card URL is invalid': 'Agent Card URL 无效',\n 'A2A Agent Card URL must use http(s) without credentials or a fragment': 'Agent Card URL 必须使用 HTTP(S),且不能包含凭据或片段',\n 'preferredTransport must be auto, jsonrpc, or http-json': '首选传输必须是自动、JSON-RPC 或 HTTP+JSON',\n 'A valid A2A header name is required': '请输入有效的 A2A 请求头名称',\n 'Too many authentication attempts': '认证失败次数过多,请稍后再试',\n 'Request body is too large': '请求内容过大',\n 'Request requires application/json': '请求必须使用 application/json',\n 'Same-origin request is required': '此操作必须从当前控制台页面发起',\n 'Request origin is invalid': '请求来源无效',\n 'Cross-origin admin request is not allowed': '不允许跨域管理请求',\n 'Control plane is unavailable': '管理控制面当前不可用',\n 'Agent Nexus run not found': '未找到运行记录',\n 'Internal server error': '服务器内部错误'\n }\n if (messages[message]) return messages[message]\n const workspaceConflict = /^Workspace change would exclude agent: (.+)$/.exec(message)\n if (workspaceConflict) return '请先修改智能体「' + workspaceConflict[1] + '」的工作区,再删除或编辑此工作区'\n const missingAgent = /^Configured agent not found: (.+)$/.exec(message)\n if (missingAgent) return '未找到已配置的智能体:' + missingAgent[1]\n const timeout = /^timeout must be between (\\d+) and (\\d+)$/.exec(message)\n if (timeout) return '超时时间必须介于 ' + Math.round(Number(timeout[1]) / 1000) + ' 和 ' + Math.round(Number(timeout[2]) / 1000) + ' 秒之间'\n const runtimeRange = /^(sessionTtlMs|promptTimeoutMs|cleanupIntervalMs) must be between (\\d+) and (\\d+)$/.exec(message)\n if (runtimeRange) {\n /** @type {Record<string, [string, string, number]>} */\n const units = {\n sessionTtlMs: ['会话空闲有效期', '小时', 3_600_000],\n promptTimeoutMs: ['ACP 任务超时', '分钟', 60_000],\n cleanupIntervalMs: ['清理任务周期', '秒', 1000]\n }\n const [label, unit, divisor] = units[runtimeRange[1]]\n /** @param {string} value */\n const display = (value) => Number((Number(value) / divisor).toFixed(7))\n return label + '必须介于 ' + display(runtimeRange[2]) + ' 和 ' + display(runtimeRange[3]) + ' ' + unit + '之间'\n }\n return message\n}\n",
|
|
6
|
-
"data": "// @ts-check\nimport { state } from './state.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\n\n/** @typedef {import('./contracts.js').Resource} Resource */\n/** @typedef {import('./contracts.js').ResourcePayloads} Payloads */\n/** @type {Map<string, Promise<unknown>>} */\nconst pending = new Map()\n/** @type {Map<Resource, string>} */\nconst failures = new Map()\n/** @type {Map<Resource, number>} */\nconst resourceVersions = new Map()\nconst resourceLabels = {\n config: '配置',\n apiKeys: 'API 密钥',\n overview: '智能体探测',\n runs: '运行记录',\n metrics: '运行指标'\n}\nlet generation = 0\n\n/** @param {Resource} resource */\nexport function markResourceSaved(resource) {\n resourceVersions.set(resource, (resourceVersions.get(resource) || 0) + 1)\n const path = '/v1/admin/' + (resource === 'apiKeys' ? 'api-keys' : resource)\n // A later retry must not coalesce with a read made before this write.\n for (const pendingPath of pending.keys()) {\n if (pendingPath.split('?')[0] === path) pending.delete(pendingPath)\n }\n markResource(resource, 'ready')\n}\n\nexport function resetData() {\n generation++\n pending.clear()\n failures.clear()\n state.config = { workspaceRoots: [], driverKinds: [], agents: [] }\n state.apiKeys = []\n state.readiness = []\n state.runs = []\n state.metrics = null\n state.runTotal = state.sessions = state.runOffset = 0\n state.runStats = { active: 0, completed: 0, failed: 0 }\n state.runHasNew = false\n state.search = state.runSearch = ''\n state.protocol = state.status = state.runAgent = state.runStatus = 'all'\n for (const resource of Object.values(state.resources)) {\n resource.status = 'idle'\n resource.lastSuccessAt = 0\n }\n connectionStatus()\n}\n\n/** @param {string} path @returns {Promise<unknown>} */\nfunction read(path) {\n const existing = pending.get(path)\n if (existing) return existing\n const promise = api(path).finally(() => { if (pending.get(path) === promise) pending.delete(path) })\n pending.set(path, promise)\n return promise\n}\n\n/** @param {Resource} resource */\nfunction resourceState(resource) {\n return state.resources[resource] || (state.resources[resource] = { status: 'idle', lastSuccessAt: 0 })\n}\n\n/** @param {Resource} [resource] */\nfunction renderApp(resource) {\n return import('./render.js').then(({ render }) => {\n const pages = {\n config: ['overview', 'agents', 'workspaces', 'keys', 'settings', 'runs'],\n apiKeys: ['keys'],\n overview: ['overview', 'agents'],\n runs: ['runs'],\n metrics: ['overview']\n }\n if (state.authenticated && (!resource || pages[resource]?.includes(state.page))) render()\n }).catch(() => {})\n}\n\nfunction connectionStatus() {\n const element = document.getElementById('connection-status')\n if (!element) return\n element.replaceChildren()\n if (!failures.size) {\n element.classList.add('hidden')\n return\n }\n element.classList.remove('hidden')\n const entries = [...failures]\n entries.forEach(([resource, message], index) => {\n if (index) element.append(document.createTextNode(';'))\n const line = document.createElement('span')\n const previous = resourceState(resource).lastSuccessAt\n line.textContent = resourceLabels[resource] + '刷新失败,当前为旧数据:' + message +\n (previous ? '(上次成功:' + new Date(previous).toLocaleString() + ')' : '(尚未成功读取)')\n element.append(line)\n const retry = document.createElement('button')\n retry.type = 'button'\n retry.className = 'button small connection-retry'\n retry.dataset.resourceRetry = resource\n retry.textContent = '重试'\n retry.onclick = () => { void retryResource(resource) }\n element.append(retry)\n })\n}\n\n/** @param {Resource} resource @param {import('./contracts.js').ResourceState['status']} status @param {Error} [error] */\nfunction markResource(resource, status, error) {\n const current = resourceState(resource)\n current.status = status\n if (status === 'ready') {\n current.lastSuccessAt = Date.now()\n failures.delete(resource)\n } else if (error) {\n failures.set(resource, error.message)\n }\n connectionStatus()\n}\n\nexport function runsPath() {\n const query = new URLSearchParams({ limit: String(state.runPageSize || 50) })\n if (state.runSearch.trim()) query.set('q', state.runSearch.trim())\n if (state.runAgent !== 'all') query.set('agentId', state.runAgent)\n if (state.runStatus !== 'all') query.set('state', state.runStatus)\n if (state.runOffset) query.set('offset', String(state.runOffset))\n return '/v1/admin/runs?' + query\n}\n\n/** @param {import('./contracts.js').RunListPayload} value */\nfunction acceptRuns(value) {\n state.runHasNew = false\n state.runs = value.runs || []\n state.runTotal = value.total || 0\n state.runStats = value.stats || { active: 0, completed: 0, failed: 0 }\n}\n\n/** @param {number} revision @param {number} epoch */\nfunction isCurrent(revision, epoch) {\n return revision === generation && epoch === state.authEpoch\n}\n\n/**\n * @template {Resource} K\n * @param {K} resource\n * @param {string} path\n * @param {number} revision\n * @param {number} epoch\n * @param {(value: Payloads[K]) => void} accept\n */\nasync function loadResource(resource, path, revision, epoch, accept) {\n const version = resourceVersions.get(resource) || 0\n const current = () => isCurrent(revision, epoch) && version === (resourceVersions.get(resource) || 0)\n markResource(resource, 'loading')\n try {\n const value = /** @type {Payloads[K]} */ (await read(path))\n if (!current()) return value\n accept(value)\n markResource(resource, 'ready')\n void renderApp(resource)\n return value\n } catch (reason) {\n const error = reason instanceof Error ? reason : new Error(String(reason))\n if (current()) {\n markResource(resource, 'error', error)\n void renderApp(resource)\n }\n return undefined\n }\n}\n\n/** @param {boolean} [force] */\nexport async function loadAll(force) {\n const revision = ++generation\n const epoch = state.authEpoch\n pending.clear()\n const path = runsPath()\n const suffix = force ? '?refresh=1' : ''\n // Each request settles independently. In particular, overview runs probes\n // that can take considerably longer than config and history reads.\n const tasks = [\n loadResource('config', '/v1/admin/config', revision, epoch, (value) => { state.config = value }),\n loadResource('apiKeys', '/v1/admin/api-keys', revision, epoch, (value) => { state.apiKeys = value.apiKeys || [] }),\n loadResource('overview', '/v1/admin/overview' + suffix, revision, epoch, (value) => {\n state.readiness = value.agents || []\n state.sessions = value.sessions || 0\n }),\n loadResource('metrics', '/v1/admin/metrics', revision, epoch, (value) => { state.metrics = value }),\n loadResource('runs', path, revision, epoch, (value) => {\n if (path === runsPath()) acceptRuns(value)\n })\n ]\n return Promise.all(tasks)\n}\n\n/** @param {Resource} resource */\nexport async function retryResource(resource) {\n // Keep the global generation unchanged: retrying one slow resource must not\n // invalidate unrelated config, key, or history requests already in flight.\n const revision = generation\n const epoch = state.authEpoch\n if (resource === 'config') return loadResource('config', '/v1/admin/config', revision, epoch, (value) => { state.config = value })\n if (resource === 'apiKeys') return loadResource('apiKeys', '/v1/admin/api-keys', revision, epoch, (value) => { state.apiKeys = value.apiKeys || [] })\n if (resource === 'overview') return loadResource('overview', '/v1/admin/overview?refresh=1', revision, epoch, (value) => { state.readiness = value.agents || []; state.sessions = value.sessions || 0 })\n if (resource === 'metrics') return loadResource('metrics', '/v1/admin/metrics', revision, epoch, (value) => { state.metrics = value })\n const path = runsPath()\n return loadResource('runs', path, revision, epoch, (value) => { if (path === runsPath()) acceptRuns(value) })\n}\n\n/** @param {boolean} [showNotice] */\nexport async function refreshRuns(showNotice) {\n const { renderRuns, refreshOpenRunDrawer } = await import('./render.js')\n void refreshOpenRunDrawer()\n const path = runsPath()\n const revision = generation\n const epoch = state.authEpoch\n try {\n const value = /** @type {import('./contracts.js').RunListPayload} */ (await read(path))\n if (path !== runsPath() || !isCurrent(revision, epoch)) return\n if (!showNotice && state.runOffset > 0 && state.runs.length && value.total > state.runTotal) {\n state.runHasNew = true\n markResource('runs', 'ready')\n if (state.page === 'runs') renderRuns()\n return\n }\n acceptRuns(value)\n markResource('runs', 'ready')\n if (state.page === 'runs') renderRuns()\n } catch (reason) {\n const error = reason instanceof Error ? reason : new Error(String(reason))\n if (path !== runsPath() || !isCurrent(revision, epoch)) return\n markResource('runs', 'error', error)\n if (showNotice) toast(error.message, true)\n }\n}\n\n/** @param {boolean} [showNotice] */\nexport async function refreshReadiness(showNotice) {\n const { render } = await import('./render.js')\n const revision = generation\n const epoch = state.authEpoch\n try {\n const overview = /** @type {import('./contracts.js').OverviewPayload} */ (await read('/v1/admin/overview?refresh=1'))\n if (!isCurrent(revision, epoch)) return\n state.readiness = overview.agents || []\n state.sessions = overview.sessions || 0\n markResource('overview', 'ready')\n if (state.page === 'overview' || state.page === 'agents') render()\n } catch (reason) {\n const error = reason instanceof Error ? reason : new Error(String(reason))\n if (!isCurrent(revision, epoch)) return\n markResource('overview', 'error', error)\n if (showNotice) toast(error.message, true)\n }\n}\n",
|
|
6
|
+
"data": "// @ts-check\nimport { state } from './state.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\n\n/** @typedef {import('./contracts.js').Resource} Resource */\n/** @typedef {import('./contracts.js').ResourcePayloads} Payloads */\n/** @type {Map<string, Promise<unknown>>} */\nconst pending = new Map()\n/** @type {Map<Resource, string>} */\nconst failures = new Map()\n/** @type {Map<Resource, number>} */\nconst resourceVersions = new Map()\nconst resourceLabels = {\n config: '配置',\n apiKeys: 'API 密钥',\n overview: '智能体探测',\n runs: '运行记录',\n metrics: '运行指标'\n}\nlet generation = 0\n\n/** @param {Resource} resource */\nexport function markResourceSaved(resource) {\n resourceVersions.set(resource, (resourceVersions.get(resource) || 0) + 1)\n const path = '/v1/admin/' + (resource === 'apiKeys' ? 'api-keys' : resource)\n // A later retry must not coalesce with a read made before this write.\n for (const pendingPath of pending.keys()) {\n if (pendingPath.split('?')[0] === path) pending.delete(pendingPath)\n }\n markResource(resource, 'ready')\n}\n\nexport function resetData() {\n generation++\n pending.clear()\n failures.clear()\n state.config = { workspaceRoots: [], driverKinds: [], agents: [] }\n state.apiKeys = []\n state.readiness = []\n state.runs = []\n state.metrics = null\n state.runTotal = state.sessions = state.runOffset = 0\n state.runStats = { active: 0, completed: 0, failed: 0 }\n state.artifacts = []\n state.artifactTotal = state.artifactOffset = 0\n state.artifactAgent = state.artifactKind = 'all'\n state.runHasNew = false\n state.search = state.runSearch = ''\n state.protocol = state.status = state.runAgent = state.runStatus = 'all'\n for (const resource of Object.values(state.resources)) {\n resource.status = 'idle'\n resource.lastSuccessAt = 0\n }\n connectionStatus()\n}\n\n/** @param {string} path @returns {Promise<unknown>} */\nfunction read(path) {\n const existing = pending.get(path)\n if (existing) return existing\n const promise = api(path).finally(() => { if (pending.get(path) === promise) pending.delete(path) })\n pending.set(path, promise)\n return promise\n}\n\n/** @param {Resource} resource */\nfunction resourceState(resource) {\n return state.resources[resource] || (state.resources[resource] = { status: 'idle', lastSuccessAt: 0 })\n}\n\n/** @param {Resource} [resource] */\nfunction renderApp(resource) {\n return import('./render.js').then(({ render }) => {\n const pages = {\n config: ['overview', 'agents', 'workspaces', 'keys', 'settings', 'runs'],\n apiKeys: ['keys'],\n overview: ['overview', 'agents'],\n runs: ['runs'],\n metrics: ['overview']\n }\n if (state.authenticated && (!resource || pages[resource]?.includes(state.page))) render()\n }).catch(() => {})\n}\n\nfunction connectionStatus() {\n const element = document.getElementById('connection-status')\n if (!element) return\n element.replaceChildren()\n if (!failures.size) {\n element.classList.add('hidden')\n return\n }\n element.classList.remove('hidden')\n const entries = [...failures]\n entries.forEach(([resource, message], index) => {\n if (index) element.append(document.createTextNode(';'))\n const line = document.createElement('span')\n const previous = resourceState(resource).lastSuccessAt\n line.textContent = resourceLabels[resource] + '刷新失败,当前为旧数据:' + message +\n (previous ? '(上次成功:' + new Date(previous).toLocaleString() + ')' : '(尚未成功读取)')\n element.append(line)\n const retry = document.createElement('button')\n retry.type = 'button'\n retry.className = 'button small connection-retry'\n retry.dataset.resourceRetry = resource\n retry.textContent = '重试'\n retry.onclick = () => { void retryResource(resource) }\n element.append(retry)\n })\n}\n\n/** @param {Resource} resource @param {import('./contracts.js').ResourceState['status']} status @param {Error} [error] */\nfunction markResource(resource, status, error) {\n const current = resourceState(resource)\n current.status = status\n if (status === 'ready') {\n current.lastSuccessAt = Date.now()\n failures.delete(resource)\n } else if (error) {\n failures.set(resource, error.message)\n }\n connectionStatus()\n}\n\nexport function runsPath() {\n const query = new URLSearchParams({ limit: String(state.runPageSize || 50) })\n if (state.runSearch.trim()) query.set('q', state.runSearch.trim())\n if (state.runAgent !== 'all') query.set('agentId', state.runAgent)\n if (state.runStatus !== 'all') query.set('state', state.runStatus)\n if (state.runOffset) query.set('offset', String(state.runOffset))\n return '/v1/admin/runs?' + query\n}\n\n/** @param {import('./contracts.js').RunListPayload} value */\nfunction acceptRuns(value) {\n state.runHasNew = false\n state.runs = value.runs || []\n state.runTotal = value.total || 0\n state.runStats = value.stats || { active: 0, completed: 0, failed: 0 }\n}\n\n/** @param {number} revision @param {number} epoch */\nfunction isCurrent(revision, epoch) {\n return revision === generation && epoch === state.authEpoch\n}\n\n/**\n * @template {Resource} K\n * @param {K} resource\n * @param {string} path\n * @param {number} revision\n * @param {number} epoch\n * @param {(value: Payloads[K]) => void} accept\n */\nasync function loadResource(resource, path, revision, epoch, accept) {\n const version = resourceVersions.get(resource) || 0\n const current = () => isCurrent(revision, epoch) && version === (resourceVersions.get(resource) || 0)\n markResource(resource, 'loading')\n try {\n const value = /** @type {Payloads[K]} */ (await read(path))\n if (!current()) return value\n accept(value)\n markResource(resource, 'ready')\n void renderApp(resource)\n return value\n } catch (reason) {\n const error = reason instanceof Error ? reason : new Error(String(reason))\n if (current()) {\n markResource(resource, 'error', error)\n void renderApp(resource)\n }\n return undefined\n }\n}\n\n/** @param {boolean} [force] */\nexport async function loadAll(force) {\n const revision = ++generation\n const epoch = state.authEpoch\n pending.clear()\n const path = runsPath()\n const suffix = force ? '?refresh=1' : ''\n // Each request settles independently. In particular, overview runs probes\n // that can take considerably longer than config and history reads.\n const tasks = [\n loadResource('config', '/v1/admin/config', revision, epoch, (value) => { state.config = value }),\n loadResource('apiKeys', '/v1/admin/api-keys', revision, epoch, (value) => { state.apiKeys = value.apiKeys || [] }),\n loadResource('overview', '/v1/admin/overview' + suffix, revision, epoch, (value) => {\n state.readiness = value.agents || []\n state.sessions = value.sessions || 0\n }),\n loadResource('metrics', '/v1/admin/metrics', revision, epoch, (value) => { state.metrics = value }),\n loadResource('runs', path, revision, epoch, (value) => {\n if (path === runsPath()) acceptRuns(value)\n })\n ]\n return Promise.all(tasks)\n}\n\n/** @param {Resource} resource */\nexport async function retryResource(resource) {\n // Keep the global generation unchanged: retrying one slow resource must not\n // invalidate unrelated config, key, or history requests already in flight.\n const revision = generation\n const epoch = state.authEpoch\n if (resource === 'config') return loadResource('config', '/v1/admin/config', revision, epoch, (value) => { state.config = value })\n if (resource === 'apiKeys') return loadResource('apiKeys', '/v1/admin/api-keys', revision, epoch, (value) => { state.apiKeys = value.apiKeys || [] })\n if (resource === 'overview') return loadResource('overview', '/v1/admin/overview?refresh=1', revision, epoch, (value) => { state.readiness = value.agents || []; state.sessions = value.sessions || 0 })\n if (resource === 'metrics') return loadResource('metrics', '/v1/admin/metrics', revision, epoch, (value) => { state.metrics = value })\n const path = runsPath()\n return loadResource('runs', path, revision, epoch, (value) => { if (path === runsPath()) acceptRuns(value) })\n}\n\n/** @param {boolean} [showNotice] */\nexport async function refreshRuns(showNotice) {\n const { renderRuns, refreshOpenRunDrawer } = await import('./render.js')\n void refreshOpenRunDrawer()\n const path = runsPath()\n const revision = generation\n const epoch = state.authEpoch\n try {\n const value = /** @type {import('./contracts.js').RunListPayload} */ (await read(path))\n if (path !== runsPath() || !isCurrent(revision, epoch)) return\n if (!showNotice && state.runOffset > 0 && state.runs.length && value.total > state.runTotal) {\n state.runHasNew = true\n markResource('runs', 'ready')\n if (state.page === 'runs') renderRuns()\n return\n }\n acceptRuns(value)\n markResource('runs', 'ready')\n if (state.page === 'runs') renderRuns()\n } catch (reason) {\n const error = reason instanceof Error ? reason : new Error(String(reason))\n if (path !== runsPath() || !isCurrent(revision, epoch)) return\n markResource('runs', 'error', error)\n if (showNotice) toast(error.message, true)\n }\n}\n\n/** @param {boolean} [showNotice] */\nexport async function refreshReadiness(showNotice) {\n const { render } = await import('./render.js')\n const revision = generation\n const epoch = state.authEpoch\n try {\n const overview = /** @type {import('./contracts.js').OverviewPayload} */ (await read('/v1/admin/overview?refresh=1'))\n if (!isCurrent(revision, epoch)) return\n state.readiness = overview.agents || []\n state.sessions = overview.sessions || 0\n markResource('overview', 'ready')\n if (state.page === 'overview' || state.page === 'agents') render()\n } catch (reason) {\n const error = reason instanceof Error ? reason : new Error(String(reason))\n if (!isCurrent(revision, epoch)) return\n markResource('overview', 'error', error)\n if (showNotice) toast(error.message, true)\n }\n}\n",
|
|
7
7
|
"dom": "export const byId = (id) => document.getElementById(id)\n\nexport const setupScreen = byId('setup-screen')\nexport const loginScreen = byId('login-screen')\nexport const appRoot = byId('app')\nexport const content = byId('page-content')\nexport const pageStats = byId('page-stats')\nexport const pageToolbar = byId('page-toolbar')\nexport const pageResults = byId('page-results')\nexport const pageStatus = byId('page-status')\nexport const actions = byId('page-actions')\nexport const drawer = byId('drawer')\nexport const drawerBackdrop = byId('drawer-backdrop')\nexport const drawerForm = byId('drawer-form')\nexport const drawerFooter = byId('drawer-footer')\n\nexport const keyActionMenu = document.createElement('div')\nkeyActionMenu.className = 'key-action-menu hidden'\nkeyActionMenu.id = 'key-action-menu'\nkeyActionMenu.setAttribute('role', 'menu')\nkeyActionMenu.setAttribute('aria-label', 'API 密钥操作')\ndocument.body.appendChild(keyActionMenu)\n\nexport function escapeHtml(value) {\n return String(value == null ? '' : value)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\nexport const selected = (value, expected) => (value === expected ? ' selected' : '')\nexport const checked = (value) => (value ? ' checked' : '')\n",
|
|
8
8
|
"drawer": "import { byId, drawer, drawerBackdrop, drawerForm, drawerFooter, keyActionMenu, escapeHtml } from './dom.js'\nimport { runAction } from './toast.js'\nimport { state } from './state.js'\nimport { writeLocationState } from './location-state.js'\n\nlet drawerSubmit = null\nlet drawerVersion = 0\nlet returnFocus = null\nlet background = []\nlet previousOverflow = ''\n\nfunction focusable() {\n return Array.from(drawer.querySelectorAll('button, input, select, textarea, a[href], [tabindex]'))\n .filter((element) => !element.disabled && element.tabIndex >= 0 && element.getClientRects().length)\n}\n\nfunction restoreFocus() {\n const previous = returnFocus\n returnFocus = null\n let target = previous?.element\n if (!target?.isConnected && previous) {\n target = Array.from(document.querySelectorAll('button, input, select, textarea')).find((element) =>\n previous.id ? element.id === previous.id : Object.keys(previous.data).length &&\n Object.entries(previous.data).every(([key, value]) => element.dataset[key] === value))\n }\n if (!target?.isConnected || target.closest('.hidden') || target.disabled) target = document.querySelector('.nav-item.active')\n target?.focus({ preventScroll: true })\n}\n\nexport function getDrawerVersion() { return drawerVersion }\n\nexport function handleDrawerKeydown(event) {\n if (drawer.classList.contains('hidden') || event.isComposing) return\n if (event.key === 'Escape') {\n event.preventDefault()\n closeDrawer()\n }\n if (event.key === 'Tab') {\n const items = focusable()\n const first = items[0] || drawer\n const last = items[items.length - 1] || drawer\n if (!drawer.contains(document.activeElement) || (event.shiftKey ? document.activeElement === first : document.activeElement === last)) {\n event.preventDefault()\n ;(event.shiftKey ? last : first).focus()\n }\n }\n}\n\ndocument.addEventListener('focusin', (event) => {\n if (!drawer.classList.contains('hidden') && !drawer.contains(event.target)) (focusable()[0] || drawer).focus()\n})\n\nexport function openDrawer(title, body, submitLabel, onSubmit, danger) {\n if (state.selectedRunId) { state.selectedRunId = undefined; writeLocationState(state) }\n const version = ++drawerVersion\n if (drawer.classList.contains('hidden')) {\n const active = document.activeElement\n returnFocus = { element: active, id: active?.id, data: { ...active?.dataset } }\n keyActionMenu.classList.add('hidden')\n document.querySelectorAll('[data-key-menu]').forEach((button) => button.setAttribute('aria-expanded', 'false'))\n drawer.insertBefore(byId('notifications'), drawerFooter)\n background = Array.from(document.body.children).filter((element) => element !== drawer && element !== drawerBackdrop)\n .map((element) => [element, element.inert])\n background.forEach(([element]) => { element.inert = true })\n previousOverflow = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n }\n byId('drawer-title').textContent = title\n drawerForm.innerHTML = body + '<p class=\"form-error\" data-form-error role=\"alert\"></p>'\n drawerFooter.innerHTML = '<button type=\"button\" class=\"button\" data-close-drawer>取消</button>' +\n (submitLabel ? '<button type=\"submit\" form=\"drawer-form\" class=\"button ' + (danger ? 'solid-danger' : 'primary') + '\">' + escapeHtml(submitLabel) + '</button>' : '')\n drawerSubmit = onSubmit ? (form) => onSubmit(form, () => version === drawerVersion) : null\n drawer.classList.remove('hidden')\n drawerBackdrop.classList.remove('hidden')\n drawer.querySelector('.drawer-body').scrollTop = 0\n byId('notifications').scrollTop = byId('notifications').scrollHeight\n setTimeout(() => {\n if (version !== drawerVersion) return\n const items = focusable()\n const first = items.find((element) => element.matches('input, select, textarea')) || items[0] || drawer\n first.focus({ preventScroll: true })\n }, 0)\n}\n\nexport function closeDrawer() {\n if (drawer.classList.contains('hidden')) return\n if (state.selectedRunId) { state.selectedRunId = undefined; writeLocationState(state) }\n drawerVersion++\n drawer.classList.add('hidden')\n drawerBackdrop.classList.add('hidden')\n drawerForm.innerHTML = ''\n drawerFooter.innerHTML = ''\n drawerSubmit = null\n drawerForm.removeAttribute('aria-busy')\n document.body.appendChild(byId('notifications'))\n background.forEach(([element, inert]) => { element.inert = inert })\n background = []\n document.body.style.overflow = previousOverflow\n restoreFocus()\n}\n\nexport function openConfirmDrawer(title, message, confirmLabel, onConfirm) {\n openDrawer(title, '<p class=\"confirm-text\">' + escapeHtml(message) + '</p>', confirmLabel, async () => {\n closeDrawer()\n const version = drawerVersion\n await runAction(() => onConfirm(() => version === drawerVersion))\n }, true)\n const cancel = drawerFooter.querySelector('[data-close-drawer]')\n if (cancel) setTimeout(() => { if (cancel.isConnected) cancel.focus() }, 0)\n}\n\nexport function getDrawerSubmit() {\n return drawerSubmit\n}\n",
|
|
9
|
-
"format": "const dateFormatter = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })\n\nexport function shortId(value) {\n return String(value || '').slice(0, 8)\n}\n\nexport function formatDate(timestamp) {\n if (!timestamp) return '—'\n return dateFormatter.format(new Date(timestamp))\n}\n\nexport function formatDuration(run) {\n const ms = run.durationMs != null ? run.durationMs : Math.max(0, Date.now() - run.startedAt)\n if (ms < 1000) return ms + ' 毫秒'\n if (ms < 60_000) return Math.round(ms / 1000) + ' 秒'\n if (ms < 3_600_000) return Math.floor(ms / 60_000) + ' 分 ' + Math.round(ms % 60_000 / 1000) + ' 秒'\n return Math.floor(ms / 3_600_000) + ' 小时 ' + Math.round(ms % 3_600_000 / 60_000) + ' 分'\n}\n\nexport const runStateLabels = {\n created: '已创建',\n running: '运行中',\n input_required: '等待输入',\n permission_required: '等待授权',\n completed: '已完成',\n failed: '失败',\n canceled: '已取消'\n}\n\nexport function runStatusLabel(value) {\n return runStateLabels[value] || value\n}\n\nexport function permissionLabel(value) {\n if (value === 'ask') return '询问'\n if (value === 'allow') return '自动允许单次'\n if (value === 'deny') return '拒绝'\n return value || '—'\n}\n",
|
|
10
|
-
"icons": "const svg = (inner) => '<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">' + inner + '</svg>'\n\nexport const icons = {\n search: svg('<circle cx=\"11\" cy=\"11\" r=\"7.5\"/><path d=\"m21 21-4.3-4.3\"/>'),\n refresh: svg('<path d=\"M3 12a9 9 0 0 1 9-9 9.7 9.7 0 0 1 6.7 2.7L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.7 9.7 0 0 1-6.7-2.7L3 16\"/><path d=\"M8 16H3v5\"/>'),\n plus: svg('<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>'),\n copy: svg('<rect x=\"9\" y=\"9\" width=\"10\" height=\"10\" rx=\"2\"/><path d=\"M15 9V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/>'),\n disable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m6.3 6.3 11.4 11.4\"/>'),\n enable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m8.5 12 2.3 2.3 4.8-5\"/>'),\n edit: svg('<path d=\"M12 20h8\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z\"/>'),\n more: svg('<circle cx=\"5\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/>'),\n reveal: svg('<path d=\"M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.5\"/>'),\n scope: svg('<path d=\"M12 3 5 6v5c0 4.7 2.9 8 7 10 4.1-2 7-5.3 7-10V6Z\"/><path d=\"M9.5 12 11 13.5l3.5-4\"/>'),\n regenerate: svg('<path d=\"M20 7v5h-5\"/><path d=\"M19 12a7 7 0 1 0-2 5\"/>'),\n trash: svg('<path d=\"M4 7h16\"/><path d=\"M9 7V4h6v3\"/><path d=\"m6 7 1 13h10l1-13\"/><path d=\"M10 11v5M14 11v5\"/>'),\n folder: svg('<path d=\"M20 20a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7.1a2 2 0 0 1-1.66-.89l-.75-1.12A2 2 0 0 0 8.83 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z\"/>'),\n robot: svg('<path d=\"M12 8V4.5\"/><circle cx=\"12\" cy=\"3\" r=\"1.1\"/><rect x=\"4\" y=\"8\" width=\"16\" height=\"12\" rx=\"3\"/><path d=\"M2 14h2\"/><path d=\"M20 14h2\"/><path d=\"M9.5 13v1.6\"/><path d=\"M14.5 13v1.6\"/><path d=\"M9.5 17.2h5\"/>'),\n activity: svg('<path d=\"M22 12h-2.5a2 2 0 0 0-1.93 1.46l-2.1 7.4a.25.25 0 0 1-.48 0L9.24 3.14a.25.25 0 0 0-.48 0l-2.1 7.4A2 2 0 0 1 4.73 12H2\"/>'),\n key: svg('<circle cx=\"8\" cy=\"15\" r=\"4.2\"/><path d=\"m11.1 12 8-8\"/><path d=\"m15.4 7.7 2.9 2.9\"/><path d=\"m17.6 5.5 2.9 2.9\"/>')\n}\n\n",
|
|
11
|
-
"location-state": "// Navigation URLs contain only identifiers and filters, never task text or credentials.\nexport function readLocationState() {\n const [name, query] = location.hash.replace(/^#\\/?/, '').split('?')\n const page = ['overview','runs','agents','workspaces','keys','settings'].includes(name) ? name : 'overview'\n const params = new URLSearchParams(query || '')\n const states = ['running','input_required','permission_required','completed','failed','canceled']\n const id = (value) => value && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) ? value : undefined\n return { page, runAgent: id(params.get('agent')) || 'all', runStatus: states.includes(params.get('state')) ? params.get('state') : 'all',\n runPageSize: [50,100,200].includes(Number(params.get('limit'))) ? Number(params.get('limit')) : 50,\n runOffset: Math.min(10_000_000, Math.max(0, Math.floor(Number(params.get('offset')) || 0))), selectedRunId: id(params.get('run')) }\n}\n\nexport function writeLocationState(state) {\n const params = new URLSearchParams()\n if (state.page === 'runs') {\n if (state.runAgent !== 'all') params.set('agent', state.runAgent)\n if (state.runStatus !== 'all') params.set('state', state.runStatus)\n if (state.runPageSize !== 50) params.set('limit', String(state.runPageSize))\n if (state.runOffset) params.set('offset', String(state.runOffset))\n }\n if (state.selectedRunId) params.set('run', state.selectedRunId)\n history.replaceState(null, '', '#/' + state.page + (params.size ? '?' + params : ''))\n}\n",
|
|
12
|
-
"main": "import { state } from './state.js'\nimport { byId, content, drawerForm, drawerFooter, drawerBackdrop, keyActionMenu } from './dom.js'\nimport { api } from './api.js'\nimport { toast, runAction, withBusy } from './toast.js'\nimport { boot, enterApp, showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { readLocationState, writeLocationState } from './location-state.js'\nimport { loadAll, refreshRuns, refreshReadiness, markResourceSaved, retryResource } from './data.js'\nimport { closeDrawer, getDrawerSubmit, getDrawerVersion, openConfirmDrawer, handleDrawerKeydown } from './drawer.js'\nimport {\n render,\n openRunDrawer,\n refreshOpenRunDrawer,\n openAgentDrawer,\n openAgentDiagnostics,\n openWorkspaceDrawer,\n workspaceDependents,\n openKeyDrawer,\n openKeyScopeDrawer,\n openRenameKeyDrawer,\n showSecret,\n copySecret,\n applyApiKey,\n toggleKeyActionMenu,\n handleKeyMenuKeydown,\n closeKeyActionMenu\n} from './render.js'\n\nfunction handleKeyAction(action, id) {\n const key = state.apiKeys.find((item) => item.id === id)\n if (!key) return\n if (action === 'rename') {\n openRenameKeyDrawer(key)\n return\n }\n if (action === 'regenerate') {\n openConfirmDrawer('重新生成 API 密钥', '旧密钥会立即拒绝新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。原密钥 ID 和会话归属保留,客户端需改用新密钥。', '重新生成', async (isCurrent) => {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/regenerate', { method: 'POST' })\n applyApiKey(value.key)\n if (isCurrent()) showSecret('API 密钥已重新生成', value.secret)\n else toast('API 密钥已重新生成,可在密钥列表中显示或复制')\n })\n return\n }\n if (action === 'delete') {\n openConfirmDrawer('删除 API 密钥', '会立即拒绝使用该密钥的新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。删除后,原密钥 ID 的已有会话无法再访问。此操作无法撤销。', '删除密钥', async () => {\n await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' })\n state.apiKeys = state.apiKeys.filter((item) => item.id !== id)\n markResourceSaved('apiKeys')\n render()\n toast('API 密钥已删除')\n })\n return\n }\n void runAction(async () => {\n if (action === 'copy') {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })\n await copySecret(value.secret)\n toast('API 密钥已复制')\n }\n if (action === 'reveal') {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })\n showSecret('显示 API 密钥', value.secret)\n }\n if (action === 'scope') openKeyScopeDrawer(key)\n if (action === 'toggle') {\n const apply = async () => {\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { enabled: !key.enabled } })\n applyApiKey(updated)\n toast(key.enabled ? 'API 密钥已禁用;现有 SSE 已关闭,正在执行的任务会继续' : 'API 密钥已启用')\n }\n if (key.enabled) {\n openConfirmDrawer('禁用 API 密钥', '禁用会立即拒绝新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。已有会话在密钥重新启用后仍按原会话归属处理。', '禁用密钥', async () => { await apply() })\n } else await apply()\n }\n })\n}\n\nasync function handleContentClick(event) {\n const runTarget = event.target.closest('[data-run-detail]')\n if (runTarget) {\n await runAction(() => openRunDrawer(runTarget.dataset.runDetail))\n return\n }\n const button = event.target.closest('button')\n if (!button) return\n if (button.dataset.emptyAction) {\n if (button.dataset.emptyAction === 'add-agent') openAgentDrawer()\n if (button.dataset.emptyAction === 'create-key') openKeyDrawer()\n if (button.dataset.emptyAction === 'add-workspace') openWorkspaceDrawer()\n return\n }\n if (button.dataset.keyMenu) {\n toggleKeyActionMenu(button, button.dataset.keyMenu)\n return\n }\n if (button.dataset.agentEdit) {\n openAgentDrawer(state.config.agents.find((agent) => agent.id === button.dataset.agentEdit))\n return\n }\n if (button.dataset.agentDiagnostics) { await openAgentDiagnostics(button.dataset.agentDiagnostics); return }\n if (button.dataset.agentDelete) {\n const id = button.dataset.agentDelete\n openConfirmDrawer('删除智能体', '删除后该智能体不再接受新任务,已有会话不受影响。', '删除智能体', async () => {\n state.config = await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'DELETE' })\n render()\n toast('智能体已删除')\n void loadAll(true)\n })\n return\n }\n if (button.dataset.workspaceEdit !== undefined) {\n openWorkspaceDrawer(Number(button.dataset.workspaceEdit))\n return\n }\n if (button.dataset.workspaceDelete !== undefined) {\n const index = Number(button.dataset.workspaceDelete)\n const root = (state.config.workspaceRoots || [])[index] || ''\n if (workspaceDependents(index).length) {\n toast('请先修改使用此工作区的智能体配置', true)\n return\n }\n openConfirmDrawer('删除工作区', '删除后,新会话将无法再使用 ' + root + ';目录中的文件不会被删除。', '删除工作区', async () => {\n const roots = state.config.workspaceRoots.filter((_, itemIndex) => itemIndex !== index)\n state.config = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })\n markResourceSaved('config')\n render()\n toast('工作区已删除')\n })\n return\n }\n if (button.dataset.keyAction) handleKeyAction(button.dataset.keyAction, button.dataset.keyId)\n}\n\n/* ── Auth forms ───────────────────────────────────────────────────── */\n\nbyId('setup-form').onsubmit = async (event) => {\n event.preventDefault()\n const form = event.currentTarget\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n try {\n await api('/v1/bootstrap/initialize', { method: 'POST', body: { setupToken: String(data.get('setupToken') || '').trim(), password: String(data.get('password') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n form.reset()\n showLogin()\n toast('初始化完成,请使用控制台密码登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n}\n\nbyId('login-form').onsubmit = async (event) => {\n event.preventDefault()\n const form = event.currentTarget\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n try {\n const data = new FormData(form)\n await api('/v1/admin/auth/login', { method: 'POST', body: { password: String(data.get('password') || '') } })\n form.reset()\n await enterApp()\n } catch (reason) { error.textContent = reason.message }\n })\n}\n\n/* ── Navigation ───────────────────────────────────────────────────── */\n\ndocument.querySelectorAll('.nav-item').forEach((item) => {\n item.onclick = () => {\n if (item.dataset.action === 'logout') {\n void runAction(async () => {\n await api('/v1/admin/auth/logout', { method: 'POST' })\n showLogin()\n })\n return\n }\n state.page = item.dataset.page\n writeLocationState(state)\n render()\n if (state.page === 'runs') void refreshRuns(false)\n if (state.page === 'overview') void retryResource('metrics')\n }\n})\n\n/* ── Global click delegation ──────────────────────────────────────── */\n\ncontent.addEventListener('click', (event) => { void handleContentClick(event) })\ncontent.addEventListener('keydown', (event) => {\n const anchor = event.target.closest('[data-key-menu]')\n if (!anchor || !['ArrowDown', 'ArrowUp'].includes(event.key)) return\n event.preventDefault()\n toggleKeyActionMenu(anchor, anchor.dataset.keyMenu)\n if (event.key === 'ArrowUp') handleKeyMenuKeydown({ key: 'End', preventDefault() {} })\n})\nkeyActionMenu.addEventListener('keydown', handleKeyMenuKeydown)\n\nkeyActionMenu.addEventListener('click', (event) => {\n const button = event.target.closest('button[data-key-action]')\n if (!button) return\n closeKeyActionMenu(true)\n void handleKeyAction(button.dataset.keyAction, button.dataset.keyId)\n})\n\n/* ── Drawer events ────────────────────────────────────────────────── */\n\ndrawerForm.onsubmit = async (event) => {\n event.preventDefault()\n const submit = getDrawerSubmit()\n if (!submit) return\n const version = getDrawerVersion()\n await withBusy(drawerFooter.querySelector('[type=\"submit\"]'), async () => {\n const error = drawerForm.querySelector('[data-form-error]')\n error.textContent = ''\n try { await submit(drawerForm) } catch (reason) {\n if (version === getDrawerVersion()) {\n error.textContent = reason.message\n error.scrollIntoView({ block: 'nearest' })\n }\n else toast(reason.message, true)\n }\n })\n}\ndrawerForm.addEventListener('click', (event) => {\n if (event.target.closest('[data-close-drawer]')) closeDrawer()\n})\ndrawerFooter.addEventListener('click', (event) => {\n if (event.target.closest('[data-close-drawer]')) closeDrawer()\n})\nbyId('drawer-close').onclick = closeDrawer\ndrawerBackdrop.onclick = closeDrawer\n\n/* ── Global listeners ─────────────────────────────────────────────── */\n\ndocument.addEventListener('click', (event) => {\n if (!event.target.closest('.key-action-menu') && !event.target.closest('[data-key-menu]')) closeKeyActionMenu()\n})\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {\n if ((localStorage.getItem('agent-nexus-theme') || 'system') === 'system') applyTheme('system')\n})\ndocument.addEventListener('keydown', (event) => {\n if (event.isComposing) return\n handleDrawerKeydown(event)\n})\nwindow.addEventListener('resize', closeKeyActionMenu)\nwindow.addEventListener('scroll', closeKeyActionMenu, true)\nwindow.addEventListener('hashchange', () => {\n Object.assign(state, readLocationState())\n if (!state.authenticated) return\n render()\n if (state.page === 'runs') void refreshRuns(true)\n if (state.selectedRunId) void openRunDrawer(state.selectedRunId)\n})\n\n/* ── Pollers ──────────────────────────────────────────────────────── */\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden && ['overview', 'agents'].includes(state.page)) void refreshReadiness(false)\n}, 20_000)\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden && state.page === 'runs') void refreshRuns(false)\n}, 5_000)\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden) void refreshOpenRunDrawer()\n if (state.authenticated && !document.hidden && state.page === 'overview') void retryResource('metrics')\n}, 5_000)\n\nvoid boot()\n",
|
|
9
|
+
"format": "const dateFormatter = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })\n\nexport function shortId(value) {\n return String(value || '').slice(0, 8)\n}\n\nexport function formatDate(timestamp) {\n if (!timestamp) return '—'\n return dateFormatter.format(new Date(timestamp))\n}\n\nexport function formatDuration(run) {\n const ms = run.durationMs != null ? run.durationMs : Math.max(0, Date.now() - run.startedAt)\n if (ms < 1000) return ms + ' 毫秒'\n if (ms < 60_000) return Math.round(ms / 1000) + ' 秒'\n if (ms < 3_600_000) return Math.floor(ms / 60_000) + ' 分 ' + Math.round(ms % 60_000 / 1000) + ' 秒'\n return Math.floor(ms / 3_600_000) + ' 小时 ' + Math.round(ms % 3_600_000 / 60_000) + ' 分'\n}\n\nexport const runStateLabels = {\n created: '已创建',\n running: '运行中',\n input_required: '等待输入',\n permission_required: '等待授权',\n completed: '已完成',\n failed: '失败',\n canceled: '已取消'\n}\n\nexport function runStatusLabel(value) {\n return runStateLabels[value] || value\n}\n\nexport function permissionLabel(value) {\n if (value === 'ask') return '询问'\n if (value === 'allow') return '自动允许单次'\n if (value === 'deny') return '拒绝'\n return value || '—'\n}\n\nexport function formatSize(bytes) {\n if (bytes == null) return '—'\n if (bytes < 1024) return bytes + ' B'\n if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'\n return (bytes / 1024 / 1024).toFixed(1) + ' MB'\n}\n\nexport const kindLabels = {\n image: '图片',\n video: '视频',\n audio: '音频',\n file: '文件'\n}\n\nexport function kindLabel(kind) {\n return kindLabels[kind] || kind || '文件'\n}\n",
|
|
10
|
+
"icons": "const svg = (inner, cls) => '<svg viewBox=\"0 0 24 24\"' + (cls ? ' class=\"' + cls + '\"' : '') + ' aria-hidden=\"true\" focusable=\"false\">' + inner + '</svg>'\n\nexport const icons = {\n search: svg('<circle cx=\"11\" cy=\"11\" r=\"7.5\"/><path d=\"m21 21-4.3-4.3\"/>'),\n refresh: svg('<path d=\"M3 12a9 9 0 0 1 9-9 9.7 9.7 0 0 1 6.7 2.7L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.7 9.7 0 0 1-6.7-2.7L3 16\"/><path d=\"M8 16H3v5\"/>'),\n plus: svg('<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>'),\n copy: svg('<rect x=\"9\" y=\"9\" width=\"10\" height=\"10\" rx=\"2\"/><path d=\"M15 9V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/>'),\n disable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m6.3 6.3 11.4 11.4\"/>'),\n enable: svg('<circle cx=\"12\" cy=\"12\" r=\"8\"/><path d=\"m8.5 12 2.3 2.3 4.8-5\"/>'),\n edit: svg('<path d=\"M12 20h8\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z\"/>'),\n more: svg('<circle cx=\"5\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/>'),\n reveal: svg('<path d=\"M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.5\"/>'),\n scope: svg('<path d=\"M12 3 5 6v5c0 4.7 2.9 8 7 10 4.1-2 7-5.3 7-10V6Z\"/><path d=\"M9.5 12 11 13.5l3.5-4\"/>'),\n regenerate: svg('<path d=\"M20 7v5h-5\"/><path d=\"M19 12a7 7 0 1 0-2 5\"/>'),\n trash: svg('<path d=\"M4 7h16\"/><path d=\"M9 7V4h6v3\"/><path d=\"m6 7 1 13h10l1-13\"/><path d=\"M10 11v5M14 11v5\"/>'),\n folder: svg('<path d=\"M20 20a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7.1a2 2 0 0 1-1.66-.89l-.75-1.12A2 2 0 0 0 8.83 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z\"/>'),\n robot: svg('<path d=\"M12 8V4.5\"/><circle cx=\"12\" cy=\"3\" r=\"1.1\"/><rect x=\"4\" y=\"8\" width=\"16\" height=\"12\" rx=\"3\"/><path d=\"M2 14h2\"/><path d=\"M20 14h2\"/><path d=\"M9.5 13v1.6\"/><path d=\"M14.5 13v1.6\"/><path d=\"M9.5 17.2h5\"/>'),\n activity: svg('<path d=\"M22 12h-2.5a2 2 0 0 0-1.93 1.46l-2.1 7.4a.25.25 0 0 1-.48 0L9.24 3.14a.25.25 0 0 0-.48 0l-2.1 7.4A2 2 0 0 1 4.73 12H2\"/>'),\n artifacts: svg('<path d=\"M21 8v8a2 2 0 0 1-1 1.73l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 16V8a2 2 0 0 1 1-1.73l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>'),\n file: svg('<path d=\"M15.75 13a.75.75 0 0 0-.75-.75H9a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 .75-.75m0 4a.75.75 0 0 0-.75-.75H9a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M7 2.25A2.75 2.75 0 0 0 4.25 5v14A2.75 2.75 0 0 0 7 21.75h10A2.75 2.75 0 0 0 19.75 19V7.968c0-.381-.124-.751-.354-1.055l-2.998-3.968a1.75 1.75 0 0 0-1.396-.695zM5.75 5c0-.69.56-1.25 1.25-1.25h7.25v4.397c0 .414.336.75.75.75h3.25V19c0 .69-.56 1.25-1.25 1.25H7c-.69 0-1.25-.56-1.25-1.25z\" clip-rule=\"evenodd\"/>', 'filled'),\n image: svg('<path d=\"M2 6a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v12a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"2.5\"/><path d=\"M14.526 12.621 6 22h12.133A3.867 3.867 0 0 0 22 18.133V18c0-.466-.175-.645-.49-.99l-4.03-4.395a2 2 0 0 0-2.954.006\"/>'),\n fileText: svg('<path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/><path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2\"/><path d=\"M9 9h1\"/><path d=\"M9 13h6\"/><path d=\"M9 17h6\"/>'),\n filePlay: svg('<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"4\"/><path d=\"m15 12-5-3v6z\"/>'),\n fileAudio: svg('<path d=\"M2 6a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v12a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z\"/><path d=\"M9 16.5V8.78a1 1 0 0 1 .757-.97l6-1.5A1 1 0 0 1 17 7.28V15\"/><path d=\"m9 11 8-2\"/><circle cx=\"7.5\" cy=\"16.5\" r=\"1.5\"/><circle cx=\"15.5\" cy=\"15.5\" r=\"1.5\"/>'),\n fileCode: svg('<path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/><path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2\"/><path d=\"m10 13-1 2 1 2\"/><path d=\"m14 13 1 2-1 2\"/>'),\n key: svg('<circle cx=\"8\" cy=\"15\" r=\"4.2\"/><path d=\"m11.1 12 8-8\"/><path d=\"m15.4 7.7 2.9 2.9\"/><path d=\"m17.6 5.5 2.9 2.9\"/>')\n}\n\n",
|
|
11
|
+
"location-state": "// Navigation URLs contain only identifiers and filters, never task text or credentials.\nexport function readLocationState() {\n const [name, query] = location.hash.replace(/^#\\/?/, '').split('?')\n const page = ['overview','runs','artifacts','agents','workspaces','keys','settings'].includes(name) ? name : 'overview'\n const params = new URLSearchParams(query || '')\n const states = ['running','input_required','permission_required','completed','failed','canceled']\n const id = (value) => value && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) ? value : undefined\n return { page, runAgent: id(params.get('agent')) || 'all', runStatus: states.includes(params.get('state')) ? params.get('state') : 'all',\n runPageSize: [50,100,200].includes(Number(params.get('limit'))) ? Number(params.get('limit')) : 50,\n runOffset: Math.min(10_000_000, Math.max(0, Math.floor(Number(params.get('offset')) || 0))), selectedRunId: id(params.get('run')),\n protocol: ['acp','a2a'].includes(params.get('protocol')) ? params.get('protocol') : 'all',\n status: ['ready','failed','disabled'].includes(params.get('status')) ? params.get('status') : 'all' }\n}\n\nexport function writeLocationState(state) {\n const params = new URLSearchParams()\n if (state.page === 'runs') {\n if (state.runAgent !== 'all') params.set('agent', state.runAgent)\n if (state.runStatus !== 'all') params.set('state', state.runStatus)\n if (state.runPageSize !== 50) params.set('limit', String(state.runPageSize))\n if (state.runOffset) params.set('offset', String(state.runOffset))\n }\n if (state.page === 'agents') {\n if (state.protocol !== 'all') params.set('protocol', state.protocol)\n if (state.status !== 'all') params.set('status', state.status)\n }\n if (state.selectedRunId) params.set('run', state.selectedRunId)\n history.replaceState(null, '', '#/' + state.page + (params.size ? '?' + params : ''))\n}\n",
|
|
12
|
+
"main": "import { state } from './state.js'\nimport { byId, content, drawerForm, drawerFooter, drawerBackdrop, keyActionMenu } from './dom.js'\nimport { api } from './api.js'\nimport { toast, runAction, withBusy } from './toast.js'\nimport { boot, enterApp, showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { readLocationState, writeLocationState } from './location-state.js'\nimport { loadAll, refreshRuns, refreshReadiness, markResourceSaved, retryResource } from './data.js'\nimport { closeDrawer, getDrawerSubmit, getDrawerVersion, openConfirmDrawer, handleDrawerKeydown } from './drawer.js'\nimport {\n render,\n openRunDrawer,\n refreshOpenRunDrawer,\n openAgentDrawer,\n openAgentDiagnostics,\n openWorkspaceDrawer,\n workspaceDependents,\n openKeyDrawer,\n openKeyScopeDrawer,\n openRenameKeyDrawer,\n showSecret,\n copySecret,\n applyApiKey,\n toggleKeyActionMenu,\n handleKeyMenuKeydown,\n closeKeyActionMenu,\n openArtifactDetail,\n confirmArtifactDelete,\n loadArtifacts\n} from './render.js'\n\nfunction handleKeyAction(action, id) {\n const key = state.apiKeys.find((item) => item.id === id)\n if (!key) return\n if (action === 'rename') {\n openRenameKeyDrawer(key)\n return\n }\n if (action === 'regenerate') {\n openConfirmDrawer('重新生成 API 密钥', '旧密钥会立即拒绝新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。原密钥 ID 和会话归属保留,客户端需改用新密钥。', '重新生成', async (isCurrent) => {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/regenerate', { method: 'POST' })\n applyApiKey(value.key)\n if (isCurrent()) showSecret('API 密钥已重新生成', value.secret)\n else toast('API 密钥已重新生成,可在密钥列表中显示或复制')\n })\n return\n }\n if (action === 'delete') {\n openConfirmDrawer('删除 API 密钥', '会立即拒绝使用该密钥的新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。删除后,原密钥 ID 的已有会话无法再访问。此操作无法撤销。', '删除密钥', async () => {\n await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'DELETE' })\n state.apiKeys = state.apiKeys.filter((item) => item.id !== id)\n markResourceSaved('apiKeys')\n render()\n toast('API 密钥已删除')\n })\n return\n }\n void runAction(async () => {\n if (action === 'copy') {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })\n await copySecret(value.secret)\n toast('API 密钥已复制')\n }\n if (action === 'reveal') {\n const value = await api('/v1/admin/api-keys/' + encodeURIComponent(id) + '/reveal', { method: 'POST' })\n showSecret('显示 API 密钥', value.secret)\n }\n if (action === 'scope') openKeyScopeDrawer(key)\n if (action === 'toggle') {\n const apply = async () => {\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(id), { method: 'PATCH', body: { enabled: !key.enabled } })\n applyApiKey(updated)\n toast(key.enabled ? 'API 密钥已禁用;现有 SSE 已关闭,正在执行的任务会继续' : 'API 密钥已启用')\n }\n if (key.enabled) {\n openConfirmDrawer('禁用 API 密钥', '禁用会立即拒绝新请求,并关闭使用它的现有 SSE 连接;正在执行的任务会继续。已有会话在密钥重新启用后仍按原会话归属处理。', '禁用密钥', async () => { await apply() })\n } else await apply()\n }\n })\n}\n\nasync function handleContentClick(event) {\n const runTarget = event.target.closest('[data-run-detail]')\n if (runTarget) {\n await runAction(() => openRunDrawer(runTarget.dataset.runDetail))\n return\n }\n const button = event.target.closest('button')\n if (!button) return\n if (button.dataset.runQuickCancel) {\n const id = button.dataset.runQuickCancel\n openConfirmDrawer('取消任务', '取消当前任务并释放智能体会话。已经完成的文件或外部操作不会撤回。', '取消任务', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(id) + '/cancel', { method: 'POST', body: {} })\n toast('任务已取消')\n void retryResource('metrics')\n })\n return\n }\n if (button.dataset.runQuickDelete) {\n const id = button.dataset.runQuickDelete\n openConfirmDrawer('删除运行记录', '仅删除这条历史记录与已保存的产物文件,不影响智能体配置和客户端会话。此操作无法撤销。', '删除记录', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(id), { method: 'DELETE', body: {} })\n toast('运行记录已删除')\n void retryResource('metrics')\n })\n return\n }\n if (button.dataset.artifactDetail) {\n openArtifactDetail(button.dataset.artifactDetail, button.dataset.artifactRun)\n return\n }\n if (button.dataset.artifactDelete) {\n confirmArtifactDelete(button.dataset.artifactDelete, button.dataset.artifactRun)\n return\n }\n if (button.dataset.emptyAction) {\n if (button.dataset.emptyAction === 'add-agent') openAgentDrawer()\n if (button.dataset.emptyAction === 'create-key') openKeyDrawer()\n if (button.dataset.emptyAction === 'add-workspace') openWorkspaceDrawer()\n return\n }\n if (button.dataset.keyMenu) {\n toggleKeyActionMenu(button, button.dataset.keyMenu)\n return\n }\n if (button.dataset.agentEdit) {\n openAgentDrawer(state.config.agents.find((agent) => agent.id === button.dataset.agentEdit))\n return\n }\n if (button.dataset.agentDiagnostics) { await openAgentDiagnostics(button.dataset.agentDiagnostics); return }\n if (button.dataset.agentDelete) {\n const id = button.dataset.agentDelete\n openConfirmDrawer('删除智能体', '删除后该智能体不再接受新任务,已有会话不受影响。', '删除智能体', async () => {\n state.config = await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'DELETE' })\n render()\n toast('智能体已删除')\n void loadAll(true)\n })\n return\n }\n if (button.dataset.workspaceEdit !== undefined) {\n openWorkspaceDrawer(Number(button.dataset.workspaceEdit))\n return\n }\n if (button.dataset.workspaceDelete !== undefined) {\n const index = Number(button.dataset.workspaceDelete)\n const root = (state.config.workspaceRoots || [])[index] || ''\n if (workspaceDependents(index).length) {\n toast('请先修改使用此工作区的智能体配置', true)\n return\n }\n openConfirmDrawer('删除工作区', '删除后,新会话将无法再使用 ' + root + ';目录中的文件不会被删除。', '删除工作区', async () => {\n const roots = state.config.workspaceRoots.filter((_, itemIndex) => itemIndex !== index)\n state.config = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })\n markResourceSaved('config')\n render()\n toast('工作区已删除')\n })\n return\n }\n if (button.dataset.keyAction) handleKeyAction(button.dataset.keyAction, button.dataset.keyId)\n}\n\n/* ── Auth forms ───────────────────────────────────────────────────── */\n\nbyId('setup-form').onsubmit = async (event) => {\n event.preventDefault()\n const form = event.currentTarget\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n try {\n await api('/v1/bootstrap/initialize', { method: 'POST', body: { setupToken: String(data.get('setupToken') || '').trim(), password: String(data.get('password') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n form.reset()\n showLogin()\n toast('初始化完成,请使用控制台密码登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n}\n\nbyId('login-form').onsubmit = async (event) => {\n event.preventDefault()\n const form = event.currentTarget\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n try {\n const data = new FormData(form)\n await api('/v1/admin/auth/login', { method: 'POST', body: { password: String(data.get('password') || '') } })\n form.reset()\n await enterApp()\n } catch (reason) { error.textContent = reason.message }\n })\n}\n\n/* ── Navigation ───────────────────────────────────────────────────── */\n\ndocument.querySelectorAll('.nav-item').forEach((item) => {\n item.onclick = () => {\n if (item.dataset.action === 'logout') {\n void runAction(async () => {\n await api('/v1/admin/auth/logout', { method: 'POST' })\n showLogin()\n })\n return\n }\n state.page = item.dataset.page\n writeLocationState(state)\n render()\n if (state.page === 'runs') void refreshRuns(false)\n if (state.page === 'overview') void retryResource('metrics')\n if (state.page === 'artifacts') void loadArtifacts(false)\n }\n})\n\n/* ── Global click delegation ──────────────────────────────────────── */\n\ncontent.addEventListener('click', (event) => { void handleContentClick(event) })\ncontent.addEventListener('keydown', (event) => {\n const anchor = event.target.closest('[data-key-menu]')\n if (!anchor || !['ArrowDown', 'ArrowUp'].includes(event.key)) return\n event.preventDefault()\n toggleKeyActionMenu(anchor, anchor.dataset.keyMenu)\n if (event.key === 'ArrowUp') handleKeyMenuKeydown({ key: 'End', preventDefault() {} })\n})\nkeyActionMenu.addEventListener('keydown', handleKeyMenuKeydown)\n\nkeyActionMenu.addEventListener('click', (event) => {\n const button = event.target.closest('button[data-key-action]')\n if (!button) return\n closeKeyActionMenu(true)\n void handleKeyAction(button.dataset.keyAction, button.dataset.keyId)\n})\n\n/* ── Drawer events ────────────────────────────────────────────────── */\n\ndrawerForm.onsubmit = async (event) => {\n event.preventDefault()\n const submit = getDrawerSubmit()\n if (!submit) return\n const version = getDrawerVersion()\n await withBusy(drawerFooter.querySelector('[type=\"submit\"]'), async () => {\n const error = drawerForm.querySelector('[data-form-error]')\n error.textContent = ''\n try { await submit(drawerForm) } catch (reason) {\n if (version === getDrawerVersion()) {\n error.textContent = reason.message\n error.scrollIntoView({ block: 'nearest' })\n }\n else toast(reason.message, true)\n }\n })\n}\ndrawerForm.addEventListener('click', (event) => {\n if (event.target.closest('[data-close-drawer]')) closeDrawer()\n})\ndrawerFooter.addEventListener('click', (event) => {\n if (event.target.closest('[data-close-drawer]')) closeDrawer()\n})\nbyId('drawer-close').onclick = closeDrawer\ndrawerBackdrop.onclick = closeDrawer\n\n/* ── Global listeners ─────────────────────────────────────────────── */\n\ndocument.addEventListener('click', (event) => {\n if (!event.target.closest('.key-action-menu') && !event.target.closest('[data-key-menu]')) closeKeyActionMenu()\n})\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {\n if ((localStorage.getItem('agent-nexus-theme') || 'system') === 'system') applyTheme('system')\n})\ndocument.addEventListener('keydown', (event) => {\n if (event.isComposing) return\n handleDrawerKeydown(event)\n})\nwindow.addEventListener('resize', closeKeyActionMenu)\nwindow.addEventListener('scroll', closeKeyActionMenu, true)\nwindow.addEventListener('hashchange', () => {\n Object.assign(state, readLocationState())\n if (!state.authenticated) return\n render()\n if (state.page === 'runs') void refreshRuns(true)\n if (state.page === 'artifacts') void loadArtifacts(false)\n if (state.selectedRunId) void openRunDrawer(state.selectedRunId)\n})\n\n/* ── Pollers ──────────────────────────────────────────────────────── */\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden && ['overview', 'agents'].includes(state.page)) void refreshReadiness(false)\n}, 20_000)\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden && state.page === 'runs') void refreshRuns(false)\n}, 5_000)\n\nsetInterval(() => {\n if (state.authenticated && !document.hidden) void refreshOpenRunDrawer()\n if (state.authenticated && !document.hidden && state.page === 'overview') void retryResource('metrics')\n}, 5_000)\n\nvoid boot()\n",
|
|
13
13
|
"render-agents": "import { state } from './state.js'\nimport { byId, drawerForm, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api, localizeError } from './api.js'\nimport { toast, withBusy } from './toast.js'\nimport { openDrawer, closeDrawer, getDrawerVersion } from './drawer.js'\nimport { copySecret } from './render-keys.js'\n\nimport { loadAll, refreshReadiness } from './data.js'\nimport { permissionLabel } from './format.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, setToolbar, announce, emptyState, bindSearch, preciseUnitValue } from './render-shared.js'\n\nexport function statusMarkup(agent) {\n if (!agent.enabled) return '<span class=\"status\">已禁用</span>'\n if (agent.checking) return '<span class=\"status checking\">检查中</span>'\n return agent.ready ? '<span class=\"status ready\" title=\"' + (agent.protocol === 'acp' ? '命令探测通过;创建会话时才进行 ACP 握手' : 'Agent Card 探测通过;实际任务可能仍失败') + '\">' + (agent.protocol === 'acp' ? '命令可用' : 'Card 可用') + '</span>' : '<span class=\"status failed\">不可用</span>'\n}\n\nexport function readinessById() {\n return new Map(state.readiness.map((agent) => [agent.id, agent]))\n}\n\nexport function agentReadiness(agent, lookup) {\n return { ...(lookup.get(agent.id) || { ready: false, checking: true }),\n id: agent.id,\n name: agent.name,\n protocol: agent.protocol,\n driver: agent.driver,\n enabled: agent.enabled\n }\n}\n\nexport function readinessRows() {\n const lookup = readinessById()\n return state.config.agents.map((agent) => agentReadiness(agent, lookup))\n}\n\nexport function agentRow(agent, live) {\n const statusAgent = { ...(live || { ready: false, checking: true }), enabled: agent.enabled }\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(agent.name) + '</strong><small>' + escapeHtml(agent.id) + '</small>' +\n (statusAgent.error && agent.enabled ? '<span class=\"error-detail\">' + escapeHtml(localizeError(statusAgent.error)) + '</span>' : '') +\n '</div></td><td><span class=\"badge\">' + escapeHtml(agent.protocol.toUpperCase()) + '</span></td><td>' + escapeHtml(agent.driver || '—') + '</td><td>' + statusMarkup(statusAgent) + '</td><td>' + escapeHtml(agent.workspace || '—') + '</td><td>' + escapeHtml(permissionLabel(agent.permissionPolicy)) + '</td><td><div class=\"row-actions\"><button class=\"button small\" data-agent-edit=\"' + escapeHtml(agent.id) + '\">编辑</button><button class=\"button small\" data-agent-diagnostics=\"' + escapeHtml(agent.id) + '\"' + (agent.enabled ? '' : ' disabled') + '>诊断</button><button class=\"button small danger\" data-agent-delete=\"' + escapeHtml(agent.id) + '\">删除</button></div></td></tr>'\n}\n\nexport function renderAgents() {\n setActions('<button id=\"stdio-guide\" class=\"button\">接入自定义 ACP</button><button id=\"refresh-agents\" class=\"button\">' + icons.refresh + '刷新</button><button id=\"add-agent\" class=\"button primary\">' + icons.plus + '添加智能体</button>')\n byId('stdio-guide').onclick = openStdioGuide\n byId('refresh-agents').onclick = (event) => withBusy(event.currentTarget, () => refreshReadiness(true))\n byId('add-agent').onclick = () => openAgentDrawer()\n byId('add-agent').disabled = !state.resources.config.lastSuccessAt\n\n if (!state.config.agents.length && ['idle', 'loading'].includes(state.resources.config.status)) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载智能体配置…</p></div>')\n announce('正在加载智能体配置')\n return\n }\n if (!state.resources.config.lastSuccessAt) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">智能体配置读取失败,请重试后编辑。</p></div>')\n return\n }\n if (!state.config.agents.length) {\n setToolbar('')\n setResults('<div class=\"panel\">' +\n emptyState(icons.robot, '尚未配置智能体', '智能体是网关转发任务的执行端,支持本地 ACP 进程与远程 A2A 服务。', { action: 'add-agent', label: '添加智能体' }) +\n '</div>')\n announce('尚未配置智能体')\n return\n }\n\n const lookup = readinessById()\n const rows = state.config.agents.filter((agent) => {\n const live = agentReadiness(agent, lookup)\n const haystack = (agent.name + ' ' + agent.id + ' ' + (agent.description || '')).toLowerCase()\n const status = !agent.enabled ? 'disabled' : !lookup.has(agent.id) ? 'checking' : live.ready ? 'ready' : 'failed'\n return haystack.indexOf(state.search.toLowerCase()) >= 0 &&\n (state.protocol === 'all' || agent.protocol === state.protocol) &&\n (state.status === 'all' || status === state.status)\n })\n setToolbar('agents',\n '<div class=\"toolbar\"><div class=\"search-box\">' + icons.search + '<input id=\"agent-search\" type=\"search\" aria-label=\"搜索智能体\" placeholder=\"搜索智能体\" value=\"' + escapeHtml(state.search) + '\"></div>' +\n '<select id=\"protocol-filter\" aria-label=\"按协议筛选\"><option value=\"all\">全部协议</option><option value=\"acp\"' + selected(state.protocol, 'acp') + '>ACP</option><option value=\"a2a\"' + selected(state.protocol, 'a2a') + '>A2A</option></select>' +\n '<select id=\"status-filter\" aria-label=\"按就绪状态筛选\"><option value=\"all\">全部状态</option><option value=\"ready\"' + selected(state.status, 'ready') + '>就绪</option><option value=\"failed\"' + selected(state.status, 'failed') + '>不可用</option><option value=\"disabled\"' + selected(state.status, 'disabled') + '>已禁用</option></select></div>')\n setResults('<div class=\"table-wrap\"><table class=\"agent-table\"><thead><tr><th>智能体</th><th>协议</th><th>驱动</th><th>状态</th><th>工作区</th><th>权限策略</th><th>操作</th></tr></thead><tbody>' +\n (rows.length ? rows.map((agent) => agentRow(agent, lookup.get(agent.id))).join('') : '<tr><td colspan=\"7\" class=\"empty\">没有符合条件的智能体。</td></tr>') +\n '</tbody></table></div>')\n bindSearch('agent-search', (value) => { state.search = value; renderAgents() })\n byId('protocol-filter').onchange = (event) => { state.protocol = event.target.value; renderAgents() }\n byId('status-filter').onchange = (event) => { state.status = event.target.value; renderAgents() }\n announce(rows.length + ' 个智能体,' + rows.filter((agent) => agentReadiness(agent, lookup).ready).length + ' 个就绪')\n}\n\nexport function openAgentDrawer(agent) {\n if (!state.resources.config.lastSuccessAt) { toast('配置尚未成功读取,请重试后编辑', true); return }\n const editing = Boolean(agent)\n const current = agent || { protocol: 'acp', enabled: true, permissionPolicy: 'ask', permissionTimeoutMs: 900000, timeoutMs: 60000 }\n const permissionTimeoutSeconds = preciseUnitValue(current.permissionTimeoutMs, 1000, 900000)\n const requestTimeoutSeconds = preciseUnitValue(current.timeoutMs, 1000, 60000)\n const inheritedTaskTimeoutMs = Number(state.config.promptTimeoutMs || 30 * 60 * 1000)\n const inheritedIdleTimeoutMs = Number(current.timeoutMs || 60 * 1000)\n const taskTimeoutSeconds = current.taskTimeoutMs === undefined ? '' : preciseUnitValue(current.taskTimeoutMs, 1000, 30 * 60)\n const streamIdleTimeoutSeconds = current.streamIdleTimeoutMs === undefined ? '' : preciseUnitValue(current.streamIdleTimeoutMs, 1000, 60)\n const taskTimeoutInherited = current.taskTimeoutMs === undefined\n const streamIdleTimeoutInherited = current.streamIdleTimeoutMs === undefined\n const configuredDriver = current.driver || state.config.driverKinds[0]\n const driverKinds = [...new Set([...(state.config.driverKinds || []), ...(editing && current.driver ? [current.driver] : [])])]\n const visibleDriverKinds = editing && current.driver === 'stdio' ? driverKinds : driverKinds.filter((driver) => driver !== 'stdio')\n const drivers = visibleDriverKinds.map((driver) => '<option value=\"' + escapeHtml(driver) + '\"' + selected(configuredDriver, driver) + '>' + escapeHtml(driver) + '</option>').join('')\n const roots = state.config.workspaceRoots.map((root) => '<option value=\"' + escapeHtml(root) + '\"></option>').join('')\n const stdioNotice = '<div data-driver-section=\"stdio\" class=\"field' + (configuredDriver === 'stdio' ? '' : ' hidden') + '\"><small class=\"field-help\">stdio 的 command、参数与环境变量由本地配置提供;控制台仅支持编辑已有项的通用设置。</small></div>'\n const body =\n '<div class=\"field\"><label for=\"f-id\">智能体 ID</label><input id=\"f-id\" name=\"id\" value=\"' + escapeHtml(current.id || '') + '\" pattern=\"[a-z0-9][a-z0-9._\\\\-]{0,63}\" required' + (editing ? ' disabled' : '') + '></div>' +\n '<div class=\"field-row\"><div class=\"field\"><label for=\"f-protocol\">协议</label><select id=\"f-protocol\" name=\"protocol\"><option value=\"acp\"' + selected(current.protocol, 'acp') + '>ACP</option><option value=\"a2a\"' + selected(current.protocol, 'a2a') + '>A2A</option></select></div><div class=\"field\"><span class=\"field-spacer\" aria-hidden=\"true\"> </span><label class=\"checkbox\"><input name=\"enabled\" type=\"checkbox\"' + checked(current.enabled) + '>启用</label></div></div>' +\n '<div class=\"field\"><label for=\"f-name\">名称</label><input id=\"f-name\" name=\"name\" value=\"' + escapeHtml(current.name || '') + '\" required></div>' +\n '<div class=\"field\"><label for=\"f-description\">描述</label><textarea id=\"f-description\" name=\"description\">' + escapeHtml(current.description || '') + '</textarea></div>' +\n '<div data-protocol-section=\"acp\"><div class=\"field\"><label for=\"f-driver\">驱动</label><select id=\"f-driver\" name=\"driver\">' + (drivers || '<option value=\"\" disabled selected>请先配置可用驱动</option>') + '</select></div>' + stdioNotice + '<div class=\"field\"><label for=\"f-workspace\">工作区</label><input id=\"f-workspace\" name=\"workspace\" list=\"workspace-roots\" value=\"' + escapeHtml(current.workspace || state.config.workspaceRoots[0] || '') + '\" required><datalist id=\"workspace-roots\">' + roots + '</datalist></div><div class=\"field-row\"><div class=\"field\"><label for=\"f-permissionPolicy\">权限策略</label><select id=\"f-permissionPolicy\" name=\"permissionPolicy\"><option value=\"ask\"' + selected(current.permissionPolicy, 'ask') + '>询问</option><option value=\"allow\"' + selected(current.permissionPolicy, 'allow') + '>自动允许单次</option><option value=\"deny\"' + selected(current.permissionPolicy, 'deny') + '>拒绝</option></select></div><div class=\"field\"><label for=\"f-permissionTimeoutMs\">权限确认超时(秒)</label><input id=\"f-permissionTimeoutMs\" name=\"permissionTimeoutMs\" type=\"number\" min=\"1\" max=\"86400\" step=\"0.001\" value=\"' + escapeHtml(permissionTimeoutSeconds) + '\"><small class=\"field-help\">默认 900 秒,最长 24 小时。自动策略只选 allow_once;只有永久选项时拒绝,请改为询问并显式授权。</small></div></div></div>' +\n '<div data-protocol-section=\"a2a\"><div class=\"field\"><label for=\"f-agentCardUrl\">Agent Card URL</label><input id=\"f-agentCardUrl\" name=\"agentCardUrl\" type=\"url\" value=\"' + escapeHtml(current.agentCardUrl || '') + '\" placeholder=\"http://agent.local:8080/.well-known/agent-card.json\" required><small class=\"field-help\">调用地址与能力从 Card 自动发现。</small></div><div class=\"field-row\"><div class=\"field\"><label for=\"f-preferredTransport\">首选传输</label><select id=\"f-preferredTransport\" name=\"preferredTransport\"><option value=\"auto\"' + selected(current.preferredTransport || 'auto', 'auto') + '>自动(按 Card)</option><option value=\"jsonrpc\"' + selected(current.preferredTransport, 'jsonrpc') + '>JSON-RPC</option><option value=\"http-json\"' + selected(current.preferredTransport, 'http-json') + '>HTTP+JSON</option></select></div><div class=\"field\"><label for=\"f-authType\">认证方式</label><select id=\"f-authType\" name=\"authType\"><option value=\"none\"' + selected(current.auth && current.auth.type || 'none', 'none') + '>无认证</option><option value=\"bearer\"' + selected(current.auth && current.auth.type, 'bearer') + '>Bearer Token</option><option value=\"header\"' + selected(current.auth && current.auth.type, 'header') + '>自定义请求头</option></select></div></div><div class=\"field\" data-auth-header><label for=\"f-authHeaderName\">请求头名称</label><input id=\"f-authHeaderName\" name=\"authHeaderName\" value=\"' + escapeHtml(current.auth && current.auth.headerName || '') + '\" placeholder=\"X-API-Key\"></div><div class=\"field\" data-auth-value><label data-auth-value-label for=\"f-authValue\">认证凭据</label><input id=\"f-authValue\" name=\"authValue\" type=\"password\" autocomplete=\"off\" placeholder=\"' + (editing && current.auth && current.auth.configured ? '留空以保留当前凭据' : '') + '\"></div><div class=\"field\"><label for=\"f-timeoutMs\">普通请求/建连超时(秒)</label><input id=\"f-timeoutMs\" name=\"timeoutMs\" type=\"number\" min=\"1\" max=\"1800\" step=\"any\" value=\"' + escapeHtml(requestTimeoutSeconds) + '\"><small class=\"field-help\">只限制单次请求和建连,不限制整轮任务。</small></div><div class=\"field\"><label for=\"f-taskTimeoutMs\">单轮任务期限(秒)</label><input id=\"f-taskTimeoutMs\" name=\"taskTimeoutMs\" type=\"number\" min=\"10\" max=\"86400\" step=\"any\" value=\"' + escapeHtml(taskTimeoutSeconds) + '\" placeholder=\"继承全局 ACP 任务超时(' + escapeHtml(preciseUnitValue(inheritedTaskTimeoutMs, 1000, 1800)) + ' 秒)\" data-inherited=\"' + (taskTimeoutInherited ? 'true' : 'false') + '\"><small class=\"field-help\">留空表示继承全局 ACP 任务超时(当前 ' + escapeHtml(preciseUnitValue(inheritedTaskTimeoutMs, 1000, 1800)) + ' 秒)。</small></div><div class=\"field\"><label for=\"f-streamIdleTimeoutMs\">流无进度超时(秒)</label><input id=\"f-streamIdleTimeoutMs\" name=\"streamIdleTimeoutMs\" type=\"number\" min=\"1\" max=\"1800\" step=\"any\" value=\"' + escapeHtml(streamIdleTimeoutSeconds) + '\" placeholder=\"继承普通请求/建连超时(' + escapeHtml(preciseUnitValue(inheritedIdleTimeoutMs, 1000, 60)) + ' 秒)\" data-inherited=\"' + (streamIdleTimeoutInherited ? 'true' : 'false') + '\"><small class=\"field-help\">留空表示继承普通请求/建连超时,收到进度会重新计时。</small></div></div>'\n openDrawer(editing ? '编辑智能体' : '添加智能体', body, editing ? '保存修改' : '添加智能体', async (form, isCurrent) => {\n const data = new FormData(form)\n const protocol = data.get('protocol')\n const id = editing ? current.id : String(data.get('id') || '').trim().toLowerCase()\n const payload = {\n protocol,\n name: String(data.get('name') || '').trim(),\n description: String(data.get('description') || '').trim(),\n enabled: data.get('enabled') === 'on'\n }\n if (protocol === 'acp') {\n payload.driver = data.get('driver')\n payload.workspace = String(data.get('workspace') || '').trim()\n payload.permissionPolicy = data.get('permissionPolicy')\n payload.permissionTimeoutMs = Math.round(Number(data.get('permissionTimeoutMs')) * 1000)\n } else {\n payload.agentCardUrl = String(data.get('agentCardUrl') || '').trim()\n payload.preferredTransport = data.get('preferredTransport')\n payload.authType = data.get('authType')\n payload.authHeaderName = String(data.get('authHeaderName') || '').trim()\n const authValue = String(data.get('authValue') || '')\n if (authValue) payload.authValue = authValue\n payload.timeoutMs = Math.round(Number(data.get('timeoutMs')) * 1000)\n const taskTimeoutInput = drawerFormElements().taskTimeoutMs\n const taskTimeoutValue = String(data.get('taskTimeoutMs') || '').trim()\n payload.taskTimeoutMs = taskTimeoutValue ? Math.round(Number(taskTimeoutValue) * 1000) : null\n if (!taskTimeoutValue && taskTimeoutInput.dataset.inherited === 'true' && current.taskTimeoutMs === undefined) delete payload.taskTimeoutMs\n const streamIdleInput = drawerFormElements().streamIdleTimeoutMs\n const streamIdleValue = String(data.get('streamIdleTimeoutMs') || '').trim()\n payload.streamIdleTimeoutMs = streamIdleValue ? Math.round(Number(streamIdleValue) * 1000) : null\n if (!streamIdleValue && streamIdleInput.dataset.inherited === 'true' && current.streamIdleTimeoutMs === undefined) delete payload.streamIdleTimeoutMs\n }\n await api('/v1/admin/agents/' + encodeURIComponent(id), { method: 'PUT', body: payload })\n if (isCurrent()) closeDrawer()\n render()\n toast(editing ? '智能体已更新' : '智能体已添加')\n void loadAll(true)\n })\n const protocolSelect = drawerFormElements().protocol\n const authSelect = drawerFormElements().authType\n const sync = () => {\n document.querySelectorAll('#drawer-form [data-protocol-section]').forEach((section) => {\n section.classList.toggle('hidden', section.dataset.protocolSection !== protocolSelect.value)\n section.querySelectorAll('input,select,textarea').forEach((field) => { field.disabled = section.classList.contains('hidden') })\n })\n const driver = drawerFormElements().driver?.value\n document.querySelectorAll('#drawer-form [data-driver-section]').forEach((section) => {\n const hidden = protocolSelect.value !== 'acp' || section.dataset.driverSection !== driver\n section.classList.toggle('hidden', hidden)\n section.querySelectorAll('input,textarea').forEach((field) => { field.disabled = hidden })\n })\n if (protocolSelect.value === 'a2a') {\n const auth = authSelect.value\n document.querySelector('#drawer-form [data-auth-header]').classList.toggle('hidden', auth !== 'header')\n document.querySelector('#drawer-form [data-auth-value]').classList.toggle('hidden', auth === 'none')\n document.querySelector('#drawer-form [data-auth-value-label]').textContent = auth === 'bearer' ? 'Bearer Token' : '认证凭据'\n }\n }\n protocolSelect.onchange = sync\n authSelect.onchange = sync\n byId('f-driver').onchange = sync\n byId('f-taskTimeoutMs').oninput = (event) => { event.currentTarget.dataset.inherited = 'false' }\n byId('f-streamIdleTimeoutMs').oninput = (event) => { event.currentTarget.dataset.inherited = 'false' }\n sync()\n}\n\nexport function drawerFormElements() {\n return byId('drawer-form').elements\n}\n\nexport async function openAgentDiagnostics(id) {\n openDrawer('智能体连接诊断', '<p role=\"status\">正在启动智能体并验证连接,完成后释放诊断会话;不会发送任务。</p>', '', null)\n const version = getDrawerVersion()\n try {\n const result = await api('/v1/admin/agents/' + encodeURIComponent(id) + '/diagnostics', { method: 'POST', body: {} })\n if (version !== getDrawerVersion()) return\n drawerForm.innerHTML = '<p>' + escapeHtml(result.ok ? '连接诊断通过' : '连接诊断未通过') + '</p><div class=\"overview-list\">' +\n (result.stages || []).map((stage) => '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(({ validate: '配置检查', start: '启动连接', initialize: '协议握手', createSession: '建立会话', connect: '连接与会话建立', handshake: '连接与会话建立', close: '清理会话' })[stage.name || stage.stage] || stage.name || stage.stage || '检查') + '</strong><small>' + escapeHtml(stage.message || stage.error || '') + '</small></div><span>' + escapeHtml(({ passed: '通过', failed: '失败', skipped: '跳过' })[stage.status] || stage.status || '') + '</span></div>').join('') +\n '</div><p class=\"field-help\">' + escapeHtml(result.message || '') + '</p>'\n } catch (error) {\n if (version === getDrawerVersion()) drawerForm.innerHTML = '<p class=\"form-error\" role=\"alert\">' + escapeHtml(error.message) + '</p>'\n }\n}\n\nexport function openStdioGuide() {\n const sample = JSON.stringify({ custom: { protocol: 'acp', driver: 'stdio', name: '自定义 ACP', command: '/absolute/path/to/your-agent', args: ['--acp'], probeArgs: ['--version'], workspace: state.config.workspaceRoots[0] || '/path/to/workspace', permissionPolicy: 'ask' } }, null, 2)\n openDrawer('接入自定义 ACP', '<p class=\"field-help\">将下方智能体项合并到本地配置的 agents 中,并按实际命令调整参数。重启网关后可在列表中执行连接诊断。command、args、probeArgs、inheritEnv 和 env 由本地配置管理。</p><pre class=\"run-detail-code\">' + escapeHtml(sample) + '</pre><button class=\"button\" type=\"button\" id=\"copy-stdio-config\">复制配置示例</button>', '', null)\n byId('copy-stdio-config').onclick = async () => {\n try { await copySecret(sample); toast('配置示例已复制') } catch (error) { toast(error.message, true) }\n }\n}\n",
|
|
14
|
-
"render-keys": "import { state } from './state.js'\nimport { byId, keyActionMenu, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { openDrawer, closeDrawer } from './drawer.js'\nimport { showLogin } from './screens.js'\n\nimport { markResourceSaved } from './data.js'\nimport { formatDate } from './format.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, announce, emptyState } from './render-shared.js'\n\nlet menuAnchor = null\n\nexport function keyRow(key) {\n const scope = key.scope.allAgents ? '全部智能体' : (key.scope.agentIds.length ? key.scope.agentIds.join(', ') : '未授权智能体')\n const id = escapeHtml(key.id)\n const toggleLabel = key.enabled ? '禁用密钥' : '启用密钥'\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(key.name) + '</strong>' + (key.legacy ? '<span class=\"badge\">旧版</span>' : '') + '</div></td>' +\n '<td>' + (key.enabled ? '<span class=\"status ready\">已启用</span>' : '<span class=\"status\">已禁用</span>') + '</td>' +\n '<td data-label=\"API 密钥\"><div class=\"key-secret-cell\"><code>••••' + escapeHtml(key.suffix) + '</code><button class=\"key-copy-button\" data-key-action=\"copy\" data-key-id=\"' + id + '\" aria-label=\"复制完整密钥\" title=\"复制完整密钥\">' + icons.copy + '</button></div></td>' +\n '<td data-label=\"授权范围\"><span class=\"key-scope\">' + escapeHtml(scope) + '</span></td>' +\n '<td data-label=\"最后使用\"><span class=\"key-last-used\">' + escapeHtml(key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用') + '</span></td>' +\n '<td><div class=\"key-actions\"><button class=\"key-icon-button' + (key.enabled ? ' danger-action' : '') + '\" data-key-action=\"toggle\" data-key-id=\"' + id + '\" aria-label=\"' + toggleLabel + '\" title=\"' + toggleLabel + '\">' + (key.enabled ? icons.disable : icons.enable) + '</button><button class=\"key-icon-button\" data-key-action=\"rename\" data-key-id=\"' + id + '\" aria-label=\"重命名\" title=\"重命名\">' + icons.edit + '</button><button class=\"key-icon-button\" data-key-menu=\"' + id + '\" aria-label=\"更多操作\" title=\"更多操作\" aria-expanded=\"false\" aria-haspopup=\"menu\" aria-controls=\"key-action-menu\">' + icons.more + '</button></div></td></tr>'\n}\n\nexport function renderApiKeys() {\n closeKeyActionMenu()\n setActions('<button id=\"create-key\" class=\"button primary\">' + icons.plus + '创建 API 密钥</button>')\n const pending = state.resources.apiKeys.status === 'loading' && !state.apiKeys.length\n setResults(pending ? '<div class=\"panel\"><p class=\"panel-note\">正在加载 API 密钥…</p></div>' : state.apiKeys.length\n ? '<div class=\"table-wrap key-table-wrap\"><table class=\"key-table\"><thead><tr><th>名称</th><th>状态</th><th>API 密钥</th><th>授权范围</th><th>最后使用</th><th>操作</th></tr></thead><tbody>' + state.apiKeys.map(keyRow).join('') + '</tbody></table></div>'\n : '<div class=\"panel\">' + emptyState(icons.key, '尚未创建 API 密钥', '客户端调用网关数据接口时需要携带密钥。', { action: 'create-key', label: '创建 API 密钥' }) + '</div>')\n byId('create-key').onclick = () => openKeyDrawer()\n byId('create-key').disabled = !state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt\n announce(state.apiKeys.length + ' 个 API 密钥')\n}\n\nexport function agentScopeOption(agent, selected = false) {\n return '<label class=\"checkbox\"><input type=\"checkbox\" name=\"agentId\" value=\"' + escapeHtml(agent.id) + '\"' + checked(selected) + '><span class=\"scope-name\">' + escapeHtml(agent.name) + '</span><span class=\"muted\">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>'\n}\n\nexport function openKeyDrawer() {\n if (!state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt) { toast('配置与密钥列表尚未成功读取,请重试后创建', true); return }\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent)).join('')\n const body = '<div class=\"field\"><label for=\"f-name\">名称</label><input id=\"f-name\" name=\"name\" required autocomplete=\"off\" placeholder=\"例如:开发客户端\"></div><div class=\"field\"><label class=\"checkbox\"><input id=\"all-agents\" name=\"allAgents\" type=\"checkbox\" checked>允许访问全部智能体</label></div><div id=\"agent-scope\" class=\"agent-scope hidden\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div><div class=\"field\"><label for=\"f-customSecret\">自定义密钥(可选)</label><input id=\"f-customSecret\" name=\"customSecret\" minlength=\"16\" autocomplete=\"off\" placeholder=\"留空则自动生成 nx_sk_ 密钥\"></div>'\n openDrawer('创建 API 密钥', body, '创建 API 密钥', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const customSecret = String(data.get('customSecret') || '').trim()\n const result = await api('/v1/admin/api-keys', {\n method: 'POST',\n body: {\n name: String(data.get('name') || '').trim(),\n scope: { allAgents, agentIds },\n customSecret: customSecret || undefined\n }\n })\n state.apiKeys.unshift(result.key)\n markResourceSaved('apiKeys')\n if (isCurrent()) showSecret('API 密钥已创建', result.secret)\n else toast('API 密钥已创建,可在密钥列表中显示或复制')\n render()\n })\n const all = byId('all-agents')\n all.onchange = () => byId('agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function showSecret(title, secret) {\n openDrawer(title, '<div class=\"secret-box\"><span class=\"muted\">请立即复制;之后可在密钥列表中再次显示。</span><code class=\"secret-value\">' + escapeHtml(secret) + '</code><button class=\"button\" type=\"button\" id=\"copy-secret\">' + icons.copy + '复制</button></div>', '', null)\n byId('copy-secret').onclick = async () => {\n try {\n await copySecret(secret)\n toast('API 密钥已复制')\n } catch (error) { toast(error.message, true) }\n }\n}\n\nexport async function copySecret(secret) {\n if (navigator.clipboard && window.isSecureContext) {\n try {\n await navigator.clipboard.writeText(secret)\n return\n } catch {}\n }\n const temporary = document.createElement('textarea')\n temporary.value = secret\n temporary.setAttribute('readonly', '')\n temporary.style.position = 'fixed'\n temporary.style.opacity = '0'\n temporary.style.pointerEvents = 'none'\n const previousFocus = document.activeElement\n const dialog = byId('drawer')\n ;(dialog.classList.contains('hidden') ? document.body : dialog).appendChild(temporary)\n temporary.select()\n const copied = document.execCommand('copy')\n temporary.remove()\n previousFocus?.focus({ preventScroll: true })\n if (!copied) throw new Error('无法自动复制,请在“显示完整密钥”中手动复制。')\n}\n\nexport function openPasswordDrawer() {\n openDrawer('修改密码', '<div class=\"field\"><label for=\"f-currentPassword\">当前密码</label><input id=\"f-currentPassword\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\" required></div><div class=\"field\"><label for=\"f-newPassword\">新密码</label><input id=\"f-newPassword\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div><div class=\"field\"><label for=\"f-confirmPassword\">确认新密码</label><input id=\"f-confirmPassword\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div>', '修改密码', async (form) => {\n const data = new FormData(form)\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword: String(data.get('currentPassword') || ''), newPassword: String(data.get('newPassword') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n closeDrawer()\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n })\n}\n\nexport function openKeyScopeDrawer(key) {\n const selectedIds = new Set(key.scope.agentIds || [])\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent, selectedIds.has(agent.id))).join('')\n const body = '<p class=\"field-help\">缩小授权范围会立即拒绝受影响的新请求并关闭对应 SSE;正在执行的任务会继续。</p><div class=\"field\"><label class=\"checkbox\"><input id=\"edit-all-agents\" name=\"allAgents\" type=\"checkbox\"' + checked(key.scope.allAgents) + '>允许访问全部智能体</label></div><div id=\"edit-agent-scope\" class=\"agent-scope' + (key.scope.allAgents ? ' hidden' : '') + '\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div>'\n openDrawer('编辑 API 密钥授权范围', body, '保存授权范围', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const narrowed = key.scope.allAgents && !allAgents || !key.scope.allAgents && !allAgents && (key.scope.agentIds || []).some((agentId) => !agentIds.includes(agentId))\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast(narrowed ? 'API 密钥授权范围已更新;受影响 SSE 已关闭,正在执行的任务会继续' : 'API 密钥授权范围已更新')\n })\n const all = byId('edit-all-agents')\n all.onchange = () => byId('edit-agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function openRenameKeyDrawer(key) {\n openDrawer('重命名 API 密钥', '<div class=\"field\"><label for=\"f-key-name\">名称</label><input id=\"f-key-name\" name=\"name\" value=\"' + escapeHtml(key.name) + '\" required autocomplete=\"off\"></div>', '保存名称', async (form, isCurrent) => {\n const name = String(new FormData(form).get('name') || '').trim()\n if (name === key.name) { closeDrawer(); return }\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { name } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast('API 密钥已重命名')\n })\n const input = byId('f-key-name')\n if (input) setTimeout(() => input.select(), 0)\n}\n\nexport function applyApiKey(key) {\n state.apiKeys = state.apiKeys.map((current) => current.id === key.id ? key : current)\n markResourceSaved('apiKeys')\n render()\n}\n\nexport function handleKeyMenuKeydown(event) {\n if (keyActionMenu.classList.contains('hidden')) return\n if (event.key === 'Escape' || event.key === 'Tab') {\n if (event.key === 'Escape') event.preventDefault()\n closeKeyActionMenu(true)\n return\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return\n event.preventDefault()\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n const index = items.indexOf(document.activeElement)\n const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :\n (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length\n items.forEach((item, position) => { item.tabIndex = position === next ? 0 : -1 })\n items[next].focus({ preventScroll: true })\n}\n\nexport function toggleKeyActionMenu(anchor, id) {\n if (!keyActionMenu.classList.contains('hidden') && keyActionMenu.dataset.keyId === id) {\n closeKeyActionMenu(true)\n return\n }\n closeKeyActionMenu()\n menuAnchor = anchor\n keyActionMenu.dataset.keyId = id\n keyActionMenu.innerHTML =\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"reveal\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.reveal + '<span>显示完整密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"scope\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.scope + '<span>编辑授权范围</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"regenerate\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.regenerate + '<span>重新生成密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" class=\"danger-text\" data-key-action=\"delete\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.trash + '<span>删除密钥</span></button>'\n keyActionMenu.classList.remove('hidden')\n anchor.setAttribute('aria-expanded', 'true')\n const anchorRect = anchor.getBoundingClientRect()\n const menuRect = keyActionMenu.getBoundingClientRect()\n const left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, anchorRect.right - menuRect.width))\n const below = anchorRect.bottom + 6\n const top = below + menuRect.height <= window.innerHeight - 8\n ? below\n : Math.max(8, anchorRect.top - menuRect.height - 6)\n keyActionMenu.style.left = left + 'px'\n keyActionMenu.style.top = top + 'px'\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n items.forEach((item, index) => { item.tabIndex = index === 0 ? 0 : -1 })\n items[0]?.focus({ preventScroll: true })\n}\n\nexport function closeKeyActionMenu(restore = false) {\n keyActionMenu.classList.add('hidden')\n keyActionMenu.innerHTML = ''\n keyActionMenu.dataset.keyId = ''\n document.querySelectorAll('[data-key-menu][aria-expanded=\"true\"]').forEach((button) => button.setAttribute('aria-expanded', 'false'))\n if (restore === true && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true })\n menuAnchor = null\n}\n",
|
|
15
|
-
"render-overview": "import { state } from './state.js'\nimport { byId, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\nimport { localizeError } from './api.js'\nimport { withBusy } from './toast.js'\n\nimport { refreshReadiness, retryResource } from './data.js'\nimport { formatDate, runStatusLabel } from './format.js'\n\nimport { setResults, setActions, announce, stat, emptyState } from './render-shared.js'\nimport { readinessRows, statusMarkup } from './render-agents.js'\n\nexport function overviewAgent(agent) {\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(agent.name) + '</strong><small>' +\n escapeHtml(agent.protocol.toUpperCase() + (agent.driver ? ' · ' + agent.driver : '')) +\n (agent.error ? ' · ' + escapeHtml(localizeError(agent.error)) : '') + '</small></div><div class=\"row-actions\">' +\n '<button class=\"button small\" data-agent-edit=\"' + escapeHtml(agent.id) + '\">编辑</button><button class=\"button small\" data-agent-diagnostics=\"' + escapeHtml(agent.id) + '\">连接诊断</button>' + statusMarkup(agent) + '</div></div>'\n}\n\nexport function renderOverview() {\n const agents = readinessRows()\n const enabled = agents.filter((agent) => agent.enabled)\n const ready = enabled.filter((agent) => agent.ready).length\n const checking = agents.filter((agent) => agent.enabled && agent.checking).length\n const attention = agents.filter((agent) => agent.enabled && !agent.checking && (!agent.ready || agent.error))\n const overviewFailed = state.resources.overview.status === 'error'\n const configPending = ['idle', 'loading'].includes(state.resources.config.status)\n const metrics = state.metrics || {}\n const storage = metrics.storageMetrics\n const recent = (metrics.recentRuns || []).filter((run) => ['failed', 'input_required', 'permission_required'].includes(run.state)).slice(0, 5)\n setActions('<button id=\"refresh-overview\" class=\"button\">' + icons.refresh + '刷新</button>')\n const body = configPending && agents.length === 0\n ? '<p class=\"panel-note\">正在加载网关配置…</p>'\n : !state.resources.config.lastSuccessAt\n ? '<p class=\"panel-note\">配置读取失败,请使用上方重试入口恢复。</p>'\n : agents.length === 0\n ? emptyState(icons.robot, '尚未配置智能体', '', { action: 'add-agent', label: '添加智能体' })\n : attention.length\n ? '<div class=\"panel-body overview-list\">' + attention.map(overviewAgent).join('') + '</div>'\n : '<p class=\"panel-note\">' + (overviewFailed ? '探测刷新失败,以下状态可能已过期。' : checking ? '正在检查 ' + checking + ' 个智能体。' : enabled.length === 0 ? '全部智能体已停用,未执行探测。' : '全部启用的智能体均可用。') + '</p>'\n setResults('<div class=\"stats\">' +\n stat('智能体', agents.length) +\n stat('启用', enabled.length) +\n stat('异常', attention.length) +\n stat('活动任务', metrics.runs?.active ?? '—') +\n '</div>' +\n '<div class=\"panel\"><div class=\"panel-header\"><h2>智能体状态</h2><span class=\"muted\">' + (attention.length ? attention.length + ' 个需要注意' : ready + ' 个可用') + '</span></div>' +\n body +\n '</div><div class=\"stats overview-metrics\">' +\n stat('现有会话', metrics.sessions ?? (state.resources.overview.lastSuccessAt ? state.sessions : '—')) +\n stat('SSE 连接', metrics.activeSse ?? '—') +\n stat('内存 RSS', metrics.rssBytes === undefined ? '—' : Math.round(metrics.rssBytes / 1024 / 1024) + ' MiB') +\n stat('配额拒绝', metrics.quotaRejections ?? '—') +\n '</div>' + (storage ? '<div class=\"stats overview-storage\">' +\n stat('历史文件', (storage.historyBytes / 1024 / 1024).toFixed(1) + ' MiB') +\n stat('保留记录', storage.retainedRuns) +\n stat('待写入', storage.pendingWrites) +\n stat('历史写入失败', storage.writeFailures) + '</div>' : '') +\n '<div class=\"panel\"><div class=\"panel-header\"><h2>失败与等待处理</h2><span class=\"muted\">' + escapeHtml(state.resources.metrics.lastSuccessAt ? formatDate(state.resources.metrics.lastSuccessAt) : '统计尚未读取') + '</span></div>' +\n (recent.length ? '<div class=\"panel-body\">' + recent.map((run) => '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(run.agentName) + '</strong><small>' + escapeHtml(runStatusLabel(run.state) + ' · ' + (run.taskPreview || '')) + '</small></div><button class=\"button small\" data-run-detail=\"' + escapeHtml(run.id) + '\">查看详情</button></div>').join('') + '</div>' : '<p class=\"panel-note\">' + (state.resources.metrics.lastSuccessAt ? '没有最近失败或等待处理的任务。' : '正在读取任务统计…') + '</p>') + '</div>')\n byId('refresh-overview').onclick = (event) => withBusy(event.currentTarget, async () => {\n await Promise.allSettled([refreshReadiness(true), retryResource('metrics')])\n })\n announce(agents.length + ' 个智能体,' + enabled.length + ' 个启用,' + attention.length + ' 个需要注意')\n}\n",
|
|
16
|
-
"render-runs": "import { state } from './state.js'\nimport { byId, pageStats, drawerForm, escapeHtml, selected } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { withBusy, toast, runAction } from './toast.js'\nimport { openDrawer, getDrawerVersion, openConfirmDrawer } from './drawer.js'\nimport { copySecret } from './render-keys.js'\nimport { writeLocationState } from './location-state.js'\n\nimport { refreshRuns } from './data.js'\nimport { shortId, formatDate, formatDuration, runStatusLabel } from './format.js'\n\nimport { setResults, setActions, setToolbar, announce, stat, emptyState, bindSearch } from './render-shared.js'\n\nlet runSearchTimer\nlet activeRunDrawer = null\nexport function resetRunView() { clearTimeout(runSearchTimer); activeRunDrawer = null }\n\nexport function runStatus(value) {\n const style = value === 'completed' ? ' completed' : value === 'failed' ? ' failed' : value === 'running' ? ' running' : (value === 'input_required' || value === 'permission_required') ? ' waiting' : ' canceled'\n return '<span class=\"status' + style + '\">' + escapeHtml(runStatusLabel(value)) + '</span>'\n}\n\nexport function runTaskPreview(run) {\n return run.taskPreview || run.task || '—'\n}\n\nexport function runCard(run) {\n const waiting = run.state === 'input_required' || run.state === 'permission_required'\n const progress = run.progress || {}\n return '<article class=\"run-card' + (waiting ? ' waiting' : '') + '\"><div class=\"run-card-head\"><div class=\"run-card-agent\"><span class=\"badge\">' + escapeHtml((run.protocol || '').toUpperCase()) + '</span><strong>' + escapeHtml(run.agentName) + '</strong></div>' + runStatus(run.state) + '</div><div class=\"run-task\">' + escapeHtml(runTaskPreview(run)) + '</div>' +\n (progress.phase || progress.message ? '<div class=\"run-progress\">' + (progress.phase ? '<strong>' + escapeHtml(progress.phase) + '</strong>' : '') + (progress.message ? '<small>' + escapeHtml(progress.message) + '</small>' : '') + '</div>' : '') +\n '<div class=\"run-meta\"><span>' + escapeHtml(shortId(run.id)) + '</span><span>' + escapeHtml(formatDuration(run)) + '</span></div>' + runDetailButton(run) + '</article>'\n}\n\nexport function runDetailButton(run) {\n return '<button type=\"button\" class=\"button small run-detail-button\" data-run-detail=\"' + escapeHtml(run.id) + '\" aria-label=\"查看运行详情:' + escapeHtml(runTaskPreview(run)) + '\">查看详情</button>'\n}\n\nexport function runRow(run) {\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(run.agentName) + '</strong><small>' + escapeHtml((run.protocol || '').toUpperCase()) + '</small></div></td><td class=\"run-task-cell\"><strong>' + escapeHtml(runTaskPreview(run)) + '</strong><small class=\"run-id\">' + escapeHtml(shortId(run.id)) + '</small></td><td class=\"run-task-cell\"><strong>' + escapeHtml(run.resultSummary || run.error || '—') + '</strong><small>' + escapeHtml(run.progress && run.progress.phase || '') + '</small></td><td>' + runStatus(run.state) + '</td><td class=\"num\">' + escapeHtml(formatDate(run.startedAt)) + '</td><td class=\"num\">' + escapeHtml(formatDuration(run)) + '</td><td>' + runDetailButton(run) + '</td></tr>'\n}\n\nexport function renderRuns() {\n writeLocationState(state)\n const pageSize = state.runPageSize || 50\n const activeStates = new Set(['running', 'input_required', 'permission_required'])\n const filtered = state.runs.filter((run) => {\n const haystack = (runTaskPreview(run) + ' ' + (run.agentName || '') + ' ' + run.id).toLowerCase()\n // The server searches the retained full task. A list response may only\n // contain taskPreview, so do not hide a server-matched row locally.\n const localSearchMatches = !state.runSearch || !run.task && run.taskPreview || haystack.indexOf(state.runSearch.toLowerCase()) >= 0\n return localSearchMatches &&\n (state.runAgent === 'all' || run.agentId === state.runAgent) &&\n (state.runStatus === 'all' || run.state === state.runStatus)\n })\n const active = filtered.filter((run) => activeStates.has(run.state))\n const history = filtered.filter((run) => !activeStates.has(run.state))\n const agentOptions = state.config.agents.map((agent) => '<option value=\"' + escapeHtml(agent.id) + '\"' + selected(state.runAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')\n setActions('<button id=\"refresh-runs\" class=\"button\">' + icons.refresh + '刷新</button>')\n byId('refresh-runs').onclick = (event) => withBusy(event.currentTarget, () => refreshRuns(true))\n\n if (!state.runs.length && state.resources.runs.status === 'loading') {\n pageStats.innerHTML = ''\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载运行记录…</p></div>')\n announce('正在加载运行记录')\n return\n }\n\n if (!state.runs.length && !state.runSearch && state.runAgent === 'all' && state.runStatus === 'all' && !state.runOffset) {\n pageStats.innerHTML = ''\n setToolbar('')\n setResults('<div class=\"panel\">' +\n emptyState(icons.activity, '暂无运行记录', '客户端通过网关数据接口发起任务后,进度与结果会出现在这里。') +\n '</div>')\n announce('暂无运行记录')\n return\n }\n\n pageStats.innerHTML =\n '<div class=\"run-summary\">' +\n stat('当前运行', state.runStats.active) +\n stat('已完成', state.runStats.completed) +\n stat('失败', state.runStats.failed) +\n stat('记录总数', state.runTotal) +\n '</div>'\n const hasFilters = Boolean(state.runSearch || state.runAgent !== 'all' || state.runStatus !== 'all')\n const pageSizes = [50, 100, 200].map((size) => '<option value=\"' + size + '\"' + selected(pageSize, size) + '>' + size + ' 条/页</option>').join('')\n setToolbar('runs|' + pageSize + '|' + JSON.stringify(state.config.agents.map((agent) => [agent.id, agent.name])),\n '<div class=\"toolbar\"><div class=\"search-box\">' + icons.search + '<input id=\"run-search\" type=\"search\" aria-label=\"搜索运行记录\" placeholder=\"搜索任务、智能体或运行 ID\" value=\"' + escapeHtml(state.runSearch) + '\"></div>' +\n '<select id=\"run-agent-filter\" aria-label=\"按智能体筛选\"><option value=\"all\">全部智能体</option>' + agentOptions + '</select>' +\n '<select id=\"run-status-filter\" aria-label=\"按运行状态筛选\"><option value=\"all\">全部状态</option><option value=\"running\"' + selected(state.runStatus, 'running') + '>运行中</option><option value=\"input_required\"' + selected(state.runStatus, 'input_required') + '>等待输入</option><option value=\"permission_required\"' + selected(state.runStatus, 'permission_required') + '>等待授权</option><option value=\"completed\"' + selected(state.runStatus, 'completed') + '>已完成</option><option value=\"failed\"' + selected(state.runStatus, 'failed') + '>失败</option><option value=\"canceled\"' + selected(state.runStatus, 'canceled') + '>已取消</option></select>' +\n '<select id=\"run-page-size\" aria-label=\"每页运行记录数\">' + pageSizes + '</select><button class=\"button small\" id=\"clear-run-filters\">清除筛选</button></div>')\n byId('clear-run-filters').hidden = !hasFilters\n const searchInput = byId('run-search')\n if (!searchInput.dataset.composing && searchInput.value !== state.runSearch) searchInput.value = state.runSearch\n byId('run-agent-filter').value = state.runAgent\n byId('run-status-filter').value = state.runStatus\n const first = state.runTotal ? state.runOffset + 1 : 0\n const last = Math.min(state.runOffset + pageSize, state.runTotal)\n setResults((state.runHasNew ? '<p class=\"panel-note\"><button class=\"button small\" id=\"runs-show-new\">有新任务,返回首页查看</button></p>' : '') + '<div class=\"run-pagination\"><span class=\"muted\">在已保留的全部记录中筛选 · 显示 ' + first + '–' + last + ',共 ' + state.runTotal + ' 条 · 第 ' + (Math.floor(state.runOffset / pageSize) + 1) + ' 页</span><div class=\"row-actions\"><button class=\"button small\" id=\"runs-prev\"' + (state.runOffset ? '' : ' disabled') + '>上一页</button><button class=\"button small\" id=\"runs-next\"' + (state.runOffset + pageSize < state.runTotal ? '' : ' disabled') + '>下一页</button></div></div>' +\n '<section class=\"run-section\"><div class=\"run-section-title\"><h2>当前运行</h2><span>' + active.length + ' 项</span></div>' +\n (active.length ? '<div class=\"run-live-grid\">' + active.map(runCard).join('') + '</div>' : '<div class=\"panel\">' + emptyState(icons.activity, '当前没有任务') + '</div>') +\n '</section>' +\n '<section class=\"run-section\"><div class=\"run-section-title\"><h2>历史记录</h2><span>' + history.length + ' 项</span></div>' +\n '<div class=\"table-wrap\"><table class=\"run-table\"><thead><tr><th>智能体</th><th>任务</th><th>结果</th><th>状态</th><th class=\"num\">开始时间</th><th class=\"num\">耗时</th><th>操作</th></tr></thead><tbody>' +\n (history.length ? history.map(runRow).join('') : '<tr><td colspan=\"7\" class=\"empty\">没有符合条件的历史记录。</td></tr>') +\n '</tbody></table></div></section>')\n const filterChanged = () => {\n state.runOffset = 0\n state.runHasNew = false\n clearTimeout(runSearchTimer)\n renderRuns()\n runSearchTimer = setTimeout(() => { if (state.authenticated) void refreshRuns(false) }, 250)\n }\n bindSearch('run-search', (value) => { state.runSearch = value; filterChanged() })\n byId('run-agent-filter').onchange = (event) => { state.runAgent = event.target.value; filterChanged() }\n byId('run-status-filter').onchange = (event) => { state.runStatus = event.target.value; filterChanged() }\n byId('run-page-size').onchange = (event) => { state.runPageSize = Math.min(200, Math.max(50, Number(event.target.value) || 50)); state.runOffset = 0; void refreshRuns(true) }\n byId('clear-run-filters').onclick = () => {\n clearTimeout(runSearchTimer)\n state.runSearch = ''\n state.runAgent = 'all'\n state.runStatus = 'all'\n state.runOffset = 0\n renderRuns()\n void refreshRuns(true)\n }\n byId('runs-prev').onclick = (event) => withBusy(event.currentTarget, async () => { state.runOffset = Math.max(0, state.runOffset - pageSize); await refreshRuns(true) })\n byId('runs-next').onclick = (event) => withBusy(event.currentTarget, async () => { state.runOffset += pageSize; await refreshRuns(true) })\n if (byId('runs-show-new')) byId('runs-show-new').onclick = () => { state.runOffset = 0; state.runHasNew = false; void refreshRuns(true) }\n announce(active.length + ' 项当前运行,' + history.length + ' 项历史记录')\n}\n\nexport function detailItem(label, value) {\n return '<div class=\"run-detail-item\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n}\n\nexport async function openRunDrawer(id) {\n openDrawer('运行详情', '<p role=\"status\">正在加载运行详情…</p>', '', null)\n state.selectedRunId = id\n writeLocationState(state)\n drawerForm.setAttribute('aria-busy', 'true')\n activeRunDrawer = { id, version: getDrawerVersion(), busy: false, html: '', revision: 0, actionBusy: false }\n await refreshOpenRunDrawer()\n}\n\nexport async function refreshOpenRunDrawer() {\n const target = activeRunDrawer\n if (!target || target.version !== getDrawerVersion() || target.busy || target.actionBusy) return\n const { id, version, revision } = target\n target.busy = true\n try {\n const run = await api('/v1/admin/runs/' + encodeURIComponent(id))\n if (version !== getDrawerVersion() || revision !== target.revision) return\n renderRunDetail(target, run)\n drawerForm.querySelector('[data-detail-error]').textContent = ''\n } catch (error) {\n if (version !== getDrawerVersion() || revision !== target.revision) return\n let warning = drawerForm.querySelector('[data-detail-error]')\n if (!warning) {\n warning = document.createElement('p')\n warning.dataset.detailError = ''\n warning.className = 'form-error'\n warning.setAttribute('role', 'status')\n drawerForm.prepend(warning)\n }\n warning.textContent = '详情刷新失败:' + error.message\n } finally {\n target.busy = false\n if (version === getDrawerVersion()) drawerForm.removeAttribute('aria-busy')\n }\n}\n\nfunction updateDetailText(element, value) {\n const text = String(value ?? '—')\n const old = element.textContent\n if (text === old) return\n if (element.childNodes.length !== 1 || element.firstChild.nodeType !== Node.TEXT_NODE) {\n element.replaceChildren(document.createTextNode(text))\n return\n }\n let prefix = 0\n while (prefix < old.length && prefix < text.length && old[prefix] === text[prefix]) prefix++\n element.firstChild.replaceData(prefix, old.length - prefix, text.slice(prefix))\n}\n\nfunction renderRunDetail(target, run) {\n if (!drawerForm.querySelector('[data-run-detail-body]')) {\n drawerForm.innerHTML = '<div data-run-detail-body>' +\n '<p class=\"field-help\">管理员可介入任意客户端的任务;操作会记录审计日志。重试使用新会话,旧记录保留。</p>' +\n '<div class=\"run-detail-actions\"><button type=\"button\" class=\"button small\" data-run-copy=\"id\">复制运行 ID</button><button type=\"button\" class=\"button small\" data-run-copy=\"output\">复制结果</button><button type=\"button\" class=\"button small\" data-run-copy=\"error\">复制错误</button><button type=\"button\" class=\"button small danger\" data-run-cancel>取消任务</button><button type=\"button\" class=\"button small\" data-run-retry>新会话重试</button><button type=\"button\" class=\"button small\" data-run-refresh>刷新</button></div>' +\n '<p class=\"field-help\" data-controls-reason></p><p class=\"form-error\" data-detail-error role=\"status\"></p>' +\n '<div class=\"run-detail-grid\">' +\n [['agent','智能体'],['state','状态'],['startedAt','开始时间'],['duration','耗时'],['protocol','协议'],['id','运行 ID'],['phase','当前阶段'],['attachments','输入附件'],['retry','重试来源']]\n .map(([field,label]) => '<div class=\"run-detail-item\"><span>' + label + '</span><strong data-detail-field=\"' + field + '\"></strong></div>').join('') +\n '</div><div data-pending-container></div>' +\n '<div class=\"run-detail-block\"><h3>用户任务</h3><pre class=\"run-detail-code\" data-detail-task></pre></div>' +\n '<div class=\"run-detail-block\" data-progress-block><h3>最近进度</h3><pre class=\"run-detail-code\" data-detail-progress></pre></div>' +\n '<div class=\"run-detail-block\" data-output-block><div class=\"run-output-heading\"><h3>智能体结果</h3><label class=\"checkbox\"><input type=\"checkbox\" data-follow-output>跟随最新</label></div><pre class=\"run-detail-code\" data-detail-output></pre></div>' +\n '<div class=\"run-detail-block\" data-error-block><h3>错误</h3><pre class=\"run-detail-code\" data-detail-failure></pre></div>' +\n '<div class=\"run-detail-block\"><h3>产物</h3><div data-detail-artifacts></div></div></div>'\n }\n byId('drawer-title').textContent = '运行详情 · ' + run.agentName\n const fields = { agent: run.agentName, state: runStatusLabel(run.state), startedAt: formatDate(run.startedAt), duration: formatDuration(run), protocol: run.protocol.toUpperCase(), id: run.id, phase: run.progress?.phase || '—', attachments: run.inputAttachmentCount || 0, retry: run.retryOfRunId || '—' }\n for (const [key,value] of Object.entries(fields)) updateDetailText(drawerForm.querySelector('[data-detail-field=\"' + key + '\"]'), value)\n updateDetailText(drawerForm.querySelector('[data-detail-task]'), (run.task || run.taskPreview || '—') + (run.taskTruncated ? '\\n\\n[记录已截断]' : ''))\n for (const [name,value] of [['progress',run.progress?.message],['output',run.output],['error',run.error]]) {\n drawerForm.querySelector('[data-' + name + '-block]').hidden = !value\n updateDetailText(drawerForm.querySelector(name === 'error' ? '[data-detail-failure]' : '[data-detail-' + name + ']'), value || '')\n }\n const output = drawerForm.querySelector('[data-detail-output]')\n if (drawerForm.querySelector('[data-follow-output]').checked) output.scrollTop = output.scrollHeight\n const artifactLabels = { pending: '保存中', metadata_only: '仅元数据', expired: '已过期', evicted: '已按容量清理', failed: '保存失败' }\n const artifactHtml = run.artifacts?.length ? run.artifacts.map((artifact) =>\n '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(artifact.name || artifact.filename || artifact.id || '未命名产物') + '</strong><small>' +\n escapeHtml([artifact.mediaType, artifact.size === undefined ? '' : artifact.size + ' 字节', artifact.downloadable ? '可下载' : artifactLabels[artifact.storageStatus] || '仅元数据'].filter(Boolean).join(' · ')) +\n '</small></div>' + (artifact.downloadable && artifact.id ? '<a class=\"button small\" download href=\"/v1/admin/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(artifact.id) + '\">下载</a>' : '') + '</div>'\n ).join('') : '<p class=\"field-help\">暂无产物</p>'\n const artifacts = drawerForm.querySelector('[data-detail-artifacts]')\n if (artifacts.dataset.signature !== artifactHtml) { artifacts.innerHTML = artifactHtml; artifacts.dataset.signature = artifactHtml }\n const controls = run.controls || {}\n const cancel = drawerForm.querySelector('[data-run-cancel]')\n const retry = drawerForm.querySelector('[data-run-retry]')\n cancel.disabled = !controls.canCancel\n retry.disabled = !controls.canRetry\n updateDetailText(drawerForm.querySelector('[data-controls-reason]'), controls.unavailableReason || '')\n drawerForm.querySelector('[data-run-refresh]').onclick = () => { void refreshOpenRunDrawer() }\n cancel.onclick = () => confirmRunAction(run, 'cancel')\n retry.onclick = () => confirmRunAction(run, 'retry')\n for (const button of drawerForm.querySelectorAll('[data-run-copy]')) {\n const value = run[button.dataset.runCopy]\n button.disabled = !value\n button.onclick = () => runAction(async () => { await copySecret(String(value)); toast('已复制') })\n }\n renderPendingControls(target, controls.pendingRequest)\n}\n\nfunction confirmRunAction(run, action) {\n const retry = action === 'retry'\n openConfirmDrawer(retry ? '新会话重试' : '取消任务',\n retry ? '将用原任务文本创建新的会话,使用当前智能体配置。旧记录保留;任务已经产生的外部操作可能重复执行。' : '取消当前任务并释放智能体会话。已经完成的文件或外部操作不会撤回。',\n retry ? '新会话重试' : '取消任务', async (isCurrent) => {\n const result = await api('/v1/admin/runs/' + encodeURIComponent(run.id) + '/' + action, { method: 'POST', body: {} })\n toast(retry ? '已创建重试任务' : '任务已取消')\n void refreshRuns(false)\n if (isCurrent()) await openRunDrawer(retry ? result.runId : run.id)\n })\n}\n\nfunction renderPendingControls(target, pending) {\n const container = drawerForm.querySelector('[data-pending-container]')\n if (!pending) { container.replaceChildren(); delete container.dataset.requestId; return }\n if (container.dataset.requestId === pending.id) return\n container.dataset.requestId = pending.id\n const options = pending.options || []\n container.innerHTML = '<section class=\"run-pending\"><h3>' + (pending.kind === 'permission' ? '等待授权' : '等待补充输入') + '</h3><p class=\"run-pending-prompt\">' + escapeHtml(pending.prompt) + '</p>' +\n (options.length ? '<div class=\"field\"><label for=\"run-response-option\">选择操作</label><select id=\"run-response-option\"><option value=\"\">请选择,不会自动授权</option>' + options.map((option) => '<option value=\"' + escapeHtml(option.id) + '\">' + escapeHtml(option.name + (option.kind?.includes('always') ? '(永久)' : '')) + '</option>').join('') + '</select></div>' : '') +\n (pending.kind === 'input' ? '<div class=\"field\"><label for=\"run-response-message\">补充信息</label><textarea id=\"run-response-message\" rows=\"4\"></textarea></div>' : '') +\n '<div class=\"row-actions\"><button type=\"button\" class=\"button primary\" data-run-respond>提交' + (pending.kind === 'permission' ? '授权选择' : '补充信息') + '</button><button type=\"button\" class=\"button\" data-run-decline>拒绝</button></div><p class=\"form-error\" data-response-error role=\"alert\"></p></section>'\n const submit = async (decline) => {\n if (target.actionBusy) return\n const error = container.querySelector('[data-response-error]')\n error.textContent = ''\n const optionId = container.querySelector('#run-response-option')?.value || ''\n const message = container.querySelector('#run-response-message')?.value || ''\n if (!decline && pending.kind === 'permission' && !optionId) { error.textContent = '请选择要授权或拒绝的操作'; return }\n if (!decline && pending.kind === 'input' && !optionId && !message.trim()) { error.textContent = '请输入补充信息或选择操作'; return }\n target.actionBusy = true\n target.revision++\n container.querySelectorAll('button').forEach((button) => { button.disabled = true })\n try {\n await api('/v1/admin/runs/' + encodeURIComponent(target.id) + '/respond', {\n method: 'POST', body: { requestId: pending.id, ...(decline ? { action: 'decline' } : { action: 'accept', ...(optionId ? { optionId } : {}), ...(message ? { message } : {}) }) }\n })\n toast('已提交,等待智能体继续执行')\n if (target.version === getDrawerVersion()) { container.replaceChildren(); delete container.dataset.requestId }\n } catch (reason) {\n if (target.version === getDrawerVersion()) error.textContent = reason.message\n else toast(reason.message, true)\n } finally {\n target.actionBusy = false\n container.querySelectorAll('button').forEach((button) => { button.disabled = false })\n if (target.version === getDrawerVersion()) void refreshOpenRunDrawer()\n }\n }\n container.querySelector('[data-run-respond]').onclick = () => { void submit(false) }\n container.querySelector('[data-run-decline]').onclick = () => { void submit(true) }\n}\n",
|
|
17
|
-
"render-settings": "import { state } from './state.js'\nimport { byId, escapeHtml, selected } from './dom.js'\n\nimport { api } from './api.js'\nimport { toast, withBusy } from './toast.js'\n\nimport { showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { markResourceSaved } from './data.js'\n\nimport { setResults, setActions, preciseUnitValue } from './render-shared.js'\n\nexport function runtimeValue(data, name, unit) {\n const value = Number(data.get(name))\n if (!Number.isFinite(value)) throw new Error('请填写有效的运行参数')\n return Math.round(value * unit)\n}\n\nexport function renderSettings() {\n const existingForm = byId('settings-form')\n const existingPasswordForm = byId('password-form')\n if (existingForm && existingPasswordForm) {\n existingForm.refreshRuntime?.()\n return\n }\n const initialRuntime = {\n sessionTtlMs: Number(state.config.sessionTtlMs || 24 * 60 * 60 * 1000),\n promptTimeoutMs: Number(state.config.promptTimeoutMs || 30 * 60 * 1000),\n cleanupIntervalMs: Number(state.config.cleanupIntervalMs || 60_000)\n }\n const runtimeUnavailable = !state.resources.config.lastSuccessAt\n const configLoading = runtimeUnavailable\n const sessionTtlHours = preciseUnitValue(initialRuntime.sessionTtlMs, 3_600_000, 24)\n const promptTimeoutMinutes = preciseUnitValue(initialRuntime.promptTimeoutMs, 60_000, 30)\n const cleanupIntervalSeconds = preciseUnitValue(initialRuntime.cleanupIntervalMs, 1000, 60)\n const initialRuntimeInput = { sessionTtlHours, promptTimeoutMinutes, cleanupIntervalSeconds }\n const theme = localStorage.getItem('agent-nexus-theme') || 'system'\n setActions('<button id=\"settings-save\" type=\"submit\" form=\"settings-form\" class=\"button primary\"' + (configLoading ? ' disabled' : '') + '>保存更改</button><button id=\"password-save\" type=\"submit\" form=\"password-form\" class=\"button\">保存密码</button>')\n setResults('<form id=\"settings-form\" class=\"settings-layout\">' +\n '<section class=\"settings-section settings-runtime\"><div class=\"settings-section-heading\"><h2>运行参数</h2>' +\n '<p class=\"settings-note\">写入网关配置文件,对所有客户端生效;会话 TTL 与清理周期立即由会话管理器采用,ACP 任务超时由新建 Session 采用。</p></div>' +\n '<div class=\"settings-fields\">' +\n '<div class=\"field\"><label for=\"session-ttl-hours\">会话空闲有效期(小时)</label><input id=\"session-ttl-hours\" name=\"sessionTtlHours\" type=\"number\" min=\"0.0166667\" max=\"720\" step=\"any\" value=\"' + escapeHtml(sessionTtlHours) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">无活动超过该时长后释放会话,支持小数小时以保留分钟级设置。默认 24 小时。</small></div>' +\n '<div class=\"field\"><label for=\"prompt-timeout-minutes\">单次 ACP 任务超时(分钟)</label><input id=\"prompt-timeout-minutes\" name=\"promptTimeoutMinutes\" type=\"number\" min=\"0.1666667\" max=\"1440\" step=\"any\" value=\"' + escapeHtml(promptTimeoutMinutes) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">超时后任务记为失败并释放资源,支持小数分钟以保留秒级设置。默认 30 分钟。</small></div>' +\n '<div class=\"field\"><label for=\"cleanup-interval-seconds\">清理任务周期(秒)</label><input id=\"cleanup-interval-seconds\" name=\"cleanupIntervalSeconds\" type=\"number\" min=\"5\" max=\"3600\" step=\"0.001\" value=\"' + escapeHtml(cleanupIntervalSeconds) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">扫描空闲会话与过期记录的间隔,支持毫秒级设置。默认 60 秒。</small></div>' +\n '</div>' +\n '<p class=\"settings-note\" data-runtime-loading role=\"status\"' + (configLoading ? '' : ' hidden') + '>运行参数尚未成功读取,暂不能保存默认值;可单独修改控制台密码。</p>' +\n '<p class=\"form-error settings-form-error\" data-form-error role=\"alert\"></p>' +\n '</section></form>' +\n '<section class=\"settings-section settings-appearance\"><div class=\"settings-section-heading\"><h2>外观</h2>' +\n '<p class=\"settings-note\">仅影响当前浏览器。</p></div>' +\n '<div class=\"settings-fields\"><div class=\"field\"><label for=\"theme-select\">界面主题</label>' +\n '<select id=\"theme-select\"><option value=\"system\"' + selected(theme, 'system') + '>跟随系统</option><option value=\"light\"' + selected(theme, 'light') + '>浅色</option><option value=\"dark\"' + selected(theme, 'dark') + '>深色</option></select>' +\n '</div></div></section>' +\n '<form id=\"password-form\" class=\"settings-layout settings-password-layout\">' +\n '<section class=\"settings-section settings-account\"><div class=\"settings-section-heading\"><h2>账户</h2></div>' +\n '<div class=\"settings-account-row\"><div class=\"settings-account-copy\"><strong>控制台密码</strong><p>用于登录当前管理控制台。</p></div>' +\n '<div class=\"settings-account-fields\"><div class=\"field\"><label for=\"settings-current-password\">当前密码</label><input id=\"settings-current-password\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-new-password\">新密码</label><input id=\"settings-new-password\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-confirm-password\">确认新密码</label><input id=\"settings-confirm-password\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div></div></div>' +\n '</section>' +\n '<p class=\"form-error settings-password-error\" data-password-error role=\"alert\"></p>' +\n '</form>')\n const form = byId('settings-form')\n const passwordForm = byId('password-form')\n // Update only the untouched runtime fields. Password entry, focus and IME\n // composition must survive a late configuration response or retry.\n form.refreshRuntime = () => {\n const save = byId('settings-save')\n if (save.getAttribute('aria-busy') === 'true') return\n const unavailable = !state.resources.config.lastSuccessAt\n save.disabled = unavailable\n if (form.dataset.dirty === 'true') return\n const fields = [\n ['sessionTtlHours', 'sessionTtlMs', 3_600_000, 86_400_000],\n ['promptTimeoutMinutes', 'promptTimeoutMs', 60_000, 1_800_000],\n ['cleanupIntervalSeconds', 'cleanupIntervalMs', 1000, 60_000]\n ]\n for (const [name, key, unit, fallback] of fields) {\n initialRuntime[key] = Number(state.config[key] ?? fallback)\n initialRuntimeInput[name] = preciseUnitValue(initialRuntime[key], unit, fallback)\n const input = form.elements.namedItem(name)\n if (input.value !== initialRuntimeInput[name]) input.value = initialRuntimeInput[name]\n input.disabled = unavailable\n }\n form.querySelector('[data-runtime-loading]').hidden = !unavailable\n }\n const markDirty = () => { form.dataset.dirty = 'true' }\n const markPasswordDirty = () => { passwordForm.dataset.dirty = 'true' }\n form.addEventListener('input', markDirty)\n form.addEventListener('change', markDirty)\n passwordForm.addEventListener('input', markPasswordDirty)\n passwordForm.addEventListener('change', markPasswordDirty)\n form.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n let values\n let runtimeChanged = false\n try {\n if (!state.resources.config.lastSuccessAt) throw new Error('运行参数尚未成功读取,请稍后重试')\n values = {\n sessionTtlMs: String(data.get('sessionTtlHours')) === initialRuntimeInput.sessionTtlHours ? initialRuntime.sessionTtlMs : runtimeValue(data, 'sessionTtlHours', 3_600_000),\n promptTimeoutMs: String(data.get('promptTimeoutMinutes')) === initialRuntimeInput.promptTimeoutMinutes ? initialRuntime.promptTimeoutMs : runtimeValue(data, 'promptTimeoutMinutes', 60_000),\n cleanupIntervalMs: String(data.get('cleanupIntervalSeconds')) === initialRuntimeInput.cleanupIntervalSeconds ? initialRuntime.cleanupIntervalMs : runtimeValue(data, 'cleanupIntervalSeconds', 1000)\n }\n runtimeChanged = String(data.get('sessionTtlHours')) !== initialRuntimeInput.sessionTtlHours ||\n String(data.get('promptTimeoutMinutes')) !== initialRuntimeInput.promptTimeoutMinutes ||\n String(data.get('cleanupIntervalSeconds')) !== initialRuntimeInput.cleanupIntervalSeconds\n } catch (reason) {\n error.textContent = reason.message\n return\n }\n let runtimeSaved = false\n let runtimeFailure\n if (runtimeChanged) {\n try {\n const result = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })\n state.config = { ...state.config, ...result }\n markResourceSaved('config')\n Object.assign(initialRuntime, values)\n for (const name of Object.keys(initialRuntimeInput)) initialRuntimeInput[name] = String(data.get(name))\n runtimeSaved = true\n } catch (reason) { runtimeFailure = reason }\n }\n if (runtimeFailure) {\n const messages = []\n if (runtimeFailure) messages.push('运行参数保存失败:' + runtimeFailure.message)\n error.textContent = messages.join(';')\n } else if (runtimeSaved) {\n const currentData = new FormData(form)\n form.dataset.dirty = String(Object.keys(initialRuntimeInput).some((name) => String(currentData.get(name)) !== initialRuntimeInput[name]))\n toast('运行参数已保存;会话 TTL 与清理周期立即生效,ACP 任务超时对新建 Session 生效')\n } else {\n form.dataset.dirty = 'false'\n toast('没有需要保存的运行参数更改')\n }\n })\n }\n passwordForm.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || byId('password-save'), async () => {\n const error = passwordForm.querySelector('[data-password-error]')\n error.textContent = ''\n const data = new FormData(passwordForm)\n const currentPassword = String(data.get('currentPassword') || '')\n const newPassword = String(data.get('newPassword') || '')\n const confirmPassword = String(data.get('confirmPassword') || '')\n try {\n if (!currentPassword || !newPassword || !confirmPassword) throw new Error('请完整填写密码修改信息')\n if (newPassword !== confirmPassword) throw new Error('两次输入的控制台密码不一致')\n if (newPassword.length < 12) throw new Error('控制台密码至少需要 12 个字符')\n if (new TextEncoder().encode(newPassword).length > 1024) throw new Error('控制台密码不能超过 1024 字节')\n if (newPassword.includes('\\u0000')) throw new Error('控制台密码包含不支持的字符')\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword, newPassword, confirmPassword } })\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n }\n byId('theme-select').onchange = (event) => {\n localStorage.setItem('agent-nexus-theme', event.target.value)\n applyTheme(event.target.value)\n }\n}\n",
|
|
18
|
-
"render-shared": "import { state, pageMeta } from './state.js'\nimport { byId, pageStats, pageToolbar, pageResults, pageStatus, actions, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\n\nlet actionsPage = null\nlet actionsHtml = null\nlet toolbarSignature = null\nlet resultsHtml = null\nexport function resetSharedView() {\n actionsPage = actionsHtml = toolbarSignature = resultsHtml = null\n for (const element of [actions,pageStats,pageToolbar,pageResults,pageStatus]) element.replaceChildren()\n}\n\nexport function setResults(html) {\n if (resultsHtml === html) return\n resultsHtml = html\n const focused = pageResults.contains(document.activeElement) ? document.activeElement : null\n pageResults.innerHTML = html\n if (focused) {\n const data = Object.entries(focused.dataset)\n const replacement = Array.from(pageResults.querySelectorAll('button, input, select')).find((element) =>\n focused.id ? focused.id === element.id : data.length && data.every(([key, value]) => element.dataset[key] === value))\n ;(replacement || document.querySelector('.nav-item.active'))?.focus({ preventScroll: true })\n }\n}\n\nexport function setActions(html) {\n if (actionsPage === state.page && actionsHtml === html) return\n actionsPage = state.page\n actionsHtml = html\n actions.innerHTML = html\n}\n\nexport function setToolbar(signature, html = '') {\n if (toolbarSignature === signature) return\n toolbarSignature = signature\n pageToolbar.innerHTML = html\n}\n\nexport function announce(message) {\n const summary = pageMeta[state.page] + ':' + message\n if (pageStatus.textContent !== summary) pageStatus.textContent = summary\n}\n\nexport function stat(label, value) {\n return '<div class=\"stat\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n}\n\nexport function emptyState(icon, title, text, cta) {\n return '<div class=\"empty-state\">' + icon + '<strong>' + escapeHtml(title) + '</strong>' +\n (text ? '<span>' + escapeHtml(text) + '</span>' : '') +\n (cta ? '<button type=\"button\" class=\"button primary\" data-empty-action=\"' + escapeHtml(cta.action) + '\">' + icons.plus + escapeHtml(cta.label) + '</button>' : '') +\n '</div>'\n}\n\nexport function bindSearch(id, apply) {\n const input = byId(id)\n if (input.dataset.searchBound) return\n input.dataset.searchBound = 'true'\n input.addEventListener('compositionstart', () => { input.dataset.composing = 'true' })\n input.addEventListener('compositionend', () => { delete input.dataset.composing; apply(input.value) })\n input.addEventListener('input', (event) => {\n if (!event.isComposing && !input.dataset.composing) apply(input.value)\n })\n}\n\nexport function preciseUnitValue(value, unit, fallback) {\n const number = Number.isFinite(Number(value)) ? Number(value) : fallback\n const rendered = (number / unit).toFixed(7)\n return rendered.replace(/0+$/, '').replace(/\\.$/, '')\n}\n",
|
|
14
|
+
"render-artifacts": "import { state } from './state.js'\nimport { byId, drawerForm, escapeHtml, selected } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { withBusy, toast } from './toast.js'\nimport { openDrawer, openConfirmDrawer } from './drawer.js'\nimport { copySecret } from './render-keys.js'\n\nimport { formatDate, formatSize, kindLabel, shortId } from './format.js'\n\nimport { setResults, setActions, setToolbar, announce, emptyState } from './render-shared.js'\n\nconst KINDS = ['image', 'video', 'audio', 'file']\n\nconst CODE_EXTS = new Set(['js','mjs','cjs','ts','tsx','jsx','py','rb','go','rs','java','c','h','cpp','css','scss','html','htm','sql','sh','bash','ps1','yaml','yml','xml','toml','ini','json'])\nconst TEXT_EXTS = new Set(['txt','md','markdown','log','csv','tsv','conf','env','gitignore','dockerfile','json','yaml','yml','xml','toml','ini','sh','bash','ps1','js','mjs','cjs','ts','tsx','jsx','py','rb','go','rs','java','c','h','cpp','css','scss','html','htm','sql'])\n\nfunction artifactName(row) {\n return (row.filename || row.name || row.id).toLowerCase()\n}\n\nfunction isCodeArtifact(row) {\n const mt = (row.mediaType || '').toLowerCase()\n if (mt === 'application/json' || mt === 'application/xml' || mt.endsWith('+json') || mt.endsWith('+xml')) return true\n const ext = artifactName(row).split('.').pop() || ''\n return CODE_EXTS.has(ext)\n}\n\nfunction isTextArtifact(row) {\n const mt = (row.mediaType || '').toLowerCase()\n if (mt.startsWith('text/')) return true\n if (mt === 'application/json' || mt === 'application/xml' || mt.endsWith('+json') || mt.endsWith('+xml')) return true\n const ext = artifactName(row).split('.').pop() || ''\n return TEXT_EXTS.has(ext)\n}\n\nfunction artifactUrl(row) {\n return '/v1/admin/runs/' + encodeURIComponent(row.runId) + '/artifacts/' + encodeURIComponent(row.id)\n}\n\nexport function findArtifact(artifactId, runId) {\n return state.artifacts.find((row) => row.id === artifactId && row.runId === runId)\n}\n\nfunction previewIcon(row) {\n if (row.kind === 'image') return icons.image\n if (row.kind === 'video') return icons.filePlay\n if (row.kind === 'audio') return icons.fileAudio\n if (isCodeArtifact(row)) return icons.fileCode\n if (isTextArtifact(row)) return icons.fileText\n return icons.file\n}\n\nfunction previewCell(row) {\n return '<button type=\"button\" class=\"artifact-tile\" aria-label=\"查看产物详情\" data-artifact-detail=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">' + previewIcon(row) + '</button>'\n}\n\nfunction artifactRow(row) {\n const title = row.name || row.filename || row.id\n return '<tr><td>' + previewCell(row) + '</td><td><div class=\"agent-name\"><strong>' + escapeHtml(title) + '</strong>' +\n (row.filename && row.filename !== title ? '<small>' + escapeHtml(row.filename) + '</small>' : '') +\n (row.mediaType ? '<small>' + escapeHtml(row.mediaType) + '</small>' : '') + '</div></td>' +\n '<td><div class=\"agent-name\"><strong>' + escapeHtml(row.agentName) + '</strong><small>' + escapeHtml(shortId(row.runId)) + '</small></div></td>' +\n '<td class=\"num\">' + escapeHtml(formatSize(row.size)) + '</td>' +\n '<td class=\"num\">' + escapeHtml(formatDate(row.createdAt || row.runStartedAt)) + '</td>' +\n '<td><div class=\"row-actions\">' +\n (row.downloadable ? '<a class=\"button small\" download href=\"' + artifactUrl(row) + '\">下载</a>' : '<span class=\"muted\">' + escapeHtml(row.storageStatus === 'metadata_only' ? '仅元数据' : '不可下载') + '</span>') +\n '<button type=\"button\" class=\"button small danger\" data-artifact-delete=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">删除</button>' +\n '</div></td></tr>'\n}\n\nfunction artifactsPath() {\n const query = new URLSearchParams({ limit: '50' })\n if (state.artifactOffset) query.set('offset', String(state.artifactOffset))\n if (state.artifactAgent !== 'all') query.set('agentId', state.artifactAgent)\n if (state.artifactKind !== 'all') query.set('kind', state.artifactKind)\n return '/v1/admin/artifacts?' + query\n}\n\nlet loadSeq = 0\n\nexport async function loadArtifacts(showError) {\n const path = artifactsPath()\n const seq = ++loadSeq\n try {\n const value = /** @type {import('./contracts.js').ArtifactListPayload} */ (await api(path))\n if (seq !== loadSeq) return\n state.artifacts = value.artifacts || []\n state.artifactTotal = value.total || 0\n renderArtifacts()\n } catch (error) {\n if (seq !== loadSeq) return\n if (showError) toast(error.message, true)\n }\n}\n\nexport function resetArtifactView() { loadSeq++ }\n\nfunction renderToolbar() {\n const agents = state.config.agents.map((agent) => '<option value=\"' + escapeHtml(agent.id) + '\"' + selected(state.artifactAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')\n const kinds = KINDS.map((kind) => '<option value=\"' + kind + '\"' + selected(state.artifactKind, kind) + '>' + escapeHtml(kindLabel(kind)) + '</option>').join('')\n setToolbar('artifacts|' + state.artifactAgent + '|' + state.artifactKind,\n '<div class=\"toolbar\"><select id=\"artifact-agent-filter\" aria-label=\"按智能体筛选\"><option value=\"all\">全部智能体</option>' + agents + '</select>' +\n '<select id=\"artifact-kind-filter\" aria-label=\"按类型筛选\"><option value=\"all\">全部类型</option>' + kinds + '</select>' +\n '<button class=\"button small\" id=\"clear-artifact-filters\"' + ((state.artifactAgent !== 'all' || state.artifactKind !== 'all') ? '' : ' hidden') + '>清除筛选</button></div>')\n byId('artifact-agent-filter').onchange = (event) => { state.artifactAgent = event.target.value; state.artifactOffset = 0; void loadArtifacts(false) }\n byId('artifact-kind-filter').onchange = (event) => { state.artifactKind = event.target.value; state.artifactOffset = 0; void loadArtifacts(false) }\n byId('clear-artifact-filters').onclick = () => { state.artifactAgent = 'all'; state.artifactKind = 'all'; state.artifactOffset = 0; void loadArtifacts(false) }\n}\n\nexport function renderArtifacts() {\n setActions('<button id=\"refresh-artifacts\" class=\"button\">' + icons.refresh + '刷新</button>')\n byId('refresh-artifacts').onclick = (event) => withBusy(event.currentTarget, () => loadArtifacts(true))\n if (!state.config.agents.length && ['idle', 'loading'].includes(state.resources.config.status)) {\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载网关配置…</p></div>')\n return\n }\n renderToolbar()\n const total = state.artifactTotal\n const offset = state.artifactOffset\n if (!total) {\n setResults('<div class=\"panel\">' + emptyState(icons.artifacts, '暂无产物', '智能体任务产出的文件、图片、视频会保存在这里,可下载或删除。') + '</div>')\n announce('暂无产物')\n return\n }\n const first = offset + 1\n const last = Math.min(offset + 50, total)\n setResults('<div class=\"run-pagination\"><span class=\"muted\">显示 ' + first + '–' + last + ',共 ' + total + ' 个产物</span><div class=\"row-actions\"><button class=\"button small\" id=\"artifacts-prev\"' + (offset ? '' : ' disabled') + '>上一页</button><button class=\"button small\" id=\"artifacts-next\"' + (offset + 50 < total ? '' : ' disabled') + '>下一页</button></div></div>' +\n '<div class=\"table-wrap\"><table class=\"artifact-table\"><thead><tr><th>预览</th><th>名称</th><th>来源</th><th class=\"num\">大小</th><th class=\"num\">保存时间</th><th>操作</th></tr></thead><tbody>' +\n (state.artifacts.length ? state.artifacts.map(artifactRow).join('') : '<tr><td colspan=\"6\" class=\"empty\">没有符合条件的产物。</td></tr>') +\n '</tbody></table></div>')\n byId('artifacts-prev').onclick = (event) => withBusy(event.currentTarget, async () => { state.artifactOffset = Math.max(0, state.artifactOffset - 50); await loadArtifacts(true) })\n byId('artifacts-next').onclick = (event) => withBusy(event.currentTarget, async () => { state.artifactOffset += 50; await loadArtifacts(true) })\n announce(total + ' 个产物')\n}\n\nconst TEXT_PREVIEW_LIMIT = 512 * 1024\nconst TEXT_DISPLAY_LIMIT = 64 * 1024\n\nfunction previewMarkup(row) {\n const url = artifactUrl(row)\n if (row.kind === 'image' && row.downloadable) return '<img class=\"artifact-preview\" src=\"' + url + '\" alt=\"' + escapeHtml(row.name || row.filename || '产物') + '\">'\n if (row.kind === 'video' && row.downloadable) return '<video class=\"artifact-preview\" controls preload=\"metadata\" src=\"' + url + '\"></video>'\n if (row.kind === 'audio' && row.downloadable) return '<audio controls preload=\"metadata\" src=\"' + url + '\"></audio>'\n if (row.kind === 'file' && row.downloadable && isTextArtifact(row)) {\n return '<pre class=\"run-detail-code artifact-text-preview\" data-text-preview>正在加载预览…</pre>'\n }\n return '<p class=\"field-help\">' + (row.downloadable ? '此类型不支持在线预览,可下载后查看。' : '该产物没有可下载的文件内容(' + escapeHtml(row.storageStatus || 'metadata_only') + ')。') + '</p>'\n}\n\nfunction loadTextPreview(row) {\n const preview = drawerForm.querySelector('[data-text-preview]')\n if (!preview) return\n if (row.size != null && row.size > TEXT_PREVIEW_LIMIT) {\n preview.textContent = '文件较大(' + formatSize(row.size) + '),未自动加载预览,可下载后查看。'\n return\n }\n fetch(artifactUrl(row)).then((response) => {\n if (!response.ok) throw new Error('HTTP ' + response.status)\n return response.text()\n }).then((text) => {\n if (!preview.isConnected) return\n preview.textContent = text.length > TEXT_DISPLAY_LIMIT\n ? text.slice(0, TEXT_DISPLAY_LIMIT) + '\\n\\n…[预览已截断,请下载查看完整内容]'\n : text\n }).catch((error) => {\n if (!preview.isConnected) return\n preview.textContent = '预览加载失败:' + (error instanceof Error ? error.message : String(error))\n })\n}\n\nexport function openArtifactDetail(artifactId, runId) {\n const row = findArtifact(artifactId, runId)\n if (!row) { toast('产物不存在或已被删除', true); return }\n const title = row.name || row.filename || row.id\n // 以当前访问地址为基准拼出完整文件链接,同源部署(127.0.0.1 / 服务器 IP / 反向代理域名)都自动正确\n const fileUrl = row.downloadable ? new URL(artifactUrl(row), window.location.href).href : ''\n const body =\n '<div class=\"run-detail-grid\">' +\n [['name', '名称'], ['mediaType', '类型'], ['size', '大小'], ['createdAt', '保存时间'], ['agent', '来源智能体'], ['run', '运行 ID']]\n .map(([key, label]) => {\n const value = { name: title, mediaType: row.mediaType || '—', size: formatSize(row.size), createdAt: formatDate(row.createdAt || row.runStartedAt), agent: row.agentName, run: shortId(row.runId) }[key]\n return '<div class=\"run-detail-item\"><span>' + label + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n }).join('') +\n '</div>' +\n (fileUrl ?\n '<div class=\"artifact-link-row\"><span class=\"muted\">文件链接</span><div class=\"artifact-link-line\"><code><a href=\"' + escapeHtml(fileUrl) + '\" target=\"_blank\" rel=\"noopener\">' + escapeHtml(fileUrl) + '</a></code>' +\n '<button type=\"button\" class=\"button small\" data-artifact-copy-link>' + icons.copy + '复制</button></div></div>' : '') +\n '<div class=\"artifact-preview-wrap\">' + previewMarkup(row) + '</div>' +\n '<div class=\"run-detail-actions\">' +\n (row.downloadable ? '<a class=\"button small\" download href=\"' + artifactUrl(row) + '\">下载</a>' : '') +\n '<button type=\"button\" class=\"button small danger\" data-artifact-delete=\"' + escapeHtml(row.id) + '\" data-artifact-run=\"' + escapeHtml(row.runId) + '\">删除产物</button>' +\n '</div>'\n openDrawer('产物详情', body, '', null)\n const remove = drawerForm.querySelector('[data-artifact-delete]')\n if (remove) remove.onclick = () => confirmArtifactDelete(remove.dataset.artifactDelete, remove.dataset.artifactRun)\n const copyLink = drawerForm.querySelector('[data-artifact-copy-link]')\n if (copyLink) copyLink.onclick = () => withBusy(copyLink, async () => { await copySecret(fileUrl, '请手动复制上方链接'); toast('文件链接已复制') })\n loadTextPreview(row)\n}\n\nexport function confirmArtifactDelete(artifactId, runId) {\n const row = findArtifact(artifactId, runId)\n const title = row ? (row.name || row.filename || row.id) : artifactId\n openConfirmDrawer('删除产物', '删除「' + title + '」的文件与记录,释放存储空间。运行记录本身保留。此操作无法撤销。', '删除产物', async () => {\n await api('/v1/admin/runs/' + encodeURIComponent(runId) + '/artifacts/' + encodeURIComponent(artifactId), { method: 'DELETE', body: {} })\n toast('产物已删除')\n if (state.page === 'artifacts') void loadArtifacts(false)\n })\n}\n",
|
|
15
|
+
"render-keys": "import { state } from './state.js'\nimport { byId, keyActionMenu, escapeHtml, selected, checked } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { openDrawer, closeDrawer } from './drawer.js'\nimport { showLogin } from './screens.js'\n\nimport { markResourceSaved } from './data.js'\nimport { formatDate } from './format.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, announce, emptyState } from './render-shared.js'\n\nlet menuAnchor = null\n\nexport function keyRow(key) {\n const scope = key.scope.allAgents ? '全部智能体' : (key.scope.agentIds.length ? key.scope.agentIds.join(', ') : '未授权智能体')\n const id = escapeHtml(key.id)\n const toggleLabel = key.enabled ? '禁用密钥' : '启用密钥'\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(key.name) + '</strong>' + (key.legacy ? '<span class=\"badge\">旧版</span>' : '') + '</div></td>' +\n '<td>' + (key.enabled ? '<span class=\"status ready\">已启用</span>' : '<span class=\"status\">已禁用</span>') + '</td>' +\n '<td data-label=\"API 密钥\"><div class=\"key-secret-cell\"><code>••••' + escapeHtml(key.suffix) + '</code><button class=\"key-copy-button\" data-key-action=\"copy\" data-key-id=\"' + id + '\" aria-label=\"复制完整密钥\" title=\"复制完整密钥\">' + icons.copy + '</button></div></td>' +\n '<td data-label=\"授权范围\"><span class=\"key-scope\">' + escapeHtml(scope) + '</span></td>' +\n '<td data-label=\"最后使用\"><span class=\"key-last-used\">' + escapeHtml(key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用') + '</span></td>' +\n '<td><div class=\"key-actions\"><button class=\"key-icon-button' + (key.enabled ? ' danger-action' : '') + '\" data-key-action=\"toggle\" data-key-id=\"' + id + '\" aria-label=\"' + toggleLabel + '\" title=\"' + toggleLabel + '\">' + (key.enabled ? icons.disable : icons.enable) + '</button><button class=\"key-icon-button\" data-key-action=\"rename\" data-key-id=\"' + id + '\" aria-label=\"重命名\" title=\"重命名\">' + icons.edit + '</button><button class=\"key-icon-button\" data-key-menu=\"' + id + '\" aria-label=\"更多操作\" title=\"更多操作\" aria-expanded=\"false\" aria-haspopup=\"menu\" aria-controls=\"key-action-menu\">' + icons.more + '</button></div></td></tr>'\n}\n\nexport function renderApiKeys() {\n closeKeyActionMenu()\n setActions('<button id=\"create-key\" class=\"button primary\">' + icons.plus + '创建 API 密钥</button>')\n const pending = state.resources.apiKeys.status === 'loading' && !state.apiKeys.length\n setResults(pending ? '<div class=\"panel\"><p class=\"panel-note\">正在加载 API 密钥…</p></div>' : state.apiKeys.length\n ? '<div class=\"table-wrap key-table-wrap\"><table class=\"key-table\"><thead><tr><th>名称</th><th>状态</th><th>API 密钥</th><th>授权范围</th><th>最后使用</th><th>操作</th></tr></thead><tbody>' + state.apiKeys.map(keyRow).join('') + '</tbody></table></div>'\n : '<div class=\"panel\">' + emptyState(icons.key, '尚未创建 API 密钥', '客户端调用网关数据接口时需要携带密钥。', { action: 'create-key', label: '创建 API 密钥' }) + '</div>')\n byId('create-key').onclick = () => openKeyDrawer()\n byId('create-key').disabled = !state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt\n announce(state.apiKeys.length + ' 个 API 密钥')\n}\n\nexport function agentScopeOption(agent, selected = false) {\n return '<label class=\"checkbox\"><input type=\"checkbox\" name=\"agentId\" value=\"' + escapeHtml(agent.id) + '\"' + checked(selected) + '><span class=\"scope-name\">' + escapeHtml(agent.name) + '</span><span class=\"muted\">(' + escapeHtml(agent.protocol.toUpperCase()) + ')</span></label>'\n}\n\nexport function openKeyDrawer() {\n if (!state.resources.config.lastSuccessAt || !state.resources.apiKeys.lastSuccessAt) { toast('配置与密钥列表尚未成功读取,请重试后创建', true); return }\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent)).join('')\n const body = '<div class=\"field\"><label for=\"f-name\">名称</label><input id=\"f-name\" name=\"name\" required autocomplete=\"off\" placeholder=\"例如:开发客户端\"></div><div class=\"field\"><label class=\"checkbox\"><input id=\"all-agents\" name=\"allAgents\" type=\"checkbox\" checked>允许访问全部智能体</label></div><div id=\"agent-scope\" class=\"agent-scope hidden\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div><div class=\"field\"><label for=\"f-customSecret\">自定义密钥(可选)</label><input id=\"f-customSecret\" name=\"customSecret\" minlength=\"16\" autocomplete=\"off\" placeholder=\"留空则自动生成 nx_sk_ 密钥\"></div>'\n openDrawer('创建 API 密钥', body, '创建 API 密钥', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const customSecret = String(data.get('customSecret') || '').trim()\n const result = await api('/v1/admin/api-keys', {\n method: 'POST',\n body: {\n name: String(data.get('name') || '').trim(),\n scope: { allAgents, agentIds },\n customSecret: customSecret || undefined\n }\n })\n state.apiKeys.unshift(result.key)\n markResourceSaved('apiKeys')\n if (isCurrent()) showSecret('API 密钥已创建', result.secret)\n else toast('API 密钥已创建,可在密钥列表中显示或复制')\n render()\n })\n const all = byId('all-agents')\n all.onchange = () => byId('agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function showSecret(title, secret) {\n openDrawer(title, '<div class=\"secret-box\"><span class=\"muted\">请立即复制;之后可在密钥列表中再次显示。</span><code class=\"secret-value\">' + escapeHtml(secret) + '</code><button class=\"button\" type=\"button\" id=\"copy-secret\">' + icons.copy + '复制</button></div>', '', null)\n byId('copy-secret').onclick = async () => {\n try {\n await copySecret(secret)\n toast('API 密钥已复制')\n } catch (error) { toast(error.message, true) }\n }\n}\n\nexport async function copySecret(secret, fallbackHint = '请在“显示完整密钥”中手动复制') {\n if (navigator.clipboard && window.isSecureContext) {\n try {\n await navigator.clipboard.writeText(secret)\n return\n } catch {}\n }\n const temporary = document.createElement('textarea')\n temporary.value = secret\n temporary.setAttribute('readonly', '')\n temporary.style.position = 'fixed'\n temporary.style.opacity = '0'\n temporary.style.pointerEvents = 'none'\n const previousFocus = document.activeElement\n const dialog = byId('drawer')\n ;(dialog.classList.contains('hidden') ? document.body : dialog).appendChild(temporary)\n temporary.select()\n const copied = document.execCommand('copy')\n temporary.remove()\n previousFocus?.focus({ preventScroll: true })\n if (!copied) throw new Error(`无法自动复制,${fallbackHint}。`)\n}\n\nexport function openPasswordDrawer() {\n openDrawer('修改密码', '<div class=\"field\"><label for=\"f-currentPassword\">当前密码</label><input id=\"f-currentPassword\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\" required></div><div class=\"field\"><label for=\"f-newPassword\">新密码</label><input id=\"f-newPassword\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div><div class=\"field\"><label for=\"f-confirmPassword\">确认新密码</label><input id=\"f-confirmPassword\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\" required></div>', '修改密码', async (form) => {\n const data = new FormData(form)\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword: String(data.get('currentPassword') || ''), newPassword: String(data.get('newPassword') || ''), confirmPassword: String(data.get('confirmPassword') || '') } })\n closeDrawer()\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n })\n}\n\nexport function openKeyScopeDrawer(key) {\n const selectedIds = new Set(key.scope.agentIds || [])\n const scopeRows = state.config.agents.map((agent) => agentScopeOption(agent, selectedIds.has(agent.id))).join('')\n const body = '<p class=\"field-help\">缩小授权范围会立即拒绝受影响的新请求并关闭对应 SSE;正在执行的任务会继续。</p><div class=\"field\"><label class=\"checkbox\"><input id=\"edit-all-agents\" name=\"allAgents\" type=\"checkbox\"' + checked(key.scope.allAgents) + '>允许访问全部智能体</label></div><div id=\"edit-agent-scope\" class=\"agent-scope' + (key.scope.allAgents ? ' hidden' : '') + '\">' + (scopeRows || '<span class=\"muted\">尚未配置智能体。</span>') + '</div>'\n openDrawer('编辑 API 密钥授权范围', body, '保存授权范围', async (form, isCurrent) => {\n const data = new FormData(form)\n const allAgents = data.get('allAgents') === 'on'\n const agentIds = data.getAll('agentId').map(String)\n if (!allAgents && !agentIds.length) throw new Error('请至少选择一个智能体')\n const narrowed = key.scope.allAgents && !allAgents || !key.scope.allAgents && !allAgents && (key.scope.agentIds || []).some((agentId) => !agentIds.includes(agentId))\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { scope: { allAgents, agentIds } } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast(narrowed ? 'API 密钥授权范围已更新;受影响 SSE 已关闭,正在执行的任务会继续' : 'API 密钥授权范围已更新')\n })\n const all = byId('edit-all-agents')\n all.onchange = () => byId('edit-agent-scope').classList.toggle('hidden', all.checked)\n}\n\nexport function openRenameKeyDrawer(key) {\n openDrawer('重命名 API 密钥', '<div class=\"field\"><label for=\"f-key-name\">名称</label><input id=\"f-key-name\" name=\"name\" value=\"' + escapeHtml(key.name) + '\" required autocomplete=\"off\"></div>', '保存名称', async (form, isCurrent) => {\n const name = String(new FormData(form).get('name') || '').trim()\n if (name === key.name) { closeDrawer(); return }\n const updated = await api('/v1/admin/api-keys/' + encodeURIComponent(key.id), { method: 'PATCH', body: { name } })\n if (isCurrent()) closeDrawer()\n applyApiKey(updated)\n toast('API 密钥已重命名')\n })\n const input = byId('f-key-name')\n if (input) setTimeout(() => input.select(), 0)\n}\n\nexport function applyApiKey(key) {\n state.apiKeys = state.apiKeys.map((current) => current.id === key.id ? key : current)\n markResourceSaved('apiKeys')\n render()\n}\n\nexport function handleKeyMenuKeydown(event) {\n if (keyActionMenu.classList.contains('hidden')) return\n if (event.key === 'Escape' || event.key === 'Tab') {\n if (event.key === 'Escape') event.preventDefault()\n closeKeyActionMenu(true)\n return\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return\n event.preventDefault()\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n const index = items.indexOf(document.activeElement)\n const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :\n (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length\n items.forEach((item, position) => { item.tabIndex = position === next ? 0 : -1 })\n items[next].focus({ preventScroll: true })\n}\n\nexport function toggleKeyActionMenu(anchor, id) {\n if (!keyActionMenu.classList.contains('hidden') && keyActionMenu.dataset.keyId === id) {\n closeKeyActionMenu(true)\n return\n }\n closeKeyActionMenu()\n menuAnchor = anchor\n keyActionMenu.dataset.keyId = id\n keyActionMenu.innerHTML =\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"reveal\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.reveal + '<span>显示完整密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"scope\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.scope + '<span>编辑授权范围</span></button>' +\n '<button type=\"button\" role=\"menuitem\" data-key-action=\"regenerate\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.regenerate + '<span>重新生成密钥</span></button>' +\n '<button type=\"button\" role=\"menuitem\" class=\"danger-text\" data-key-action=\"delete\" data-key-id=\"' + escapeHtml(id) + '\">' + icons.trash + '<span>删除密钥</span></button>'\n keyActionMenu.classList.remove('hidden')\n anchor.setAttribute('aria-expanded', 'true')\n const anchorRect = anchor.getBoundingClientRect()\n const menuRect = keyActionMenu.getBoundingClientRect()\n const left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, anchorRect.right - menuRect.width))\n const below = anchorRect.bottom + 6\n const top = below + menuRect.height <= window.innerHeight - 8\n ? below\n : Math.max(8, anchorRect.top - menuRect.height - 6)\n keyActionMenu.style.left = left + 'px'\n keyActionMenu.style.top = top + 'px'\n const items = Array.from(keyActionMenu.querySelectorAll('[role=\"menuitem\"]'))\n items.forEach((item, index) => { item.tabIndex = index === 0 ? 0 : -1 })\n items[0]?.focus({ preventScroll: true })\n}\n\nexport function closeKeyActionMenu(restore = false) {\n keyActionMenu.classList.add('hidden')\n keyActionMenu.innerHTML = ''\n keyActionMenu.dataset.keyId = ''\n document.querySelectorAll('[data-key-menu][aria-expanded=\"true\"]').forEach((button) => button.setAttribute('aria-expanded', 'false'))\n if (restore === true && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true })\n menuAnchor = null\n}\n",
|
|
16
|
+
"render-overview": "import { state } from './state.js'\nimport { byId, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\nimport { localizeError } from './api.js'\nimport { withBusy } from './toast.js'\n\nimport { refreshReadiness, retryResource } from './data.js'\nimport { runStatusLabel } from './format.js'\n\nimport { setResults, setActions, announce, stat, emptyState } from './render-shared.js'\nimport { readinessRows, statusMarkup } from './render-agents.js'\n\nexport function overviewAgent(agent) {\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(agent.name) + '</strong><small>' +\n escapeHtml(agent.protocol.toUpperCase() + (agent.driver ? ' · ' + agent.driver : '')) +\n (agent.error ? ' · ' + escapeHtml(localizeError(agent.error)) : '') + '</small></div><div class=\"row-actions\">' +\n '<button class=\"button small\" data-agent-edit=\"' + escapeHtml(agent.id) + '\">编辑</button><button class=\"button small\" data-agent-diagnostics=\"' + escapeHtml(agent.id) + '\">连接诊断</button>' + statusMarkup(agent) + '</div></div>'\n}\n\n/** 紧凑指标单元:标签 + 值,不单独成卡。 */\nfunction miniMetric(label, value) {\n return '<div class=\"run-detail-item\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(String(value)) + '</strong></div>'\n}\n\nconst WAITING_STATES = new Set(['input_required', 'permission_required'])\n\nfunction attentionRow(run) {\n const waiting = WAITING_STATES.has(run.state)\n const actions =\n (waiting ? '<button class=\"button small\" data-run-quick-cancel=\"' + escapeHtml(run.id) + '\">取消任务</button>' : '') +\n '<button class=\"button small\" data-run-detail=\"' + escapeHtml(run.id) + '\">查看详情</button>' +\n '<button class=\"button small danger\" data-run-quick-delete=\"' + escapeHtml(run.id) + '\">删除记录</button>'\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(run.agentName) + '</strong><small>' +\n escapeHtml(runStatusLabel(run.state) + ' · ' + (run.taskPreview || '')) + '</small></div><div class=\"row-actions\">' + actions + '</div></div>'\n}\n\nexport function renderOverview() {\n const agents = readinessRows()\n const enabled = agents.filter((agent) => agent.enabled)\n const ready = enabled.filter((agent) => agent.ready).length\n const attention = agents.filter((agent) => agent.enabled && !agent.checking && (!agent.ready || agent.error))\n const configPending = ['idle', 'loading'].includes(state.resources.config.status)\n const metrics = state.metrics || {}\n const storage = metrics.storageMetrics\n const recent = (metrics.recentRuns || []).filter((run) => WAITING_STATES.has(run.state) || run.state === 'failed').slice(0, 5)\n setActions('<button id=\"refresh-overview\" class=\"button\">' + icons.refresh + '刷新</button>')\n const configBroken = !state.resources.config.lastSuccessAt\n const body = configPending && agents.length === 0\n ? '<p class=\"panel-note\">正在加载网关配置…</p>'\n : configBroken\n ? '<p class=\"panel-note\">配置读取失败,请使用上方重试入口恢复。</p>'\n : agents.length === 0\n ? emptyState(icons.robot, '尚未配置智能体', '', { action: 'add-agent', label: '添加智能体' })\n : '<div class=\"panel-body overview-list\">' + attention.map(overviewAgent).join('') + '</div>'\n /* 没有异常、有配置、有智能体时整块隐藏:健康状态由顶部统计表达即可 */\n const agentPanel = (configPending && agents.length === 0) || configBroken || agents.length === 0 || attention.length\n ? '<div class=\"panel\"><div class=\"panel-header\"><h2>智能体状态</h2><span class=\"muted\">' + (attention.length ? attention.length + ' 个需要注意' : ready + ' 个可用') + '</span></div>' +\n body +\n '</div>'\n : ''\n const sysMetrics =\n miniMetric('现有会话', metrics.sessions ?? (state.resources.overview.lastSuccessAt ? state.sessions : '—')) +\n miniMetric('SSE 连接', metrics.activeSse ?? '—') +\n miniMetric('内存 RSS', metrics.rssBytes === undefined ? '—' : Math.round(metrics.rssBytes / 1024 / 1024) + ' MiB') +\n miniMetric('配额拒绝', metrics.quotaRejections ?? '—') +\n (storage ? miniMetric('历史文件', (storage.historyBytes / 1024 / 1024).toFixed(1) + ' MiB') +\n miniMetric('保留记录', storage.retainedRuns) +\n miniMetric('待写入', storage.pendingWrites) +\n miniMetric('历史写入失败', storage.writeFailures) : '')\n setResults('<div class=\"stats\">' +\n stat('智能体', agents.length, '#/agents') +\n stat('启用', enabled.length) +\n stat('异常', attention.length, attention.length ? '#/agents?status=failed' : undefined) +\n stat('活动任务', metrics.runs?.active ?? '—', '#/runs') +\n '</div>' +\n (agentPanel || '') +\n (sysMetrics ? '<div class=\"panel\"><div class=\"panel-header\"><h2>系统状态</h2></div>' +\n '<div class=\"run-detail-grid compact\">' + sysMetrics + '</div></div>' : '') +\n '<div class=\"panel\"><div class=\"panel-header\"><h2>失败与等待处理</h2>' +\n (recent.length >= 5 ? '<a class=\"button small\" href=\"#/runs?state=failed\">查看全部</a>' : '<span class=\"muted\">最近 5 条</span>') + '</div>' +\n (recent.length ? '<div class=\"panel-body\">' + recent.map(attentionRow).join('') + '</div>' : '<p class=\"panel-note\">' + (state.resources.metrics.lastSuccessAt ? '没有最近失败或等待处理的任务。' : '正在读取任务统计…') + '</p>') + '</div>')\n byId('refresh-overview').onclick = (event) => withBusy(event.currentTarget, async () => {\n await Promise.allSettled([refreshReadiness(true), retryResource('metrics')])\n })\n announce(agents.length + ' 个智能体,' + enabled.length + ' 个启用,' + attention.length + ' 个需要注意')\n}\n",
|
|
17
|
+
"render-runs": "import { state } from './state.js'\nimport { byId, pageStats, drawerForm, escapeHtml, selected } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { withBusy, toast, runAction } from './toast.js'\nimport { openDrawer, getDrawerVersion, openConfirmDrawer, closeDrawer } from './drawer.js'\nimport { copySecret } from './render-keys.js'\nimport { writeLocationState } from './location-state.js'\n\nimport { refreshRuns } from './data.js'\nimport { shortId, formatDate, formatDuration, runStatusLabel } from './format.js'\n\nimport { setResults, setActions, setToolbar, announce, stat, emptyState, bindSearch } from './render-shared.js'\n\nlet runSearchTimer\nlet activeRunDrawer = null\nexport function resetRunView() { clearTimeout(runSearchTimer); activeRunDrawer = null }\n\nexport function runStatus(value) {\n const style = value === 'completed' ? ' completed' : value === 'failed' ? ' failed' : value === 'running' ? ' running' : (value === 'input_required' || value === 'permission_required') ? ' waiting' : ' canceled'\n return '<span class=\"status' + style + '\">' + escapeHtml(runStatusLabel(value)) + '</span>'\n}\n\nexport function runTaskPreview(run) {\n return run.taskPreview || run.task || '—'\n}\n\nexport function runCard(run) {\n const waiting = run.state === 'input_required' || run.state === 'permission_required'\n const progress = run.progress || {}\n return '<article class=\"run-card' + (waiting ? ' waiting' : '') + '\"><div class=\"run-card-head\"><div class=\"run-card-agent\"><span class=\"badge\">' + escapeHtml((run.protocol || '').toUpperCase()) + '</span><strong>' + escapeHtml(run.agentName) + '</strong></div>' + runStatus(run.state) + '</div><div class=\"run-task\">' + escapeHtml(runTaskPreview(run)) + '</div>' +\n (progress.phase || progress.message ? '<div class=\"run-progress\">' + (progress.phase ? '<strong>' + escapeHtml(progress.phase) + '</strong>' : '') + (progress.message ? '<small>' + escapeHtml(progress.message) + '</small>' : '') + '</div>' : '') +\n '<div class=\"run-meta\"><span>' + escapeHtml(shortId(run.id)) + '</span><span>' + escapeHtml(formatDuration(run)) + '</span></div>' + runDetailButton(run) + '</article>'\n}\n\nexport function runDetailButton(run) {\n return '<button type=\"button\" class=\"button small run-detail-button\" data-run-detail=\"' + escapeHtml(run.id) + '\" aria-label=\"查看运行详情:' + escapeHtml(runTaskPreview(run)) + '\">查看详情</button>'\n}\n\nexport function runRow(run) {\n return '<tr><td><div class=\"agent-name\"><strong>' + escapeHtml(run.agentName) + '</strong><small>' + escapeHtml((run.protocol || '').toUpperCase()) + '</small></div></td><td class=\"run-task-cell\"><strong>' + escapeHtml(runTaskPreview(run)) + '</strong><small class=\"run-id\">' + escapeHtml(shortId(run.id)) + '</small></td><td class=\"run-task-cell\"><strong>' + escapeHtml(run.resultSummary || run.error || '—') + '</strong><small>' + escapeHtml(run.progress && run.progress.phase || '') + '</small></td><td>' + runStatus(run.state) + '</td><td class=\"num\">' + escapeHtml(formatDate(run.startedAt)) + '</td><td class=\"num\">' + escapeHtml(formatDuration(run)) + '</td><td>' + runDetailButton(run) + '</td></tr>'\n}\n\nexport function renderRuns() {\n writeLocationState(state)\n const pageSize = state.runPageSize || 50\n const activeStates = new Set(['running', 'input_required', 'permission_required'])\n const filtered = state.runs.filter((run) => {\n const haystack = (runTaskPreview(run) + ' ' + (run.agentName || '') + ' ' + run.id).toLowerCase()\n // The server searches the retained full task. A list response may only\n // contain taskPreview, so do not hide a server-matched row locally.\n const localSearchMatches = !state.runSearch || !run.task && run.taskPreview || haystack.indexOf(state.runSearch.toLowerCase()) >= 0\n return localSearchMatches &&\n (state.runAgent === 'all' || run.agentId === state.runAgent) &&\n (state.runStatus === 'all' || run.state === state.runStatus)\n })\n const active = filtered.filter((run) => activeStates.has(run.state))\n const history = filtered.filter((run) => !activeStates.has(run.state))\n const agentOptions = state.config.agents.map((agent) => '<option value=\"' + escapeHtml(agent.id) + '\"' + selected(state.runAgent, agent.id) + '>' + escapeHtml(agent.name) + '</option>').join('')\n setActions('<button id=\"refresh-runs\" class=\"button\">' + icons.refresh + '刷新</button>')\n byId('refresh-runs').onclick = (event) => withBusy(event.currentTarget, () => refreshRuns(true))\n\n if (!state.runs.length && state.resources.runs.status === 'loading') {\n pageStats.innerHTML = ''\n setToolbar('')\n setResults('<div class=\"panel\"><p class=\"panel-note\">正在加载运行记录…</p></div>')\n announce('正在加载运行记录')\n return\n }\n\n if (!state.runs.length && !state.runSearch && state.runAgent === 'all' && state.runStatus === 'all' && !state.runOffset) {\n pageStats.innerHTML = ''\n setToolbar('')\n setResults('<div class=\"panel\">' +\n emptyState(icons.activity, '暂无运行记录', '客户端通过网关数据接口发起任务后,进度与结果会出现在这里。') +\n '</div>')\n announce('暂无运行记录')\n return\n }\n\n pageStats.innerHTML =\n '<div class=\"run-summary\">' +\n stat('当前运行', state.runStats.active) +\n stat('已完成', state.runStats.completed) +\n stat('失败', state.runStats.failed) +\n stat('记录总数', state.runTotal) +\n '</div>'\n const hasFilters = Boolean(state.runSearch || state.runAgent !== 'all' || state.runStatus !== 'all')\n const pageSizes = [50, 100, 200].map((size) => '<option value=\"' + size + '\"' + selected(pageSize, size) + '>' + size + ' 条/页</option>').join('')\n setToolbar('runs|' + pageSize + '|' + JSON.stringify(state.config.agents.map((agent) => [agent.id, agent.name])),\n '<div class=\"toolbar\"><div class=\"search-box\">' + icons.search + '<input id=\"run-search\" type=\"search\" aria-label=\"搜索运行记录\" placeholder=\"搜索任务、智能体或运行 ID\" value=\"' + escapeHtml(state.runSearch) + '\"></div>' +\n '<select id=\"run-agent-filter\" aria-label=\"按智能体筛选\"><option value=\"all\">全部智能体</option>' + agentOptions + '</select>' +\n '<select id=\"run-status-filter\" aria-label=\"按运行状态筛选\"><option value=\"all\">全部状态</option><option value=\"running\"' + selected(state.runStatus, 'running') + '>运行中</option><option value=\"input_required\"' + selected(state.runStatus, 'input_required') + '>等待输入</option><option value=\"permission_required\"' + selected(state.runStatus, 'permission_required') + '>等待授权</option><option value=\"completed\"' + selected(state.runStatus, 'completed') + '>已完成</option><option value=\"failed\"' + selected(state.runStatus, 'failed') + '>失败</option><option value=\"canceled\"' + selected(state.runStatus, 'canceled') + '>已取消</option></select>' +\n '<select id=\"run-page-size\" aria-label=\"每页运行记录数\">' + pageSizes + '</select><button class=\"button small\" id=\"clear-run-filters\">清除筛选</button></div>')\n byId('clear-run-filters').hidden = !hasFilters\n const searchInput = byId('run-search')\n if (!searchInput.dataset.composing && searchInput.value !== state.runSearch) searchInput.value = state.runSearch\n byId('run-agent-filter').value = state.runAgent\n byId('run-status-filter').value = state.runStatus\n const first = state.runTotal ? state.runOffset + 1 : 0\n const last = Math.min(state.runOffset + pageSize, state.runTotal)\n setResults((state.runHasNew ? '<p class=\"panel-note\"><button class=\"button small\" id=\"runs-show-new\">有新任务,返回首页查看</button></p>' : '') + '<div class=\"run-pagination\"><span class=\"muted\">在已保留的全部记录中筛选 · 显示 ' + first + '–' + last + ',共 ' + state.runTotal + ' 条 · 第 ' + (Math.floor(state.runOffset / pageSize) + 1) + ' 页</span><div class=\"row-actions\"><button class=\"button small\" id=\"runs-prev\"' + (state.runOffset ? '' : ' disabled') + '>上一页</button><button class=\"button small\" id=\"runs-next\"' + (state.runOffset + pageSize < state.runTotal ? '' : ' disabled') + '>下一页</button></div></div>' +\n '<section class=\"run-section\"><div class=\"run-section-title\"><h2>当前运行</h2><span>' + active.length + ' 项</span></div>' +\n (active.length ? '<div class=\"run-live-grid\">' + active.map(runCard).join('') + '</div>' : '<div class=\"panel\">' + emptyState(icons.activity, '当前没有任务') + '</div>') +\n '</section>' +\n '<section class=\"run-section\"><div class=\"run-section-title\"><h2>历史记录</h2><span>' + history.length + ' 项</span></div>' +\n '<div class=\"table-wrap\"><table class=\"run-table\"><thead><tr><th>智能体</th><th>任务</th><th>结果</th><th>状态</th><th class=\"num\">开始时间</th><th class=\"num\">耗时</th><th>操作</th></tr></thead><tbody>' +\n (history.length ? history.map(runRow).join('') : '<tr><td colspan=\"7\" class=\"empty\">没有符合条件的历史记录。</td></tr>') +\n '</tbody></table></div></section>')\n const filterChanged = () => {\n state.runOffset = 0\n state.runHasNew = false\n clearTimeout(runSearchTimer)\n renderRuns()\n runSearchTimer = setTimeout(() => { if (state.authenticated) void refreshRuns(false) }, 250)\n }\n bindSearch('run-search', (value) => { state.runSearch = value; filterChanged() })\n byId('run-agent-filter').onchange = (event) => { state.runAgent = event.target.value; filterChanged() }\n byId('run-status-filter').onchange = (event) => { state.runStatus = event.target.value; filterChanged() }\n byId('run-page-size').onchange = (event) => { state.runPageSize = Math.min(200, Math.max(50, Number(event.target.value) || 50)); state.runOffset = 0; void refreshRuns(true) }\n byId('clear-run-filters').onclick = () => {\n clearTimeout(runSearchTimer)\n state.runSearch = ''\n state.runAgent = 'all'\n state.runStatus = 'all'\n state.runOffset = 0\n renderRuns()\n void refreshRuns(true)\n }\n byId('runs-prev').onclick = (event) => withBusy(event.currentTarget, async () => { state.runOffset = Math.max(0, state.runOffset - pageSize); await refreshRuns(true) })\n byId('runs-next').onclick = (event) => withBusy(event.currentTarget, async () => { state.runOffset += pageSize; await refreshRuns(true) })\n if (byId('runs-show-new')) byId('runs-show-new').onclick = () => { state.runOffset = 0; state.runHasNew = false; void refreshRuns(true) }\n announce(active.length + ' 项当前运行,' + history.length + ' 项历史记录')\n}\n\nexport function detailItem(label, value) {\n return '<div class=\"run-detail-item\"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'\n}\n\nexport async function openRunDrawer(id) {\n openDrawer('运行详情', '<p role=\"status\">正在加载运行详情…</p>', '', null)\n state.selectedRunId = id\n writeLocationState(state)\n drawerForm.setAttribute('aria-busy', 'true')\n activeRunDrawer = { id, version: getDrawerVersion(), busy: false, html: '', revision: 0, actionBusy: false }\n await refreshOpenRunDrawer()\n}\n\nexport async function refreshOpenRunDrawer() {\n const target = activeRunDrawer\n if (!target || target.version !== getDrawerVersion() || target.busy || target.actionBusy) return\n const { id, version, revision } = target\n target.busy = true\n try {\n const run = await api('/v1/admin/runs/' + encodeURIComponent(id))\n if (version !== getDrawerVersion() || revision !== target.revision) return\n renderRunDetail(target, run)\n drawerForm.querySelector('[data-detail-error]').textContent = ''\n } catch (error) {\n if (version !== getDrawerVersion() || revision !== target.revision) return\n let warning = drawerForm.querySelector('[data-detail-error]')\n if (!warning) {\n warning = document.createElement('p')\n warning.dataset.detailError = ''\n warning.className = 'form-error'\n warning.setAttribute('role', 'status')\n drawerForm.prepend(warning)\n }\n warning.textContent = '详情刷新失败:' + error.message\n } finally {\n target.busy = false\n if (version === getDrawerVersion()) drawerForm.removeAttribute('aria-busy')\n }\n}\n\nfunction updateDetailText(element, value) {\n const text = String(value ?? '—')\n const old = element.textContent\n if (text === old) return\n if (element.childNodes.length !== 1 || element.firstChild.nodeType !== Node.TEXT_NODE) {\n element.replaceChildren(document.createTextNode(text))\n return\n }\n let prefix = 0\n while (prefix < old.length && prefix < text.length && old[prefix] === text[prefix]) prefix++\n element.firstChild.replaceData(prefix, old.length - prefix, text.slice(prefix))\n}\n\nfunction renderRunDetail(target, run) {\n if (!drawerForm.querySelector('[data-run-detail-body]')) {\n drawerForm.innerHTML = '<div data-run-detail-body>' +\n '<p class=\"field-help\">管理员可介入任意客户端的任务;操作会记录审计日志。重试使用新会话,旧记录保留。</p>' +\n '<div class=\"run-detail-actions\"><button type=\"button\" class=\"button small\" data-run-copy=\"id\">复制运行 ID</button><button type=\"button\" class=\"button small\" data-run-copy=\"output\">复制结果</button><button type=\"button\" class=\"button small\" data-run-copy=\"error\">复制错误</button><button type=\"button\" class=\"button small danger\" data-run-cancel>取消任务</button><button type=\"button\" class=\"button small\" data-run-retry>新会话重试</button><button type=\"button\" class=\"button small danger\" data-run-delete>删除记录</button><button type=\"button\" class=\"button small\" data-run-refresh>刷新</button></div>' +\n '<p class=\"field-help\" data-controls-reason></p><p class=\"form-error\" data-detail-error role=\"status\"></p>' +\n '<div class=\"run-detail-grid\">' +\n [['agent','智能体'],['state','状态'],['startedAt','开始时间'],['duration','耗时'],['protocol','协议'],['id','运行 ID'],['phase','当前阶段'],['attachments','输入附件'],['retry','重试来源']]\n .map(([field,label]) => '<div class=\"run-detail-item\"><span>' + label + '</span><strong data-detail-field=\"' + field + '\"></strong></div>').join('') +\n '</div><div data-pending-container></div>' +\n '<div class=\"run-detail-block\"><h3>用户任务</h3><pre class=\"run-detail-code\" data-detail-task></pre></div>' +\n '<div class=\"run-detail-block\" data-progress-block><h3>最近进度</h3><pre class=\"run-detail-code\" data-detail-progress></pre></div>' +\n '<div class=\"run-detail-block\" data-output-block><div class=\"run-output-heading\"><h3>智能体结果</h3><label class=\"checkbox\"><input type=\"checkbox\" data-follow-output>跟随最新</label></div><pre class=\"run-detail-code\" data-detail-output></pre></div>' +\n '<div class=\"run-detail-block\" data-error-block><h3>错误</h3><pre class=\"run-detail-code\" data-detail-failure></pre></div>' +\n '<div class=\"run-detail-block\"><h3>产物</h3><div data-detail-artifacts></div></div></div>'\n }\n byId('drawer-title').textContent = '运行详情 · ' + run.agentName\n const fields = { agent: run.agentName, state: runStatusLabel(run.state), startedAt: formatDate(run.startedAt), duration: formatDuration(run), protocol: run.protocol.toUpperCase(), id: run.id, phase: run.progress?.phase || '—', attachments: run.inputAttachmentCount || 0, retry: run.retryOfRunId || '—' }\n for (const [key,value] of Object.entries(fields)) updateDetailText(drawerForm.querySelector('[data-detail-field=\"' + key + '\"]'), value)\n updateDetailText(drawerForm.querySelector('[data-detail-task]'), (run.task || run.taskPreview || '—') + (run.taskTruncated ? '\\n\\n[记录已截断]' : ''))\n for (const [name,value] of [['progress',run.progress?.message],['output',run.output],['error',run.error]]) {\n drawerForm.querySelector('[data-' + name + '-block]').hidden = !value\n updateDetailText(drawerForm.querySelector(name === 'error' ? '[data-detail-failure]' : '[data-detail-' + name + ']'), value || '')\n }\n const output = drawerForm.querySelector('[data-detail-output]')\n if (drawerForm.querySelector('[data-follow-output]').checked) output.scrollTop = output.scrollHeight\n const artifactLabels = { pending: '保存中', metadata_only: '仅元数据', expired: '已过期', evicted: '已按容量清理', failed: '保存失败' }\n const artifactHtml = run.artifacts?.length ? run.artifacts.map((artifact) =>\n '<div class=\"list-row\"><div class=\"list-row-main\"><strong>' + escapeHtml(artifact.name || artifact.filename || artifact.id || '未命名产物') + '</strong><small>' +\n escapeHtml([artifact.mediaType, artifact.size === undefined ? '' : artifact.size + ' 字节', artifact.downloadable ? '可下载' : artifactLabels[artifact.storageStatus] || '仅元数据'].filter(Boolean).join(' · ')) +\n '</small></div>' + (artifact.downloadable && artifact.id ? '<a class=\"button small\" download href=\"/v1/admin/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(artifact.id) + '\">下载</a>' : '') + '</div>'\n ).join('') : '<p class=\"field-help\">暂无产物</p>'\n const artifacts = drawerForm.querySelector('[data-detail-artifacts]')\n if (artifacts.dataset.signature !== artifactHtml) { artifacts.innerHTML = artifactHtml; artifacts.dataset.signature = artifactHtml }\n const controls = run.controls || {}\n const cancel = drawerForm.querySelector('[data-run-cancel]')\n const retry = drawerForm.querySelector('[data-run-retry]')\n const remove = drawerForm.querySelector('[data-run-delete]')\n cancel.disabled = !controls.canCancel\n retry.disabled = !controls.canRetry\n remove.disabled = !controls.canDelete\n updateDetailText(drawerForm.querySelector('[data-controls-reason]'), controls.unavailableReason || '')\n drawerForm.querySelector('[data-run-refresh]').onclick = () => { void refreshOpenRunDrawer() }\n cancel.onclick = () => confirmRunAction(run, 'cancel')\n retry.onclick = () => confirmRunAction(run, 'retry')\n remove.onclick = () => confirmRunAction(run, 'delete')\n for (const button of drawerForm.querySelectorAll('[data-run-copy]')) {\n const value = run[button.dataset.runCopy]\n button.disabled = !value\n button.onclick = () => runAction(async () => { await copySecret(String(value)); toast('已复制') })\n }\n renderPendingControls(target, controls.pendingRequest)\n}\n\nfunction confirmRunAction(run, action) {\n if (action === 'delete') {\n openConfirmDrawer('删除运行记录', '仅删除这条历史记录与已保存的产物文件,不影响智能体配置和客户端会话。此操作无法撤销。', '删除记录', async (isCurrent) => {\n await api('/v1/admin/runs/' + encodeURIComponent(run.id), { method: 'DELETE', body: {} })\n toast('运行记录已删除')\n void refreshRuns(false)\n if (isCurrent()) closeDrawer()\n })\n return\n }\n const retry = action === 'retry'\n openConfirmDrawer(retry ? '新会话重试' : '取消任务',\n retry ? '将用原任务文本创建新的会话,使用当前智能体配置。旧记录保留;任务已经产生的外部操作可能重复执行。' : '取消当前任务并释放智能体会话。已经完成的文件或外部操作不会撤回。',\n retry ? '新会话重试' : '取消任务', async (isCurrent) => {\n const result = await api('/v1/admin/runs/' + encodeURIComponent(run.id) + '/' + action, { method: 'POST', body: {} })\n toast(retry ? '已创建重试任务' : '任务已取消')\n void refreshRuns(false)\n if (isCurrent()) await openRunDrawer(retry ? result.runId : run.id)\n })\n}\n\nfunction renderPendingControls(target, pending) {\n const container = drawerForm.querySelector('[data-pending-container]')\n if (!pending) { container.replaceChildren(); delete container.dataset.requestId; return }\n if (container.dataset.requestId === pending.id) return\n container.dataset.requestId = pending.id\n const options = pending.options || []\n container.innerHTML = '<section class=\"run-pending\"><h3>' + (pending.kind === 'permission' ? '等待授权' : '等待补充输入') + '</h3><p class=\"run-pending-prompt\">' + escapeHtml(pending.prompt) + '</p>' +\n (options.length ? '<div class=\"field\"><label for=\"run-response-option\">选择操作</label><select id=\"run-response-option\"><option value=\"\">请选择,不会自动授权</option>' + options.map((option) => '<option value=\"' + escapeHtml(option.id) + '\">' + escapeHtml(option.name + (option.kind?.includes('always') ? '(永久)' : '')) + '</option>').join('') + '</select></div>' : '') +\n (pending.kind === 'input' ? '<div class=\"field\"><label for=\"run-response-message\">补充信息</label><textarea id=\"run-response-message\" rows=\"4\"></textarea></div>' : '') +\n '<div class=\"row-actions\"><button type=\"button\" class=\"button primary\" data-run-respond>提交' + (pending.kind === 'permission' ? '授权选择' : '补充信息') + '</button><button type=\"button\" class=\"button\" data-run-decline>拒绝</button></div><p class=\"form-error\" data-response-error role=\"alert\"></p></section>'\n const submit = async (decline) => {\n if (target.actionBusy) return\n const error = container.querySelector('[data-response-error]')\n error.textContent = ''\n const optionId = container.querySelector('#run-response-option')?.value || ''\n const message = container.querySelector('#run-response-message')?.value || ''\n if (!decline && pending.kind === 'permission' && !optionId) { error.textContent = '请选择要授权或拒绝的操作'; return }\n if (!decline && pending.kind === 'input' && !optionId && !message.trim()) { error.textContent = '请输入补充信息或选择操作'; return }\n target.actionBusy = true\n target.revision++\n container.querySelectorAll('button').forEach((button) => { button.disabled = true })\n try {\n await api('/v1/admin/runs/' + encodeURIComponent(target.id) + '/respond', {\n method: 'POST', body: { requestId: pending.id, ...(decline ? { action: 'decline' } : { action: 'accept', ...(optionId ? { optionId } : {}), ...(message ? { message } : {}) }) }\n })\n toast('已提交,等待智能体继续执行')\n if (target.version === getDrawerVersion()) { container.replaceChildren(); delete container.dataset.requestId }\n } catch (reason) {\n if (target.version === getDrawerVersion()) error.textContent = reason.message\n else toast(reason.message, true)\n } finally {\n target.actionBusy = false\n container.querySelectorAll('button').forEach((button) => { button.disabled = false })\n if (target.version === getDrawerVersion()) void refreshOpenRunDrawer()\n }\n }\n container.querySelector('[data-run-respond]').onclick = () => { void submit(false) }\n container.querySelector('[data-run-decline]').onclick = () => { void submit(true) }\n}\n",
|
|
18
|
+
"render-settings": "import { state } from './state.js'\nimport { byId, escapeHtml, selected } from './dom.js'\n\nimport { api } from './api.js'\nimport { toast, withBusy } from './toast.js'\n\nimport { showLogin } from './screens.js'\nimport { applyTheme } from './theme.js'\nimport { markResourceSaved } from './data.js'\n\nimport { setResults, setActions, preciseUnitValue } from './render-shared.js'\n\nexport function runtimeValue(data, name, unit) {\n const value = Number(data.get(name))\n if (!Number.isFinite(value)) throw new Error('请填写有效的运行参数')\n return Math.round(value * unit)\n}\n\nexport function renderSettings() {\n const existingForm = byId('settings-form')\n const existingPasswordForm = byId('password-form')\n if (existingForm && existingPasswordForm) {\n existingForm.refreshRuntime?.()\n return\n }\n const initialRuntime = {\n sessionTtlMs: Number(state.config.sessionTtlMs || 24 * 60 * 60 * 1000),\n promptTimeoutMs: Number(state.config.promptTimeoutMs || 30 * 60 * 1000),\n cleanupIntervalMs: Number(state.config.cleanupIntervalMs || 60_000)\n }\n const runtimeUnavailable = !state.resources.config.lastSuccessAt\n const configLoading = runtimeUnavailable\n const sessionTtlHours = preciseUnitValue(initialRuntime.sessionTtlMs, 3_600_000, 24)\n const promptTimeoutMinutes = preciseUnitValue(initialRuntime.promptTimeoutMs, 60_000, 30)\n const cleanupIntervalSeconds = preciseUnitValue(initialRuntime.cleanupIntervalMs, 1000, 60)\n const initialRuntimeInput = { sessionTtlHours, promptTimeoutMinutes, cleanupIntervalSeconds }\n const theme = localStorage.getItem('agent-nexus-theme') || 'system'\n setActions('<button id=\"settings-save\" type=\"submit\" form=\"settings-form\" class=\"button primary\"' + (configLoading ? ' disabled' : '') + '>保存更改</button><button id=\"password-save\" type=\"submit\" form=\"password-form\" class=\"button\">保存密码</button>')\n setResults('<form id=\"settings-form\" class=\"settings-layout\">' +\n '<section class=\"settings-section settings-runtime\"><div class=\"settings-section-heading\"><h2>运行参数</h2>' +\n '<p class=\"settings-note\">写入网关配置文件,对所有客户端生效;会话 TTL 与清理周期立即由会话管理器采用,ACP 任务超时由新建 Session 采用。</p></div>' +\n '<div class=\"settings-fields\">' +\n '<div class=\"field\"><label for=\"session-ttl-hours\">会话空闲有效期(小时)</label><input id=\"session-ttl-hours\" name=\"sessionTtlHours\" type=\"number\" min=\"0.0166667\" max=\"720\" step=\"any\" value=\"' + escapeHtml(sessionTtlHours) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">无活动超过该时长后释放会话,支持小数小时以保留分钟级设置。默认 24 小时。</small></div>' +\n '<div class=\"field\"><label for=\"prompt-timeout-minutes\">单次 ACP 任务超时(分钟)</label><input id=\"prompt-timeout-minutes\" name=\"promptTimeoutMinutes\" type=\"number\" min=\"0.1666667\" max=\"1440\" step=\"any\" value=\"' + escapeHtml(promptTimeoutMinutes) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">超时后任务记为失败并释放资源,支持小数分钟以保留秒级设置。默认 30 分钟。</small></div>' +\n '<div class=\"field\"><label for=\"cleanup-interval-seconds\">清理任务周期(秒)</label><input id=\"cleanup-interval-seconds\" name=\"cleanupIntervalSeconds\" type=\"number\" min=\"5\" max=\"3600\" step=\"0.001\" value=\"' + escapeHtml(cleanupIntervalSeconds) + '\"' + (configLoading ? ' disabled' : '') + '><small class=\"field-help\">扫描空闲会话与过期记录的间隔,支持毫秒级设置。默认 60 秒。</small></div>' +\n '</div>' +\n '<p class=\"settings-note\" data-runtime-loading role=\"status\"' + (configLoading ? '' : ' hidden') + '>运行参数尚未成功读取,暂不能保存默认值;可单独修改控制台密码。</p>' +\n '<p class=\"form-error settings-form-error\" data-form-error role=\"alert\"></p>' +\n '</section></form>' +\n '<div class=\"settings-layout\"><section class=\"settings-section settings-appearance\"><div class=\"settings-section-heading\"><h2>外观</h2>' +\n '<p class=\"settings-note\">仅影响当前浏览器。</p></div>' +\n '<div class=\"settings-fields\"><div class=\"field\"><label for=\"theme-select\">界面主题</label>' +\n '<select id=\"theme-select\"><option value=\"system\"' + selected(theme, 'system') + '>跟随系统</option><option value=\"light\"' + selected(theme, 'light') + '>浅色</option><option value=\"dark\"' + selected(theme, 'dark') + '>深色</option></select>' +\n '</div></div></section></div>' +\n '<form id=\"password-form\" class=\"settings-layout settings-password-layout\">' +\n '<section class=\"settings-section settings-account\"><div class=\"settings-section-heading\"><h2>账户</h2>' +\n '<p class=\"settings-note\">控制台密码用于登录当前管理控制台。</p></div>' +\n '<div class=\"settings-fields\">' +\n '<div class=\"field\"><label for=\"settings-current-password\">当前密码</label><input id=\"settings-current-password\" name=\"currentPassword\" type=\"password\" autocomplete=\"current-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-new-password\">新密码</label><input id=\"settings-new-password\" name=\"newPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div>' +\n '<div class=\"field\"><label for=\"settings-confirm-password\">确认新密码</label><input id=\"settings-confirm-password\" name=\"confirmPassword\" type=\"password\" minlength=\"12\" autocomplete=\"new-password\"></div></div></section>' +\n '<p class=\"form-error settings-password-error\" data-password-error role=\"alert\"></p>' +\n '</form>')\n const form = byId('settings-form')\n const passwordForm = byId('password-form')\n // Update only the untouched runtime fields. Password entry, focus and IME\n // composition must survive a late configuration response or retry.\n form.refreshRuntime = () => {\n const save = byId('settings-save')\n if (save.getAttribute('aria-busy') === 'true') return\n const unavailable = !state.resources.config.lastSuccessAt\n save.disabled = unavailable\n if (form.dataset.dirty === 'true') return\n const fields = [\n ['sessionTtlHours', 'sessionTtlMs', 3_600_000, 86_400_000],\n ['promptTimeoutMinutes', 'promptTimeoutMs', 60_000, 1_800_000],\n ['cleanupIntervalSeconds', 'cleanupIntervalMs', 1000, 60_000]\n ]\n for (const [name, key, unit, fallback] of fields) {\n initialRuntime[key] = Number(state.config[key] ?? fallback)\n initialRuntimeInput[name] = preciseUnitValue(initialRuntime[key], unit, fallback)\n const input = form.elements.namedItem(name)\n if (input.value !== initialRuntimeInput[name]) input.value = initialRuntimeInput[name]\n input.disabled = unavailable\n }\n form.querySelector('[data-runtime-loading]').hidden = !unavailable\n }\n const markDirty = () => { form.dataset.dirty = 'true' }\n const markPasswordDirty = () => { passwordForm.dataset.dirty = 'true' }\n form.addEventListener('input', markDirty)\n form.addEventListener('change', markDirty)\n passwordForm.addEventListener('input', markPasswordDirty)\n passwordForm.addEventListener('change', markPasswordDirty)\n form.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || form.querySelector('[type=\"submit\"]'), async () => {\n const error = form.querySelector('[data-form-error]')\n error.textContent = ''\n const data = new FormData(form)\n let values\n let runtimeChanged = false\n try {\n if (!state.resources.config.lastSuccessAt) throw new Error('运行参数尚未成功读取,请稍后重试')\n values = {\n sessionTtlMs: String(data.get('sessionTtlHours')) === initialRuntimeInput.sessionTtlHours ? initialRuntime.sessionTtlMs : runtimeValue(data, 'sessionTtlHours', 3_600_000),\n promptTimeoutMs: String(data.get('promptTimeoutMinutes')) === initialRuntimeInput.promptTimeoutMinutes ? initialRuntime.promptTimeoutMs : runtimeValue(data, 'promptTimeoutMinutes', 60_000),\n cleanupIntervalMs: String(data.get('cleanupIntervalSeconds')) === initialRuntimeInput.cleanupIntervalSeconds ? initialRuntime.cleanupIntervalMs : runtimeValue(data, 'cleanupIntervalSeconds', 1000)\n }\n runtimeChanged = String(data.get('sessionTtlHours')) !== initialRuntimeInput.sessionTtlHours ||\n String(data.get('promptTimeoutMinutes')) !== initialRuntimeInput.promptTimeoutMinutes ||\n String(data.get('cleanupIntervalSeconds')) !== initialRuntimeInput.cleanupIntervalSeconds\n } catch (reason) {\n error.textContent = reason.message\n return\n }\n let runtimeSaved = false\n let runtimeFailure\n if (runtimeChanged) {\n try {\n const result = await api('/v1/admin/config/runtime', { method: 'PUT', body: values })\n state.config = { ...state.config, ...result }\n markResourceSaved('config')\n Object.assign(initialRuntime, values)\n for (const name of Object.keys(initialRuntimeInput)) initialRuntimeInput[name] = String(data.get(name))\n runtimeSaved = true\n } catch (reason) { runtimeFailure = reason }\n }\n if (runtimeFailure) {\n const messages = []\n if (runtimeFailure) messages.push('运行参数保存失败:' + runtimeFailure.message)\n error.textContent = messages.join(';')\n } else if (runtimeSaved) {\n const currentData = new FormData(form)\n form.dataset.dirty = String(Object.keys(initialRuntimeInput).some((name) => String(currentData.get(name)) !== initialRuntimeInput[name]))\n toast('运行参数已保存;会话 TTL 与清理周期立即生效,ACP 任务超时对新建 Session 生效')\n } else {\n form.dataset.dirty = 'false'\n toast('没有需要保存的运行参数更改')\n }\n })\n }\n passwordForm.onsubmit = async (event) => {\n event.preventDefault()\n await withBusy(event.submitter || byId('password-save'), async () => {\n const error = passwordForm.querySelector('[data-password-error]')\n error.textContent = ''\n const data = new FormData(passwordForm)\n const currentPassword = String(data.get('currentPassword') || '')\n const newPassword = String(data.get('newPassword') || '')\n const confirmPassword = String(data.get('confirmPassword') || '')\n try {\n if (!currentPassword || !newPassword || !confirmPassword) throw new Error('请完整填写密码修改信息')\n if (newPassword !== confirmPassword) throw new Error('两次输入的控制台密码不一致')\n if (newPassword.length < 12) throw new Error('控制台密码至少需要 12 个字符')\n if (new TextEncoder().encode(newPassword).length > 1024) throw new Error('控制台密码不能超过 1024 字节')\n if (newPassword.includes('\\u0000')) throw new Error('控制台密码包含不支持的字符')\n await api('/v1/admin/password', { method: 'PUT', body: { currentPassword, newPassword, confirmPassword } })\n showLogin()\n toast('控制台密码已修改,请重新登录。')\n } catch (reason) { error.textContent = reason.message }\n })\n }\n byId('theme-select').onchange = (event) => {\n localStorage.setItem('agent-nexus-theme', event.target.value)\n applyTheme(event.target.value)\n }\n}\n",
|
|
19
|
+
"render-shared": "import { state, pageMeta } from './state.js'\nimport { byId, pageStats, pageToolbar, pageResults, pageStatus, actions, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\n\nlet actionsPage = null\nlet actionsHtml = null\nlet toolbarSignature = null\nlet resultsHtml = null\nexport function resetSharedView() {\n actionsPage = actionsHtml = toolbarSignature = resultsHtml = null\n for (const element of [actions,pageStats,pageToolbar,pageResults,pageStatus]) element.replaceChildren()\n}\n\nexport function setResults(html) {\n if (resultsHtml === html) return\n resultsHtml = html\n const focused = pageResults.contains(document.activeElement) ? document.activeElement : null\n pageResults.innerHTML = html\n if (focused) {\n const data = Object.entries(focused.dataset)\n const replacement = Array.from(pageResults.querySelectorAll('button, input, select')).find((element) =>\n focused.id ? focused.id === element.id : data.length && data.every(([key, value]) => element.dataset[key] === value))\n ;(replacement || document.querySelector('.nav-item.active'))?.focus({ preventScroll: true })\n }\n}\n\nexport function setActions(html) {\n if (actionsPage === state.page && actionsHtml === html) return\n actionsPage = state.page\n actionsHtml = html\n actions.innerHTML = html\n}\n\nexport function setToolbar(signature, html = '') {\n if (toolbarSignature === signature) return\n toolbarSignature = signature\n pageToolbar.innerHTML = html\n}\n\nexport function announce(message) {\n const summary = pageMeta[state.page] + ':' + message\n if (pageStatus.textContent !== summary) pageStatus.textContent = summary\n}\n\nexport function stat(label, value, href) {\n const open = href ? '<a class=\"stat\" href=\"' + escapeHtml(href) + '\">' : '<div class=\"stat\">'\n const close = href ? '</a>' : '</div>'\n return open + '<span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong>' + close\n}\n\nexport function emptyState(icon, title, text, cta) {\n return '<div class=\"empty-state\">' + icon + '<strong>' + escapeHtml(title) + '</strong>' +\n (text ? '<span>' + escapeHtml(text) + '</span>' : '') +\n (cta ? '<button type=\"button\" class=\"button primary\" data-empty-action=\"' + escapeHtml(cta.action) + '\">' + icons.plus + escapeHtml(cta.label) + '</button>' : '') +\n '</div>'\n}\n\nexport function bindSearch(id, apply) {\n const input = byId(id)\n if (input.dataset.searchBound) return\n input.dataset.searchBound = 'true'\n input.addEventListener('compositionstart', () => { input.dataset.composing = 'true' })\n input.addEventListener('compositionend', () => { delete input.dataset.composing; apply(input.value) })\n input.addEventListener('input', (event) => {\n if (!event.isComposing && !input.dataset.composing) apply(input.value)\n })\n}\n\nexport function preciseUnitValue(value, unit, fallback) {\n const number = Number.isFinite(Number(value)) ? Number(value) : fallback\n const rendered = (number / unit).toFixed(7)\n return rendered.replace(/0+$/, '').replace(/\\.$/, '')\n}\n",
|
|
19
20
|
"render-workspaces": "import { state } from './state.js'\nimport { byId, escapeHtml } from './dom.js'\nimport { icons } from './icons.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { openDrawer, closeDrawer } from './drawer.js'\n\nimport { markResourceSaved } from './data.js'\n\nimport { render } from './render.js'\nimport { setResults, setActions, announce, emptyState } from './render-shared.js'\n\nexport function withinRoot(root, workspace) {\n const windows = /^[a-z]:[\\\\/]|^\\\\\\\\/i.test(root)\n const normalize = (value) => windows ? value.replace(/\\\\/g, '/').toLowerCase() : value\n const base = normalize(root).replace(/\\/+$/, '')\n const candidate = normalize(workspace)\n return candidate === base || candidate.startsWith(base + '/')\n}\n\nexport function workspaceDependents(index) {\n const roots = state.config.workspaceRoots\n return state.config.agents.filter((agent) => agent.protocol === 'acp' && agent.workspace &&\n withinRoot(roots[index], agent.workspace) &&\n !roots.some((root, other) => other !== index && withinRoot(root, agent.workspace)))\n}\n\nexport function workspaceRow(root, index, total) {\n const usedBy = state.config.agents.filter((agent) => agent.protocol === 'acp' && agent.workspace && withinRoot(root, agent.workspace)).map((agent) => escapeHtml(agent.name))\n const blocked = workspaceDependents(index).length > 0\n return '<div class=\"list-row\"><div class=\"list-row-main\"><strong class=\"path\">' + escapeHtml(root) + '</strong>' +\n '<small>' + (usedBy.length ? '使用中:' + usedBy.join('、') : '没有智能体使用') + '</small></div>' +\n '<div class=\"row-actions\"><button class=\"button small\" data-workspace-edit=\"' + index + '\">编辑</button>' +\n '<button class=\"button small danger\" data-workspace-delete=\"' + index + '\"' +\n (total === 1 ? ' disabled title=\"至少保留一个工作区\"' : blocked ? ' disabled title=\"请先修改使用此工作区的智能体配置\"' : '') + '>删除</button></div></div>'\n}\n\nexport function renderWorkspaces() {\n setActions('<button id=\"add-workspace\" class=\"button primary\">' + icons.plus + '添加工作区</button>')\n byId('add-workspace').onclick = () => openWorkspaceDrawer()\n byId('add-workspace').disabled = !state.resources.config.lastSuccessAt\n const roots = state.config.workspaceRoots || []\n const pending = !state.resources.config.lastSuccessAt && ['idle', 'loading'].includes(state.resources.config.status)\n setResults(pending ? '<div class=\"panel\"><p class=\"panel-note\">正在加载工作区配置…</p></div>' : !state.resources.config.lastSuccessAt ? '<div class=\"panel\"><p class=\"panel-note\">工作区配置读取失败,请重试后编辑。</p></div>' : '<div class=\"panel\"><div class=\"panel-header\"><h2>允许的根目录</h2><span class=\"muted\">' + roots.length + ' 个</span></div>' +\n (roots.length\n ? '<div class=\"panel-body workspace-list\">' + roots.map((root, index) => workspaceRow(root, index, roots.length)).join('') + '</div>'\n : emptyState(icons.folder, '尚未配置工作区', '', { action: 'add-workspace', label: '添加工作区' })) +\n '</div>')\n announce(roots.length + ' 个工作区')\n}\n\nexport function openWorkspaceDrawer(index) {\n if (!state.resources.config.lastSuccessAt) { toast('配置尚未成功读取,请重试后编辑', true); return }\n const editing = Number.isInteger(index)\n const current = editing ? state.config.workspaceRoots[index] : ''\n openDrawer(editing ? '编辑工作区' : '添加工作区', '<div class=\"field\"><label for=\"f-path\">允许访问的根目录</label><input id=\"f-path\" name=\"path\" value=\"' + escapeHtml(current) + '\" required></div>', editing ? '保存修改' : '添加工作区', async (form, isCurrent) => {\n const roots = state.config.workspaceRoots.slice()\n const value = String(new FormData(form).get('path') || '').trim()\n if (editing) roots[index] = value\n else roots.push(value)\n const result = await api('/v1/admin/config/workspace-roots', { method: 'PUT', body: { workspaceRoots: roots } })\n state.config = result\n markResourceSaved('config')\n if (isCurrent()) closeDrawer()\n render()\n toast(editing ? '工作区已更新' : '工作区已添加')\n })\n}\n",
|
|
20
|
-
"render": "// Page dispatch; rendering and write state live with each page.\nimport { state, pageMeta } from './state.js'\nimport { byId, pageStats } from './dom.js'\nimport { setToolbar, resetSharedView } from './render-shared.js'\nimport { renderOverview } from './render-overview.js'\nimport { renderRuns, resetRunView } from './render-runs.js'\nimport { renderAgents } from './render-agents.js'\nimport { renderWorkspaces } from './render-workspaces.js'\nimport { renderApiKeys, closeKeyActionMenu } from './render-keys.js'\nimport { renderSettings } from './render-settings.js'\nexport * from './render-runs.js'\nexport * from './render-agents.js'\nexport * from './render-workspaces.js'\nexport * from './render-keys.js'\nexport function resetView() {\n resetRunView()\n closeKeyActionMenu()\n resetSharedView()\n}\n\nexport function render() {\n byId('page-title').textContent = pageMeta[state.page]\n byId('page-content').closest('.main')?.classList.toggle('settings-page', state.page === 'settings')\n document.querySelectorAll('.nav-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.page === state.page)\n if (item.dataset.page === state.page) item.setAttribute('aria-current', 'page')\n else item.removeAttribute('aria-current')\n })\n if (state.page !== 'runs') pageStats.innerHTML = ''\n if (state.page !== 'runs' && state.page !== 'agents') setToolbar('')\n if (state.page === 'overview') renderOverview()\n if (state.page === 'runs') renderRuns()\n if (state.page === 'agents') renderAgents()\n if (state.page === 'workspaces') renderWorkspaces()\n if (state.page === 'keys') renderApiKeys()\n if (state.page === 'settings') renderSettings()\n}\n",
|
|
21
|
+
"render": "// Page dispatch; rendering and write state live with each page.\nimport { state, pageMeta } from './state.js'\nimport { byId, pageStats } from './dom.js'\nimport { setToolbar, resetSharedView } from './render-shared.js'\nimport { renderOverview } from './render-overview.js'\nimport { renderRuns, resetRunView } from './render-runs.js'\nimport { renderAgents } from './render-agents.js'\nimport { renderWorkspaces } from './render-workspaces.js'\nimport { renderApiKeys, closeKeyActionMenu } from './render-keys.js'\nimport { renderSettings } from './render-settings.js'\nimport { renderArtifacts } from './render-artifacts.js'\nexport * from './render-runs.js'\nexport * from './render-agents.js'\nexport * from './render-workspaces.js'\nexport * from './render-keys.js'\nexport * from './render-artifacts.js'\nexport function resetView() {\n resetRunView()\n closeKeyActionMenu()\n resetSharedView()\n}\n\nexport function render() {\n byId('page-title').textContent = pageMeta[state.page]\n byId('page-content').closest('.main')?.classList.toggle('settings-page', state.page === 'settings')\n document.querySelectorAll('.nav-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.page === state.page)\n if (item.dataset.page === state.page) item.setAttribute('aria-current', 'page')\n else item.removeAttribute('aria-current')\n })\n if (state.page !== 'runs') pageStats.innerHTML = ''\n if (state.page !== 'runs' && state.page !== 'agents') setToolbar('')\n if (state.page === 'overview') renderOverview()\n if (state.page === 'runs') renderRuns()\n if (state.page === 'artifacts') renderArtifacts()\n if (state.page === 'agents') renderAgents()\n if (state.page === 'workspaces') renderWorkspaces()\n if (state.page === 'keys') renderApiKeys()\n if (state.page === 'settings') renderSettings()\n}\n",
|
|
21
22
|
"screens": "import { state } from './state.js'\nimport { setupScreen, loginScreen, appRoot, byId } from './dom.js'\nimport { api } from './api.js'\nimport { toast } from './toast.js'\nimport { loadAll, resetData } from './data.js'\nimport { render, resetView, openRunDrawer } from './render.js'\nimport { readLocationState } from './location-state.js'\nimport { applyTheme } from './theme.js'\nimport { closeDrawer } from './drawer.js'\n\nfunction showOnly(element) {\n byId('boot-screen').classList.add('hidden')\n setupScreen.classList.add('hidden')\n loginScreen.classList.add('hidden')\n appRoot.classList.add('hidden')\n element.classList.remove('hidden')\n}\n\nexport function showLogin() {\n state.authenticated = false\n state.authEpoch++\n closeDrawer()\n resetData()\n resetView()\n showOnly(loginScreen)\n byId('login-form').reset()\n byId('login-password').focus()\n}\n\nexport async function boot() {\n applyTheme(localStorage.getItem('agent-nexus-theme') || 'system')\n try {\n const bootstrap = await api('/v1/bootstrap/status')\n if (bootstrap.adminSetupRequired) {\n showOnly(setupScreen)\n byId('setup-token').focus()\n return\n }\n const auth = await api('/v1/admin/auth/status')\n if (!auth.authenticated) {\n showLogin()\n return\n }\n await enterApp()\n } catch (error) {\n showLogin()\n toast(error.message, true)\n }\n}\n\nexport async function enterApp() {\n const epoch = state.authEpoch\n // Authentication is the gate for the shell. Agent probes and history reads\n // are deliberately started after the shell is visible so a slow/unreachable\n // remote agent cannot send an administrator back to the login screen.\n state.authenticated = true\n Object.assign(state, readLocationState())\n showOnly(appRoot)\n render()\n if (state.selectedRunId) void openRunDrawer(state.selectedRunId)\n if (epoch !== state.authEpoch) return\n void loadAll().catch((error) => {\n // Individual resources already keep their last good value and expose a\n // retry affordance. This catch is only a guard for an unexpected loader\n // failure and must not turn a valid admin session into a login prompt.\n if (epoch === state.authEpoch) toast(error.message, true)\n })\n}\n",
|
|
22
|
-
"state": "// @ts-check\nimport { readLocationState } from './location-state.js'\nconst initialLocation = /** @type {Pick<import('./contracts.js').WebUiState, 'page'|'runAgent'|'runStatus'|'runPageSize'|'runOffset'|'selectedRunId'>} */ (readLocationState())\n/** @type {import('./contracts.js').WebUiState} */\nexport const state = {\n config: { workspaceRoots: [], driverKinds: [], agents: [] },\n readiness: [],\n apiKeys: [],\n runs: [],\n runTotal: 0,\n runStats: { active: 0, completed: 0, failed: 0 },\n sessions: 0,\n search: '',\n protocol: 'all',\n status: 'all',\n runSearch: '',\n runHasNew: false,\n authEpoch: 0,\n authenticated: false,\n metrics: null,\n resources: {\n config: { status: 'idle', lastSuccessAt: 0 },\n apiKeys: { status: 'idle', lastSuccessAt: 0 },\n overview: { status: 'idle', lastSuccessAt: 0 },\n runs: { status: 'idle', lastSuccessAt: 0 },\n metrics: { status: 'idle', lastSuccessAt: 0 }\n },\n ...initialLocation\n}\n\nexport const pageMeta = {\n overview: '总览',\n runs: '运行记录',\n agents: '智能体',\n workspaces: '工作区',\n keys: 'API 密钥',\n settings: '设置'\n}\n",
|
|
23
|
+
"state": "// @ts-check\nimport { readLocationState } from './location-state.js'\nconst initialLocation = /** @type {Pick<import('./contracts.js').WebUiState, 'page'|'runAgent'|'runStatus'|'runPageSize'|'runOffset'|'selectedRunId'>} */ (readLocationState())\n/** @type {import('./contracts.js').WebUiState} */\nexport const state = {\n config: { workspaceRoots: [], driverKinds: [], agents: [] },\n readiness: [],\n apiKeys: [],\n runs: [],\n runTotal: 0,\n runStats: { active: 0, completed: 0, failed: 0 },\n sessions: 0,\n search: '',\n protocol: 'all',\n status: 'all',\n runSearch: '',\n runHasNew: false,\n artifacts: [],\n artifactTotal: 0,\n artifactAgent: 'all',\n artifactKind: 'all',\n artifactOffset: 0,\n authEpoch: 0,\n authenticated: false,\n metrics: null,\n resources: {\n config: { status: 'idle', lastSuccessAt: 0 },\n apiKeys: { status: 'idle', lastSuccessAt: 0 },\n overview: { status: 'idle', lastSuccessAt: 0 },\n runs: { status: 'idle', lastSuccessAt: 0 },\n metrics: { status: 'idle', lastSuccessAt: 0 }\n },\n ...initialLocation\n}\n\nexport const pageMeta = {\n overview: '总览',\n runs: '运行记录',\n artifacts: '产物',\n agents: '智能体',\n workspaces: '工作区',\n keys: 'API 密钥',\n settings: '设置'\n}\n",
|
|
23
24
|
"theme": "import { byId } from './dom.js'\n\nexport function applyTheme(preference) {\n const theme = preference === 'system'\n ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')\n : preference\n document.documentElement.dataset.theme = theme\n const select = byId('theme-select')\n if (select) select.value = preference\n}\n\n",
|
|
24
25
|
"toast": "import { byId } from './dom.js'\n\nexport function toast(message, error) {\n const item = document.createElement('div')\n item.className = 'toast' + (error ? ' error' : '')\n item.textContent = message\n byId(error ? 'toast-alert' : 'toast-status').appendChild(item)\n const region = byId('notifications')\n if (region.closest('#drawer')) region.scrollTop = region.scrollHeight\n setTimeout(() => item.remove(), 4200)\n}\n\nexport async function runAction(task) {\n try { await task() } catch (error) { toast(error.message, true) }\n}\n\nexport async function withBusy(button, task) {\n if (!button) return task()\n if (button.getAttribute('aria-busy') === 'true') return\n const disabled = button.disabled\n button.disabled = true\n button.setAttribute('aria-busy', 'true')\n try { return await task() } finally {\n button.disabled = disabled\n button.removeAttribute('aria-busy')\n }\n}\n"
|
|
25
26
|
};
|