diff --git a/.gitignore b/.gitignore index 091738dc0db3755a559d1aae3522d0f9e00ff914..fa79406e33fc75ddd2edbc5327b4a2b215a7c126 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ apps/management-website/.debug-runtime/ apps/.debug-runtime/app-invoke-proxy-service.pid.json kernal/docker-image/.aios-full-smoke/ apps/.debug-runtime/ + + +output/ +.playwright-cli/ \ No newline at end of file diff --git a/apps/management-website/README.md b/apps/management-website/README.md index e570905e62d70d05315f16567b244969eec3df89..5d2ec0e85617ee7a07cbced431fde774c64e9b43 100644 --- a/apps/management-website/README.md +++ b/apps/management-website/README.md @@ -14,7 +14,7 @@ AIOS 管理控制台是面向运维和管理员的 Web 应用,技术栈为 `Re - 本地数据库缓存环境目录和 Token 用量 - 启动同步、定时同步和手动同步共用同一套同步计划 -管理控制台的列表、状态和统计统一从 SQLite 读取。远端数据只通过 `environment.info` 和 `agent.usage.list` 两类同步任务刷新。 +管理控制台的列表、状态和统计统一从 SQLite 读取。远端数据只通过 `catalog.snapshot` 和 `agent.usage.list` 两类同步任务刷新。 ## 目录结构 @@ -109,12 +109,12 @@ npm run test:full 启动后后台流程为: 1. 等待 `aios-management-serivce` 的 `service.ping` 可用 -2. 执行一次 `environment.info` -3. 如果环境同步成功,60 秒后执行第一次 `agent.usage.list` -4. 每次 `environment.info` 完成后 120 秒再次执行 +2. 执行一次 `catalog.snapshot` +3. 如果目录快照同步成功,60 秒后执行第一次 `agent.usage.list` +4. 每次 `catalog.snapshot` 完成后 120 秒再次执行 5. 每次 `agent.usage.list` 完成后 120 秒再次执行 -设置页“同步数据”按钮会先执行 `environment.info`,再执行 `agent.usage.list`。同步过程会显示蒙版,完成后用 toast 提示结果。 +设置页“同步数据”按钮会先执行 `catalog.snapshot`,再执行 `agent.usage.list`。同步过程会显示蒙版,完成后用 toast 提示结果。 同步状态写入这些表: diff --git a/apps/management-website/docs/spec.md b/apps/management-website/docs/spec.md index e63d0c0e28557643c2b096db2e70669f59571513..b96550db5687ed8487d3bb5ecba2eabdac452fd7 100644 --- a/apps/management-website/docs/spec.md +++ b/apps/management-website/docs/spec.md @@ -44,13 +44,13 @@ Token 用量由 `agent.usage.list` 同步到 `agents.usage_snapshot_json`。超 ### Skill -当前只管理全局 Skill。上传 zip 顶层必须包含 `SKILL.md`。安装和删除通过 `skills.global.install.local` / `skills.global.delete` 执行。 +当前只管理全局 Skill。上传 zip 顶层必须包含符合 AgentSkills 规范的 `SKILL.md`,frontmatter 至少包含 `name` 和 `description`。安装和删除通过 `skills.global.install.local` / `skills.global.delete` 执行。 ### LLM Provider 表示 OpenAI 兼容模型服务的 `baseUrl + SK`。Model 归属 Provider,运行时引用格式为 `provider_id/model_id`。 -Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.info` 同步后缓存到本地数据库。 +Provider / Model 的真实目录来自 OpenClaw,控制台通过 `catalog.snapshot` 同步后缓存到本地数据库。 ### 业务系统 @@ -60,7 +60,7 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i ## 同步策略 -### 环境信息同步 +### 目录快照同步 触发时机: @@ -70,7 +70,7 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i 行为: -- 发送 `environment.info` 到 `aios-management-serivce` +- 发送 `catalog.snapshot` 到 `aios-management-serivce` - 一次性获取 LLM、Agent、Template、Skill、System、Ontology 目录 - 写入本地 SQLite - 更新目录同步状态表 @@ -79,9 +79,9 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i 触发时机: -- 启动 `environment.info` 成功后 60 秒执行第一次 +- 启动 `catalog.snapshot` 成功后 60 秒执行第一次 - 每次完成后 120 秒再次执行 -- 管理员点击“同步数据”时,在 `environment.info` 后执行 +- 管理员点击“同步数据”时,在 `catalog.snapshot` 后执行 行为: @@ -101,11 +101,11 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i 服务操作包含: -- “同步数据”按钮:手动执行 `environment.info` 后接 `agent.usage.list` +- “同步数据”按钮:手动执行 `catalog.snapshot` 后接 `agent.usage.list` - 服务器状态 - 服务器诊断 - 重启服务 -- 环境信息同步状态表 +- 目录快照同步状态表 - Token 同步状态表 设置页不再提供定时任务 Tab。 diff --git a/apps/management-website/package-lock.json b/apps/management-website/package-lock.json index 585f09e72d9efd1d0108f1d28a75fd9827762406..040ef612e5f766b1b45106af918b9bd65649d037 100644 --- a/apps/management-website/package-lock.json +++ b/apps/management-website/package-lock.json @@ -1,12 +1,12 @@ { "name": "aios-management-web", - "version": "0.1.6", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-management-web", - "version": "0.1.6", + "version": "0.2.8", "dependencies": { "@ant-design/icons": "^6.0.0", "@ant-design/x": "^1.6.1", diff --git a/apps/management-website/package.json b/apps/management-website/package.json index a234bfad99619e124af947027a2c99f2b7b3fea6..51f51c252a0026a8d622621337a5b6065b4e8cd6 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.2.7", + "version": "0.3.4", "type": "module", "files": [ "dist", @@ -20,6 +20,7 @@ "test:e2e-browser": "node test/run-browser-e2e.js", "test:fast": "node test/integration/fast.js", "test:full": "node test/integration/full.js", + "test:website-full": "node test/integration/website-full.js", "prepack": "npm run build", "prepublishOnly": "npm test" }, diff --git a/apps/management-website/server/src/api/routes/index.js b/apps/management-website/server/src/api/routes/index.js index 6a39baa6bd17a6579609e6d4402d52abd70e8914..747029589b5d6470de15691863ce0678384482b1 100644 --- a/apps/management-website/server/src/api/routes/index.js +++ b/apps/management-website/server/src/api/routes/index.js @@ -22,13 +22,13 @@ function asyncRoute(handler) { return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); } -function normalizeTimeoutMs(value, fallback = 120000) { +function normalizeTimeoutMs(value, fallback = 120000, max = 120000) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } - return Math.max(1000, Math.min(120000, Math.floor(parsed))); + return Math.max(1000, Math.min(max, Math.floor(parsed))); } function handleLogoUpload(req, res, next) { @@ -801,10 +801,10 @@ export function createRoutes(services) { res.json(result); })); - router.post("/settings/environment-sync", asyncRoute(async (req, res) => { + router.post("/settings/catalog-sync", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); const result = await services.catalogSyncService.runManualSyncPlan({ trigger: "manual" }); - writeAuditLog(services, req, "同步数据", "手动触发 environment.info 与 agent.usage.list 数据同步"); + writeAuditLog(services, req, "同步数据", "手动触发 catalog.snapshot 与 agent.usage.list 数据同步"); res.json({ result, ...getCatalogSyncStatuses(services) @@ -840,10 +840,11 @@ export function createRoutes(services) { throw badRequest("参数必须是 JSON 对象"); } + const timeoutMs = normalizeTimeoutMs(req.query.timeout_ms ?? req.body?.timeout_ms, 180000, 180000); const startedAt = Date.now(); writeAuditLog(services, req, "执行管理指令", `手动执行管理指令:${action}`); try { - const result = await services.rpcClient.call(action, params, 180000); + const result = await services.rpcClient.call(action, params, timeoutMs); res.json({ ok: true, result, diff --git a/apps/management-website/server/src/background/index.js b/apps/management-website/server/src/background/index.js index 08d6b76d3ffd4b4cfdb597e5ec7d7e278ff61029..c93542027e3cc84a2ad59391fdab87585af839cd 100644 --- a/apps/management-website/server/src/background/index.js +++ b/apps/management-website/server/src/background/index.js @@ -1,4 +1,4 @@ -const DEFAULT_STARTUP_SYNC_READY_TIMEOUT_MS = 60000; +const DEFAULT_STARTUP_SYNC_READY_TIMEOUT_MS = 240000; const DEFAULT_STARTUP_SYNC_PING_TIMEOUT_MS = 1000; const DEFAULT_STARTUP_SYNC_POLL_INTERVAL_MS = 1000; @@ -59,7 +59,7 @@ export async function waitForManagementRpcReady({ } export function startBackgroundServices({ env, services }) { - let environmentTimer = null; + let catalogTimer = null; let usageTimer = null; const scheduleDelayMs = 2 * 60 * 1000; const usageStartupDelayMs = 60 * 1000; @@ -70,16 +70,16 @@ export function startBackgroundServices({ env, services }) { } } - function scheduleEnvironmentSync(delayMs) { - clearTimer(environmentTimer); - environmentTimer = setTimeout(async () => { - environmentTimer = null; + function scheduleCatalogSync(delayMs) { + clearTimer(catalogTimer); + catalogTimer = setTimeout(async () => { + catalogTimer = null; try { - await services.catalogSyncService.syncEnvironmentTask({ trigger: "scheduled" }); + await services.catalogSyncService.syncCatalogTask({ trigger: "scheduled" }); } catch (error) { - console.error("Scheduled environment catalog sync failed", error); + console.error("Scheduled catalog sync failed", error); } finally { - scheduleEnvironmentSync(scheduleDelayMs); + scheduleCatalogSync(scheduleDelayMs); } }, delayMs); } @@ -122,14 +122,14 @@ export function startBackgroundServices({ env, services }) { } try { - const result = await services.catalogSyncService.syncStartupEnvironmentTask({ trigger: "startup" }); + const result = await services.catalogSyncService.syncStartupCatalogTask({ trigger: "startup" }); if (result?.status === "success") { scheduleUsageSync(usageStartupDelayMs); } } catch (error) { - console.error("Startup environment catalog startup sync failed", error); + console.error("Startup catalog sync failed", error); } finally { - scheduleEnvironmentSync(scheduleDelayMs); + scheduleCatalogSync(scheduleDelayMs); } }) .catch((error) => { @@ -139,8 +139,8 @@ export function startBackgroundServices({ env, services }) { return { async stop() { - clearTimer(environmentTimer); - environmentTimer = null; + clearTimer(catalogTimer); + catalogTimer = null; clearTimer(usageTimer); usageTimer = null; diff --git a/apps/management-website/server/src/config/env.js b/apps/management-website/server/src/config/env.js index 6987c8d2cf380f1eed0fe7a61f7898cf2cf7fd7c..8d2d168e85905dacfa99cbdf008234cd21708ccf 100644 --- a/apps/management-website/server/src/config/env.js +++ b/apps/management-website/server/src/config/env.js @@ -60,6 +60,9 @@ export function loadEnv() { dataDir, sessionTtlHours: Number(readEnvValue("AIOS_WEB_SESSION_TTL_HOURS", "12")), managementTimeoutMs: readEnvNumber("AIOS_MANAGEMENT_WEBSITE_TIMEOUT", 120) * 1000, + startupSyncReadyTimeoutMs: readEnvNumber("AIOS_STARTUP_SYNC_READY_TIMEOUT_MS", 240000), + startupSyncPingTimeoutMs: readEnvNumber("AIOS_STARTUP_SYNC_PING_TIMEOUT_MS", 1000), + startupSyncPollIntervalMs: readEnvNumber("AIOS_STARTUP_SYNC_POLL_INTERVAL_MS", 1000), mqtt: { brokerUrl: readEnvValue("AIOS_MQTT_CHANNEL_BROKER"), username: readEnvValue("AIOS_MQTT_CHANNEL_USERNAME"), diff --git a/apps/management-website/server/src/services/agent-service.js b/apps/management-website/server/src/services/agent-service.js index 96d272cd86b6661046526aa88a87e4dd1ce289c8..f39a2ac14afa8aa08c2132ae4b3f278318e3a1f0 100644 --- a/apps/management-website/server/src/services/agent-service.js +++ b/apps/management-website/server/src/services/agent-service.js @@ -119,6 +119,38 @@ function computeHealth(agent, usage) { return "normal"; } +function extractAgentTopics(remote) { + const topics = remote?.topics && typeof remote.topics === "object" ? remote.topics : {}; + return { + inboundTopic: firstText( + remote?.inboundTopic, + remote?.["inbound-topic"], + topics.inbound, + topics.inboundTopic, + topics["inbound-topic"] + ), + outboundTopic: firstText( + remote?.outboundTopic, + remote?.["outbound-topic"], + topics.outbound, + topics.outboundTopic, + topics["outbound-topic"] + ) + }; +} + +function normalizeRemoteState(remote) { + const normalized = remote && typeof remote === "object" ? { ...remote } : {}; + const { inboundTopic, outboundTopic } = extractAgentTopics(normalized); + if (inboundTopic) { + normalized.inboundTopic = inboundTopic; + } + if (outboundTopic) { + normalized.outboundTopic = outboundTopic; + } + return normalized; +} + function effectiveAgentStatus(row) { if (row.status === "overlimit") { return "overlimit"; @@ -128,13 +160,13 @@ function effectiveAgentStatus(row) { } function agentDirectoryItem(row, users) { - const remote = jsonParse(row.remote_state_json, {}); + const remote = normalizeRemoteState(jsonParse(row.remote_state_json, {})); return { agent_id: row.slug, agent_name: firstText(row.agent_name, row.slug), status: effectiveAgentStatus(row), - inbound_topic: firstText(remote?.inboundTopic, remote?.["inbound-topic"]), - outbound_topic: firstText(remote?.outboundTopic, remote?.["outbound-topic"]), + inbound_topic: remote.inboundTopic || "", + outbound_topic: remote.outboundTopic || "", users }; } @@ -306,7 +338,7 @@ export class AgentService { const rows = this.localRows(); return rows.map((row) => { const usage = normalizeUsage(jsonParse(row.usage_snapshot_json, {}).usage); - const remote = jsonParse(row.remote_state_json, {}); + const remote = normalizeRemoteState(jsonParse(row.remote_state_json, {})); const permissions = this.getPermissions(row.id); return { ...row, diff --git a/apps/management-website/server/src/services/catalog-sync-service.js b/apps/management-website/server/src/services/catalog-sync-service.js index c5d0c9ba31f3cfe1b0b90935a95fe1e87e37e6d7..5bb76b25a251e180ad6ff1dd93de215ccfedc995 100644 --- a/apps/management-website/server/src/services/catalog-sync-service.js +++ b/apps/management-website/server/src/services/catalog-sync-service.js @@ -21,7 +21,7 @@ const STARTUP_SYNC_STATE_TABLES = [ "system_sync_state" ]; -const STARTUP_ENVIRONMENT_SYNC_STATES = [ +const CATALOG_SYNC_STATES = [ { tableName: "llm_sync_state" }, @@ -38,6 +38,7 @@ const STARTUP_ENVIRONMENT_SYNC_STATES = [ tableName: "system_sync_state" } ]; +const CATALOG_SNAPSHOT_VERSION = 1; function hasUsageFields(payload) { if (!payload || typeof payload !== "object") { @@ -72,6 +73,7 @@ function firstText(...values) { function isBuiltInItem(item) { return [ + item?.builtIn, item?.["is-built-in"], item?.is_builtin, item?.isBuiltIn, @@ -115,8 +117,54 @@ function normalizeRemoteModelRef(value) { return splitModelRef(firstText(value.primary)); } -function listItems(payload) { - return Array.isArray(payload?.items) ? payload.items : []; +function normalizeAgentRemoteState(item) { + const remote = item && typeof item === "object" ? { ...item } : {}; + const topics = remote.topics && typeof remote.topics === "object" ? remote.topics : {}; + const inboundTopic = firstText( + remote.inboundTopic, + remote["inbound-topic"], + topics.inbound, + topics.inboundTopic, + topics["inbound-topic"] + ); + const outboundTopic = firstText( + remote.outboundTopic, + remote["outbound-topic"], + topics.outbound, + topics.outboundTopic, + topics["outbound-topic"] + ); + + if (inboundTopic) { + remote.inboundTopic = inboundTopic; + } + if (outboundTopic) { + remote.outboundTopic = outboundTopic; + } + + return remote; +} + +function listRpcItems(payload, label) { + if (!payload || !Array.isArray(payload.items)) { + throw new Error(`${label}.items must be an array`); + } + return payload.items; +} + +function readCatalogItems(snapshot, kind) { + if (!snapshot || typeof snapshot !== "object") { + throw new Error("catalog.snapshot returned an empty payload"); + } + if (snapshot.version !== CATALOG_SNAPSHOT_VERSION) { + throw new Error(`catalog.snapshot version must be ${CATALOG_SNAPSHOT_VERSION}`); + } + + const catalog = snapshot.catalogs?.[kind]; + if (!catalog || catalog.kind !== kind || !Array.isArray(catalog.items)) { + throw new Error(`catalogs.${kind}.items must be an array`); + } + return catalog.items; } function normalizeUsageItemPayload(item) { @@ -160,7 +208,7 @@ export class CatalogSyncService { this.pendingSkillSync = null; this.pendingTemplateSync = null; this.pendingSystemSync = null; - this.pendingStartupEnvironmentSync = null; + this.pendingCatalogSnapshotSync = null; this.pendingCatalogSyncPlan = null; } @@ -206,8 +254,8 @@ export class CatalogSyncService { return this.rpcClient.call(action, params, timeoutMs); } - async syncStartupEnvironmentTask({ trigger = "startup" } = {}) { - return await this.syncEnvironmentTask({ trigger }); + async syncStartupCatalogTask({ trigger = "startup" } = {}) { + return await this.syncCatalogTask({ trigger }); } async runManualSyncPlan({ trigger = "manual" } = {}) { @@ -216,10 +264,10 @@ export class CatalogSyncService { } this.pendingCatalogSyncPlan = (async () => { - const environment = await this.syncEnvironmentTask({ trigger }); + const catalog = await this.syncCatalogTask({ trigger }); const usage = await this.refreshAgentUsage({ trigger }); return { - environment, + catalog, usage }; })().finally(() => { @@ -229,19 +277,19 @@ export class CatalogSyncService { return await this.pendingCatalogSyncPlan; } - async syncEnvironmentTask({ trigger = "manual" } = {}) { - if (this.pendingStartupEnvironmentSync) { - return await this.pendingStartupEnvironmentSync; + async syncCatalogTask({ trigger = "manual" } = {}) { + if (this.pendingCatalogSnapshotSync) { + return await this.pendingCatalogSnapshotSync; } - this.pendingStartupEnvironmentSync = this.runStartupEnvironmentSync(trigger).finally(() => { - this.pendingStartupEnvironmentSync = null; + this.pendingCatalogSnapshotSync = this.runCatalogSnapshotSync(trigger).finally(() => { + this.pendingCatalogSnapshotSync = null; }); - return await this.pendingStartupEnvironmentSync; + return await this.pendingCatalogSnapshotSync; } - async runStartupEnvironmentSync(trigger) { + async runCatalogSnapshotSync(trigger) { const startedAt = new Date().toISOString(); if (!this.rpcClient.isConfigured()) { @@ -278,14 +326,14 @@ export class CatalogSyncService { } try { - const remote = await this.callManagement("environment.info"); + const remote = await this.callManagement("catalog.snapshot"); const now = new Date().toISOString(); - const llmItems = listItems(remote?.llmProviders); - const templateItems = listItems(remote?.templates); - const agentItems = listItems(remote?.agents); - const skillItems = listItems(remote?.skills); - const systemItems = listItems(remote?.systems); - const ontologyItems = listItems(remote?.ontologies); + const llmItems = readCatalogItems(remote, "llmProviders"); + const templateItems = readCatalogItems(remote, "templates"); + const agentItems = readCatalogItems(remote, "agents"); + const skillItems = readCatalogItems(remote, "skills"); + const systemItems = readCatalogItems(remote, "systems"); + const ontologyItems = readCatalogItems(remote, "ontologies"); const summaries = { llm_sync_state: { models: countLargeLanguageModels(llmItems) @@ -312,7 +360,7 @@ export class CatalogSyncService { this.syncSystems(systemItems, now, builtInOntologyNames(ontologyItems)); const finishedAt = new Date().toISOString(); - for (const item of STARTUP_ENVIRONMENT_SYNC_STATES) { + for (const item of CATALOG_SYNC_STATES) { this.updateState(item.tableName, { status: "success", triggerSource: trigger, @@ -364,9 +412,9 @@ export class CatalogSyncService { } async fetchAgentUsageSnapshots(agentItems) { - const agentIds = agentItems.map((item) => String(item?.agentId || "").trim()).filter(Boolean); + const agentIds = agentItems.map((item) => String(item?.id || "").trim()).filter(Boolean); const remote = await this.callManagement("agent.usage.list", { agentIds }); - const pairs = listItems(remote) + const pairs = listRpcItems(remote, "agent.usage.list") .map((item) => normalizeUsageItemPayload(item)) .filter(Boolean) .map((snapshot) => [snapshot.agentId, { @@ -512,9 +560,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM agents WHERE id = ?"); for (const item of items) { - const slug = String(item?.agentId || "").trim(); + const slug = String(item?.id || "").trim(); if (!slug) { - continue; + throw new Error("catalogs.agents.items[].id is required"); } seen.add(slug); @@ -526,11 +574,11 @@ export class CatalogSyncService { ...existingUsageSnapshot, usage: normalizeUsage(existingUsageSnapshot.usage) }; - const remoteStateJson = JSON.stringify(item ?? {}); + const remoteStateJson = JSON.stringify(normalizeAgentRemoteState(item)); const agentName = firstText(item?.name, slug); const status = normalizeAgentStatus(item?.status); - const templateName = firstText(item?.templateName, existing?.template_name, DEFAULT_TEMPLATE_NAME); - const model = normalizeRemoteModelRef(item?.model); + const templateName = firstText(item?.templateId, existing?.template_name, DEFAULT_TEMPLATE_NAME); + const model = normalizeRemoteModelRef(item?.modelRef); if (existing) { const nextStatus = existing.status === "disabled" @@ -613,18 +661,18 @@ export class CatalogSyncService { const removeModel = this.db.prepare("DELETE FROM llm_models WHERE model_ref = ?"); for (const provider of items) { - const providerId = firstText(provider?.providerId, provider?.provider_id); + const providerId = firstText(provider?.id); if (!providerId) { - continue; + throw new Error("catalogs.llmProviders.items[].id is required"); } seenProviders.add(providerId); insertProvider.run( providerId, - firstText(provider?.baseUrl, provider?.base_url), - firstText(provider?.apiKeyMask, provider?.api_key_mask), - firstText(provider?.apiKeyFingerprint, provider?.api_key_fingerprint), - firstText(provider?.api, "openai-completions"), + firstText(provider?.baseUrl), + firstText(provider?.secret?.mask), + firstText(provider?.secret?.fingerprint), + firstText(provider?.api, "openaiCompletions") === "openaiCompletions" ? "openai-completions" : firstText(provider?.api), JSON.stringify(provider ?? {}), now, now @@ -632,10 +680,10 @@ export class CatalogSyncService { const models = Array.isArray(provider?.models) ? provider.models : []; for (const model of models) { - const modelId = firstText(model?.modelId, model?.model_id); - const modelRef = firstText(model?.modelRef, model?.model_ref, buildModelRef(providerId, modelId)); + const modelId = firstText(model?.id); + const modelRef = firstText(model?.ref, buildModelRef(providerId, modelId)); if (!modelId || !modelRef) { - continue; + throw new Error("catalogs.llmProviders.items[].models[].id is required"); } seenModels.add(modelRef); @@ -644,8 +692,8 @@ export class CatalogSyncService { providerId, modelId, firstText(model?.name, modelId), - Number.isFinite(Number(model?.contextTokens ?? model?.context_tokens)) ? Number(model?.contextTokens ?? model?.context_tokens) : null, - Number.isFinite(Number(model?.maxTokens ?? model?.max_tokens)) ? Number(model?.maxTokens ?? model?.max_tokens) : null, + Number.isFinite(Number(model?.limits?.contextTokens)) ? Number(model?.limits?.contextTokens) : null, + Number.isFinite(Number(model?.limits?.maxOutputTokens)) ? Number(model?.limits?.maxOutputTokens) : null, JSON.stringify(model ?? {}), now, now @@ -683,9 +731,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM agent_templates WHERE id = ?"); for (const item of items) { - const templateName = String(item?.templateName || "").trim(); + const templateName = String(item?.id || "").trim(); if (!templateName) { - continue; + throw new Error("catalogs.templates.items[].id is required"); } seen.add(templateName); @@ -753,15 +801,15 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM skills WHERE id = ?"); for (const item of items) { - const slug = String(item?.slug || "").trim(); + const slug = String(item?.id || "").trim(); if (!slug) { - continue; + throw new Error("catalogs.skills.items[].id is required"); } seen.add(slug); const existing = existingMap.get(slug); const origin = item?.origin && typeof item.origin === "object" ? item.origin : {}; - const description = firstText(origin.description, existing?.description); + const description = firstText(item?.description, origin.description, existing?.description); const isBuiltin = isBuiltInItem(item) ? 1 : 0; if (existing) { @@ -805,9 +853,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM business_systems WHERE id = ?"); for (const item of items) { - const applicationName = firstText(item?.applicationName, item?.id, item?.name).toLowerCase(); + const applicationName = firstText(item?.id, item?.name).toLowerCase(); if (!applicationName) { - continue; + throw new Error("catalogs.systems.items[].id is required"); } seen.add(applicationName); diff --git a/apps/management-website/server/src/services/external-service.js b/apps/management-website/server/src/services/external-service.js index 50d3c72ff4cca3345bc4a8ab148d42452b7b9242..383da45e58662d6352505d5cc092341d7819df30 100644 --- a/apps/management-website/server/src/services/external-service.js +++ b/apps/management-website/server/src/services/external-service.js @@ -55,9 +55,24 @@ function normalizeResponsePayload(value) { function extractAgentTopics(remoteStateJson) { const remote = parseJsonString(remoteStateJson, {}); + const topics = remote?.topics && typeof remote.topics === "object" ? remote.topics : {}; return { - inboundTopic: String(remote?.inboundTopic || remote?.["inbound-topic"] || "").trim(), - outboundTopic: String(remote?.outboundTopic || remote?.["outbound-topic"] || "").trim() + inboundTopic: String( + remote?.inboundTopic + || remote?.["inbound-topic"] + || topics.inbound + || topics.inboundTopic + || topics["inbound-topic"] + || "" + ).trim(), + outboundTopic: String( + remote?.outboundTopic + || remote?.["outbound-topic"] + || topics.outbound + || topics.outboundTopic + || topics["outbound-topic"] + || "" + ).trim() }; } diff --git a/apps/management-website/server/src/services/portal-service.js b/apps/management-website/server/src/services/portal-service.js index fdc5bc94a4bc6ff124df86f351dfe16dd5f80dc3..81fdbe01ba2d94dbf11824da8d5e084112eb1d78 100644 --- a/apps/management-website/server/src/services/portal-service.js +++ b/apps/management-website/server/src/services/portal-service.js @@ -558,7 +558,9 @@ export class PortalService { a.original_name AS artifact_name FROM skills s LEFT JOIN artifacts a ON a.id = s.artifact_id - ORDER BY s.updated_at DESC + ORDER BY + CASE WHEN lower(s.slug) LIKE 'aios-%' THEN 0 ELSE 1 END, + lower(s.slug) ASC `).all().map((row) => ({ ...row, is_builtin: Boolean(row.is_builtin) diff --git a/apps/management-website/server/src/services/topic-ping-service.js b/apps/management-website/server/src/services/topic-ping-service.js index b70c8b0f79def4438e8d9c9d24352e8660cc4e3f..4ed8e91a8b28e06bbd9153ceeeccd0c33b21d054 100644 --- a/apps/management-website/server/src/services/topic-ping-service.js +++ b/apps/management-website/server/src/services/topic-ping-service.js @@ -47,6 +47,28 @@ function normalizeTimeoutMs(value, fallback = 120000) { return Math.max(1000, Math.min(240000, Math.round(parsed))); } +function extractAgentTopics(remoteState, agentId, env) { + const topics = remoteState?.topics && typeof remoteState.topics === "object" ? remoteState.topics : {}; + return { + inboundTopic: firstText( + remoteState?.inboundTopic, + remoteState?.["inbound-topic"], + topics.inbound, + topics.inboundTopic, + topics["inbound-topic"], + env?.mqtt?.agentInboundTopicTemplate?.replaceAll("{agentId}", agentId) + ), + outboundTopic: firstText( + remoteState?.outboundTopic, + remoteState?.["outbound-topic"], + topics.outbound, + topics.outboundTopic, + topics["outbound-topic"], + env?.mqtt?.agentOutboundTopicTemplate?.replaceAll("{agentId}", agentId) + ) + }; +} + export class TopicPingService { constructor({ db, env, mqttFactory = mqtt, rpcClient = null }) { this.db = db; @@ -81,16 +103,7 @@ export class TopicPingService { } const remoteState = JSON.parse(row.remote_state_json || "{}"); - const inboundTopic = firstText( - remoteState?.inboundTopic, - remoteState?.["inbound-topic"], - this.env?.mqtt?.agentInboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId) - ); - const outboundTopic = firstText( - remoteState?.outboundTopic, - remoteState?.["outbound-topic"], - this.env?.mqtt?.agentOutboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId) - ); + const { inboundTopic, outboundTopic } = extractAgentTopics(remoteState, normalizedAgentId, this.env); if (!inboundTopic || !outboundTopic) { throw badRequest(`数字员工 ${normalizedAgentId} 缺少 inbound/outbound topic`); @@ -115,16 +128,7 @@ export class TopicPingService { } const remoteState = JSON.parse(row.remote_state_json || "{}"); - const inboundTopic = firstText( - remoteState?.inboundTopic, - remoteState?.["inbound-topic"], - this.env?.mqtt?.agentInboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId) - ); - const outboundTopic = firstText( - remoteState?.outboundTopic, - remoteState?.["outbound-topic"], - this.env?.mqtt?.agentOutboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId) - ); + const { inboundTopic, outboundTopic } = extractAgentTopics(remoteState, normalizedAgentId, this.env); if (!inboundTopic || !outboundTopic) { throw badRequest(`数字员工 ${normalizedAgentId} 缺少 inbound/outbound topic`); @@ -171,6 +175,8 @@ export class TopicPingService { payload }; const timings = {}; + const replyMessages = []; + const blockTexts = []; const startedAt = Date.now(); const mark = (key, start) => { timings[key] = Date.now() - start; @@ -210,8 +216,6 @@ export class TopicPingService { }); let cancelReplyWait = () => {}; - const replyMessages = []; - const blockTexts = []; const replyPromise = new Promise((resolve, reject) => { const waitStartedAt = Date.now(); let timer; @@ -390,16 +394,7 @@ export class TopicPingService { } const remoteState = JSON.parse(row.remote_state_json || "{}"); - const inboundTopic = firstText( - remoteState?.inboundTopic, - remoteState?.["inbound-topic"], - this.env?.mqtt?.agentInboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId) - ); - const outboundTopic = firstText( - remoteState?.outboundTopic, - remoteState?.["outbound-topic"], - this.env?.mqtt?.agentOutboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId) - ); + const { inboundTopic, outboundTopic } = extractAgentTopics(remoteState, normalizedAgentId, this.env); const startedAt = Date.now(); return await new Promise((resolve, reject) => { diff --git a/apps/management-website/server/test/agent-service-alignment.test.js b/apps/management-website/server/test/agent-service-alignment.test.js index 6bfbd6527ac33d3b226eae49d93cfd56c87ab1ff..bb638310df15f7087564173fc7165df5352a4a34 100644 --- a/apps/management-website/server/test/agent-service-alignment.test.js +++ b/apps/management-website/server/test/agent-service-alignment.test.js @@ -351,6 +351,54 @@ it("builds active agent directory for cui consumers", async () => { }]); }); +it("normalizes catalog snapshot topics for agent details and directories", async () => { + const service = new AgentService({ + db: createDb({ + id: 8, + slug: "catalog-agent", + agent_name: "Catalog Agent", + description: "", + docs_content: "", + template_name: "default", + status: "normal", + tags_json: "[]", + daily_limit: -1, + usage_snapshot_json: "{}", + remote_state_json: JSON.stringify({ + name: "Catalog Agent Remote", + topics: { + inbound: "catalog/in", + outbound: "catalog/out" + } + }), + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }), + rpcClient: {}, + objectStorage: {} + }); + + service.getPermissions = () => ([ + { username: "zhangsan" } + ]); + + const [agent] = await service.listAgents(); + expect(agent.remote_state).toEqual(jasmine.objectContaining({ + topics: { + inbound: "catalog/in", + outbound: "catalog/out" + }, + inboundTopic: "catalog/in", + outboundTopic: "catalog/out" + })); + + const directory = await service.listActiveAgentDirectory(); + expect(directory[0]).toEqual(jasmine.objectContaining({ + inbound_topic: "catalog/in", + outbound_topic: "catalog/out" + })); +}); + it("reports overlimit status in user agent directory when usage reaches quota", async () => { const service = new AgentService({ db: createDb({ diff --git a/apps/management-website/server/test/background-services.test.js b/apps/management-website/server/test/background-services.test.js index 5230f33706d00fa921f80bc75c25722d58ae8bbe..d3b764341c46973eae091eb3738ca36024747400 100644 --- a/apps/management-website/server/test/background-services.test.js +++ b/apps/management-website/server/test/background-services.test.js @@ -62,15 +62,15 @@ function createServices({ } }, catalogSyncService: { - syncStartupEnvironmentTask: taskFactory("sync-startup-environment"), - syncEnvironmentTask: taskFactory("sync-environment"), + syncStartupCatalogTask: taskFactory("sync-startup-catalog"), + syncCatalogTask: taskFactory("sync-catalog"), refreshAgentUsage: taskFactory("usage"), markStartupSyncFailed } }; } -it("schedules environment and token sync from completion times after startup environment sync succeeds", async () => { +it("schedules catalog and token sync from completion times after startup catalog sync succeeds", async () => { const timers = installFakeTimers(); const calls = []; const taskFactory = (label) => async ({ trigger }) => { @@ -97,7 +97,7 @@ it("schedules environment and token sync from completion times after startup env expect(calls).toEqual([ "rpc.start", - "sync-startup-environment:startup" + "sync-startup-catalog:startup" ]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ 60 * 1000, @@ -111,9 +111,9 @@ it("schedules environment and token sync from completion times after startup env 2 * 60 * 1000 ]); - const environmentTimer = timers.activeTimeouts()[0]; - await timers.runTimer(environmentTimer); - expect(calls.filter((entry) => entry === "sync-environment:scheduled").length).toBe(1); + const catalogTimer = timers.activeTimeouts()[0]; + await timers.runTimer(catalogTimer); + expect(calls.filter((entry) => entry === "sync-catalog:scheduled").length).toBe(1); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ 2 * 60 * 1000, 2 * 60 * 1000 @@ -124,14 +124,14 @@ it("schedules environment and token sync from completion times after startup env } }); -it("does not schedule token sync when startup environment sync fails", async () => { +it("does not schedule token sync when startup catalog sync fails", async () => { spyOn(console, "error").and.stub(); const timers = installFakeTimers(); const calls = []; const taskFactory = (label) => async ({ trigger }) => { calls.push(`${label}:${trigger}`); - if (label === "sync-startup-environment" && trigger === "startup") { + if (label === "sync-startup-catalog" && trigger === "startup") { return { status: "failed" }; } return { status: "success" }; @@ -154,7 +154,7 @@ it("does not schedule token sync when startup environment sync fails", async () await flushMicrotasks(); expect(calls).toEqual([ "rpc.start", - "sync-startup-environment:startup" + "sync-startup-catalog:startup" ]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ 2 * 60 * 1000 @@ -202,7 +202,71 @@ it("waits for a management RPC ping before startup sync", async () => { await flushMicrotasks(); expect(calls.filter((entry) => entry.endsWith(":startup"))).toEqual([ - "sync-startup-environment:startup" + "sync-startup-catalog:startup" + ]); + } finally { + await background.stop(); + timers.restore(); + } +}); + +it("retries management RPC readiness before startup sync failure is marked", async () => { + const timers = installFakeTimers(); + const calls = []; + const startupFailures = []; + let rpcAttempt = 0; + const taskFactory = (label) => async ({ trigger }) => { + calls.push(`${label}:${trigger}`); + return { status: "success" }; + }; + + const background = startBackgroundServices({ + env: { + startupSyncReadyTimeoutMs: 1000, + startupSyncPingTimeoutMs: 1, + startupSyncPollIntervalMs: 25 + }, + services: createServices({ + calls, + rpcStart: async () => { + calls.push("rpc.start"); + }, + rpcCall: async (action) => { + rpcAttempt += 1; + calls.push(`rpc.call:${action}:${rpcAttempt}`); + if (rpcAttempt === 1) { + throw new Error("not ready yet"); + } + return { pong: true }; + }, + markStartupSyncFailed: (message) => { + startupFailures.push(message); + }, + taskFactory + }) + }); + + try { + await flushMicrotasks(); + expect(calls).toEqual([ + "rpc.start", + "rpc.call:service.ping:1" + ]); + expect(startupFailures).toEqual([]); + expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([25]); + + await timers.runTimer(timers.activeTimeouts()[0]); + + expect(calls).toEqual([ + "rpc.start", + "rpc.call:service.ping:1", + "rpc.call:service.ping:2", + "sync-startup-catalog:startup" + ]); + expect(startupFailures).toEqual([]); + expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ + 60 * 1000, + 2 * 60 * 1000 ]); } finally { await background.stop(); diff --git a/apps/management-website/server/test/environment-sync-route.test.js b/apps/management-website/server/test/catalog-sync-route.test.js similarity index 93% rename from apps/management-website/server/test/environment-sync-route.test.js rename to apps/management-website/server/test/catalog-sync-route.test.js index dfa4e5b0500311dedfbf829f83d1fa8f4d13d1f4..f41880487fa28ab4e7e023fb500130cba1fae73c 100644 --- a/apps/management-website/server/test/environment-sync-route.test.js +++ b/apps/management-website/server/test/catalog-sync-route.test.js @@ -84,7 +84,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => async runManualSyncPlan({ trigger }) { calls.push({ trigger }); return { - environment: { + catalog: { status: "success", trigger_source: trigger }, @@ -109,7 +109,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => try { const address = server.address(); const response = await requestJson( - `http://127.0.0.1:${address.port}/api/settings/environment-sync`, + `http://127.0.0.1:${address.port}/api/settings/catalog-sync`, { method: "POST", body: {} @@ -118,7 +118,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => expect(response.status).toBe(200); expect(calls).toEqual([{ trigger: "manual" }]); - expect(response.body.result.environment.status).toBe("success"); + expect(response.body.result.catalog.status).toBe("success"); expect(response.body.result.usage.status).toBe("success"); expect(response.body.agent_sync).toEqual(states.agent_sync); expect(response.body.usage_refresh).toEqual(states.usage_refresh); @@ -126,7 +126,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => userId: 1, username: "aios", action: "同步数据", - detail: "手动触发 environment.info 与 agent.usage.list 数据同步" + detail: "手动触发 catalog.snapshot 与 agent.usage.list 数据同步" }]); } finally { await close(server); diff --git a/apps/management-website/server/test/catalog-sync-service.test.js b/apps/management-website/server/test/catalog-sync-service.test.js index 81766dd934e498e00a3e079b9b9d6e40bd2738d2..5829da9673a81fc3e9740eed8eaabafcd3442d88 100644 --- a/apps/management-website/server/test/catalog-sync-service.test.js +++ b/apps/management-website/server/test/catalog-sync-service.test.js @@ -4,6 +4,38 @@ import { CatalogSyncService } from "../src/services/catalog-sync-service.js"; const TEST_NOW = "2026-05-26T00:00:00.000Z"; +function catalog(kind, items) { + return { + kind, + items, + summary: { + count: items.length + } + }; +} + +function catalogSnapshot({ + llmProviders = [], + agents = [], + templates = [], + skills = [], + systems = [], + ontologies = [] +} = {}) { + return { + version: 1, + generatedAt: TEST_NOW, + catalogs: { + llmProviders: catalog("llmProviders", llmProviders), + agents: catalog("agents", agents), + templates: catalog("templates", templates), + skills: catalog("skills", skills), + systems: catalog("systems", systems), + ontologies: catalog("ontologies", ontologies) + } + }; +} + function insertAgent(db, { slug, agentName = slug, @@ -274,7 +306,7 @@ function createDb() { return db; } -it("syncs startup environment catalog with one management rpc call", async () => { +it("syncs startup catalog snapshot with one management rpc call", async () => { const db = createDb(); insertAgent(db, { slug: "stale-agent", @@ -384,60 +416,54 @@ it("syncs startup environment catalog with one management rpc call", async () => }, async call(action) { calls.push(action); - if (action === "environment.info") { - return { - llmProviders: { - items: [{ - providerId: "corp-openai", - baseUrl: "https://api.example.com/v1", - apiKeyMask: "sk****test", - models: [{ - modelId: "gpt-4.1-mini", - name: "GPT 4.1 Mini" - }] - }] - }, - templates: { - items: [{ - templateName: "default", - path: "/var/aios/workspace-templates/default", - isDefault: true, - "is-built-in": true - }] - }, - agents: { - items: [{ - agentId: "alpha", - name: "Alpha", - status: "active", - templateName: "default", - model: { - primary: "corp-openai/gpt-4.1-mini" - } - }] - }, - skills: { - items: [{ - slug: "global-skill", - "is-built-in": true, - origin: { - description: "from kernel" - } - }] - }, - systems: { - items: [{ - id: "crm", - name: "CRM" - }] - }, - ontologies: { - items: [{ - name: "crm", - "is-built-in": true + if (action === "catalog.snapshot") { + return catalogSnapshot({ + llmProviders: [{ + id: "corp-openai", + api: "openaiCompletions", + baseUrl: "https://api.example.com/v1", + secret: { + mask: "sk****test", + fingerprint: "" + }, + models: [{ + id: "gpt-4.1-mini", + ref: "corp-openai/gpt-4.1-mini", + name: "GPT 4.1 Mini", + limits: {} }] - } - }; + }], + templates: [{ + id: "default", + path: "/var/aios/workspace-templates/default", + builtIn: true + }], + agents: [{ + id: "alpha", + name: "Alpha", + status: "active", + templateId: "default", + modelRef: "corp-openai/gpt-4.1-mini", + topics: { + inbound: "aios/agent/alpha/inbound", + outbound: "aios/agent/alpha/outbound" + } + }], + skills: [{ + id: "global-skill", + builtIn: true, + description: "from kernel" + }], + systems: [{ + id: "crm", + name: "CRM" + }], + ontologies: [{ + id: "crm", + name: "crm", + builtIn: true + }] + }); } throw new Error(`Unexpected action: ${action}`); @@ -445,7 +471,7 @@ it("syncs startup environment catalog with one management rpc call", async () => } }); - const result = await service.syncStartupEnvironmentTask({ trigger: "startup" }); + const result = await service.syncStartupCatalogTask({ trigger: "startup" }); expect(result.status).toBe("success"); expect(result.summary).toEqual({ @@ -455,7 +481,7 @@ it("syncs startup environment catalog with one management rpc call", async () => skills: 1, systems: 1 }); - expect(calls).toEqual(["environment.info"]); + expect(calls).toEqual(["catalog.snapshot"]); expect(db.prepare("SELECT provider_id FROM llm_providers ORDER BY provider_id").all()).toEqual([ jasmine.objectContaining({ provider_id: "corp-openai" }) @@ -466,12 +492,24 @@ it("syncs startup environment catalog with one management rpc call", async () => expect(db.prepare("SELECT slug, llm_model_ref FROM agents ORDER BY slug").all()).toEqual([ jasmine.objectContaining({ slug: "alpha", llm_model_ref: "corp-openai/gpt-4.1-mini" }) ]); + const syncedAgent = db.prepare("SELECT remote_state_json FROM agents WHERE slug = ?").get("alpha"); + expect(JSON.parse(syncedAgent.remote_state_json)).toEqual(jasmine.objectContaining({ + topics: { + inbound: "aios/agent/alpha/inbound", + outbound: "aios/agent/alpha/outbound" + }, + inboundTopic: "aios/agent/alpha/inbound", + outboundTopic: "aios/agent/alpha/outbound" + })); expect(db.prepare("SELECT template_name, is_builtin FROM agent_templates ORDER BY template_name").all()).toEqual([ jasmine.objectContaining({ template_name: "default", is_builtin: 1 }) ]); expect(db.prepare("SELECT slug, is_builtin FROM skills ORDER BY slug").all()).toEqual([ jasmine.objectContaining({ slug: "global-skill", is_builtin: 1 }) ]); + expect(db.prepare("SELECT slug, description FROM skills ORDER BY slug").all()).toEqual([ + jasmine.objectContaining({ slug: "global-skill", description: "from kernel" }) + ]); expect(db.prepare("SELECT application_name, is_builtin FROM business_systems ORDER BY application_name").all()).toEqual([ jasmine.objectContaining({ application_name: "crm", is_builtin: 1 }) ]); @@ -485,7 +523,7 @@ it("syncs startup environment catalog with one management rpc call", async () => expect(service.getUsageRefreshStatus().summary).toEqual({}); }); -it("keeps local catalog rows when startup environment sync fails", async () => { +it("keeps local catalog rows when startup catalog sync fails", async () => { const db = createDb(); insertAgent(db, { slug: "local-agent", @@ -514,7 +552,7 @@ it("keeps local catalog rows when startup environment sync fails", async () => { return true; }, async call(action) { - if (action === "environment.info") { + if (action === "catalog.snapshot") { throw new Error("kernel unavailable"); } throw new Error(`Unexpected action: ${action}`); @@ -522,7 +560,7 @@ it("keeps local catalog rows when startup environment sync fails", async () => { } }); - const result = await service.syncStartupEnvironmentTask({ trigger: "startup" }); + const result = await service.syncStartupCatalogTask({ trigger: "startup" }); expect(result.status).toBe("failed"); expect(result.error_message).toBe("kernel unavailable"); @@ -542,7 +580,7 @@ it("keeps local catalog rows when startup environment sync fails", async () => { } }); -it("runs the manual sync plan as environment sync followed by token usage sync", async () => { +it("runs the manual sync plan as catalog snapshot sync followed by token usage sync", async () => { const db = createDb(); insertAgent(db, { slug: "alpha", @@ -558,21 +596,14 @@ it("runs the manual sync plan as environment sync followed by token usage sync", }, async call(action) { calls.push(action); - if (action === "environment.info") { - return { - llmProviders: { items: [] }, - templates: { items: [] }, - agents: { - items: [{ - agentId: "alpha", - name: "Alpha", - status: "active" - }] - }, - skills: { items: [] }, - systems: { items: [] }, - ontologies: { items: [] } - }; + if (action === "catalog.snapshot") { + return catalogSnapshot({ + agents: [{ + id: "alpha", + name: "Alpha", + status: "active" + }] + }); } if (action === "agent.usage.list") { return { @@ -591,10 +622,10 @@ it("runs the manual sync plan as environment sync followed by token usage sync", const result = await service.runManualSyncPlan({ trigger: "manual" }); - expect(result.environment.status).toBe("success"); + expect(result.catalog.status).toBe("success"); expect(result.usage.status).toBe("success"); expect(calls).toEqual([ - "environment.info", + "catalog.snapshot", "agent.usage.list" ]); expect(service.getUsageRefreshStatus().summary.refreshed_agents).toBe(1); @@ -803,7 +834,7 @@ it("records skipped status when rpc is not configured", async () => { } }); - const status = await service.syncEnvironmentTask({ trigger: "startup" }); + const status = await service.syncCatalogTask({ trigger: "startup" }); expect(status.status).toBe("skipped"); expect(status.trigger_source).toBe("startup"); diff --git a/apps/management-website/server/test/external-service.test.js b/apps/management-website/server/test/external-service.test.js index fb56f383f5d76e9e63a11bf8ba076ec91be89aba..c0ec96079cf687b20b6ab7edd954f8014841deb4 100644 --- a/apps/management-website/server/test/external-service.test.js +++ b/apps/management-website/server/test/external-service.test.js @@ -141,6 +141,36 @@ it("creates and reuses external session ids per user-agent pair and returns topi expect(auditWrites[0].detail).toContain("agent状态=normal"); }); +it("returns topics from catalog snapshot shaped remote state", () => { + const db = createDb(); + db.prepare(` + UPDATE agents + SET remote_state_json = ? + WHERE slug = ? + `).run(JSON.stringify({ + topics: { + inbound: "aios/catalog-agent/inbound", + outbound: "aios/catalog-agent/outbound" + } + }), "agent-a"); + const service = createService(db); + + const session = service.getOrCreateSession({ + userName: "zhangsan", + agentId: "agent-a" + }); + const context = service.getContext(session.sessionId); + + expect(session).toEqual(jasmine.objectContaining({ + inboundTopic: "aios/catalog-agent/inbound", + outboundTopic: "aios/catalog-agent/outbound" + })); + expect(context).toEqual(jasmine.objectContaining({ + inboundTopic: "aios/catalog-agent/inbound", + outboundTopic: "aios/catalog-agent/outbound" + })); +}); + it("throws internal error with reason when creating session for disabled agent", () => { const db = createDb(); db.prepare("UPDATE agents SET status = 'disabled' WHERE slug = ?").run("agent-a"); diff --git a/apps/management-website/server/test/management-rpc-client.test.js b/apps/management-website/server/test/management-rpc-client.test.js index 99c27b9daaee07859e0dffe4aeae328d50e926af..3366a51da15f90ac755ebb734e00b57f9d639b6e 100644 --- a/apps/management-website/server/test/management-rpc-client.test.js +++ b/apps/management-website/server/test/management-rpc-client.test.js @@ -18,7 +18,7 @@ it("publishes a request and resolves the matching response", async () => { await rpcClient.start(); const client = mqttFactory.clients[0]; - const pending = rpcClient.call("environment.info", {}, 1000); + const pending = rpcClient.call("catalog.snapshot", {}, 1000); const published = client.publishes[0]; const request = JSON.parse(published.payload); @@ -29,13 +29,31 @@ it("publishes a request and resolves the matching response", async () => { JSON.stringify({ requestId: request.requestId, ok: true, - result: { agents: { items: [{ agentId: "alpha" }] } } + result: { + version: 1, + catalogs: { + agents: { + kind: "agents", + items: [{ id: "alpha" }], + summary: { count: 1 } + } + } + } }) ) ); await expectAsync(pending).toBeResolved(); - expect(await pending).toEqual({ agents: { items: [{ agentId: "alpha" }] } }); + expect(await pending).toEqual({ + version: 1, + catalogs: { + agents: { + kind: "agents", + items: [{ id: "alpha" }], + summary: { count: 1 } + } + } + }); expect(db.statements.some((item) => item.sql.includes("INSERT INTO management_requests"))).toBeTrue(); expect(db.statements.some((item) => item.sql.includes("UPDATE management_requests"))).toBeTrue(); @@ -122,13 +140,13 @@ it("uses management timeout from env by default", async () => { await rpcClient.start(); try { - await rpcClient.call("environment.info", {}); + await rpcClient.call("catalog.snapshot", {}); fail("Expected call to time out"); } catch (error) { expect(error.message).toContain("超时"); expect(error.details).toEqual(jasmine.objectContaining({ timeout: true, - action: "environment.info" + action: "catalog.snapshot" })); } diff --git a/apps/management-website/server/test/portal-service-alignment.test.js b/apps/management-website/server/test/portal-service-alignment.test.js index 89f9420da1eaf6a2fe9d5accad7d8c7ef3ad37ca..1d74bfd3436cf336895e872e0410e68c102660d3 100644 --- a/apps/management-website/server/test/portal-service-alignment.test.js +++ b/apps/management-website/server/test/portal-service-alignment.test.js @@ -225,6 +225,50 @@ it("updates and clears uploaded portal logo", () => { expect(calls.length).toBe(2); }); +it("lists aios skills first and sorts each group alphabetically", () => { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE artifacts ( + id INTEGER PRIMARY KEY, + original_name TEXT NOT NULL + ); + + CREATE TABLE skills ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + artifact_id INTEGER, + remote_status TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + const insert = db.prepare(` + INSERT INTO skills ( + id, slug, description, artifact_id, remote_status, is_builtin, created_at, updated_at + ) VALUES (?, ?, '', NULL, 'installed', 1, ?, ?) + `); + insert.run(1, "pdf", "2026-05-26T00:00:00.000Z", "2026-05-26T04:00:00.000Z"); + insert.run(2, "aios-transfer-file", "2026-05-26T00:00:00.000Z", "2026-05-26T03:00:00.000Z"); + insert.run(3, "browser", "2026-05-26T00:00:00.000Z", "2026-05-26T02:00:00.000Z"); + insert.run(4, "aios-call-app-service", "2026-05-26T00:00:00.000Z", "2026-05-26T01:00:00.000Z"); + const service = new PortalService({ + db, + objectStorage: {}, + rpcClient: {}, + authService: {} + }); + + expect(service.listSkills().map((item) => item.slug)).toEqual([ + "aios-call-app-service", + "aios-transfer-file", + "browser", + "pdf" + ]); + db.close(); +}); + it("requires slug for global skill installation", async () => { const db = createDb(); const rpcCalls = []; diff --git a/apps/management-website/server/test/portal-service-management-logs.test.js b/apps/management-website/server/test/portal-service-management-logs.test.js index 0e4b70c3d13ad2e0cfb7c861f8a4c2ce3d1c8a66..a7b246eda4bde78fddb18b1a3ad2a1f1a8aadfc3 100644 --- a/apps/management-website/server/test/portal-service-management-logs.test.js +++ b/apps/management-website/server/test/portal-service-management-logs.test.js @@ -47,7 +47,7 @@ it("lists kernel management requests with parsed payloads and response time", () { id: 1, request_id: "req-1", - action: "environment.info", + action: "catalog.snapshot", params_json: "{}", ok: 0, result_json: null, diff --git a/apps/management-website/server/test/server-restart-route.test.js b/apps/management-website/server/test/server-restart-route.test.js index 3acb21cfa3cd920cd9f46e4be1b148c01a6b7583..eb379bff22a43e52f948368404d02b9558b3899c 100644 --- a/apps/management-website/server/test/server-restart-route.test.js +++ b/apps/management-website/server/test/server-restart-route.test.js @@ -179,3 +179,64 @@ it("checks server status with short timeout without writing audit logs during po await close(server); } }); + +it("executes management command with requested timeout", async () => { + const auditWrites = []; + const rpcCalls = []; + const app = express(); + app.use(express.json()); + app.use("/api", createRoutes({ + authService: { + getLocalApiUser() { + return { + id: 1, + username: "aios", + role: "aios-admin" + }; + }, + assertAdmin() {} + }, + auditLogService: { + write(entry) { + auditWrites.push(entry); + } + }, + rpcClient: { + async call(action, params, timeoutMs) { + rpcCalls.push({ action, params, timeoutMs }); + return { + pong: true + }; + } + }, + portalService: {}, + catalogSyncService: {}, + env: {} + })); + + const server = await listen(app); + try { + const address = server.address(); + const response = await requestJson( + `http://127.0.0.1:${address.port}/api/settings/command`, + { + method: "POST", + body: { + action: "service.ping", + params: {}, + timeout_ms: 2500 + } + } + ); + + expect(response.status).toBe(200); + expect(response.body.ok).toBeTrue(); + expect(response.body.result).toEqual({ pong: true }); + expect(rpcCalls).toEqual([ + { action: "service.ping", params: {}, timeoutMs: 2500 } + ]); + expect(auditWrites.length).toBe(1); + } finally { + await close(server); + } +}); diff --git a/apps/management-website/server/test/topic-ping-service.test.js b/apps/management-website/server/test/topic-ping-service.test.js index 8a979a4b356687f36ff8df7b85d81c862e8b0ed3..5a0bac3ee6165df50b82c58fe528055cc9019503 100644 --- a/apps/management-website/server/test/topic-ping-service.test.js +++ b/apps/management-website/server/test/topic-ping-service.test.js @@ -121,6 +121,40 @@ it("waits until agent ping succeeds", async () => { expect(attempt).toBe(3); }); +it("resolves agent topics from catalog snapshot shaped remote state", () => { + const service = new TopicPingService({ + db: { + prepare() { + return { + get() { + return { + slug: "agent-a", + remote_state_json: JSON.stringify({ + topics: { + inbound: "aios/catalog-agent/inbound", + outbound: "aios/catalog-agent/outbound" + } + }) + }; + } + }; + } + }, + env: { + mqtt: { + brokerUrl: "mqtt://broker", + agentInboundTopicTemplate: "aios/agent/{agentId}/inbound", + agentOutboundTopicTemplate: "aios/agent/{agentId}/outbound" + } + } + }); + + expect(service.resolveAgentTopics("agent-a")).toEqual(jasmine.objectContaining({ + inboundTopic: "aios/catalog-agent/inbound", + outboundTopic: "aios/catalog-agent/outbound" + })); +}); + it("waits until agent runtime becomes active without pinging the model", async () => { const listeners = new Map(); const service = new TopicPingService({ @@ -244,6 +278,52 @@ it("sends a single agent test message and returns MQTT payloads with timings", a expect(result.timings.total_ms).toEqual(jasmine.any(Number)); }); +it("returns an empty reply list when agent test fails before waiting for replies", async () => { + const client = new MockMqttClient(); + const service = new TopicPingService({ + db: { + prepare() { + return { + get() { + return { + slug: "agent-a", + remote_state_json: JSON.stringify({ + inboundTopic: "aios/agent/agent-a/inbound", + outboundTopic: "aios/agent/agent-a/outbound" + }) + }; + } + }; + } + }, + env: { + mqtt: { + brokerUrl: "mqtt://broker", + agentInboundTopicTemplate: "aios/agent/{agentId}/inbound", + agentOutboundTopicTemplate: "aios/agent/{agentId}/outbound" + } + }, + mqttFactory: { + connect() { + queueMicrotask(() => client.emit("error", new Error("broker down"))); + return client; + } + } + }); + + const result = await service.testAgentMessage("agent-a", { + text: "hello", + timeoutMs: 1000 + }); + + expect(result.ok).toBe(false); + expect(result.replyMessage).toBeNull(); + expect(result.replyMessages).toEqual([]); + expect(result.error_message).toContain("broker down"); + expect(result.timings.connect_ms).toEqual(jasmine.any(Number)); + expect(result.timings.total_ms).toEqual(jasmine.any(Number)); +}); + it("aggregates block replies when agent test allows block streaming", async () => { const client = new MockMqttClient(); const service = new TopicPingService({ diff --git a/apps/management-website/src/app/App.jsx b/apps/management-website/src/app/App.jsx index 1d52c033247d9eaec0bb2fbd327294f88db23f81..c124be92906e3b76edfb1005f7db760418bef2bf 100644 --- a/apps/management-website/src/app/App.jsx +++ b/apps/management-website/src/app/App.jsx @@ -263,8 +263,8 @@ export default function App() { })); messageApi.success("访问令牌已删除"); }} - onEnvironmentSync={async () => { - const next = await api.post("/api/settings/environment-sync", {}); + onCatalogSync={async () => { + const next = await api.post("/api/settings/catalog-sync", {}); setBoot((current) => ({ ...current, agent_sync: next.agent_sync, @@ -378,7 +378,7 @@ export default function App() { width={320} zIndex={1250} > -
+
数据同步中,请稍等。
@@ -392,7 +392,7 @@ export default function App() { width={320} zIndex={1300} > -
+
正在修改密码,请稍候...
@@ -403,6 +403,8 @@ export default function App() { okText="确认" cancelText="取消" confirmLoading={passwordChanging} + okButtonProps={{ "data-testid": "change-password-submit" }} + cancelButtonProps={{ "data-testid": "change-password-cancel" }} onCancel={() => { if (passwordChanging) { return; @@ -433,7 +435,7 @@ export default function App() { label="当前密码" rules={[{ required: true, message: "请输入当前密码" }]} > - + - + diff --git a/apps/management-website/src/components/AppShell.jsx b/apps/management-website/src/components/AppShell.jsx index e1dc31c38d380795d053e7231e7d93a39c13370b..6dab5b5c598de91e7d8f5f1dfc2f0904da830fac 100644 --- a/apps/management-website/src/components/AppShell.jsx +++ b/apps/management-website/src/components/AppShell.jsx @@ -94,6 +94,7 @@ export function AppShell({ return (
@@ -123,6 +125,7 @@ export function AppShell({
: } className="portal-header-trigger" + data-testid="app-menu-toggle" aria-label={collapsed ? "展开菜单" : "折叠菜单"} onClick={() => setCollapsed((current) => !current)} /> - - diff --git a/apps/management-website/src/components/CardTitleWithReload.jsx b/apps/management-website/src/components/CardTitleWithReload.jsx index 7dd92029a7b8e9c8a07797f39aba14375eb0240d..5ddd1d16cd2ad387f497635f6998295cc16eadf8 100644 --- a/apps/management-website/src/components/CardTitleWithReload.jsx +++ b/apps/management-website/src/components/CardTitleWithReload.jsx @@ -2,11 +2,12 @@ import React from "react"; import { ReloadOutlined } from "@ant-design/icons"; import { Space } from "antd"; -export function CardTitleWithReload({ title, loading = false, onReload }) { +export function CardTitleWithReload({ title, loading = false, onReload, testId }) { return ( {title} { if (!loading) { diff --git a/apps/management-website/src/components/DeleteActionButton.jsx b/apps/management-website/src/components/DeleteActionButton.jsx index 0be5b8df84ebfb7b79659a475cd371141772b63c..b5fac841bf586d32c2eef31b367015dfe1bb454e 100644 --- a/apps/management-website/src/components/DeleteActionButton.jsx +++ b/apps/management-website/src/components/DeleteActionButton.jsx @@ -8,6 +8,9 @@ export function DeleteActionButton({ okText = "删除", cancelText = "取消", onConfirm, + testId, + confirmTestId, + cancelTestId, children = "删除" }) { if (hidden) { @@ -20,10 +23,11 @@ export function DeleteActionButton({ description={description} okText={okText} cancelText={cancelText} - okButtonProps={{ danger: true }} + okButtonProps={{ danger: true, ...(confirmTestId ? { "data-testid": confirmTestId } : {}) }} + cancelButtonProps={cancelTestId ? { "data-testid": cancelTestId } : undefined} onConfirm={onConfirm} > - diff --git a/apps/management-website/src/pages/AgentsPage.jsx b/apps/management-website/src/pages/AgentsPage.jsx index 90573eb73f6f7f2f09bdb48db3d6157628b3bb25..34e83bacf996e28afaa644d27ee0a23e70b482b2 100644 --- a/apps/management-website/src/pages/AgentsPage.jsx +++ b/apps/management-website/src/pages/AgentsPage.jsx @@ -464,6 +464,7 @@ export function AgentsPage() { title={ load()} />} extra={(