From bb144973bb16d1116c3d9cfd8d903e5cbfd9f7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Tue, 16 Jun 2026 21:50:04 +0800 Subject: [PATCH 01/10] bugfix --- apps/management-website/package.json | 2 +- .../server/src/api/routes/index.js | 19 +- .../server/src/services/agent-service.js | 8 + .../src/services/catalog-sync-service.js | 43 +++-- .../server/src/services/portal-service.js | 168 ++++++++++++++++-- .../server/test/agent-service-create.test.js | 12 +- .../server/test/catalog-sync-service.test.js | 111 +++++++++++- .../test/portal-service-alignment.test.js | 136 ++++++++++++-- .../src/pages/SkillsPage.jsx | 104 ++++++++--- .../test/integration/common.js | 14 +- .../test/integration/full.js | 80 ++++++++- .../test/integration/website-full.js | 11 +- docker-images/apps/Dockerfile | 2 +- docker-images/kernal/Dockerfile | 2 +- joint-test/dmz-mode/README.md | 11 ++ joint-test/dmz-mode/playwright-dev2-flow.js | 127 +++++++++++-- .../dmz-mode/run-all-in-one-dev2-flow.core.sh | 4 +- kernal/aios-management-serivce/package.json | 2 +- .../capabilities/agent/lifecycle-service.ts | 34 ++++ .../src/capabilities/agent/name-set.ts | 6 + .../src/capabilities/agent/service.ts | 7 + .../src/capabilities/context.ts | 2 + .../src/capabilities/registry.ts | 2 + kernal/aios-management-serivce/src/types.ts | 6 + .../test/capabilities-registry.test.ts | 8 + .../test/openclaw-manager.test.ts | 29 ++- 26 files changed, 832 insertions(+), 118 deletions(-) create mode 100644 kernal/aios-management-serivce/src/capabilities/agent/name-set.ts diff --git a/apps/management-website/package.json b/apps/management-website/package.json index 51f51c2..822fe07 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.3.4", + "version": "0.3.5", "type": "module", "files": [ "dist", diff --git a/apps/management-website/server/src/api/routes/index.js b/apps/management-website/server/src/api/routes/index.js index 7470295..3c39a5d 100644 --- a/apps/management-website/server/src/api/routes/index.js +++ b/apps/management-website/server/src/api/routes/index.js @@ -482,17 +482,21 @@ export function createRoutes(services) { res.json(services.portalService.listSkills()); })); + router.post("/skills/inspect", upload.single("artifact"), asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + if (!req.file) { + throw badRequest("请上传技能文件"); + } + res.json(services.portalService.inspectSkillArchive(req.file)); + })); + router.post("/skills", upload.single("artifact"), asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); if (!req.file) { throw badRequest("请上传技能文件"); } - const payload = { - slug: req.body.slug, - description: req.body.description || "" - }; const result = await services.portalService.createSkill({ - payload, + payload: {}, file: req.file || null, createdBy: req.currentUser.id }); @@ -508,11 +512,8 @@ export function createRoutes(services) { if (!req.file) { throw badRequest("请上传技能文件"); } - const payload = { - description: req.body.description || "" - }; const result = await services.portalService.updateSkill(Number(req.params.id), { - payload, + payload: {}, file: req.file, createdBy: req.currentUser.id }); diff --git a/apps/management-website/server/src/services/agent-service.js b/apps/management-website/server/src/services/agent-service.js index f39a2ac..58261d1 100644 --- a/apps/management-website/server/src/services/agent-service.js +++ b/apps/management-website/server/src/services/agent-service.js @@ -839,6 +839,14 @@ export class AgentService { }); } + if (nextAgentName !== current.agent_name) { + await this.rpcClient.call("agent.name.set", { + agentId: current.slug, + name: nextAgentName, + restart: payload.restart !== false + }); + } + if (llmSelection.changed) { await this.rpcClient.call("agent.model.set", { agentId: current.slug, 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 5bb76b2..a5f8fc1 100644 --- a/apps/management-website/server/src/services/catalog-sync-service.js +++ b/apps/management-website/server/src/services/catalog-sync-service.js @@ -196,6 +196,17 @@ function builtInOntologyNames(items) { .filter(Boolean)); } +function normalizeSystemEndpoint(item, existing = null) { + const endpoint = item?.endpoint && typeof item.endpoint === "object" ? item.endpoint : {}; + return { + scheme: firstText(endpoint.scheme, existing?.scheme, "http"), + host: firstText(endpoint.host, existing?.host, item?.host, "localhost"), + port: Number.isInteger(Number(endpoint.port)) + ? Number(endpoint.port) + : (Number.isInteger(Number(existing?.port)) ? Number(existing.port) : 80) + }; +} + export class CatalogSyncService { constructor({ db, rpcClient, auditLogService = null, env = null }) { this.db = db; @@ -581,9 +592,7 @@ export class CatalogSyncService { const model = normalizeRemoteModelRef(item?.modelRef); if (existing) { - const nextStatus = existing.status === "disabled" - ? "disabled" - : (existing.status === "overlimit" ? "overlimit" : normalizeAgentStatus(item?.status)); + const nextStatus = existing.status === "overlimit" ? "overlimit" : normalizeAgentStatus(item?.status); update.run( agentName, templateName, @@ -844,10 +853,11 @@ export class CatalogSyncService { syncSystems(items, now = new Date().toISOString(), builtInOntologies = new Set()) { const existingRows = this.db.prepare("SELECT * FROM business_systems").all(); + const existingMap = new Map(existingRows.map((row) => [row.application_name, row])); const seen = new Set(); - const updateBuiltin = this.db.prepare(` + const update = this.db.prepare(` UPDATE business_systems - SET is_builtin = ?, updated_at = ? + SET provider = ?, scheme = ?, host = ?, port = ?, status = ?, is_builtin = ?, updated_at = ? WHERE id = ? `); const remove = this.db.prepare("DELETE FROM business_systems WHERE id = ?"); @@ -859,17 +869,28 @@ export class CatalogSyncService { } seen.add(applicationName); + const existing = existingMap.get(applicationName); + if (!existing) { + continue; + } + + const endpoint = normalizeSystemEndpoint(item, existing); + const isBuiltin = builtInOntologies.has(applicationName) ? 1 : 0; + update.run( + firstText(item?.provider, existing.provider), + endpoint.scheme, + endpoint.host, + endpoint.port, + firstText(item?.status, existing.status) === "disabled" ? "disabled" : "active", + isBuiltin, + now, + existing.id + ); } for (const row of existingRows) { if (!seen.has(row.application_name)) { remove.run(row.id); - continue; - } - - const isBuiltin = builtInOntologies.has(String(row.application_name || "").toLowerCase()) ? 1 : 0; - if (Number(row.is_builtin || 0) !== isBuiltin) { - updateBuiltin.run(isBuiltin, now, row.id); } } } diff --git a/apps/management-website/server/src/services/portal-service.js b/apps/management-website/server/src/services/portal-service.js index 81fdbe0..2511af9 100644 --- a/apps/management-website/server/src/services/portal-service.js +++ b/apps/management-website/server/src/services/portal-service.js @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import AdmZip from "adm-zip"; import { jsonParse, jsonStringify, newAccessToken } from "../db/index.js"; @@ -5,6 +7,7 @@ import { badRequest, conflict, notFound } from "../utils/errors.js"; import { hashPassword } from "../utils/security.js"; const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const FRONTMATTER_PATTERN = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const MAX_LOGO_BYTES = 1024 * 1024; const LOGO_MIME_TYPES = new Map([ ["image/jpeg", "jpg"], @@ -25,6 +28,131 @@ function ensureZipContains(buffer, requiredName) { } } +function toSkillSlug(value) { + const raw = String(value || "").trim(); + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-"); + + return validateSlug( + normalized || `skill-${createHash("sha256").update(raw).digest("hex").slice(0, 10)}`, + "技能ID" + ); +} + +function unquoteFrontmatterValue(value) { + const normalized = String(value || "").trim(); + if ( + (normalized.startsWith("\"") && normalized.endsWith("\"")) + || (normalized.startsWith("'") && normalized.endsWith("'")) + ) { + return normalized.slice(1, -1).trim(); + } + return normalized; +} + +function parseSkillFrontmatter(raw) { + const match = FRONTMATTER_PATTERN.exec(String(raw || "")); + if (!match) { + throw badRequest("SKILL.md 必须包含 AgentSkills frontmatter"); + } + + const metadata = {}; + for (const rawLine of match[1].split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const fieldMatch = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); + if (!fieldMatch) { + continue; + } + + metadata[fieldMatch[1].trim()] = unquoteFrontmatterValue(fieldMatch[2]); + } + + if (!metadata.name?.trim()) { + throw badRequest("SKILL.md frontmatter 必须包含 name"); + } + if (!metadata.description?.trim()) { + throw badRequest("SKILL.md frontmatter 必须包含 description"); + } + + return { + name: metadata.name.trim(), + description: metadata.description.trim() + }; +} + +function serializeFrontmatterValue(value) { + return JSON.stringify(String(value || "")); +} + +function replaceFrontmatterField(frontmatter, key, value) { + const pattern = new RegExp(`(^|\\n)(${key}:\\s*)(.*)(?=\\n|$)`); + if (pattern.test(frontmatter)) { + return frontmatter.replace(pattern, `$1$2${serializeFrontmatterValue(value)}`); + } + return `${frontmatter.replace(/\s*$/, "")}\n${key}: ${serializeFrontmatterValue(value)}`; +} + +function rewriteSkillManifestName(raw, slug) { + const text = String(raw || ""); + const match = FRONTMATTER_PATTERN.exec(text); + if (!match) { + throw badRequest("SKILL.md 必须包含 AgentSkills frontmatter"); + } + + const frontmatter = replaceFrontmatterField(match[1], "name", slug); + const body = text.slice(match.index + match[0].length); + return `${text.slice(0, match.index)}---\n${frontmatter}\n---\n${body}`; +} + +function normalizeSkillArchive(file) { + if (!file) { + throw badRequest("请上传技能 zip 文件"); + } + + let zip; + try { + zip = new AdmZip(file.buffer); + } catch (error) { + throw badRequest("请上传有效的技能 zip 文件", { + message: error instanceof Error ? error.message : String(error) + }); + } + const entries = zip.getEntries().filter((entry) => !entry.isDirectory); + const manifest = entries.find((entry) => entry.entryName.replace(/^\/+/, "") === "SKILL.md"); + if (!manifest) { + throw badRequest("Zip 包中必须包含顶层文件 SKILL.md"); + } + + const manifestContent = manifest.getData().toString("utf8"); + const metadata = parseSkillFrontmatter(manifestContent); + const slug = toSkillSlug(metadata.name); + const normalizedManifestContent = rewriteSkillManifestName(manifestContent, slug); + if (normalizedManifestContent !== manifestContent) { + zip.updateFile(manifest.entryName, Buffer.from(normalizedManifestContent, "utf8")); + } + + const buffer = zip.toBuffer(); + return { + file: { + ...file, + buffer, + size: buffer.length + }, + metadata: { + ...metadata, + slug + } + }; +} + function parseJsonOrFallback(value, fallback = null) { if (value === undefined || value === null || value === "") { return fallback; @@ -568,21 +696,18 @@ export class PortalService { } async createSkill({ payload, file, createdBy }) { - const slug = validateSlug(payload.slug, "技能ID"); - if (!file) { - throw badRequest("请上传技能 zip 文件"); - } - - ensureZipContains(file.buffer, "SKILL.md"); - const artifact = await this.persistArtifact({ kind: "skill", file, createdBy }); + const normalizedArchive = normalizeSkillArchive(file); + const slug = normalizedArchive.metadata.slug; + const artifact = await this.persistArtifact({ kind: "skill", file: normalizedArchive.file, createdBy }); - await this.rpcClient.call("skills.global.install.local", { + const remoteResult = await this.rpcClient.call("skills.global.install.local", { slug, bucket: artifact.bucket, objectKey: artifact.objectKey, force: false }); const remoteStatus = "installed"; + const description = firstText(remoteResult?.description, normalizedArchive.metadata.description); const now = new Date().toISOString(); const result = this.db.prepare(` @@ -591,7 +716,7 @@ export class PortalService { ) VALUES (?, ?, ?, ?, ?, ?) `).run( slug, - payload.description || "", + description, artifact.id, remoteStatus, now, @@ -608,12 +733,11 @@ export class PortalService { throw notFound("技能不存在"); } const slug = validateSlug(current.slug, "技能ID"); - if (!file) { - throw badRequest("请上传技能 zip 文件"); + const normalizedArchive = normalizeSkillArchive(file); + if (normalizedArchive.metadata.slug !== slug) { + throw badRequest(`技能 zip 中的 name 归一化后必须与当前技能 ${slug} 一致`); } - - ensureZipContains(file.buffer, "SKILL.md"); - const artifact = await this.persistArtifact({ kind: "skill", file, createdBy }); + const artifact = await this.persistArtifact({ kind: "skill", file: normalizedArchive.file, createdBy }); const next = { ...current, @@ -622,20 +746,21 @@ export class PortalService { updated_at: new Date().toISOString() }; - await this.rpcClient.call("skills.global.install.local", { + const remoteResult = await this.rpcClient.call("skills.global.install.local", { slug, bucket: artifact.bucket, objectKey: artifact.objectKey, force: true }); const remoteStatus = "installed"; + const description = firstText(remoteResult?.description, normalizedArchive.metadata.description); this.db.prepare(` UPDATE skills SET description = ?, artifact_id = ?, remote_status = ?, updated_at = ? WHERE id = ? `).run( - next.description ?? current.description, + description, next.artifact_id, remoteStatus, next.updated_at, @@ -644,6 +769,17 @@ export class PortalService { return this.listSkills().find((item) => item.id === skillId); } + inspectSkillArchive(file) { + const normalizedArchive = normalizeSkillArchive(file); + return { + name: normalizedArchive.metadata.name, + slug: normalizedArchive.metadata.slug, + description: normalizedArchive.metadata.description, + normalized_name: normalizedArchive.metadata.slug, + name_changed: normalizedArchive.metadata.name !== normalizedArchive.metadata.slug + }; + } + async deleteSkill(skillId) { const current = this.db.prepare("SELECT * FROM skills WHERE id = ?").get(skillId); if (!current) { diff --git a/apps/management-website/server/test/agent-service-create.test.js b/apps/management-website/server/test/agent-service-create.test.js index 3bce83b..c703235 100644 --- a/apps/management-website/server/test/agent-service-create.test.js +++ b/apps/management-website/server/test/agent-service-create.test.js @@ -844,11 +844,13 @@ it("keeps agent-user bindings when update payload omits assignment fields", asyn agent_id: 1, aios_user_id: 11 }); + const rpcCalls = []; const service = new AgentService({ db, rpcClient: { - async call() { + async call(action, params) { + rpcCalls.push({ action, params }); return { ok: true }; } }, @@ -863,6 +865,14 @@ it("keeps agent-user bindings when update payload omits assignment fields", asyn expect(updated.permissions).toEqual([ { id: 11, username: "zhangsan" } ]); + expect(rpcCalls).toEqual([{ + action: "agent.name.set", + params: { + agentId: "agent-a", + name: "Agent A Updated", + restart: true + } + }]); }); it("deletes local record when remote agent is already missing", async () => { 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 5829da9..8976c25 100644 --- a/apps/management-website/server/test/catalog-sync-service.test.js +++ b/apps/management-website/server/test/catalog-sync-service.test.js @@ -456,7 +456,14 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { }], systems: [{ id: "crm", - name: "CRM" + name: "CRM", + provider: "phx", + status: "disabled", + endpoint: { + scheme: "https", + host: "crm-kernel.example.com", + port: 8443 + } }], ontologies: [{ id: "crm", @@ -513,6 +520,13 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { 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 }) ]); + expect(db.prepare("SELECT provider, scheme, host, port, status FROM business_systems WHERE application_name = ?").get("crm")).toEqual({ + provider: "phx", + scheme: "https", + host: "crm-kernel.example.com", + port: 8443, + status: "disabled" + }); expect(service.getLlmSyncStatus().summary).toEqual({ models: 1 }); expect(service.getTemplateSyncStatus().summary).toEqual({ templates: 1 }); @@ -631,6 +645,101 @@ it("runs the manual sync plan as catalog snapshot sync followed by token usage s expect(service.getUsageRefreshStatus().summary.refreshed_agents).toBe(1); }); +it("uses kernel agent status as source while keeping local overlimit state", async () => { + const db = createDb(); + insertAgent(db, { + slug: "disabled-agent", + agentName: "Disabled Agent", + status: "disabled" + }); + insertAgent(db, { + slug: "quota-agent", + agentName: "Quota Agent", + status: "overlimit" + }); + + const service = new CatalogSyncService({ + db, + rpcClient: { + isConfigured() { + return true; + }, + async call(action) { + if (action === "catalog.snapshot") { + return catalogSnapshot({ + agents: [ + { + id: "disabled-agent", + name: "Disabled Agent", + status: "active" + }, + { + id: "quota-agent", + name: "Quota Agent", + status: "active" + } + ] + }); + } + throw new Error(`Unexpected action: ${action}`); + } + } + }); + + await service.syncCatalogTask({ trigger: "scheduled" }); + + expect(db.prepare("SELECT status FROM agents WHERE slug = ?").get("disabled-agent").status).toBe("normal"); + expect(db.prepare("SELECT status FROM agents WHERE slug = ?").get("quota-agent").status).toBe("overlimit"); +}); + +it("keeps renamed agent name from kernel catalog sync for management and external API mirrors", async () => { + const db = createDb(); + insertAgent(db, { + slug: "renamed-agent", + agentName: "Old Management Name", + status: "normal", + remoteStateJson: JSON.stringify({ + inboundTopic: "aios/agent/renamed-agent/inbound", + outboundTopic: "aios/agent/renamed-agent/outbound" + }) + }); + + const service = new CatalogSyncService({ + db, + rpcClient: { + isConfigured() { + return true; + }, + async call(action) { + if (action === "catalog.snapshot") { + return catalogSnapshot({ + agents: [{ + id: "renamed-agent", + name: "Kernel Synced Name", + status: "active", + topics: { + inbound: "aios/agent/renamed-agent/inbound", + outbound: "aios/agent/renamed-agent/outbound" + } + }] + }); + } + throw new Error(`Unexpected action: ${action}`); + } + } + }); + + await service.syncCatalogTask({ trigger: "scheduled" }); + + const agent = db.prepare("SELECT agent_name, remote_state_json FROM agents WHERE slug = ?").get("renamed-agent"); + expect(agent.agent_name).toBe("Kernel Synced Name"); + expect(JSON.parse(agent.remote_state_json)).toEqual(jasmine.objectContaining({ + name: "Kernel Synced Name", + inboundTopic: "aios/agent/renamed-agent/inbound", + outboundTopic: "aios/agent/renamed-agent/outbound" + })); +}); + it("refreshes agent usage snapshots with kernel stock values", async () => { const db = createDb(); insertAgent(db, { 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 1d74bfd..1451cbe 100644 --- a/apps/management-website/server/test/portal-service-alignment.test.js +++ b/apps/management-website/server/test/portal-service-alignment.test.js @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; +import AdmZip from "adm-zip"; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aios-web-portal-test-")); process.env.AIOS_WEB_DATA_DIR = dataDir; @@ -24,6 +25,20 @@ function createDb() { }; } +function createSkillZip({ name = "skill-a", description = "Skill A description" } = {}) { + const zip = new AdmZip(); + zip.addFile("SKILL.md", Buffer.from([ + "---", + `name: ${name}`, + `description: ${description}`, + "---", + "", + "# Demo Skill", + "" + ].join("\n"), "utf8")); + return zip.toBuffer(); +} + it("returns dashboard placeholders before agent and usage startup sync complete", () => { const scalarValues = new Map([ ["SELECT status, last_success_at FROM agent_sync_state WHERE id = 1", { status: "running", last_success_at: null }], @@ -269,7 +284,7 @@ it("lists aios skills first and sorts each group alphabetically", () => { db.close(); }); -it("requires slug for global skill installation", async () => { +it("requires uploaded global skill zip to contain top-level SKILL.md", async () => { const db = createDb(); const rpcCalls = []; const service = new PortalService({ @@ -294,9 +309,7 @@ it("requires slug for global skill installation", async () => { const zipBuffer = Buffer.from("PK\u0005\u0006" + "\u0000".repeat(18), "binary"); try { await service.createSkill({ - payload: { - slug: "" - }, + payload: {}, file: { buffer: zipBuffer, originalname: "demo.zip", @@ -307,7 +320,7 @@ it("requires slug for global skill installation", async () => { }); fail("Expected createSkill to reject"); } catch (error) { - expect(error.message).toContain("技能ID"); + expect(error.message).toContain("SKILL.md"); } expect(rpcCalls.length).toBe(0); }); @@ -315,10 +328,12 @@ it("requires slug for global skill installation", async () => { it("installs uploaded global skill through skills.global.install.local", async () => { const db = createDb(); const rpcCalls = []; + const uploadedArtifacts = []; const service = new PortalService({ db, objectStorage: { - async uploadAdminArtifact() { + async uploadAdminArtifact({ file }) { + uploadedArtifacts.push(file); return { bucket: "admin-in", objectKey: "skill/demo.zip" @@ -328,38 +343,125 @@ it("installs uploaded global skill through skills.global.install.local", async ( rpcClient: { async call(action, params) { rpcCalls.push({ action, params }); - return {}; + return { + slug: params.slug, + description: "Description from kernel" + }; } }, authService: {} }); - service.listSkills = () => [{ id: 1 }]; + service.listSkills = () => { + const lastStatement = db.statements[db.statements.length - 1]; + return [{ id: 1, description: lastStatement?.args?.[1] }]; + }; - const zip = new (await import("adm-zip")).default(); - zip.addFile("SKILL.md", Buffer.from("# Demo Skill\n", "utf8")); + const zipBuffer = createSkillZip({ + name: "财务助手", + description: "Description from package" + }); + const preview = service.inspectSkillArchive({ + buffer: zipBuffer, + originalname: "demo.zip", + mimetype: "application/zip", + size: zipBuffer.length + }); const result = await service.createSkill({ - payload: { - slug: "skill-a" - }, + payload: {}, file: { - buffer: zip.toBuffer(), + buffer: zipBuffer, originalname: "demo.zip", mimetype: "application/zip", - size: zip.toBuffer().length + size: zipBuffer.length }, createdBy: 1 }); - expect(result).toEqual({ id: 1 }); + expect(preview.name).toBe("财务助手"); + expect(preview.slug).toMatch(/^skill-[a-f0-9]{10}$/); + expect(preview.description).toBe("Description from package"); + expect(result).toEqual({ id: 1, description: "Description from kernel" }); expect(rpcCalls.length).toBe(1); expect(rpcCalls[0].action).toBe("skills.global.install.local"); expect(rpcCalls[0].params).toEqual({ - slug: "skill-a", + slug: preview.slug, bucket: "admin-in", objectKey: "skill/demo.zip", force: false }); + const uploadedZip = new AdmZip(uploadedArtifacts[0].buffer); + const uploadedManifest = uploadedZip.getEntry("SKILL.md").getData().toString("utf8"); + expect(uploadedManifest).toContain(`name: "${preview.slug}"`); + expect(uploadedManifest).toContain("description: Description from package"); +}); + +it("rejects updating a global skill with a zip for a different slug before persisting artifact", async () => { + const rpcCalls = []; + const uploadedArtifacts = []; + const zipBuffer = createSkillZip({ + name: "other-skill", + description: "Other skill" + }); + const service = new PortalService({ + db: { + prepare(sql) { + return { + run: () => ({ changes: 1 }), + get: () => { + if (sql.includes("SELECT * FROM skills WHERE id = ?")) { + return { + id: 1, + slug: "skill-a", + description: "Skill A", + artifact_id: null, + remote_status: "installed", + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }; + } + return null; + }, + all: () => [] + }; + } + }, + objectStorage: { + async uploadAdminArtifact({ file }) { + uploadedArtifacts.push(file); + return { + bucket: "admin-in", + objectKey: "skill/demo.zip" + }; + } + }, + rpcClient: { + async call(action, params) { + rpcCalls.push({ action, params }); + return {}; + } + }, + authService: {} + }); + + try { + await service.updateSkill(1, { + payload: {}, + file: { + buffer: zipBuffer, + originalname: "other.zip", + mimetype: "application/zip", + size: zipBuffer.length + }, + createdBy: 1 + }); + fail("Expected updateSkill to reject"); + } catch (error) { + expect(error.message).toContain("必须与当前技能 skill-a 一致"); + } + + expect(uploadedArtifacts.length).toBe(0); + expect(rpcCalls.length).toBe(0); }); it("deletes installed global skill through skills.global.delete using skill slug only", async () => { diff --git a/apps/management-website/src/pages/SkillsPage.jsx b/apps/management-website/src/pages/SkillsPage.jsx index 1af23f4..166e9ee 100644 --- a/apps/management-website/src/pages/SkillsPage.jsx +++ b/apps/management-website/src/pages/SkillsPage.jsx @@ -1,12 +1,11 @@ import React, { useEffect, useState } from "react"; -import { Button, Card, Empty, Form, Input, List, Modal, Space, Spin, Tag, Typography, Upload, message } from "antd"; +import { Alert, Button, Card, Descriptions, Empty, Form, List, Modal, Space, Spin, Tag, Tooltip, Typography, Upload, message } from "antd"; import { api } from "../app/api-client.js"; import { CardTitleWithReload } from "../components/CardTitleWithReload.jsx"; import { DeleteActionButton } from "../components/DeleteActionButton.jsx"; const { Paragraph, Text } = Typography; -const skillIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; function getRemoteStatusLabel(status) { switch (status) { @@ -32,6 +31,8 @@ export function SkillsPage() { const [submitLoading, setSubmitLoading] = useState(false); const [submitMaskOpen, setSubmitMaskOpen] = useState(false); const [submitMaskText, setSubmitMaskText] = useState("正在处理,请稍候..."); + const [skillPreview, setSkillPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); const [form] = Form.useForm(); const [messageApi, contextHolder] = message.useMessage(); @@ -52,9 +53,12 @@ export function SkillsPage() { const handleSubmit = async () => { try { const values = await form.validateFields(); + if (!skillPreview?.slug) { + messageApi.error("请先上传技能 zip 并确认解析结果"); + return; + } + const installedSlug = skillPreview.slug; const formData = new FormData(); - formData.set("slug", String(values.slug || "").trim()); - formData.set("description", String(values.description || "")); if (values.artifact?.[0]?.originFileObj) { formData.set("artifact", values.artifact[0].originFileObj); } @@ -65,9 +69,10 @@ export function SkillsPage() { await api.multipart("/api/skills", formData); setOpen(false); + setSkillPreview(null); form.resetFields(); await load(); - messageApi.success(`全局技能 ${values.slug} 处理完成`); + messageApi.success(`全局技能 ${installedSlug} 处理完成`); } catch (error) { if (error?.errorFields) { return; @@ -79,6 +84,23 @@ export function SkillsPage() { } }; + const inspectSkillFile = async (file) => { + const formData = new FormData(); + formData.set("artifact", file); + setPreviewLoading(true); + setSkillPreview(null); + try { + const preview = await api.multipart("/api/skills/inspect", formData); + setSkillPreview(preview); + messageApi.success(`已解析技能 ${preview.slug}`); + } catch (error) { + form.setFieldValue("artifact", []); + messageApi.error(error.message || "技能 zip 解析失败"); + } finally { + setPreviewLoading(false); + } + }; + const handleDelete = async (row) => { try { setSubmitLoading(true); @@ -131,7 +153,6 @@ export function SkillsPage() {
{skill.slug} - 技能 ID
{skill.is_builtin ? 内置 : 自定义} @@ -141,9 +162,11 @@ export function SkillsPage() {
描述 {description ? ( - - {description} - + + + {description} + + ) : ( 暂无描述 )} @@ -175,42 +198,65 @@ export function SkillsPage() { okText="确认" cancelText="取消" confirmLoading={submitLoading} - okButtonProps={{ "data-testid": "skill-form-submit" }} + okButtonProps={{ + "data-testid": "skill-form-submit", + disabled: previewLoading || !skillPreview?.slug + }} cancelButtonProps={{ "data-testid": "skill-form-cancel" }} - onCancel={() => setOpen(false)} + onCancel={() => { + setOpen(false); + setSkillPreview(null); + form.resetFields(); + }} onOk={async () => { await handleSubmit(); }} >
- - - - - - event?.fileList || []} rules={[{ required: true, message: "必须上传技能 zip" }]} - extra="zip 第一层必须包含符合 AgentSkills 规范的 SKILL.md,frontmatter 至少包含 name 和 description。" + extra="选择后会读取 SKILL.md 中的 name 和 description;若 name 不是 slug,会自动转换为 slug 后安装。" > - false} maxCount={1}> + { + void inspectSkillFile(file); + return false; + }} + onRemove={() => { + setSkillPreview(null); + }} + maxCount={1} + > + + {skillPreview ? ( + + {skillPreview.name_changed ? ( + + ) : null} + {skillPreview.name || "-"} }, + { key: "slug", label: "安装 ID", children: {skillPreview.slug || "-"} }, + { key: "description", label: "描述", children: {skillPreview.description || "-"} } + ]} + /> + + ) : null} +
diff --git a/apps/management-website/test/integration/common.js b/apps/management-website/test/integration/common.js index fa7569a..de4b348 100644 --- a/apps/management-website/test/integration/common.js +++ b/apps/management-website/test/integration/common.js @@ -82,16 +82,22 @@ export class HttpSession { constructor(baseUrl) { this.baseUrl = baseUrl; this.token = ""; + this.serviceToken = ""; } setToken(token) { this.token = token; } + setServiceToken(token) { + this.serviceToken = token; + } + async request(method, route, options = {}) { const headers = new Headers(options.headers || {}); - if (this.token) { - headers.set("Authorization", `Bearer ${this.token}`); + const token = options.token === "service" ? this.serviceToken : this.token; + if (token) { + headers.set("Authorization", `Bearer ${token}`); } let body = undefined; @@ -143,6 +149,10 @@ export class HttpSession { return this.request("GET", route); } + getExternal(route) { + return this.request("GET", route, { token: "service" }); + } + post(route, json) { return this.request("POST", route, { json }); } diff --git a/apps/management-website/test/integration/full.js b/apps/management-website/test/integration/full.js index cd6a4bc..550f4a0 100644 --- a/apps/management-website/test/integration/full.js +++ b/apps/management-website/test/integration/full.js @@ -27,6 +27,10 @@ function hasExternalTopics(value) { return nonEmptyText(value?.inboundTopic) && nonEmptyText(value?.outboundTopic); } +function findExternalAgent(items, agentSlug) { + return Array.isArray(items) ? findBy(items, "agent_id", agentSlug) : null; +} + async function main() { const settings = loadSettings(); const report = newReport("full", { @@ -57,6 +61,13 @@ async function main() { const login = await loginAsAdmin(session, report); browserSession = await createBrowserSession(settings, login.token); + const accessTokenCreate = await session.post("/api/settings/access-tokens", {}); + addAction(report, "settings.access-tokens.create-for-external-api", accessTokenCreate.ok, accessTokenCreate.status, { + tokenCreated: Boolean(accessTokenCreate.body?.token) + }); + assertOk(accessTokenCreate, "settings.access-tokens.create-for-external-api"); + session.setServiceToken(accessTokenCreate.body.token); + for (const route of ["/api/auth/me", "/api/bootstrap", "/api/dashboard", "/api/settings"]) { const response = await session.get(route); addAction(report, route, response.ok, response.status, response.body); @@ -187,10 +198,8 @@ async function main() { throw new Error(`settings.catalog-sync.before-external-api did not sync agents: ${JSON.stringify(catalogSync.body)}`); } - const externalAgents = await session.get(`/api/external/agents?userName=${encodeURIComponent("zhangsan")}`); - const externalAgent = Array.isArray(externalAgents.body?.items) - ? findBy(externalAgents.body.items, "agent_id", agentSlug) - : null; + const externalAgents = await session.getExternal(`/api/external/agents?userName=${encodeURIComponent("zhangsan")}`); + const externalAgent = findExternalAgent(externalAgents.body?.items, agentSlug); addAction( report, "external.agents.topics", @@ -203,7 +212,66 @@ async function main() { throw new Error(`external.agents.topics returned empty topics: ${JSON.stringify(externalAgents.body)}`); } - const externalSession = await session.get(`/api/external/session?userName=${encodeURIComponent("zhangsan")}&agentId=${encodeURIComponent(agentSlug)}`); + const renamedAgentName = "Integration Agent Synced Rename"; + const agentRename = await session.put(`/api/agents/${agentRow.id}`, { + agent_name: renamedAgentName, + permission_usernames: ["zhangsan"], + restart: false + }); + addAction( + report, + "agents.rename.write-through", + agentRename.ok && agentRename.body?.agent_name === renamedAgentName, + agentRename.status, + agentRename.body + ); + assertOk(agentRename, "agents.rename.write-through"); + if (agentRename.body?.agent_name !== renamedAgentName) { + throw new Error(`agents.rename.write-through returned wrong name: ${JSON.stringify(agentRename.body)}`); + } + + const catalogSyncAfterRename = await session.request("POST", "/api/settings/catalog-sync"); + addAction( + report, + "settings.catalog-sync.after-agent-rename", + catalogSyncAfterRename.ok && catalogSyncAfterRename.body?.agent_sync?.status === "success", + catalogSyncAfterRename.status, + catalogSyncAfterRename.body + ); + assertOk(catalogSyncAfterRename, "settings.catalog-sync.after-agent-rename"); + if (catalogSyncAfterRename.body?.agent_sync?.status !== "success") { + throw new Error(`settings.catalog-sync.after-agent-rename did not sync agents: ${JSON.stringify(catalogSyncAfterRename.body)}`); + } + + const agentsAfterRenameSync = await session.get("/api/agents"); + const managementRenamedAgent = findBy(agentsAfterRenameSync.body, "slug", agentSlug); + addAction( + report, + "agents.rename.catalog-sync.management-name", + agentsAfterRenameSync.ok && managementRenamedAgent?.agent_name === renamedAgentName, + agentsAfterRenameSync.status, + { agent: managementRenamedAgent } + ); + assertOk(agentsAfterRenameSync, "agents.rename.catalog-sync.management-name"); + if (managementRenamedAgent?.agent_name !== renamedAgentName) { + throw new Error(`management agent name regressed after catalog sync: ${JSON.stringify(managementRenamedAgent)}`); + } + + const externalAgentsAfterRenameSync = await session.getExternal(`/api/external/agents?userName=${encodeURIComponent("zhangsan")}`); + const externalRenamedAgent = findExternalAgent(externalAgentsAfterRenameSync.body?.items, agentSlug); + addAction( + report, + "agents.rename.catalog-sync.external-name", + externalAgentsAfterRenameSync.ok && externalRenamedAgent?.agent_name === renamedAgentName, + externalAgentsAfterRenameSync.status, + { agent: externalRenamedAgent } + ); + assertOk(externalAgentsAfterRenameSync, "agents.rename.catalog-sync.external-name"); + if (externalRenamedAgent?.agent_name !== renamedAgentName) { + throw new Error(`external agent name regressed after catalog sync: ${JSON.stringify(externalAgentsAfterRenameSync.body)}`); + } + + const externalSession = await session.getExternal(`/api/external/session?userName=${encodeURIComponent("zhangsan")}&agentId=${encodeURIComponent(agentSlug)}`); addAction( report, "external.session.topics", @@ -216,7 +284,7 @@ async function main() { throw new Error(`external.session.topics returned empty topics: ${JSON.stringify(externalSession.body)}`); } - const externalContext = await session.get(`/api/external/context?sessionId=${encodeURIComponent(externalSession.body.sessionId)}`); + const externalContext = await session.getExternal(`/api/external/context?sessionId=${encodeURIComponent(externalSession.body.sessionId)}`); addAction( report, "external.context.topics", diff --git a/apps/management-website/test/integration/website-full.js b/apps/management-website/test/integration/website-full.js index e2a6f2e..b76b1c1 100644 --- a/apps/management-website/test/integration/website-full.js +++ b/apps/management-website/test/integration/website-full.js @@ -287,9 +287,16 @@ async function createSkillOnPage(page, skillSlug, skillDescription, skillZip) { await navigate(page, "skills", "skill-create-button"); await page.getByTestId("skill-create-button").click(); await page.getByTestId("skill-form").waitFor({ state: "visible" }); - await page.getByTestId("skill-form-slug").fill(skillSlug); - await page.getByTestId("skill-form-description").fill(skillDescription); await uploadFile(page, "skill-form", skillZip); + await page.getByTestId("skill-preview-slug").waitFor({ state: "visible" }); + const previewSlug = await page.getByTestId("skill-preview-slug").innerText(); + if (previewSlug !== skillSlug) { + throw new Error(`skill preview slug mismatch: ${previewSlug}`); + } + const previewDescription = await page.getByTestId("skill-preview-description").innerText(); + if (!previewDescription.includes(skillDescription)) { + throw new Error(`skill preview description mismatch: ${previewDescription}`); + } await page.getByTestId("skill-form-submit").click(); await page.getByTestId(`skill-card-${skillSlug}`).waitFor({ state: "visible" }); const descriptionText = await page.getByTestId(`skill-card-description-${skillSlug}`).innerText(); diff --git a/docker-images/apps/Dockerfile b/docker-images/apps/Dockerfile index 9d0693e..8927531 100644 --- a/docker-images/apps/Dockerfile +++ b/docker-images/apps/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.3.4 +ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.3.5 ARG AIOS_PROXY_NPM_VERSION=0.1.3 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index ce23eca..8a1649a 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -166,7 +166,7 @@ ARG CLAWHUB_VERSION=0.18.0 ARG QMD_VERSION=2.5.3 ARG AGENT_BROWSER_VERSION=0.27.1 ARG MCPORTER_VERSION=0.11.3 -ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.3.9 +ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.4.0 ARG AIOS_APPS_INVOKE_CLI_VERSION=0.0.1 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org diff --git a/joint-test/dmz-mode/README.md b/joint-test/dmz-mode/README.md index 9bfb5e9..d65e19b 100644 --- a/joint-test/dmz-mode/README.md +++ b/joint-test/dmz-mode/README.md @@ -97,6 +97,17 @@ AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.win AIOS_TEST_SKIP_BUILD=1 AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh ``` +使用历史问题镜像复现/验证“管理控制台改名后 catalog 同步不应回退名称”的场景: + +```bash +AIOS_TEST_SKIP_BUILD=1 \ +AIOS_ALL_IN_ONE_IMAGE=sha256:b2ad0841e6c17229fc7eb189e1e69d235ee2b227242924839c1e141bebc52154 \ +AIOS_OPENAI_API_KEY=sk-... \ +bash joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh +``` + +Playwright 流程会在创建并授权 `public-demo` 后执行 `5a.verify-agent-rename-survives-catalog-sync`:先通过管理 API 修改数字员工名称,再触发 `/api/settings/catalog-sync`,最后分别断言 `/api/agents` 和 `/api/external/agents` 返回的新名称没有被内核旧快照覆盖。 + all-in-one 联调会使用内置 MinIO 和内置 MQTT。测试脚本只把管理端口 `3030` 和 MQTT 端口 `1883` 映射到宿主机,其中 MQTT 映射用于 Playwright 脚本实际发送消息验证 agent 可用。 旧入口 `run-all-in-one-dev2-flow.sh` 仍保留为兼容分发脚本,会根据当前 shell 自动转到 macOS 或 Windows 入口。 diff --git a/joint-test/dmz-mode/playwright-dev2-flow.js b/joint-test/dmz-mode/playwright-dev2-flow.js index 0a3a18f..ad2a4c6 100644 --- a/joint-test/dmz-mode/playwright-dev2-flow.js +++ b/joint-test/dmz-mode/playwright-dev2-flow.js @@ -337,6 +337,17 @@ async function nodeApi(baseUrl, token, method, route, body, timeoutMs) { return result.body; } +async function createServiceAccessToken(baseUrl, adminToken) { + const created = await nodeApi(baseUrl, adminToken, "POST", "/api/settings/access-tokens", {}, 60000); + assert.ok(created?.token, `access token creation should return token: ${JSON.stringify(created)}`); + return created.token; +} + +async function externalApi(baseUrl, serviceToken, method, route, body, timeoutMs) { + assert.ok(serviceToken, "service access token is required for external API checks"); + return await nodeApi(baseUrl, serviceToken, method, route, body, timeoutMs); +} + async function nodeMultipart(baseUrl, token, method, route, fields, fileField, timeoutMs = Number(process.env.AIOS_TEST_API_TIMEOUT_MS || "180000")) { const form = new FormData(); for (const [key, value] of Object.entries(fields)) { @@ -538,6 +549,49 @@ async function assignAdministrator(baseUrl, token, agentId) { return updated; } +async function verifyAgentRenameSurvivesCatalogSync(baseUrl, token, serviceToken, agentId) { + const renamedAgentName = "Public Demo Synced Rename"; + const renamed = await nodeApi(baseUrl, token, "PUT", `/api/agents/${agentId}`, { + agent_name: renamedAgentName, + permission_usernames: [ASSIGNEE_USERNAME], + restart: false + }, 120000); + assert.equal(renamed.agent_name, renamedAgentName, "agent rename should update management record"); + + const syncResult = await nodeApi(baseUrl, token, "POST", "/api/settings/catalog-sync", {}, 300000); + assert.equal(syncResult?.agent_sync?.status, "success", `catalog sync after rename should succeed: ${JSON.stringify(syncResult)}`); + + const agents = await nodeApi(baseUrl, token, "GET", "/api/agents", undefined, 60000); + const managementAgent = findBy(agents, "slug", AGENT_ID); + assert.equal( + managementAgent?.agent_name, + renamedAgentName, + `management agent name should not regress after catalog sync: ${JSON.stringify(managementAgent)}` + ); + + const externalAgents = await externalApi( + baseUrl, + serviceToken, + "GET", + `/api/external/agents?userName=${encodeURIComponent(ASSIGNEE_USERNAME)}`, + undefined, + 60000 + ); + const externalAgent = findBy(externalAgents?.items, "agent_id", AGENT_ID); + assert.equal( + externalAgent?.agent_name, + renamedAgentName, + `external API agent name should not regress after catalog sync: ${JSON.stringify(externalAgents)}` + ); + + return { + renamed, + syncSummary: syncResult?.agent_sync?.summary || {}, + managementAgent, + externalAgent + }; +} + async function uploadGlobalSkillWithPlaywright(page, baseUrl, skillArtifact) { await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); await page.getByTestId("nav-skills").waitFor({ state: "visible", timeout: 60000 }); @@ -548,9 +602,11 @@ async function uploadGlobalSkillWithPlaywright(page, baseUrl, skillArtifact) { const skillForm = page.getByTestId("skill-form"); await skillForm.waitFor({ state: "visible", timeout: 60000 }); - await page.getByTestId("skill-form-slug").fill(skillArtifact.slug); - await page.getByTestId("skill-form-description").fill(skillArtifact.description); await skillForm.locator('input[type="file"]').setInputFiles(skillArtifact.skillZip); + await page.getByTestId("skill-preview-slug").waitFor({ state: "visible", timeout: 60000 }); + assert.equal(await page.getByTestId("skill-preview-slug").innerText(), skillArtifact.slug); + const previewDescription = await page.getByTestId("skill-preview-description").innerText(); + assert.ok(previewDescription.includes(skillArtifact.description), "skill preview should show package description"); const startedAt = Date.now(); const [response] = await Promise.all([ @@ -1238,7 +1294,8 @@ async function main() { const baseUrl = process.env.AIOS_WEB_BASE_URL || DEFAULT_BASE_URL; const envJsonPath = path.resolve(process.env.AIOS_TEST_ENV_JSON || ".env.json"); const reportDir = path.resolve(process.env.AIOS_TEST_REPORT_DIR || path.join("test", ".reports")); - const apiKey = readModelApiKey(); + const focus = String(process.env.AIOS_TEST_FOCUS || "").trim(); + const apiKey = focus === "agent-rename-sync" ? "sk-agent-rename-sync-placeholder" : readModelApiKey(); const artifactRoot = fs.mkdtempSync(path.join(os.tmpdir(), "aios-pw-flow-")); const artifacts = createDefaultWorkspaceTemplateZip(artifactRoot); const globalSkillArtifact = createGlobalSkillZip(artifactRoot); @@ -1255,6 +1312,8 @@ async function main() { agentId: AGENT_ID, assigneeUsername: ASSIGNEE_USERNAME, globalSkillSlug: GLOBAL_SKILL_SLUG, + allInOneImage: process.env.AIOS_TEST_ALL_IN_ONE_IMAGE || process.env.AIOS_ALL_IN_ONE_IMAGE || null, + focus: focus || null, packageInstalls: PACKAGE_INSTALLS }, artifacts: { @@ -1282,6 +1341,7 @@ async function main() { let browser; let context; let token = ""; + let serviceToken = ""; try { browser = await chromium.launch({ @@ -1297,6 +1357,11 @@ async function main() { assert.ok(token, "login did not store auth token"); addStep(report, "login", true); + serviceToken = await createServiceAccessToken(baseUrl, token); + addStep(report, "0.create-service-access-token", true, { + tokenCreated: Boolean(serviceToken) + }); + report.managementRpcReady = await waitForManagementRpcReady( baseUrl, token, @@ -1304,21 +1369,29 @@ async function main() { ); addStep(report, "0.wait-management-rpc-ready", true); - report.startupCatalogSync = await waitForStartupCatalogSyncReady( - baseUrl, - token, - Number(process.env.AIOS_TEST_STARTUP_SYNC_READY_TIMEOUT_MS || "120000") - ); - addStep(report, "0.wait-startup-catalog-sync-ready", true, { - states: Object.fromEntries(Object.entries(report.startupCatalogSync).map(([key, state]) => [ - key, - { - status: state.status, - lastSuccessAt: state.last_success_at, - summary: state.summary || {} - } - ])) - }); + if (focus === "agent-rename-sync") { + report.startupCatalogSync = null; + addStep(report, "0.skip-startup-catalog-sync-ready-for-focus", true, { + focus, + reason: "focused rename regression triggers catalog sync explicitly after rename" + }); + } else { + report.startupCatalogSync = await waitForStartupCatalogSyncReady( + baseUrl, + token, + Number(process.env.AIOS_TEST_STARTUP_SYNC_READY_TIMEOUT_MS || "120000") + ); + addStep(report, "0.wait-startup-catalog-sync-ready", true, { + states: Object.fromEntries(Object.entries(report.startupCatalogSync).map(([key, state]) => [ + key, + { + status: state.status, + lastSuccessAt: state.last_success_at, + summary: state.summary || {} + } + ])) + }); + } report.cleanup = await cleanupCustomResources(baseUrl, token); addStep(report, "0.cleanup-custom-resources", report.cleanup.every((item) => item.ok), { @@ -1343,6 +1416,24 @@ async function main() { report.agent = await assignAdministrator(baseUrl, token, report.agent.id); addStep(report, "5.assign-administrator", true, { username: ASSIGNEE_USERNAME }); + report.agentRenameSync = await verifyAgentRenameSurvivesCatalogSync(baseUrl, token, serviceToken, report.agent.id); + report.agent = report.agentRenameSync.managementAgent || report.agent; + addStep(report, "5a.verify-agent-rename-survives-catalog-sync", true, { + agentId: AGENT_ID, + agentName: report.agentRenameSync.managementAgent?.agent_name || null, + externalAgentName: report.agentRenameSync.externalAgent?.agent_name || null, + syncSummary: report.agentRenameSync.syncSummary + }); + + if (focus === "agent-rename-sync") { + report.ok = true; + addStep(report, "5b.stop-after-agent-rename-sync-focus", true, { + focus, + reason: "focused regression does not require model API calls or MQTT agent dialogue" + }); + return; + } + report.globalSkillUpload = await uploadGlobalSkillWithPlaywright(page, baseUrl, globalSkillArtifact); addStep(report, "6.upload-global-skill-with-playwright", true, { slug: globalSkillArtifact.slug, diff --git a/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh b/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh index 5f3521f..a64b95d 100644 --- a/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh +++ b/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh @@ -152,8 +152,9 @@ wait_for_url() { } run_flow() { - if [[ -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then + if [[ "${AIOS_TEST_FOCUS:-}" != "agent-rename-sync" && -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then printf 'AIOS_OPENAI_API_KEY or AIOS_TEST_MODEL_API_KEY is required for the public-demo MQTT echo step.\n' >&2 + printf 'Set AIOS_TEST_FOCUS=agent-rename-sync to run only the catalog-sync rename regression without a model API key.\n' >&2 return 1 fi @@ -197,6 +198,7 @@ run_flow() { AIOS_TEST_REPORT_DIR="${REPORT_DIR}" \ AIOS_TEST_STARTUP_TIME_MS="${startup_time_ms}" \ AIOS_TEST_RESTART_TARGETS="${CONTAINER}" \ + AIOS_TEST_ALL_IN_ONE_IMAGE="${IMAGE}" \ node "${SCRIPT_DIR}/playwright-dev2-flow.js" } diff --git a/kernal/aios-management-serivce/package.json b/kernal/aios-management-serivce/package.json index 98b3174..d34e352 100644 --- a/kernal/aios-management-serivce/package.json +++ b/kernal/aios-management-serivce/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-serivce", - "version": "0.3.9", + "version": "0.4.0", "description": "AIOS / OpenClaw 的 MQTT 管理控制台服务。", "type": "module", "private": false, diff --git a/kernal/aios-management-serivce/src/capabilities/agent/lifecycle-service.ts b/kernal/aios-management-serivce/src/capabilities/agent/lifecycle-service.ts index a18cf5e..f7eddb6 100644 --- a/kernal/aios-management-serivce/src/capabilities/agent/lifecycle-service.ts +++ b/kernal/aios-management-serivce/src/capabilities/agent/lifecycle-service.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { CliRunner } from "../../tools/cli-runner.js"; import type { + AgentNameSetParams, AgentModelSetParams, EnvironmentConfig, ManagedAgentRecord @@ -358,6 +359,39 @@ export class AgentLifecycleService { }; } + async setAgentName(params: AgentNameSetParams): Promise { + const name = firstText(params.name); + if (!name) { + throw new Error("Agent name is required"); + } + + if (this.env.gatewayRestartMode === "container") { + const config = await this.readOpenClawConfig(); + const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; + const agent = agents.find((entry: { id?: string }) => entry?.id === params.agentId); + if (!agent) { + throw new Error(`Agent config not found: ${params.agentId}`); + } + + agent.name = name; + await this.writeOpenClawConfig(config); + } else { + const index = await this.requireAgentConfigIndex(params.agentId); + await this.cli.run([ + "config", + "set", + `agents.list[${index}].name`, + JSON.stringify(name), + "--strict-json" + ]); + } + + return { + agentId: params.agentId, + name + }; + } + async queryAllUsage(agentIds?: string[]): Promise { const normalizedAgentIds = Array.isArray(agentIds) ? [...new Set(agentIds.map((item) => String(item || "").trim()).filter(Boolean))] diff --git a/kernal/aios-management-serivce/src/capabilities/agent/name-set.ts b/kernal/aios-management-serivce/src/capabilities/agent/name-set.ts new file mode 100644 index 0000000..adccb82 --- /dev/null +++ b/kernal/aios-management-serivce/src/capabilities/agent/name-set.ts @@ -0,0 +1,6 @@ +import type { AgentNameSetParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentNameSet: CapabilityHandler = async (request, context) => { + return await context.agentManager.setAgentName(readCapabilityParams(request)); +}; diff --git a/kernal/aios-management-serivce/src/capabilities/agent/service.ts b/kernal/aios-management-serivce/src/capabilities/agent/service.ts index 61d40fb..38b10ee 100644 --- a/kernal/aios-management-serivce/src/capabilities/agent/service.ts +++ b/kernal/aios-management-serivce/src/capabilities/agent/service.ts @@ -5,6 +5,7 @@ import type { AgentCreateParams, AgentDocsUpdateParams, AgentGetParams, + AgentNameSetParams, AgentModelSetParams, AgentMutationParams, AgentWorkspaceExportParams, @@ -171,6 +172,12 @@ export class OpenClawManager { return result; } + async setAgentName(params: AgentNameSetParams): Promise { + const result = await this.lifecycleService.setAgentName(params); + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.name.set:${params.agentId}`); + return result; + } + async listLlmProviders(): Promise { return await this.llmModelConfigService.listProviders(); } diff --git a/kernal/aios-management-serivce/src/capabilities/context.ts b/kernal/aios-management-serivce/src/capabilities/context.ts index d3a79fb..4c7bbd7 100644 --- a/kernal/aios-management-serivce/src/capabilities/context.ts +++ b/kernal/aios-management-serivce/src/capabilities/context.ts @@ -2,6 +2,7 @@ import type { AgentCreateParams, AgentDocsUpdateParams, AgentGetParams, + AgentNameSetParams, AgentModelSetParams, AgentMutationParams, AgentWorkspaceExportParams, @@ -40,6 +41,7 @@ export interface AgentCapacityService { deleteAgent(params: AgentMutationParams): Promise<{ agentId: string; accountId: string }>; queryAllUsage(params?: { agentIds?: string[] }): Promise; setAgentModel(params: AgentModelSetParams): Promise; + setAgentName(params: AgentNameSetParams): Promise; updateAgentDocs(params: AgentDocsUpdateParams): Promise<{ path: string }>; exportAgentWorkspace(params: AgentWorkspaceExportParams): Promise; listLlmProviders(): Promise; diff --git a/kernal/aios-management-serivce/src/capabilities/registry.ts b/kernal/aios-management-serivce/src/capabilities/registry.ts index 7440432..882ec46 100644 --- a/kernal/aios-management-serivce/src/capabilities/registry.ts +++ b/kernal/aios-management-serivce/src/capabilities/registry.ts @@ -5,6 +5,7 @@ import { agentDocsUpdate } from "./agent/docs-update.js"; import { agentEnable } from "./agent/enable.js"; import { agentGet } from "./agent/get.js"; import { agentModelSet } from "./agent/model-set.js"; +import { agentNameSet } from "./agent/name-set.js"; import { agentUsageList } from "./agent/usage-list.js"; import { agentWorkspaceExport } from "./agent/workspace-export.js"; import { agentTemplateCreate } from "./agent-template/create.js"; @@ -41,6 +42,7 @@ export const capabilityHandlers: Record = { "agent.delete": agentDelete, "agent.usage.list": agentUsageList, "agent.model.set": agentModelSet, + "agent.name.set": agentNameSet, "agent.docs.update": agentDocsUpdate, "agent.workspace.export": agentWorkspaceExport, "agent.template.create": agentTemplateCreate, diff --git a/kernal/aios-management-serivce/src/types.ts b/kernal/aios-management-serivce/src/types.ts index d0aa47c..8362ad9 100644 --- a/kernal/aios-management-serivce/src/types.ts +++ b/kernal/aios-management-serivce/src/types.ts @@ -134,6 +134,12 @@ export interface AgentModelSetParams { restart?: boolean; } +export interface AgentNameSetParams { + agentId: string; + name: string; + restart?: boolean; +} + export interface LlmProviderCreateParams { providerId: string; baseUrl: string; diff --git a/kernal/aios-management-serivce/test/capabilities-registry.test.ts b/kernal/aios-management-serivce/test/capabilities-registry.test.ts index 228424a..85a87c8 100644 --- a/kernal/aios-management-serivce/test/capabilities-registry.test.ts +++ b/kernal/aios-management-serivce/test/capabilities-registry.test.ts @@ -14,6 +14,7 @@ const expectedActions = [ "agent.delete", "agent.usage.list", "agent.model.set", + "agent.name.set", "agent.docs.update", "agent.workspace.export", "agent.template.create", @@ -64,6 +65,7 @@ function createContext(calls: Array<{ method: string; args: unknown[] }>): Capab deleteAgent: respond("agentManager.deleteAgent") as never, queryAllUsage: respond("agentManager.queryAllUsage"), setAgentModel: respond("agentManager.setAgentModel"), + setAgentName: respond("agentManager.setAgentName"), updateAgentDocs: respond("agentManager.updateAgentDocs") as never, exportAgentWorkspace: respond("agentManager.exportAgentWorkspace") as never, listLlmProviders: respond("agentManager.listLlmProviders"), @@ -193,6 +195,12 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.setAgentModel", expectedArgs: [{ agentId: "lowcode", model: { primary: "openai/gpt-5.5" } }] }, + { + action: "agent.name.set", + params: { agentId: "lowcode", name: "Lowcode Agent Updated" }, + expectedMethod: "agentManager.setAgentName", + expectedArgs: [{ agentId: "lowcode", name: "Lowcode Agent Updated" }] + }, { action: "agent.docs.update", params: { agentId: "lowcode", content: "# AGENTS" }, diff --git a/kernal/aios-management-serivce/test/openclaw-manager.test.ts b/kernal/aios-management-serivce/test/openclaw-manager.test.ts index 87fe04e..4b17f10 100644 --- a/kernal/aios-management-serivce/test/openclaw-manager.test.ts +++ b/kernal/aios-management-serivce/test/openclaw-manager.test.ts @@ -982,6 +982,17 @@ test("container-mode agent mutations edit openclaw.json without shelling out", a }, restart: false }); + await manager.setAgentName({ + agentId: "lowcode", + name: "Lowcode Agent Updated", + restart: false + }); + + const renamedConfig = JSON.parse(await readFile(configPath, "utf8")) as { + agents?: { list?: Array<{ id?: string; name?: string }> }; + }; + assert.equal(renamedConfig.agents?.list?.find((agent) => agent.id === "lowcode")?.name, "Lowcode Agent Updated"); + await manager.disableAgent({ agentId: "lowcode", restart: false }); await manager.deleteAgent({ agentId: "lowcode", restart: false }); @@ -998,7 +1009,7 @@ test("container-mode agent mutations edit openclaw.json without shelling out", a assert.deepEqual(config.bindings, []); }); -test("getAgent, setAgentModel, and updateAgentDocs follow the capability contract", async () => { +test("getAgent, setAgentName, setAgentModel, and updateAgentDocs follow the capability contract", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-contract-")); const workspace = path.join(root, "workspaces", "lowcode"); const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); @@ -1019,6 +1030,11 @@ test("getAgent, setAgentModel, and updateAgentDocs follow the capability contrac name: "Lowcode Agent", workspace: "/ignored/from/config" }], + [{ + id: "lowcode", + name: "Lowcode Agent", + workspace: "/ignored/from/config" + }], [{ id: "lowcode", name: "Lowcode Agent", @@ -1040,6 +1056,11 @@ test("getAgent, setAgentModel, and updateAgentDocs follow the capability contrac ); const agent = await manager.getAgent({ agentId: "lowcode" }) as Record; + const renamed = await manager.setAgentName({ + agentId: "lowcode", + name: "Lowcode Agent Updated", + restart: false + }); const model = await manager.setAgentModel({ agentId: "lowcode", model: "openai/gpt-5.5", @@ -1051,6 +1072,10 @@ test("getAgent, setAgentModel, and updateAgentDocs follow the capability contrac }); assert.equal(agent.agentId, "lowcode"); + assert.deepEqual(renamed, { + agentId: "lowcode", + name: "Lowcode Agent Updated" + }); assert.deepEqual(model, { agentId: "lowcode", model: { @@ -1063,10 +1088,12 @@ test("getAgent, setAgentModel, and updateAgentDocs follow the capability contrac assert.equal(docsStat.mode & 0o777, 0o640); } assert.deepEqual(cli.runJsonCalls, [ + ["agents", "list", "--json", "--bindings"], ["agents", "list", "--json", "--bindings"], ["agents", "list", "--json", "--bindings"] ]); assert.deepEqual(cli.runCalls.map((call) => call.args), [ + ["config", "set", "agents.list[0].name", "\"Lowcode Agent Updated\"", "--strict-json"], ["config", "set", "agents.list[0].model", "{\"primary\":\"openai/gpt-5.5\"}", "--strict-json"] ]); }); -- Gitee From 4d59c9023cb536c32e17adcc61e907a1565d3055 Mon Sep 17 00:00:00 2001 From: NingWei Date: Wed, 17 Jun 2026 11:31:37 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=8D=87=E7=BA=A7openclaw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/management-website/README.md | 13 +- apps/management-website/docs/spec.md | 2 +- apps/management-website/package.json | 2 +- .../server/src/api/routes/index.js | 17 ++ apps/management-website/server/src/app.js | 1 + .../server/src/background/index.js | 6 + .../management-website/server/src/db/index.js | 50 +++- .../services/large-language-model-service.js | 37 ++- .../server/src/services/portal-service.js | 161 +++++++++++ .../server/test/background-services.test.js | 7 + .../server/test/db-reset-migration.test.js | 16 +- .../test/large-language-model-route.test.js | 60 ++++ .../test/large-language-model-service.test.js | 13 +- .../test/portal-service-alignment.test.js | 100 +++++++ apps/management-website/src/app/App.jsx | 4 +- .../src/pages/LargeLanguageModelsPage.jsx | 111 +++++--- .../src/pages/SettingsPage.jsx | 36 +++ apps/management-website/src/styles.css | 20 ++ .../test/integration/common.js | 23 +- .../test/integration/fast.js | 18 +- .../test/integration/full.js | 18 +- .../test/integration/website-full.js | 10 +- docker-images/apps/Dockerfile | 2 +- docker-images/kernal/Dockerfile | 22 +- docker-images/kernal/README.md | 4 + docker-images/kernal/docs/build.md | 6 +- docker-images/kernal/docs/design.md | 4 + .../test/kernal-tests/test/fast-smoke.js | 8 + .../test/kernal-tests/test/full-smoke.js | 2 +- joint-test/dmz-mode/run-dev2-flow.core.sh | 1 + kernal/aios-management-serivce/README.md | 9 +- .../docs/compatibility-list.md | 51 ++++ kernal/aios-management-serivce/docs/design.md | 24 +- kernal/aios-management-serivce/package.json | 2 +- .../src/capabilities/agent/service.ts | 5 + .../capabilities/agent/workspace-service.ts | 1 + .../src/capabilities/context.ts | 2 + .../src/capabilities/llm/model-test.ts | 6 + .../src/capabilities/llm/service.ts | 61 +++- .../src/capabilities/registry.ts | 4 + .../src/capabilities/version/list.ts | 78 ++++++ kernal/aios-management-serivce/src/service.ts | 1 + kernal/aios-management-serivce/src/types.ts | 9 + .../test/capabilities-registry.test.ts | 24 ++ .../test/llm-model-config-service.test.ts | 30 +- .../test/openclaw-manager.test.ts | 45 +++ .../test/service-queue.test.ts | 1 + .../aios-self-improving-agent/SKILL.md | 261 ++++++++++++++++++ .../agents/openai.yaml | 7 + 49 files changed, 1259 insertions(+), 136 deletions(-) create mode 100644 kernal/aios-management-serivce/src/capabilities/llm/model-test.ts create mode 100644 kernal/aios-management-serivce/src/capabilities/version/list.ts create mode 100644 kernal/clawhub-skills/aios-self-improving-agent/SKILL.md create mode 100644 kernal/clawhub-skills/aios-self-improving-agent/agents/openai.yaml diff --git a/apps/management-website/README.md b/apps/management-website/README.md index 5d2ec0e..3f9904b 100644 --- a/apps/management-website/README.md +++ b/apps/management-website/README.md @@ -14,7 +14,7 @@ AIOS 管理控制台是面向运维和管理员的 Web 应用,技术栈为 `Re - 本地数据库缓存环境目录和 Token 用量 - 启动同步、定时同步和手动同步共用同一套同步计划 -管理控制台的列表、状态和统计统一从 SQLite 读取。远端数据只通过 `catalog.snapshot` 和 `agent.usage.list` 两类同步任务刷新。 +管理控制台的列表、状态和统计统一从 SQLite 读取。远端数据只通过 `catalog.snapshot` 和 `agent.usage.list` 两类同步任务刷新;版本信息在启动时通过一次 `version.list` 拉取并缓存到 `component_versions`,后续只读本地数据库。 ## 目录结构 @@ -78,6 +78,7 @@ npm run test:full - `SOUL.md` - `TOOLS.md` - `HEARTBEAT.md` +- `.learnings/` - `skills/` - `knowledge/` @@ -109,10 +110,11 @@ npm run test:full 启动后后台流程为: 1. 等待 `aios-management-serivce` 的 `service.ping` 可用 -2. 执行一次 `catalog.snapshot` -3. 如果目录快照同步成功,60 秒后执行第一次 `agent.usage.list` -4. 每次 `catalog.snapshot` 完成后 120 秒再次执行 -5. 每次 `agent.usage.list` 完成后 120 秒再次执行 +2. 执行一次 `version.list` 并写入 `component_versions` +3. 执行一次 `catalog.snapshot` +4. 如果目录快照同步成功,60 秒后执行第一次 `agent.usage.list` +5. 每次 `catalog.snapshot` 完成后 120 秒再次执行 +6. 每次 `agent.usage.list` 完成后 120 秒再次执行 设置页“同步数据”按钮会先执行 `catalog.snapshot`,再执行 `agent.usage.list`。同步过程会显示蒙版,完成后用 toast 提示结果。 @@ -124,6 +126,7 @@ npm run test:full - `template_sync_state` - `system_sync_state` - `usage_refresh_state` +- `component_versions` ## 对外 API diff --git a/apps/management-website/docs/spec.md b/apps/management-website/docs/spec.md index b96550d..ca441c8 100644 --- a/apps/management-website/docs/spec.md +++ b/apps/management-website/docs/spec.md @@ -40,7 +40,7 @@ Token 用量由 `agent.usage.list` 同步到 `agents.usage_snapshot_json`。超 ### 模板 -模板由模板名和 workspace zip 组成,zip 顶层必须包含 `AGENTS.md`。模板可配置默认 Provider / Model,创建数字员工时作为表单默认值。 +模板由模板名和 workspace zip 组成,zip 顶层必须包含 `AGENTS.md`,并应保留可复用目录(例如 `.learnings/`、`skills/`、`knowledge/`)。模板可配置默认 Provider / Model,创建数字员工时作为表单默认值。 ### Skill diff --git a/apps/management-website/package.json b/apps/management-website/package.json index 822fe07..a3808f9 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.3.5", + "version": "0.3.6", "type": "module", "files": [ "dist", diff --git a/apps/management-website/server/src/api/routes/index.js b/apps/management-website/server/src/api/routes/index.js index 3c39a5d..de27211 100644 --- a/apps/management-website/server/src/api/routes/index.js +++ b/apps/management-website/server/src/api/routes/index.js @@ -293,6 +293,7 @@ export function createRoutes(services) { current_user: req.currentUser, settings: services.portalService.getSettings(), access_tokens: services.portalService.listAccessTokens(), + version_info: services.portalService.getVersionInfo(), ...getCatalogSyncStatuses(services), env_config: { admin_inbound_topic: services.env?.mqtt?.adminInboundTopic || "" @@ -468,6 +469,22 @@ export function createRoutes(services) { res.json(result); })); + router.post("/llm/providers/:providerId/models/:modelId/test", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.largeLanguageModelService.testModel( + req.params.providerId, + req.params.modelId || "", + req.body || {} + ); + writeAuditLog( + services, + req, + "测试大语言模型 Model 连接", + `测试大语言模型 Model:${result.model_ref || `${req.params.providerId}/${req.params.modelId}`},结果:${result.ok ? "成功" : "失败"}` + ); + res.json(result); + })); + router.delete("/llm/providers/:providerId/models/:modelId", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); const result = await services.largeLanguageModelService.deleteModel( diff --git a/apps/management-website/server/src/app.js b/apps/management-website/server/src/app.js index ca22ab7..6c9cb01 100644 --- a/apps/management-website/server/src/app.js +++ b/apps/management-website/server/src/app.js @@ -29,6 +29,7 @@ export function createServerApp() { const auditLogService = new AuditLogService({ db }); const catalogSyncService = new CatalogSyncService({ db, rpcClient, auditLogService, env }); const portalService = new PortalService({ db, objectStorage, rpcClient, authService }); + portalService.cacheLocalVersionInfo(); const largeLanguageModelService = new LargeLanguageModelService({ db, rpcClient }); const topicPingService = new TopicPingService({ db, env, rpcClient }); const agentService = new AgentService({ db, rpcClient, objectStorage, env }); diff --git a/apps/management-website/server/src/background/index.js b/apps/management-website/server/src/background/index.js index c935420..fcd211e 100644 --- a/apps/management-website/server/src/background/index.js +++ b/apps/management-website/server/src/background/index.js @@ -121,6 +121,12 @@ export function startBackgroundServices({ env, services }) { return; } + try { + await services.portalService.refreshVersionInfoCache?.({ trigger: "startup" }); + } catch (error) { + console.error("Startup version sync failed", error); + } + try { const result = await services.catalogSyncService.syncStartupCatalogTask({ trigger: "startup" }); if (result?.status === "success") { diff --git a/apps/management-website/server/src/db/index.js b/apps/management-website/server/src/db/index.js index 3a79bc4..1d2cbb4 100644 --- a/apps/management-website/server/src/db/index.js +++ b/apps/management-website/server/src/db/index.js @@ -7,7 +7,7 @@ import { loadEnv } from "../config/env.js"; import { hashPassword } from "../utils/security.js"; const env = loadEnv(); -const SCHEMA_VERSION = 6; +const SCHEMA_VERSION = 7; const DEFAULT_ACCESS_TOKEN = "sk-1234567890qwertyuiop"; const DEFAULT_PORTAL_NAME = "AIOS 管理控制台"; const DEFAULT_BRAND_SUBTITLE = "Your Company Name"; @@ -288,6 +288,26 @@ function migrateV5ToV6() { `); } +function createComponentVersionsTable() { + return ` + CREATE TABLE IF NOT EXISTS component_versions ( + component_key TEXT PRIMARY KEY, + group_name TEXT NOT NULL, + name TEXT NOT NULL, + package_name TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL, + source TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `; +} + +function migrateV6ToV7() { + db.exec(createComponentVersionsTable()); +} + function addAgentCreatorColumn() { if (!hasTable("agents")) { return; @@ -333,6 +353,8 @@ function createSchema() { updated_at TEXT NOT NULL ); + ${createComponentVersionsTable()} + ${createStateTable("usage_refresh_state")} ${createStateTable("agent_sync_state")} ${createStateTable("llm_sync_state")} @@ -676,6 +698,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -695,6 +720,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -711,6 +739,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -724,6 +755,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -734,6 +768,19 @@ function migrate() { db.exec("BEGIN"); try { migrateV5ToV6(); + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } + setSchemaVersion(SCHEMA_VERSION); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + } else if (version === 6 && SCHEMA_VERSION >= 7) { + db.exec("BEGIN"); + try { + migrateV6ToV7(); setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -758,6 +805,7 @@ function migrate() { } addAgentCreatorColumn(); + migrateV6ToV7(); ensureCoreRows(); } diff --git a/apps/management-website/server/src/services/large-language-model-service.js b/apps/management-website/server/src/services/large-language-model-service.js index 6313038..aaeecbc 100644 --- a/apps/management-website/server/src/services/large-language-model-service.js +++ b/apps/management-website/server/src/services/large-language-model-service.js @@ -333,24 +333,43 @@ export class LargeLanguageModelService { async testProvider(providerId, payload = {}) { const normalizedProviderId = normalizeProviderId(providerId); - const provider = this.getProvider(normalizedProviderId); const model = this.listModels(normalizedProviderId)[0] || null; + return await this.testModel( + normalizedProviderId, + payload.model_id || payload.modelId || model?.model_id, + payload + ); + } + + async testModel(providerId, modelId, payload = {}) { + const normalizedProviderId = normalizeProviderId(providerId); + const normalizedModelId = normalizeModelId(modelId); + const provider = this.getProvider(normalizedProviderId); + const model = this.getModel(normalizedProviderId, normalizedModelId); const testedAt = new Date().toISOString(); const startedAt = Date.now(); + const timeoutMs = payload.timeout_ms || payload.timeoutMs || 30000; try { - const result = await this.rpcClient.call("llm.provider.test", { + const result = await this.rpcClient.call("llm.model.test", { providerId: normalizedProviderId, - modelId: payload.model_id || payload.modelId || model?.model_id, - timeoutMs: payload.timeout_ms || payload.timeoutMs || 30000 + modelId: normalizedModelId, + modelRef: model.model_ref, + contextTokens: model.context_tokens, + maxTokens: model.max_tokens, + timeoutMs }, payload.timeout_ms || payload.timeoutMs || 45000); return { ok: Boolean(result?.ok), provider_id: normalizedProviderId, provider, - model_ref: result?.modelRef || model?.model_ref || "", + model_id: normalizedModelId, + model, + model_ref: result?.modelRef || model.model_ref, base_url: result?.baseUrl || provider.base_url, + context_tokens: result?.contextTokens ?? model.context_tokens, + max_tokens: result?.outputTokens ?? model.max_tokens, tested_at: result?.testedAt || testedAt, response_time_ms: result?.responseTimeMs ?? (Date.now() - startedAt), result @@ -360,11 +379,15 @@ export class LargeLanguageModelService { ok: false, provider_id: normalizedProviderId, provider, - model_ref: model?.model_ref || "", + model_id: normalizedModelId, + model, + model_ref: model.model_ref, base_url: provider.base_url, + context_tokens: model.context_tokens, + max_tokens: model.max_tokens, tested_at: testedAt, response_time_ms: Date.now() - startedAt, - error_message: error.message || "Provider 测试失败", + error_message: error.message || "Model 测试失败", error_details: error.details || null }; } diff --git a/apps/management-website/server/src/services/portal-service.js b/apps/management-website/server/src/services/portal-service.js index 2511af9..d46dedc 100644 --- a/apps/management-website/server/src/services/portal-service.js +++ b/apps/management-website/server/src/services/portal-service.js @@ -1,4 +1,7 @@ import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import AdmZip from "adm-zip"; @@ -15,6 +18,69 @@ const LOGO_MIME_TYPES = new Map([ ["image/bmp", "bmp"], ["image/x-ms-bmp", "bmp"] ]); +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", ".."); +const LOCAL_VERSION_COMPONENTS = [ + { + group: "apps", + packagePath: "apps/management-website/package.json" + }, + { + group: "apps", + packagePath: "apps/aios-app-invoke-proxy-service/package.json" + } +]; + +function readPackageMetadata(relativePath) { + const packagePath = path.join(REPO_ROOT, relativePath); + try { + const metadata = JSON.parse(fs.readFileSync(packagePath, "utf8")); + return { + name: firstText(metadata?.name, path.basename(path.dirname(packagePath))), + version: firstText(metadata?.version, "unknown") + }; + } catch { + return { + name: path.basename(path.dirname(packagePath)), + version: "unknown" + }; + } +} + +function normalizeVersionItem(item, fallbackGroup = "kernal") { + const packageName = firstText(item?.package_name, item?.packageName, item?.name); + const name = packageName === "openclaw" + ? "core" + : firstText(item?.name, item?.displayName, packageName); + + return { + group: firstText(item?.group, fallbackGroup), + name: name || "-", + package_name: packageName || name || "-", + version: firstText(item?.version, "unknown") + }; +} + +function versionComponentKey(item) { + return `${item.group || "unknown"}:${item.package_name || item.name || "unknown"}`; +} + +function toVersionCacheRow(row) { + return { + group: row.group_name, + name: row.name, + package_name: row.package_name, + version: row.version, + source: row.source, + updated_at: row.updated_at + }; +} + +function getErrorMessage(error, fallback = "Unknown error") { + if (error instanceof Error && error.message) { + return error.message; + } + return error ? String(error) : fallback; +} function ensureZipContains(buffer, requiredName) { const zip = new AdmZip(buffer); @@ -259,6 +325,101 @@ export class PortalService { }; } + getVersionInfo() { + const rows = this.db.prepare(` + SELECT group_name, name, package_name, version, source, updated_at + FROM component_versions + ORDER BY display_order ASC, name ASC + `).all(); + const generatedAt = rows.reduce((latest, row) => ( + row.updated_at && row.updated_at > latest ? row.updated_at : latest + ), ""); + + return { + generated_at: generatedAt || null, + items: rows.map(toVersionCacheRow) + }; + } + + localVersionItems() { + return LOCAL_VERSION_COMPONENTS.map((component) => { + const metadata = readPackageMetadata(component.packagePath); + return normalizeVersionItem({ + group: component.group, + name: component.displayName || metadata.name, + package_name: metadata.name, + version: metadata.version + }, component.group); + }); + } + + writeVersionInfoCache(items, source) { + const now = new Date().toISOString(); + const normalizedItems = Array.isArray(items) + ? items.map((item) => normalizeVersionItem(item)).filter((item) => item.name && item.version) + : []; + const removeSource = this.db.prepare("DELETE FROM component_versions WHERE source = ?"); + const upsert = this.db.prepare(` + INSERT INTO component_versions ( + component_key, group_name, name, package_name, version, source, + display_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(component_key) DO UPDATE SET + group_name = excluded.group_name, + name = excluded.name, + package_name = excluded.package_name, + version = excluded.version, + source = excluded.source, + display_order = excluded.display_order, + updated_at = excluded.updated_at + `); + + removeSource.run(source); + normalizedItems.forEach((item, index) => { + upsert.run( + versionComponentKey(item), + item.group, + item.name, + item.package_name, + item.version, + source, + source === "local" ? index : index + LOCAL_VERSION_COMPONENTS.length, + now, + now + ); + }); + } + + cacheLocalVersionInfo() { + this.writeVersionInfoCache(this.localVersionItems(), "local"); + return this.getVersionInfo(); + } + + async refreshVersionInfoCache({ timeoutMs = 15000 } = {}) { + this.cacheLocalVersionInfo(); + + if ( + typeof this.rpcClient?.call !== "function" + || (typeof this.rpcClient?.isConfigured === "function" && !this.rpcClient.isConfigured()) + ) { + return this.getVersionInfo(); + } + + try { + const remote = await this.rpcClient.call("version.list", {}, timeoutMs); + const remoteItems = Array.isArray(remote?.items) + ? remote.items.map((item) => normalizeVersionItem(item, "kernal")) + : []; + this.writeVersionInfoCache(remoteItems, "management-service"); + return this.getVersionInfo(); + } catch (error) { + return { + ...this.getVersionInfo(), + error_message: getErrorMessage(error, "version.list failed") + }; + } + } + listAccessTokens() { return this.db.prepare(` SELECT token, created_at, updated_at diff --git a/apps/management-website/server/test/background-services.test.js b/apps/management-website/server/test/background-services.test.js index d3b7643..4f02b32 100644 --- a/apps/management-website/server/test/background-services.test.js +++ b/apps/management-website/server/test/background-services.test.js @@ -61,6 +61,9 @@ function createServices({ calls.push("rpc.stop"); } }, + portalService: { + refreshVersionInfoCache: taskFactory("sync-version") + }, catalogSyncService: { syncStartupCatalogTask: taskFactory("sync-startup-catalog"), syncCatalogTask: taskFactory("sync-catalog"), @@ -97,6 +100,7 @@ it("schedules catalog and token sync from completion times after startup catalog expect(calls).toEqual([ "rpc.start", + "sync-version:startup", "sync-startup-catalog:startup" ]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ @@ -154,6 +158,7 @@ it("does not schedule token sync when startup catalog sync fails", async () => { await flushMicrotasks(); expect(calls).toEqual([ "rpc.start", + "sync-version:startup", "sync-startup-catalog:startup" ]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ @@ -202,6 +207,7 @@ it("waits for a management RPC ping before startup sync", async () => { await flushMicrotasks(); expect(calls.filter((entry) => entry.endsWith(":startup"))).toEqual([ + "sync-version:startup", "sync-startup-catalog:startup" ]); } finally { @@ -261,6 +267,7 @@ it("retries management RPC readiness before startup sync failure is marked", asy "rpc.start", "rpc.call:service.ping:1", "rpc.call:service.ping:2", + "sync-version:startup", "sync-startup-catalog:startup" ]); expect(startupFailures).toEqual([]); diff --git a/apps/management-website/server/test/db-reset-migration.test.js b/apps/management-website/server/test/db-reset-migration.test.js index 688c97e..c18b470 100644 --- a/apps/management-website/server/test/db-reset-migration.test.js +++ b/apps/management-website/server/test/db-reset-migration.test.js @@ -105,6 +105,11 @@ it("resets drifted legacy schemas to the latest database structure", async () => FROM sqlite_master WHERE type = 'table' AND name = 'llm_providers' `).get(); + const componentVersionsTable = db.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'component_versions' + `).get(); const llmModelTable = db.prepare(` SELECT name FROM sqlite_master @@ -146,10 +151,11 @@ it("resets drifted legacy schemas to the latest database structure", async () => const settingsColumns = db.prepare("PRAGMA table_info(settings)").all().map((row) => row.name); - expect(schemaVersion?.value).toBe("6"); + expect(schemaVersion?.value).toBe("7"); expect(settingsColumns).toContain("logo_mime_type"); expect(settingsColumns).toContain("logo_data"); expect(aiosUsersTable?.name).toBe("aios_users"); + expect(componentVersionsTable?.name).toBe("component_versions"); expect(agentColumns).toContain("template_name"); expect(agentColumns).toContain("llm_provider_id"); expect(agentColumns).toContain("llm_model_id"); @@ -301,7 +307,7 @@ it("migrates v1 agents to template relationships without dropping data", async ( ORDER BY slug `).all(); - expect(schemaVersion?.value).toBe("6"); + expect(schemaVersion?.value).toBe("7"); expect(agentColumns).toContain("template_name"); expect(agentColumns).toContain("llm_model_ref"); expect(agentColumns).toContain("created_by"); @@ -422,7 +428,7 @@ it("migrates v3 templates to default llm columns and removes template descriptio const templateColumns = db.prepare("PRAGMA table_info(agent_templates)").all().map((row) => row.name); const template = db.prepare("SELECT template_name, default_llm_model_ref FROM agent_templates WHERE template_name = ?").get("default"); - expect(schemaVersion?.value).toBe("6"); + expect(schemaVersion?.value).toBe("7"); expect(templateColumns).toContain("default_llm_provider_id"); expect(templateColumns).toContain("default_llm_model_id"); expect(templateColumns).toContain("default_llm_model_ref"); @@ -506,7 +512,7 @@ it("migrates v4 settings through uploaded logo columns without dropping data", a const schemaVersion = db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get(); const settings = db.prepare("SELECT portal_name, brand_subtitle, theme_color, logo_mime_type, logo_data FROM settings WHERE id = 1").get(); - expect(schemaVersion?.value).toBe("6"); + expect(schemaVersion?.value).toBe("7"); expect(settings).toEqual({ portal_name: "AIOS", brand_subtitle: "Test", @@ -647,7 +653,7 @@ it("migrates v5 templates by dropping template description without dropping data WHERE template_name = ? `).get("default"); - expect(schemaVersion?.value).toBe("6"); + expect(schemaVersion?.value).toBe("7"); expect(templateColumns).not.toContain("description"); expect(template).toEqual({ id: 1, diff --git a/apps/management-website/server/test/large-language-model-route.test.js b/apps/management-website/server/test/large-language-model-route.test.js index d203e09..1b39172 100644 --- a/apps/management-website/server/test/large-language-model-route.test.js +++ b/apps/management-website/server/test/large-language-model-route.test.js @@ -109,3 +109,63 @@ it("passes encoded model id through a single decode step", async () => { await close(server); } }); + +it("routes model test requests with encoded model id", async () => { + const calls = []; + const app = express(); + app.use(express.json()); + app.use("/api", createRoutes({ + authService: { + getLocalApiUser() { + return { + id: 1, + username: "aios", + role: "aios-admin" + }; + }, + assertAdmin() {} + }, + auditLogService: { + write() {} + }, + largeLanguageModelService: { + async testModel(providerId, modelId, payload) { + calls.push({ providerId, modelId, payload }); + return { + ok: true, + provider_id: providerId, + model_id: modelId, + model_ref: `${providerId}/${modelId}` + }; + } + }, + portalService: {}, + catalogSyncService: {}, + env: {} + })); + + const server = await listen(app); + try { + const address = server.address(); + const modelId = "gpt%mini"; + const response = await requestJson( + `http://127.0.0.1:${address.port}/api/llm/providers/corp-openai/models/${encodeURIComponent(modelId)}/test`, + { + method: "POST", + body: { timeout_ms: 1234 } + } + ); + + expect(response.status).toBe(200); + expect(response.body.model_id).toBe(modelId); + expect(calls).toEqual([{ + providerId: "corp-openai", + modelId, + payload: { + timeout_ms: 1234 + } + }]); + } finally { + await close(server); + } +}); diff --git a/apps/management-website/server/test/large-language-model-service.test.js b/apps/management-website/server/test/large-language-model-service.test.js index 48d92d0..cfdfa04 100644 --- a/apps/management-website/server/test/large-language-model-service.test.js +++ b/apps/management-website/server/test/large-language-model-service.test.js @@ -300,7 +300,7 @@ it("keeps existing OpenAI compatible v1 provider URL unchanged", async () => { } }); -it("tests provider through management RPC with the first local model", async () => { +it("tests model through management RPC with local context and output limits", async () => { const db = createDb(); const { service, calls } = createService(db, (_action, payload) => ({ ok: true, @@ -308,21 +308,28 @@ it("tests provider through management RPC with the first local model", async () modelId: payload.modelId, modelRef: `${payload.providerId}/${payload.modelId}`, baseUrl: "https://api.example.com/v1", + contextTokens: payload.contextTokens, + outputTokens: payload.maxTokens, responseTimeMs: 25, testedAt: TEST_NOW })); try { - const result = await service.testProvider("corp-openai"); + const result = await service.testModel("corp-openai", "gpt-4.1-mini"); expect(result.ok).toBe(true); expect(result.model_ref).toBe("corp-openai/gpt-4.1-mini"); + expect(result.context_tokens).toBe(32768); + expect(result.max_tokens).toBe(4096); expect(result.response_time_ms).toBe(25); expect(calls).toEqual([{ - action: "llm.provider.test", + action: "llm.model.test", payload: { providerId: "corp-openai", modelId: "gpt-4.1-mini", + modelRef: "corp-openai/gpt-4.1-mini", + contextTokens: 32768, + maxTokens: 4096, timeoutMs: 30000 } }]); 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 1451cbe..8c4c60a 100644 --- a/apps/management-website/server/test/portal-service-alignment.test.js +++ b/apps/management-website/server/test/portal-service-alignment.test.js @@ -25,6 +25,24 @@ function createDb() { }; } +function createVersionDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE component_versions ( + component_key TEXT PRIMARY KEY, + group_name TEXT NOT NULL, + name TEXT NOT NULL, + package_name TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL, + source TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + return db; +} + function createSkillZip({ name = "skill-a", description = "Skill A description" } = {}) { const zip = new AdmZip(); zip.addFile("SKILL.md", Buffer.from([ @@ -133,6 +151,88 @@ it("returns dashboard metrics after agent and usage startup sync complete", () = }]); }); +it("returns component version info from the local cache", () => { + const db = createVersionDb(); + db.prepare(` + INSERT INTO component_versions ( + component_key, group_name, name, package_name, version, source, + display_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "kernal:openclaw", + "kernal", + "core", + "openclaw", + "2026.5.28", + "management-service", + 1, + "2026-06-17T00:00:00.000Z", + "2026-06-17T00:00:00.000Z" + ); + const service = new PortalService({ + db, + objectStorage: {}, + rpcClient: {}, + authService: {} + }); + + const versionInfo = service.getVersionInfo(); + + expect(versionInfo.generated_at).toBe("2026-06-17T00:00:00.000Z"); + expect(versionInfo.items).toEqual([{ + group: "kernal", + name: "core", + package_name: "openclaw", + version: "2026.5.28", + source: "management-service", + updated_at: "2026-06-17T00:00:00.000Z" + }]); +}); + +it("refreshes component version cache from local packages and version.list", async () => { + const db = createVersionDb(); + const calls = []; + const service = new PortalService({ + db, + objectStorage: {}, + rpcClient: { + isConfigured() { + return true; + }, + async call(action, params, timeoutMs) { + calls.push({ action, params, timeoutMs }); + return { + version: 1, + items: [ + { group: "kernal", name: "aios-apps-invoke-cli", packageName: "aios-apps-invoke-cli", version: "0.0.1" }, + { group: "kernal", name: "aios-management-serivce", packageName: "aios-management-serivce", version: "0.4.0" }, + { group: "kernal", name: "aios-mqtt-channel", packageName: "aios-mqtt-channel", version: "0.1.0" }, + { group: "kernal", name: "core", packageName: "openclaw", version: "2026.5.28" } + ] + }; + } + }, + authService: {} + }); + + const versionInfo = await service.refreshVersionInfoCache({ timeoutMs: 1234 }); + + expect(calls).toEqual([{ + action: "version.list", + params: {}, + timeoutMs: 1234 + }]); + expect(versionInfo.items.map((item) => item.name)).toEqual([ + "aios-management-web", + "aios-app-invoke-proxy-service", + "aios-apps-invoke-cli", + "aios-management-serivce", + "aios-mqtt-channel", + "core" + ]); + expect(versionInfo.items.find((item) => item.name === "core")?.package_name).toBe("openclaw"); +}); + it("updates portal settings without touching uploaded logo data", () => { const calls = []; let settings = { diff --git a/apps/management-website/src/app/App.jsx b/apps/management-website/src/app/App.jsx index c124be9..3793c99 100644 --- a/apps/management-website/src/app/App.jsx +++ b/apps/management-website/src/app/App.jsx @@ -165,6 +165,7 @@ export default function App() { const templateSync = boot?.template_sync; const systemSync = boot?.system_sync; const usageRefresh = boot?.usage_refresh; + const versionInfo = boot?.version_info; useEffect(() => { document.title = getPortalTitle(activeSettings); @@ -226,6 +227,7 @@ export default function App() { templateSyncStatus={templateSync} systemSyncStatus={systemSync} usageRefreshStatus={usageRefresh} + versionInfo={versionInfo} notify={messageApi} onSave={async (values) => { const next = await api.put("/api/settings", { @@ -303,7 +305,7 @@ export default function App() { default: return null; } - }, [accessTokens, agentSync, currentUser, dashboardRefreshKey, llmSync, messageApi, selectedKey, settings, skillSync, systemSync, templateSync, usageRefresh]); + }, [accessTokens, agentSync, currentUser, dashboardRefreshKey, llmSync, messageApi, selectedKey, settings, skillSync, systemSync, templateSync, usageRefresh, versionInfo]); return ( { - setProviderTestingId(row.provider_id); + const handleModelTest = async (row) => { + setModelTestingRef(row.model_ref); try { - const result = await api.post(`/api/llm/providers/${encodePath(row.provider_id)}/test`, {}); - setProviderTestResult({ + const result = await api.post( + `/api/llm/providers/${encodePath(row.provider_id)}/models/${encodePath(row.model_id)}/test`, + {} + ); + setModelTestResult({ providerId: row.provider_id, + modelRef: row.model_ref, ok: Boolean(result?.ok), data: result, errorMessage: result?.error_message || "" }); - setProviderTestOpen(true); + setModelTestOpen(true); } catch (error) { - setProviderTestResult({ + setModelTestResult({ providerId: row.provider_id, + modelRef: row.model_ref, ok: false, data: null, - errorMessage: error.message || "Provider 连接测试失败" + errorMessage: error.message || "Model 连接测试失败" }); - setProviderTestOpen(true); + setModelTestOpen(true); } finally { - setProviderTestingId(""); + setModelTestingRef(""); } }; @@ -344,17 +349,6 @@ export function LargeLanguageModelsPage() { -
+ + {value} + }, + { + title: "版本", + dataIndex: "version", + width: 180, + render: (value) => {value} + } + ]} + /> + ) }, diff --git a/apps/management-website/src/styles.css b/apps/management-website/src/styles.css index aa7ff40..ac8447c 100644 --- a/apps/management-website/src/styles.css +++ b/apps/management-website/src/styles.css @@ -519,6 +519,26 @@ body { width: 100%; } +.settings-version-card { + margin-top: 0; +} + +.settings-version-table .ant-table { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 8px; + overflow: hidden; +} + +.settings-version-table .ant-table-thead > tr > th { + color: #475569; + background: #f8fafc !important; +} + +.settings-version-table .ant-table-tbody > tr > td { + padding-top: 12px !important; + padding-bottom: 12px !important; +} + .dashboard-hero { display: flex; align-items: flex-end; diff --git a/apps/management-website/test/integration/common.js b/apps/management-website/test/integration/common.js index de4b348..9a9dad8 100644 --- a/apps/management-website/test/integration/common.js +++ b/apps/management-website/test/integration/common.js @@ -281,28 +281,32 @@ function apiPath(response, expectedPath, method = "POST") { return url.pathname === expectedPath && response.request().method() === method; } -export async function runProviderConnectionTestOnPage(browserSession, providerId) { +export async function runModelConnectionTestOnPage(browserSession, providerId, modelId) { const { page } = browserSession; const encodedProviderId = encodeURIComponent(providerId); + const encodedModelId = encodeURIComponent(modelId); + const modelRef = `${providerId}/${modelId}`; await openConsolePage(browserSession); await page.getByTestId("nav-largeLanguageModels").click(); + await page.getByRole("tab", { name: "模型" }).click(); + await page.getByTestId("models-table").waitFor({ state: "visible" }); - const button = page.getByTestId(`provider-test-${providerId}`); + const button = page.getByTestId(`model-test-${modelRef}`); await button.waitFor({ state: "visible" }); const [response] = await Promise.all([ page.waitForResponse( - (item) => apiPath(item, `/api/llm/providers/${encodedProviderId}/test`), - { timeout: Number(process.env.AIOS_TEST_PROVIDER_TEST_TIMEOUT_MS || "60000") } + (item) => apiPath(item, `/api/llm/providers/${encodedProviderId}/models/${encodedModelId}/test`), + { timeout: Number(process.env.AIOS_TEST_MODEL_TEST_TIMEOUT_MS || process.env.AIOS_TEST_PROVIDER_TEST_TIMEOUT_MS || "60000") } ), button.click() ]); const body = await readJsonResponse(response); - const modal = page.getByTestId("provider-test-result-modal"); + const modal = page.getByTestId("model-test-result-modal"); await modal.waitFor({ state: "visible" }); const modalText = await modal.innerText(); - await page.getByTestId("provider-test-result-ok").click(); + await page.getByTestId("model-test-result-ok").click(); return { ok: response.ok() && body?.ok === true, @@ -312,6 +316,13 @@ export async function runProviderConnectionTestOnPage(browserSession, providerId }; } +export async function runProviderConnectionTestOnPage(browserSession, providerId, modelId) { + if (!modelId) { + throw new Error("runProviderConnectionTestOnPage now requires modelId because connection testing belongs to models."); + } + return await runModelConnectionTestOnPage(browserSession, providerId, modelId); +} + export async function runAgentConversationTestOnPage(browserSession, agentSlug, text, options = {}) { const { page } = browserSession; const encodedAgentSlug = encodeURIComponent(agentSlug); diff --git a/apps/management-website/test/integration/fast.js b/apps/management-website/test/integration/fast.js index 9bef85b..a88c381 100644 --- a/apps/management-website/test/integration/fast.js +++ b/apps/management-website/test/integration/fast.js @@ -7,7 +7,7 @@ import { loadSettings, loginAsAdmin, newReport, - runProviderConnectionTestOnPage, + runModelConnectionTestOnPage, runCleanup, writeReport } from "./common.js"; @@ -76,17 +76,17 @@ async function main() { } llmModelRow = modelCreate.body; - const providerPageTest = await runProviderConnectionTestOnPage(browserSession, llmProviderId); + const modelPageTest = await runModelConnectionTestOnPage(browserSession, llmProviderId, llmModelId); addAction( report, - "llm.providers.test.page", - providerPageTest.ok, - providerPageTest.status, - providerPageTest.body, - providerPageTest.note + "llm.models.test.page", + modelPageTest.ok, + modelPageTest.status, + modelPageTest.body, + modelPageTest.note ); - if (!providerPageTest.ok) { - throw new Error(`llm.providers.test.page failed: ${JSON.stringify(providerPageTest.body)}`); + if (!modelPageTest.ok) { + throw new Error(`llm.models.test.page failed: ${JSON.stringify(modelPageTest.body)}`); } const templateDefaultLlm = await session.put(`/api/templates/${encodeURIComponent(templateName)}/default-llm`, { diff --git a/apps/management-website/test/integration/full.js b/apps/management-website/test/integration/full.js index 550f4a0..1a6bce7 100644 --- a/apps/management-website/test/integration/full.js +++ b/apps/management-website/test/integration/full.js @@ -8,7 +8,7 @@ import { loginAsAdmin, newReport, runAgentConversationTestOnPage, - runProviderConnectionTestOnPage, + runModelConnectionTestOnPage, runCleanup, writeReport } from "./common.js"; @@ -133,17 +133,17 @@ async function main() { assertOk(modelCreate, "llm.models.create"); llmModelRow = modelCreate.body; - const providerPageTest = await runProviderConnectionTestOnPage(browserSession, llmProviderId); + const modelPageTest = await runModelConnectionTestOnPage(browserSession, llmProviderId, llmModelId); addAction( report, - "llm.providers.test.page", - providerPageTest.ok, - providerPageTest.status, - providerPageTest.body, - providerPageTest.note + "llm.models.test.page", + modelPageTest.ok, + modelPageTest.status, + modelPageTest.body, + modelPageTest.note ); - if (!providerPageTest.ok) { - throw new Error(`llm.providers.test.page failed: ${JSON.stringify(providerPageTest.body)}`); + if (!modelPageTest.ok) { + throw new Error(`llm.models.test.page failed: ${JSON.stringify(modelPageTest.body)}`); } const templateDefaultLlm = await session.put(`/api/templates/${encodeURIComponent(templateName)}/default-llm`, { diff --git a/apps/management-website/test/integration/website-full.js b/apps/management-website/test/integration/website-full.js index b76b1c1..04c166b 100644 --- a/apps/management-website/test/integration/website-full.js +++ b/apps/management-website/test/integration/website-full.js @@ -255,7 +255,7 @@ async function createProviderOnPage(page, providerId, modelBaseUrl, modelApiKey) await page.getByTestId("provider-form-base-url").fill(modelBaseUrl); await page.getByTestId("provider-form-api-key").fill(modelApiKey); await page.getByTestId("provider-form-submit").click(); - await page.getByTestId(`provider-test-${providerId}`).waitFor({ state: "visible" }); + await page.getByTestId(`provider-edit-${providerId}`).waitFor({ state: "visible" }); } async function createModelOnPage(page, providerId, modelId) { @@ -617,10 +617,10 @@ async function main() { await createModelOnPage(page, providerId, modelId); addSafeAction(report, "page.llm.model.create", true, 200, { model_ref: `${providerId}/${modelId}` }); - const providerPageTest = await import("./common.js").then((mod) => mod.runProviderConnectionTestOnPage(browserSession, providerId)); - addSafeAction(report, "page.llm.provider.test", providerPageTest.ok, providerPageTest.status, providerPageTest.body, providerPageTest.note); - if (!providerPageTest.ok) { - throw new Error(`page.llm.provider.test failed: ${JSON.stringify(redactSecrets(providerPageTest.body))}`); + const modelPageTest = await import("./common.js").then((mod) => mod.runModelConnectionTestOnPage(browserSession, providerId, modelId)); + addSafeAction(report, "page.llm.model.test", modelPageTest.ok, modelPageTest.status, modelPageTest.body, modelPageTest.note); + if (!modelPageTest.ok) { + throw new Error(`page.llm.model.test failed: ${JSON.stringify(redactSecrets(modelPageTest.body))}`); } await createTemplateOnPage(page, templateName, providerId, modelId, templateZip); diff --git a/docker-images/apps/Dockerfile b/docker-images/apps/Dockerfile index 8927531..1d06c36 100644 --- a/docker-images/apps/Dockerfile +++ b/docker-images/apps/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.3.5 +ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.3.6 ARG AIOS_PROXY_NPM_VERSION=0.1.3 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index 8a1649a..b8d1140 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -39,6 +39,7 @@ ENV XDG_CACHE_HOME=/var/aios/.openclaw/cache ENV PATH=/var/aios/.openclaw/.npm-package/bin:/var/aios/.openclaw/.pip-package/venv/bin:/var/aios/.openclaw/.uv-package/bin:/opt/aios-toolchain/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ENV EXIFTOOL_PATH=/usr/bin/exiftool ENV FFMPEG_PATH=/usr/bin/ffmpeg +ENV FFPROBE_PATH=/usr/bin/ffprobe ENV AGENT_BROWSER_EXECUTABLE_PATH= ENV OPENCLAW_COMMAND=/opt/aios-toolchain/bin/openclaw ENV QMD_COMMAND=/opt/aios-toolchain/bin/qmd @@ -161,12 +162,12 @@ RUN set -eux; \ FROM runtime-base AS tool-builder ARG TARGETARCH -ARG OPENCLAW_VERSION=2026.6.6 -ARG CLAWHUB_VERSION=0.18.0 +ARG OPENCLAW_VERSION=2026.6.8 +ARG CLAWHUB_VERSION=0.22.0 ARG QMD_VERSION=2.5.3 -ARG AGENT_BROWSER_VERSION=0.27.1 -ARG MCPORTER_VERSION=0.11.3 -ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.4.0 +ARG AGENT_BROWSER_VERSION=0.28.0 +ARG MCPORTER_VERSION=0.12.0 +ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.4.1 ARG AIOS_APPS_INVOKE_CLI_VERSION=0.0.1 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org @@ -290,6 +291,15 @@ RUN skill="aios-make-chart-image"; \ install-seed-skill "${skill}"; \ NPM_CONFIG_REGISTRY="${AIOS_BUILD_NPM_REGISTRY}" npm install --omit=dev --prefix "${AIOS_OPENCLAW_HOME}/skills/${skill}" +RUN skill="aios-self-improving-agent"; \ + install-seed-skill "${skill}" + +RUN skill="video-frames"; \ + install-seed-skill "${skill}" + +RUN skill="ffmpeg-video-editor"; \ + install-seed-skill "${skill}" + RUN skill="multi-search-engine"; \ install-seed-skill "${skill}" @@ -375,7 +385,7 @@ RUN --mount=type=bind,source=assets/workspace-templates/default,target=/tmp/defa done; \ fi; \ done; \ - printf '%s\n' "{\"openclaw-version\":\"${formatted_openclaw_version}\",\"agent-template\":[\"default\"],\"ontology\":[],\"skill\":[\"aios-call-app-service\",\"aios-transfer-file\",\"aios-make-chart-image\",\"multi-search-engine\",\"agent-browser-clawdbot\",\"skill-creator\",\"tesseract-ocr\",\"word-docx\",\"excel-xlsx\",\"powerpoint-pptx\",\"pdf\",\"mcporter\",\"markdown-converter\"],\"npm\":${npm_packages},\"pip\":${pip_packages}}" > "${built_in_manifest}"; \ + printf '%s\n' "{\"openclaw-version\":\"${formatted_openclaw_version}\",\"agent-template\":[\"default\"],\"ontology\":[],\"skill\":[\"aios-call-app-service\",\"aios-transfer-file\",\"aios-make-chart-image\",\"aios-self-improving-agent\",\"video-frames\",\"ffmpeg-video-editor\",\"multi-search-engine\",\"agent-browser-clawdbot\",\"skill-creator\",\"tesseract-ocr\",\"word-docx\",\"excel-xlsx\",\"powerpoint-pptx\",\"pdf\",\"mcporter\",\"markdown-converter\"],\"npm\":${npm_packages},\"pip\":${pip_packages}}" > "${built_in_manifest}"; \ { \ printf 'manifest:'; sha256sum "${built_in_manifest}"; \ for tree in "${seed_openclaw_home}/skills" /opt/aios-seed/workspace-templates; do \ diff --git a/docker-images/kernal/README.md b/docker-images/kernal/README.md index 3c2fe91..626cac5 100644 --- a/docker-images/kernal/README.md +++ b/docker-images/kernal/README.md @@ -284,6 +284,7 @@ ClawHub seed plugin/skill 安装步骤不挂载 OpenClaw cache、config 目录 - `python3` - `python3-pip` - `python3-venv` +- `ffmpeg` - `git` - `curl` - `jq` @@ -319,6 +320,9 @@ Python/uv 工具: - `aios-call-app-service` - `aios-transfer-file` - `aios-make-chart-image` +- `aios-self-improving-agent` +- `video-frames` +- `ffmpeg-video-editor` - `multi-search-engine` - `agent-browser-clawdbot` - `skill-creator` diff --git a/docker-images/kernal/docs/build.md b/docker-images/kernal/docs/build.md index af67782..87598f7 100644 --- a/docker-images/kernal/docs/build.md +++ b/docker-images/kernal/docs/build.md @@ -65,7 +65,7 @@ powershell -ExecutionPolicy Bypass -File .\build-local.ps1 -ImageName aios-kerna 镜像中的全局 Node 工具当前全部通过 npm registry 安装: -- `openclaw@2026.5.28` +- `openclaw@2026.6.8` - `clawhub@0.15.0` - `@tobilu/qmd@2.1.0` - `aios-management-serivce@0.3.0` @@ -82,6 +82,7 @@ powershell -ExecutionPolicy Bypass -File .\build-local.ps1 -ImageName aios-kerna - `python3` - `python3-pip` - `python3-venv` +- `ffmpeg` - `git` - `curl` - `jq` @@ -102,6 +103,9 @@ powershell -ExecutionPolicy Bypass -File .\build-local.ps1 -ImageName aios-kerna - `aios-call-app-service` - `aios-transfer-file` - `aios-make-chart-image` +- `aios-self-improving-agent` +- `video-frames` +- `ffmpeg-video-editor` - `multi-search-engine` - `agent-browser-clawdbot` - `skill-creator` diff --git a/docker-images/kernal/docs/design.md b/docker-images/kernal/docs/design.md index cb7c0c6..18802bd 100644 --- a/docker-images/kernal/docs/design.md +++ b/docker-images/kernal/docs/design.md @@ -94,6 +94,7 @@ 这一层包含: - Debian 基础系统包 +- FFmpeg 视频处理运行时包 - Tesseract OCR 运行时包 - 用户和用户组 - 通用运行时环境变量 @@ -143,6 +144,9 @@ - `aios-call-app-service` - `aios-transfer-file` - `aios-make-chart-image` +- `aios-self-improving-agent` +- `video-frames` +- `ffmpeg-video-editor` - `multi-search-engine` - `agent-browser-clawdbot` - `skill-creator` diff --git a/docker-images/test/kernal-tests/test/fast-smoke.js b/docker-images/test/kernal-tests/test/fast-smoke.js index 699c865..fadc71f 100644 --- a/docker-images/test/kernal-tests/test/fast-smoke.js +++ b/docker-images/test/kernal-tests/test/fast-smoke.js @@ -316,6 +316,14 @@ async function runChecks(containerName) { name: 'qmd version', args: ['exec', containerName, 'qmd', '--version'], }, + { + name: 'ffmpeg version', + args: ['exec', containerName, 'ffmpeg', '-version'], + }, + { + name: 'ffprobe version', + args: ['exec', containerName, 'ffprobe', '-version'], + }, { name: 'python3 version', args: ['exec', containerName, 'python3', '--version'], diff --git a/docker-images/test/kernal-tests/test/full-smoke.js b/docker-images/test/kernal-tests/test/full-smoke.js index e9eafeb..8fdd74e 100644 --- a/docker-images/test/kernal-tests/test/full-smoke.js +++ b/docker-images/test/kernal-tests/test/full-smoke.js @@ -793,7 +793,7 @@ async function main() { await waitForCommandSuccess( containerName, - "([ -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md ] || [ -f /var/aios/.openclaw/skills/agent-browser-clawdbot/SKILL.md ]) && test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json && test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules && test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", + "([ -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md ] || [ -f /var/aios/.openclaw/skills/agent-browser-clawdbot/SKILL.md ]) && test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json && test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules && test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules && test -f /var/aios/.openclaw/skills/aios-self-improving-agent/SKILL.md && test -f /var/aios/.openclaw/skills/video-frames/SKILL.md && test -f /var/aios/.openclaw/skills/video-frames/scripts/frame.sh && test -f /var/aios/.openclaw/skills/ffmpeg-video-editor/SKILL.md", 300000, "shared skills are not fully seeded yet", ); diff --git a/joint-test/dmz-mode/run-dev2-flow.core.sh b/joint-test/dmz-mode/run-dev2-flow.core.sh index a729ed6..6649103 100644 --- a/joint-test/dmz-mode/run-dev2-flow.core.sh +++ b/joint-test/dmz-mode/run-dev2-flow.core.sh @@ -165,6 +165,7 @@ build_images() { docker build \ -f "${REPO_ROOT}/docker-images/kernal/Dockerfile" \ -t "${KERNEL_IMAGE}" \ + --build-context "aios-clawhub-skills=${REPO_ROOT}/kernal/clawhub-skills" \ --build-arg "TARGETARCH=${TARGETARCH}" \ "${REPO_ROOT}/docker-images/kernal" diff --git a/kernal/aios-management-serivce/README.md b/kernal/aios-management-serivce/README.md index afa2e4b..1abd99e 100644 --- a/kernal/aios-management-serivce/README.md +++ b/kernal/aios-management-serivce/README.md @@ -83,9 +83,10 @@ aios-management-serivce ## 公开指令 -公开 31 个指令,以 `src/capabilities/registry.ts` 为准: +公开 34 个指令,以 `src/capabilities/registry.ts` 为准: - `service.ping` +- `version.list` - `catalog.snapshot` - `agent.get` - `agent.create` @@ -94,6 +95,7 @@ aios-management-serivce - `agent.delete` - `agent.usage.list` - `agent.model.set` +- `agent.name.set` - `agent.docs.update` - `agent.workspace.export` - `agent.template.create` @@ -103,6 +105,7 @@ aios-management-serivce - `llm.provider.test` - `llm.provider.delete` - `llm.model.create` +- `llm.model.test` - `llm.model.update` - `llm.model.delete` - `skills.global.list` @@ -133,7 +136,7 @@ aios-management-serivce `bucket` 建议使用 `AIOS_S3_ADMIN_OUTBOX_BUCKET`。`objectKey` 可由调用方生成;未传入时服务会生成 `agent-workspace//-.zip`。 -zip 根目录固定导出 `AGENTS.md`,并会在存在时包含 `SOUL.md`、`TOOLS.md`、`HEARTBEAT.md`、`skills/`、`knowledge/`。响应会返回 S3 位置、文件名、字节数、已包含条目和缺失的可选条目: +zip 根目录固定导出 `AGENTS.md`,并会在存在时包含 `SOUL.md`、`TOOLS.md`、`HEARTBEAT.md`、`.learnings/`、`skills/`、`knowledge/`。响应会返回 S3 位置、文件名、字节数、已包含条目和缺失的可选条目: ```json { @@ -142,7 +145,7 @@ zip 根目录固定导出 `AGENTS.md`,并会在存在时包含 `SOUL.md`、`TO "objectKey": "agent-workspace/2026-06-04/finance-agent-workspace.zip", "fileName": "finance-agent-workspace.zip", "byteSize": 2048, - "entries": ["AGENTS.md", "SOUL.md", "skills/planner/SKILL.md"], + "entries": ["AGENTS.md", "SOUL.md", ".learnings/LEARNINGS.md", "skills/planner/SKILL.md"], "missing": ["TOOLS.md", "HEARTBEAT.md", "knowledge"] } ``` diff --git a/kernal/aios-management-serivce/docs/compatibility-list.md b/kernal/aios-management-serivce/docs/compatibility-list.md index ba8f801..247aebf 100644 --- a/kernal/aios-management-serivce/docs/compatibility-list.md +++ b/kernal/aios-management-serivce/docs/compatibility-list.md @@ -36,6 +36,41 @@ } ``` +### `version.list` + +管理控制台启动时读取一次 kernal 组件版本并缓存到本地数据库。 + +```json +{ + "requestId": "req-version-list", + "action": "version.list", + "params": {} +} +``` + +结果包含版本生成时间和组件列表: + +```json +{ + "version": 1, + "generatedAt": "2026-06-17T00:00:00.000Z", + "items": [ + { + "group": "kernal", + "name": "aios-apps-invoke-cli", + "packageName": "aios-apps-invoke-cli", + "version": "0.0.1" + }, + { + "group": "kernal", + "name": "core", + "packageName": "openclaw", + "version": "2026.5.28" + } + ] +} +``` + ## 同步 ### `catalog.snapshot` @@ -310,6 +345,22 @@ } ``` +### `llm.model.test` + +```json +{ + "requestId": "req-model-test", + "action": "llm.model.test", + "params": { + "providerId": "corp-openai", + "modelId": "gpt-4.1-mini", + "contextTokens": 128000, + "maxTokens": 4096, + "timeoutMs": 30000 + } +} +``` + ### `llm.model.update` ```json diff --git a/kernal/aios-management-serivce/docs/design.md b/kernal/aios-management-serivce/docs/design.md index 4c0e266..a25b1fe 100644 --- a/kernal/aios-management-serivce/docs/design.md +++ b/kernal/aios-management-serivce/docs/design.md @@ -26,6 +26,7 @@ 轻量同步和状态类动作允许绕过串行队列: - `service.ping` +- `version.list` - `catalog.snapshot` - `agent.usage.list` - `gateway.status` @@ -34,6 +35,10 @@ ## 同步模型 +### 版本信息缓存 + +`version.list` 一次性返回 kernal 侧组件版本。管理控制台仅在启动时、`service.ping` ready 后调用一次,并把结果写入本地 `component_versions` 表。设置页版本信息只读取数据库缓存,不随 `/api/bootstrap` 或手动“同步数据”再次调用远端。 + ### 目录快照同步 `catalog.snapshot` 一次性返回管理控制台初始化需要的目录快照: @@ -53,23 +58,24 @@ 管理控制台启动流程: -1. `t0` 执行 `catalog.snapshot` -2. `t1` 目录快照同步成功 -3. `t1 + 60s` 执行 `agent.usage.list` -4. 后续 `catalog.snapshot` 按上次完成时间每 120 秒执行 -5. 后续 `agent.usage.list` 按上次完成时间每 120 秒执行 +1. 等待 `service.ping` 可用 +2. 执行一次 `version.list`,管理控制台缓存版本信息到 `component_versions` +3. 执行 `catalog.snapshot` +4. 目录快照同步成功后 60 秒执行 `agent.usage.list` +5. 后续 `catalog.snapshot` 按上次完成时间每 120 秒执行 +6. 后续 `agent.usage.list` 按上次完成时间每 120 秒执行 手动“同步数据”按钮按顺序执行 `catalog.snapshot`,再执行 `agent.usage.list`。 ## 公开指令边界 -当前公开 31 个指令: +当前公开 34 个指令: -- 服务:`service.ping` +- 服务:`service.ping`、`version.list` - 同步:`catalog.snapshot`、`agent.usage.list` -- Agent:`agent.get`、`agent.create`、`agent.enable`、`agent.disable`、`agent.delete`、`agent.model.set`、`agent.docs.update`、`agent.workspace.export` +- Agent:`agent.get`、`agent.create`、`agent.enable`、`agent.disable`、`agent.delete`、`agent.model.set`、`agent.name.set`、`agent.docs.update`、`agent.workspace.export` - 模板:`agent.template.create`、`agent.template.delete` -- LLM:`llm.provider.create`、`llm.provider.update`、`llm.provider.test`、`llm.provider.delete`、`llm.model.create`、`llm.model.update`、`llm.model.delete` +- LLM:`llm.provider.create`、`llm.provider.update`、`llm.provider.test`、`llm.provider.delete`、`llm.model.create`、`llm.model.test`、`llm.model.update`、`llm.model.delete` - Skill:`skills.global.list`、`skills.global.install.local`、`skills.global.delete` - 业务系统:`apps.create`、`apps.enable`、`apps.disable`、`apps.delete`、`apps.log.record` - 运维:`diagnostics.run`、`gateway.status`、`gateway.restart` diff --git a/kernal/aios-management-serivce/package.json b/kernal/aios-management-serivce/package.json index d34e352..6bb302d 100644 --- a/kernal/aios-management-serivce/package.json +++ b/kernal/aios-management-serivce/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-serivce", - "version": "0.4.0", + "version": "0.4.1", "description": "AIOS / OpenClaw 的 MQTT 管理控制台服务。", "type": "module", "private": false, diff --git a/kernal/aios-management-serivce/src/capabilities/agent/service.ts b/kernal/aios-management-serivce/src/capabilities/agent/service.ts index 38b10ee..b078075 100644 --- a/kernal/aios-management-serivce/src/capabilities/agent/service.ts +++ b/kernal/aios-management-serivce/src/capabilities/agent/service.ts @@ -18,6 +18,7 @@ import type { GatewayRestartParams, LlmModelCreateParams, LlmModelDeleteParams, + LlmModelTestParams, LlmModelUpdateParams, LlmProviderCreateParams, LlmProviderDeleteParams, @@ -206,6 +207,10 @@ export class OpenClawManager { return await this.llmModelConfigService.createModel(params); } + async testLlmModel(params: LlmModelTestParams): Promise { + return await this.llmModelConfigService.testModel(params); + } + async updateLlmModel(params: LlmModelUpdateParams): Promise { return await this.llmModelConfigService.updateModel(params); } diff --git a/kernal/aios-management-serivce/src/capabilities/agent/workspace-service.ts b/kernal/aios-management-serivce/src/capabilities/agent/workspace-service.ts index 315b517..59fa397 100644 --- a/kernal/aios-management-serivce/src/capabilities/agent/workspace-service.ts +++ b/kernal/aios-management-serivce/src/capabilities/agent/workspace-service.ts @@ -21,6 +21,7 @@ const WORKSPACE_ARCHIVE_ROOT_FILES = [ "HEARTBEAT.md" ]; const WORKSPACE_ARCHIVE_ROOT_DIRECTORIES = [ + ".learnings", "skills", "knowledge" ]; diff --git a/kernal/aios-management-serivce/src/capabilities/context.ts b/kernal/aios-management-serivce/src/capabilities/context.ts index 4c7bbd7..2894e34 100644 --- a/kernal/aios-management-serivce/src/capabilities/context.ts +++ b/kernal/aios-management-serivce/src/capabilities/context.ts @@ -22,6 +22,7 @@ import type { GatewayRestartParams, LlmModelCreateParams, LlmModelDeleteParams, + LlmModelTestParams, LlmModelUpdateParams, LlmProviderCreateParams, LlmProviderDeleteParams, @@ -51,6 +52,7 @@ export interface AgentCapacityService { deleteLlmProvider(params: LlmProviderDeleteParams): Promise; listLlmModels(params?: { providerId?: string }): Promise; createLlmModel(params: LlmModelCreateParams): Promise; + testLlmModel(params: LlmModelTestParams): Promise; updateLlmModel(params: LlmModelUpdateParams): Promise; deleteLlmModel(params: LlmModelDeleteParams): Promise; listGlobalSkills(): Promise; diff --git a/kernal/aios-management-serivce/src/capabilities/llm/model-test.ts b/kernal/aios-management-serivce/src/capabilities/llm/model-test.ts new file mode 100644 index 0000000..5520d53 --- /dev/null +++ b/kernal/aios-management-serivce/src/capabilities/llm/model-test.ts @@ -0,0 +1,6 @@ +import type { LlmModelTestParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const llmModelTest: CapabilityHandler = async (request, context) => { + return await context.agentManager.testLlmModel(readCapabilityParams(request)); +}; diff --git a/kernal/aios-management-serivce/src/capabilities/llm/service.ts b/kernal/aios-management-serivce/src/capabilities/llm/service.ts index aedec34..f7b2602 100644 --- a/kernal/aios-management-serivce/src/capabilities/llm/service.ts +++ b/kernal/aios-management-serivce/src/capabilities/llm/service.ts @@ -6,6 +6,7 @@ import type { EnvironmentConfig, LlmModelCreateParams, LlmModelDeleteParams, + LlmModelTestParams, LlmModelUpdateParams, LlmProviderCreateParams, LlmProviderDeleteParams, @@ -307,8 +308,16 @@ export class LlmModelConfigService { } async testProvider(params: LlmProviderTestParams): Promise { - const providerId = normalizeProviderId(params.providerId); - const requestedModelId = params.modelId ? normalizeModelId(params.modelId) : ""; + return await this.testModel(params); + } + + async testModel(params: LlmModelTestParams): Promise { + const resolved = params.modelRef ? splitModelRef(params.modelRef) : { + providerId: normalizeProviderId(params.providerId), + modelId: params.modelId ? normalizeModelId(params.modelId) : "" + }; + const providerId = resolved.providerId; + const requestedModelId = resolved.modelId; const timeoutMs = normalizeTimeoutMs(params.timeoutMs); const config = await this.readOpenClawConfig(); const provider = this.requireProvider(config, providerId); @@ -334,21 +343,47 @@ export class LlmModelConfigService { } const modelId = firstText(model.id); + const contextWindow = sanitizeOptionalNumber(model.contextWindow); + const contextTokens = sanitizeOptionalNumber(params.contextTokens) ?? sanitizeOptionalNumber(model.contextTokens) ?? contextWindow; + const outputTokens = sanitizeOptionalNumber(params.maxTokens) ?? sanitizeOptionalNumber(model.maxTokens) ?? 32; const startedAt = Date.now(); const testedAt = new Date().toISOString(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const endpoint = buildChatCompletionsUrl(normalizeBaseUrl(baseUrl)); + const messages = [ + { + role: "system", + content: "You are testing an AIOS/OpenClaw model configuration. Return only the requested token." + }, + { + role: "user", + content: "Reply with exactly: AIOS_MODEL_TEST_OK" + } + ]; const requestPayload = { model: modelId, - messages: [ - { - role: "user", - content: "Reply with exactly: AIOS_PROVIDER_TEST_OK" - } - ], + messages, temperature: 0, - max_tokens: 32 + max_tokens: outputTokens + }; + const openClawCall = { + model: { + provider: providerId, + id: modelId, + api: MODEL_API, + baseUrl: normalizeBaseUrl(baseUrl), + contextWindow, + contextTokens, + maxTokens: outputTokens + }, + context: { + messages, + contextTokens + }, + output: { + maxTokens: outputTokens + } }; try { @@ -379,10 +414,13 @@ export class LlmModelConfigService { api: MODEL_API, baseUrl: normalizeBaseUrl(baseUrl), endpoint, + contextTokens, + outputTokens, status: response.status, statusText: response.statusText, responseTimeMs, testedAt, + openClawCall, request: { method: "POST", body: { @@ -407,11 +445,14 @@ export class LlmModelConfigService { api: MODEL_API, baseUrl: normalizeBaseUrl(baseUrl), endpoint, + contextTokens, + outputTokens, responseTimeMs, testedAt, timeout, + openClawCall, errorMessage: timeout - ? `Provider test timed out after ${timeoutMs} ms` + ? `Model test timed out after ${timeoutMs} ms` : (error as Error)?.message || String(error) }; } finally { diff --git a/kernal/aios-management-serivce/src/capabilities/registry.ts b/kernal/aios-management-serivce/src/capabilities/registry.ts index 882ec46..3f2b879 100644 --- a/kernal/aios-management-serivce/src/capabilities/registry.ts +++ b/kernal/aios-management-serivce/src/capabilities/registry.ts @@ -21,6 +21,7 @@ import { operationsGatewayRestart } from "./operations/gateway-restart.js"; import { operationsGatewayStatus } from "./operations/gateway-status.js"; import { llmModelCreate } from "./llm/model-create.js"; import { llmModelDelete } from "./llm/model-delete.js"; +import { llmModelTest } from "./llm/model-test.js"; import { llmModelUpdate } from "./llm/model-update.js"; import { llmProviderCreate } from "./llm/provider-create.js"; import { llmProviderDelete } from "./llm/provider-delete.js"; @@ -30,10 +31,12 @@ import { servicePing } from "./service/ping.js"; import { skillsGlobalDelete } from "./skills-global/delete.js"; import { skillsGlobalInstallLocal } from "./skills-global/install-local.js"; import { skillsGlobalList } from "./skills-global/list.js"; +import { versionList } from "./version/list.js"; import type { CapabilityHandler } from "./context.js"; export const capabilityHandlers: Record = { "service.ping": servicePing, + "version.list": versionList, "catalog.snapshot": catalogSnapshot, "agent.get": agentGet, "agent.create": agentCreate, @@ -52,6 +55,7 @@ export const capabilityHandlers: Record = { "llm.provider.test": llmProviderTest, "llm.provider.delete": llmProviderDelete, "llm.model.create": llmModelCreate, + "llm.model.test": llmModelTest, "llm.model.update": llmModelUpdate, "llm.model.delete": llmModelDelete, "skills.global.list": skillsGlobalList, diff --git a/kernal/aios-management-serivce/src/capabilities/version/list.ts b/kernal/aios-management-serivce/src/capabilities/version/list.ts new file mode 100644 index 0000000..0bc6945 --- /dev/null +++ b/kernal/aios-management-serivce/src/capabilities/version/list.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { CapabilityHandler } from "../context.js"; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const KERNAL_ROOT = path.resolve(PACKAGE_ROOT, ".."); + +interface VersionComponent { + group: "kernal"; + packagePath: string; + displayName?: string; +} + +const VERSION_COMPONENTS: VersionComponent[] = [ + { + group: "kernal", + packagePath: path.join(KERNAL_ROOT, "aios-apps-invoke-cli", "package.json") + }, + { + group: "kernal", + packagePath: path.join(PACKAGE_ROOT, "package.json") + }, + { + group: "kernal", + packagePath: path.join(KERNAL_ROOT, "openclaw-plugins", "aios-mqtt-channel", "package.json") + }, + { + group: "kernal", + displayName: "core", + packagePath: path.join(KERNAL_ROOT, "openclaw-plugins", "aios-mqtt-channel", "node_modules", "openclaw", "package.json") + } +]; + +function firstText(...values: unknown[]): string { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return ""; +} + +function readPackageMetadata(packagePath: string): { name: string; version: string } { + try { + const metadata = JSON.parse(readFileSync(packagePath, "utf8")) as { + name?: unknown; + version?: unknown; + }; + return { + name: firstText(metadata.name, path.basename(path.dirname(packagePath))), + version: firstText(metadata.version, "unknown") + }; + } catch { + return { + name: path.basename(path.dirname(packagePath)), + version: "unknown" + }; + } +} + +export const versionList: CapabilityHandler = async () => { + return { + version: 1, + generatedAt: new Date().toISOString(), + items: VERSION_COMPONENTS.map((component) => { + const metadata = readPackageMetadata(component.packagePath); + return { + group: component.group, + name: component.displayName ?? metadata.name, + packageName: metadata.name, + version: metadata.version + }; + }) + }; +}; diff --git a/kernal/aios-management-serivce/src/service.ts b/kernal/aios-management-serivce/src/service.ts index 8c765b3..0e3c436 100644 --- a/kernal/aios-management-serivce/src/service.ts +++ b/kernal/aios-management-serivce/src/service.ts @@ -113,6 +113,7 @@ class TaskQueue { const QUEUE_BYPASS_ACTIONS = new Set([ "service.ping", + "version.list", "catalog.snapshot", "agent.usage.list", "skills.global.list", diff --git a/kernal/aios-management-serivce/src/types.ts b/kernal/aios-management-serivce/src/types.ts index 8362ad9..eef358d 100644 --- a/kernal/aios-management-serivce/src/types.ts +++ b/kernal/aios-management-serivce/src/types.ts @@ -160,6 +160,15 @@ export interface LlmProviderTestParams { timeoutMs?: number; } +export interface LlmModelTestParams { + providerId?: string; + modelId?: string; + modelRef?: string; + timeoutMs?: number; + contextTokens?: number | string | null; + maxTokens?: number | string | null; +} + export interface LlmProviderDeleteParams { providerId: string; restart?: boolean; diff --git a/kernal/aios-management-serivce/test/capabilities-registry.test.ts b/kernal/aios-management-serivce/test/capabilities-registry.test.ts index 85a87c8..a834dbd 100644 --- a/kernal/aios-management-serivce/test/capabilities-registry.test.ts +++ b/kernal/aios-management-serivce/test/capabilities-registry.test.ts @@ -6,6 +6,7 @@ import type { ManagementRequest } from "../src/types.js"; const expectedActions = [ "service.ping", + "version.list", "catalog.snapshot", "agent.get", "agent.create", @@ -24,6 +25,7 @@ const expectedActions = [ "llm.provider.test", "llm.provider.delete", "llm.model.create", + "llm.model.test", "llm.model.update", "llm.model.delete", "skills.global.list", @@ -75,6 +77,7 @@ function createContext(calls: Array<{ method: string; args: unknown[] }>): Capab deleteLlmProvider: respond("agentManager.deleteLlmProvider"), listLlmModels: respond("agentManager.listLlmModels"), createLlmModel: respond("agentManager.createLlmModel"), + testLlmModel: respond("agentManager.testLlmModel"), updateLlmModel: respond("agentManager.updateLlmModel"), deleteLlmModel: respond("agentManager.deleteLlmModel"), listGlobalSkills: respond("agentManager.listGlobalSkills") as never, @@ -115,6 +118,9 @@ test("capability handlers delegate to the expected service methods", async () => action: "service.ping", expectedResult: { pong: true } }, + { + action: "version.list" + }, { action: "catalog.snapshot", expectedResult: { @@ -255,6 +261,12 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.createLlmModel", expectedArgs: [{ providerId: "corp-openai", modelId: "deepseek-v4-flash", name: "DeepSeek" }] }, + { + action: "llm.model.test", + params: { providerId: "corp-openai", modelId: "deepseek-v4-flash", contextTokens: 128000, maxTokens: 4096 }, + expectedMethod: "agentManager.testLlmModel", + expectedArgs: [{ providerId: "corp-openai", modelId: "deepseek-v4-flash", contextTokens: 128000, maxTokens: 4096 }] + }, { action: "llm.model.update", params: { providerId: "corp-openai", modelId: "deepseek-v4-flash", name: "DeepSeek V4" }, @@ -366,6 +378,18 @@ test("capability handlers delegate to the expected service methods", async () => assert.equal(typeof (result as { generatedAt?: unknown }).generatedAt, "string", item.action); const { generatedAt: _generatedAt, ...snapshot } = result as Record; assert.deepEqual(snapshot, item.expectedResult, item.action); + } else if (item.action === "version.list") { + assert.deepEqual(calls, [], item.action); + assert.equal(typeof (result as { generatedAt?: unknown }).generatedAt, "string", item.action); + const versionItems = (result as { items?: Array<{ name?: string; packageName?: string; version?: string }> }).items; + assert.deepEqual(versionItems?.map((versionItem) => versionItem.name), [ + "aios-apps-invoke-cli", + "aios-management-serivce", + "aios-mqtt-channel", + "core" + ], item.action); + assert.equal(versionItems?.find((versionItem) => versionItem.name === "core")?.packageName, "openclaw", item.action); + assert.equal(versionItems?.every((versionItem) => typeof versionItem.version === "string" && versionItem.version.length > 0), true, item.action); } else if (item.expectedMethod) { assert.deepEqual(calls, [{ method: item.expectedMethod, diff --git a/kernal/aios-management-serivce/test/llm-model-config-service.test.ts b/kernal/aios-management-serivce/test/llm-model-config-service.test.ts index 95d0e12..8de38ae 100644 --- a/kernal/aios-management-serivce/test/llm-model-config-service.test.ts +++ b/kernal/aios-management-serivce/test/llm-model-config-service.test.ts @@ -159,11 +159,13 @@ test("updateModel preserves token limits unless they are explicitly cleared", as }); }); -test("testProvider calls OpenAI compatible chat completions with stored provider credentials", async () => { +test("testModel calls OpenAI compatible chat completions with OpenClaw context and output limits", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-test-")); const env = await seedConfig(root, [{ id: "gpt-4.1-mini", - name: "GPT 4.1 Mini" + name: "GPT 4.1 Mini", + contextTokens: 32768, + maxTokens: 4096 }]); const service = createService(env); const originalFetch = globalThis.fetch; @@ -189,22 +191,36 @@ test("testProvider calls OpenAI compatible chat completions with stored provider }) as typeof fetch; try { - const result = await service.testProvider({ + const result = await service.testModel({ providerId: "corp-openai", modelId: "gpt-4.1-mini", + contextTokens: 8192, + maxTokens: 128, timeoutMs: 1000 }) as Record; assert.equal(result.ok, true); assert.equal(result.providerId, "corp-openai"); assert.equal(result.modelRef, "corp-openai/gpt-4.1-mini"); + assert.equal(result.contextTokens, 8192); + assert.equal(result.outputTokens, 128); assert.equal(result.status, 200); + assert.equal(result.openClawCall.context.contextTokens, 8192); + assert.equal(result.openClawCall.output.maxTokens, 128); assert.equal(calls[0].url, "https://api.example.com/v1/chat/completions"); assert.equal((calls[0].init.headers as Record).Authorization, "Bearer sk-test"); - assert.deepEqual(JSON.parse(String(calls[0].init.body)).messages, [{ - role: "user", - content: "Reply with exactly: AIOS_PROVIDER_TEST_OK" - }]); + const requestBody = JSON.parse(String(calls[0].init.body)); + assert.equal(requestBody.max_tokens, 128); + assert.deepEqual(requestBody.messages, [ + { + role: "system", + content: "You are testing an AIOS/OpenClaw model configuration. Return only the requested token." + }, + { + role: "user", + content: "Reply with exactly: AIOS_MODEL_TEST_OK" + } + ]); } finally { globalThis.fetch = originalFetch; } diff --git a/kernal/aios-management-serivce/test/openclaw-manager.test.ts b/kernal/aios-management-serivce/test/openclaw-manager.test.ts index 4b17f10..5a83213 100644 --- a/kernal/aios-management-serivce/test/openclaw-manager.test.ts +++ b/kernal/aios-management-serivce/test/openclaw-manager.test.ts @@ -8,6 +8,7 @@ import AdmZip from "adm-zip"; import { ManagedStateStore } from "../src/capabilities/agent/state-store.js"; import { OpenClawManager } from "../src/capabilities/agent/service.js"; import { AgentOperationsService } from "../src/capabilities/agent/operations-service.js"; +import { TemplateManager } from "../src/capabilities/agent-template/service.js"; import { loadEnvironmentConfig, renderTopic } from "../src/env.js"; import { buildHealthReport, buildOpenClawCliEnv, collectConfigIssues } from "../src/service.js"; import { CommandError } from "../src/tools/cli-runner.js"; @@ -1304,12 +1305,14 @@ test("exportAgentWorkspace uploads selected workspace files to S3", async () => const workspace = path.join(root, "workspaces", "lowcode"); await mkdir(path.join(workspace, "skills", "planner"), { recursive: true }); await mkdir(path.join(workspace, "knowledge"), { recursive: true }); + await mkdir(path.join(workspace, ".learnings"), { recursive: true }); await writeFile(path.join(workspace, "AGENTS.md"), "# Agent\n", "utf8"); await writeFile(path.join(workspace, "SOUL.md"), "# Soul\n", "utf8"); await writeFile(path.join(workspace, "TOOLS.md"), "# Tools\n", "utf8"); await writeFile(path.join(workspace, "HEARTBEAT.md"), "# Heartbeat\n", "utf8"); await writeFile(path.join(workspace, "skills", "planner", "SKILL.md"), "# Planner\n", "utf8"); await writeFile(path.join(workspace, "knowledge", "guide.md"), "# Guide\n", "utf8"); + await writeFile(path.join(workspace, ".learnings", "LEARNINGS.md"), "# Learnings\n", "utf8"); await writeFile(path.join(workspace, "ignored.md"), "# Ignore me\n", "utf8"); const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); @@ -1361,6 +1364,7 @@ test("exportAgentWorkspace uploads selected workspace files to S3", async () => const zip = new AdmZip(uploads[0]!.body); const names = zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => entry.entryName).sort(); assert.deepEqual(names, [ + ".learnings/LEARNINGS.md", "AGENTS.md", "HEARTBEAT.md", "SOUL.md", @@ -1372,6 +1376,47 @@ test("exportAgentWorkspace uploads selected workspace files to S3", async () => assert.equal(zip.getEntry("ignored.md"), null); }); +test("createTemplate preserves learning files from exported workspace zip", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-template-learnings-")); + const zip = new AdmZip(); + zip.addFile("AGENTS.md", Buffer.from("# Agent\n", "utf8")); + zip.addFile(".learnings/LEARNINGS.md", Buffer.from("# Learnings\n", "utf8")); + zip.addFile("skills/planner/SKILL.md", Buffer.from("# Planner\n", "utf8")); + const zipPayload = zip.toBuffer(); + + const objectStore: S3ObjectStore = { + async getObject(bucket: string, objectKey: string): Promise { + assert.equal(bucket, "admin-in"); + assert.equal(objectKey, "agent-workspace/lowcode.zip"); + return zipPayload; + } + }; + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const templateManager = new TemplateManager( + createEnv({ + workspaceTemplatesRoot: path.join(root, "workspace-templates") + }), + stateStore, + { objectStore } + ); + + const record = await templateManager.createTemplate({ + templateName: "lowcode", + bucket: "admin-in", + objectKey: "agent-workspace/lowcode.zip" + }); + + assert.equal(record.templateName, "lowcode"); + assert.equal( + await readFile(path.join(root, "workspace-templates", "lowcode", ".learnings", "LEARNINGS.md"), "utf8"), + "# Learnings\n" + ); + assert.equal( + await readFile(path.join(root, "workspace-templates", "lowcode", "skills", "planner", "SKILL.md"), "utf8"), + "# Planner\n" + ); +}); + test("global skills delete uses clawhub workspace uninstall", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skills-")); const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); diff --git a/kernal/aios-management-serivce/test/service-queue.test.ts b/kernal/aios-management-serivce/test/service-queue.test.ts index b9d4d9b..88b233e 100644 --- a/kernal/aios-management-serivce/test/service-queue.test.ts +++ b/kernal/aios-management-serivce/test/service-queue.test.ts @@ -4,6 +4,7 @@ import { shouldBypassManagementQueue } from "../src/service.js"; test("bypasses the serial management queue for lightweight status requests", () => { assert.equal(shouldBypassManagementQueue("service.ping"), true); + assert.equal(shouldBypassManagementQueue("version.list"), true); assert.equal(shouldBypassManagementQueue("catalog.snapshot"), true); assert.equal(shouldBypassManagementQueue("agent.usage.list"), true); assert.equal(shouldBypassManagementQueue("skills.global.list"), true); diff --git a/kernal/clawhub-skills/aios-self-improving-agent/SKILL.md b/kernal/clawhub-skills/aios-self-improving-agent/SKILL.md new file mode 100644 index 0000000..7231a11 --- /dev/null +++ b/kernal/clawhub-skills/aios-self-improving-agent/SKILL.md @@ -0,0 +1,261 @@ +--- +name: aios-self-improving-agent +description: 在 AIOS/OpenClaw 运行环境中记录当前 agent 的错误、纠正、经验、知识缺口和可复用改进。适用于命令失败、用户纠正回答、发现过时知识、外部工具/API 异常、同类问题反复出现、完成复杂任务后需要沉淀经验、或开始重要任务前需要回顾当前 agent workspace 内历史 learnings 的场景。若环境中有 QMD,优先使用 per-workspace QMD 索引检索和去重。该技能必须保持 per-agent 逻辑隔离,只在当前 agent workspace 内读写 `.learnings/`,不得默认写全局 workspace、其他 agent workspace、共享 skill 目录或管理面配置。 +--- + +# AIOS 自改进 Agent + +本技能用于把当前 agent 在任务中遇到的错误、纠正和可复用经验沉淀到当前 workspace 的 `.learnings/`。它面向 AIOS 的 per-agent 逻辑隔离模型:共享 skill 可以全局预装,但学习记录必须默认留在当前 agent 的 workspace 内。 + +## 边界规则 + +- 只在当前工作目录或当前 agent workspace 内读写 `.learnings/`。 +- 不要使用 `~/.openclaw/workspace` 作为默认路径;AIOS 中 workspace 位于 `/var/aios/.openclaw/workspaces/` 或等价运行路径。 +- 不要读取、写入或汇总其他 agent 的 workspace,除非用户明确指定且当前运行环境允许。 +- 不要把学习记录自动写入全局 `AGENTS.md`、`SOUL.md`、`TOOLS.md`、workspace template、`.openclaw/skills`、`openclaw.json` 或任何管理面配置。 +- 不要调用 `openclaw agents add/delete`、`openclaw skills install/uninstall` 等拓扑或全局 skill 管理命令;这些变更必须走 `aios-management-serivce` 或人工管理流程。 +- 不要记录密钥、token、私钥、完整环境变量、完整配置文件、完整源码文件或未经脱敏的命令输出。 +- 只记录足够复现和预防问题的摘要、路径、错误片段和建议动作。 +- 可以使用 QMD 提升 `.learnings/` 的搜索质量,但 QMD 只作为当前 workspace 的搜索缓存;不要把其他 agent workspace、全局 skill、workspace template 或管理面目录加入该技能的 QMD 索引。 + +## 初始化 + +第一次需要记录时,在当前 workspace 根目录创建 `.learnings/` 和三个日志文件。不要覆盖已有文件。 + +```bash +mkdir -p .learnings +[ -f .learnings/LEARNINGS.md ] || printf "# Learnings\n\n当前 agent 的纠正、洞察、知识缺口和最佳实践。\n\n**分类**: correction | insight | knowledge_gap | best_practice\n\n---\n" > .learnings/LEARNINGS.md +[ -f .learnings/ERRORS.md ] || printf "# Errors\n\n当前 agent 遇到的命令失败、工具异常和集成错误。\n\n---\n" > .learnings/ERRORS.md +[ -f .learnings/FEATURE_REQUESTS.md ] || printf "# Feature Requests\n\n用户提出但当前能力或系统尚未稳定支持的能力请求。\n\n---\n" > .learnings/FEATURE_REQUESTS.md +``` + +如果当前目录不确定,先确认自己位于业务 agent 的 workspace 根目录。AIOS 中不要把仓库源码目录、全局 skill 目录或 `/var/aios/.openclaw` 根目录当成学习记录根目录。 + +## QMD 搜索优先 + +AIOS 环境通常内置 QMD。需要回顾、去重或查找相似经验时,优先为当前 `.learnings/` 使用独立的 per-workspace QMD index;不要使用可能混入其他 workspace 的默认 index。 + +初始化或刷新当前 `.learnings/` 搜索缓存: + +```bash +workspace_root="$(pwd -P)" +qmd_index="aios-learnings-$(printf "%s" "$workspace_root" | sha256sum | awk '{print substr($1,1,12)}')" +if command -v qmd >/dev/null 2>&1 && [ -d .learnings ]; then + qmd --index "$qmd_index" collection add "$workspace_root/.learnings" --name learnings --mask "**/*.md" >/dev/null 2>&1 || true + qmd --index "$qmd_index" context add qmd://learnings "当前 agent workspace 的自改进学习记录、错误和能力请求" >/dev/null 2>&1 || true + qmd --index "$qmd_index" update >/dev/null 2>&1 || true +fi +``` + +搜索选择: + +- 精确找 ID、Pattern-Key、文件名或固定术语:优先 `qmd search`,必要时用 `rg` 兜底。 +- 查找相似错误、相似纠正或语义相近经验:优先 `qmd query --json -n 10 --min-score 0.25 "查询词"`。 +- 刚写入新条目后需要立刻搜索:先 `qmd --index "$qmd_index" update`。只有语义搜索质量明显不足且时间允许时,再运行 `qmd --index "$qmd_index" embed`。 +- QMD 不可用、超时或索引不可信时,退回 `rg -n "关键词" .learnings` 或 `grep -R "关键词" .learnings`。 + +## 何时记录 + +| 场景 | 目标文件 | +| --- | --- | +| 命令、测试、构建或工具调用失败 | `.learnings/ERRORS.md` | +| 用户指出回答、假设或实现错误 | `.learnings/LEARNINGS.md`,分类 `correction` | +| 发现模型知识过时或项目事实不符合预期 | `.learnings/LEARNINGS.md`,分类 `knowledge_gap` | +| 找到可复用的更好做法 | `.learnings/LEARNINGS.md`,分类 `best_practice` | +| 用户要求当前系统没有的能力 | `.learnings/FEATURE_REQUESTS.md` | +| 同类问题再次出现 | 更新已有条目、增加关联和复现次数 | + +在重大任务开始前,只搜索当前 workspace 的 `.learnings/`,不要跨 agent 汇总。优先用 QMD: + +```bash +workspace_root="$(pwd -P)" +qmd_index="aios-learnings-$(printf "%s" "$workspace_root" | sha256sum | awk '{print substr($1,1,12)}')" +if command -v qmd >/dev/null 2>&1 && [ -d .learnings ]; then + qmd --index "$qmd_index" update >/dev/null 2>&1 || true + qmd --index "$qmd_index" search --json -n 10 "Area Tags Pattern-Key recent errors corrections" +else + rg -n "Area|Tags|Pattern-Key|ERR-|LRN-|FEAT-" .learnings 2>/dev/null || grep -R "Area\\|Tags\\|Pattern-Key\\|ERR-\\|LRN-\\|FEAT-" .learnings 2>/dev/null || true +fi +``` + +## 记录格式 + +### 学习条目 + +追加到 `.learnings/LEARNINGS.md`: + +```markdown +## [LRN-YYYYMMDD-XXX] category + +**Logged**: ISO-8601 时间 +**Priority**: low | medium | high | critical +**Status**: pending +**Area**: frontend | backend | infra | tests | docs | config | ops + +### Summary +一句话说明学到了什么。 + +### Details +说明当时的上下文、错误假设和正确做法。只写脱敏摘要。 + +### Suggested Action +后续如何避免或修复。 + +### Metadata +- Source: conversation | error | user_feedback | review +- Related Files: 相对路径或当前 workspace 内路径 +- Tags: tag1, tag2 +- See Also: LRN-YYYYMMDD-XXX +- Pattern-Key: 可选的稳定去重键 +- Recurrence-Count: 1 +- First-Seen: YYYY-MM-DD +- Last-Seen: YYYY-MM-DD + +--- +``` + +### 错误条目 + +追加到 `.learnings/ERRORS.md`: + +````markdown +## [ERR-YYYYMMDD-XXX] command_or_tool + +**Logged**: ISO-8601 时间 +**Priority**: medium | high | critical +**Status**: pending +**Area**: frontend | backend | infra | tests | docs | config | ops + +### Summary +简要说明失败内容。 + +### Error +```text +脱敏后的关键错误片段。 +``` + +### Context +- Command/operation: 执行了什么 +- Input: 只写必要摘要 +- Environment: 只写非敏感环境事实 + +### Suggested Fix +可行修复或下次排查顺序。 + +### Metadata +- Reproducible: yes | no | unknown +- Related Files: 相对路径或当前 workspace 内路径 +- See Also: ERR-YYYYMMDD-XXX + +--- +```` + +### 能力请求条目 + +追加到 `.learnings/FEATURE_REQUESTS.md`: + +```markdown +## [FEAT-YYYYMMDD-XXX] capability_name + +**Logged**: ISO-8601 时间 +**Priority**: low | medium | high +**Status**: pending +**Area**: frontend | backend | infra | tests | docs | config | ops + +### Requested Capability +用户想完成什么。 + +### User Context +为什么需要它。 + +### Suggested Implementation +可以如何实现,是否需要平台管理能力。 + +### Metadata +- Frequency: first_time | recurring +- Related Features: existing_feature_name + +--- +``` + +## ID 规则 + +ID 格式为 `TYPE-YYYYMMDD-XXX`: + +- `LRN`:学习条目 +- `ERR`:错误条目 +- `FEAT`:能力请求 +- `XXX`:当天递增三位编号;无法可靠计算时使用随机三位大写字母或数字 + +## 去重和回顾 + +记录前先搜索当前 workspace: + +```bash +query="关键词" +workspace_root="$(pwd -P)" +qmd_index="aios-learnings-$(printf "%s" "$workspace_root" | sha256sum | awk '{print substr($1,1,12)}')" +if command -v qmd >/dev/null 2>&1 && [ -d .learnings ]; then + qmd --index "$qmd_index" update >/dev/null 2>&1 || true + qmd --index "$qmd_index" query --json -n 10 --min-score 0.25 "$query" +else + rg -n "$query" .learnings 2>/dev/null || grep -R "$query" .learnings 2>/dev/null || true +fi +``` + +如果已有相关条目: + +- 优先更新原条目的 `Last-Seen` 和 `Recurrence-Count`。 +- 在新条目中添加 `See Also`。 +- 反复出现且影响任务质量时,将优先级提升一级。 + +## Promotion 规则 + +将经验提升为更长期的工作指导时,必须区分“当前 agent workspace”和“平台共享内容”。 + +允许在当前 agent workspace 内、经用户明确同意后更新: + +- `AGENTS.md`:当前 agent 的工作流、自动化规则和项目约定 +- `TOOLS.md`:当前 agent 的工具使用注意事项 +- `SOUL.md`:当前 agent 的行为偏好和协作风格 + +禁止自动更新: + +- `/var/aios/workspace-templates` +- `/var/aios/.openclaw/skills` +- `/var/aios/.openclaw/openclaw.json` +- 其他 agent workspace +- 管理服务数据文件 + +需要跨 agent 共享的经验,应输出一段脱敏摘要,让管理员通过模板、全局 skill 或管理端流程评估后再发布。 + +## 状态更新 + +当问题已修复时,更新原条目: + +```markdown +### Resolution +- **Resolved**: ISO-8601 时间 +- **Commit/PR**: 提交、PR 或变更说明 +- **Notes**: 做了什么,如何验证 +``` + +状态可用值: + +- `pending` +- `in_progress` +- `resolved` +- `wont_fix` +- `promoted` + +## 安全检查 + +写入任何 `.learnings/` 文件前,检查: + +- 内容是否只属于当前 agent workspace。 +- 是否包含密钥、token、cookie、连接串、账号密码、私钥、完整环境变量或完整配置。 +- 是否泄露其他 agent、其他用户或管理面的上下文。 +- 是否把长日志压缩成了最小可用错误片段。 +- 是否使用相对路径或当前 workspace 内路径,而不是无关宿主机路径。 + +若不确定是否敏感,先写“已脱敏摘要”,不要写原文。 diff --git a/kernal/clawhub-skills/aios-self-improving-agent/agents/openai.yaml b/kernal/clawhub-skills/aios-self-improving-agent/agents/openai.yaml new file mode 100644 index 0000000..97539d4 --- /dev/null +++ b/kernal/clawhub-skills/aios-self-improving-agent/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "AIOS 自改进 Agent" + short_description: "在当前 agent workspace 内记录经验和错误" + default_prompt: "Use $aios-self-improving-agent 在当前 AIOS agent workspace 内记录纠正、命令失败和可复用经验。" + +policy: + allow_implicit_invocation: true -- Gitee From 9293b5b2f66a90e5da177ebf5f471b6d7dfb7dc8 Mon Sep 17 00:00:00 2001 From: NingWei Date: Wed, 17 Jun 2026 14:31:47 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=8C=E6=94=BE=E5=AE=BDclawhub=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/all-in-one/build-arm64.zsh | 8 ++ docker-images/all-in-one/build-x64.ps1 | 2 + docker-images/kernal/Dockerfile | 12 +- docker-images/kernal/build-linux-arm64.sh | 8 ++ docker-images/kernal/build-local.ps1 | 4 +- .../build-scripts/patch-clawhub-timeouts.cjs | 134 ++++++++++++++++++ 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 docker-images/kernal/build-scripts/patch-clawhub-timeouts.cjs diff --git a/docker-images/all-in-one/build-arm64.zsh b/docker-images/all-in-one/build-arm64.zsh index 40891a5..681206d 100644 --- a/docker-images/all-in-one/build-arm64.zsh +++ b/docker-images/all-in-one/build-arm64.zsh @@ -5,6 +5,7 @@ image_name="${IMAGE_NAME:-aios-all-in-one-arm64}" image_tag="${IMAGE_TAG:-local}" kernel_image_name="${KERNEL_IMAGE_NAME:-aios-kernal-arm64}" apps_image_name="${APPS_IMAGE_NAME:-aios-apps-arm64}" +clawhub_fetch_timeout_ms="${CLAW_HUB_FETCH_TIMEOUT_MS:-180000}" no_cache=false pull=false @@ -30,6 +31,10 @@ while (( $# > 0 )); do apps_image_name="$2" shift 2 ;; + --clawhub-fetch-timeout-ms) + clawhub_fetch_timeout_ms="$2" + shift 2 + ;; --no-cache) no_cache=true shift @@ -50,6 +55,8 @@ Options: Kernal image name, default: aios-kernal-arm64 --apps-image-name Apps image name, default: aios-apps-arm64 + --clawhub-fetch-timeout-ms + ClawHub/OpenClaw request timeout, default: 180000 --no-cache Disable Docker build cache --pull Always attempt to pull newer base images EOF @@ -92,6 +99,7 @@ build_kernal_args=( --file "${kernal_dir}/Dockerfile" --tag "${kernel_image_ref}" --build-arg TARGETARCH=arm64 + --build-arg "AIOS_CLAWHUB_FETCH_TIMEOUT_MS=${clawhub_fetch_timeout_ms}" --platform linux/arm64 ) diff --git a/docker-images/all-in-one/build-x64.ps1 b/docker-images/all-in-one/build-x64.ps1 index 9e5db9a..ebaeda4 100644 --- a/docker-images/all-in-one/build-x64.ps1 +++ b/docker-images/all-in-one/build-x64.ps1 @@ -4,6 +4,7 @@ param( [string]$Version, [string]$KernelImageName = "aios-kernal-amd64", [string]$AppsImageName = "aios-apps-x64", + [int]$ClawHubFetchTimeoutMs = 180000, [switch]$NoCache, [switch]$Pull ) @@ -45,6 +46,7 @@ $buildKernalArgs = @( "--tag", $kernelImageRef, "--no-cache-filter", "seed-builder", "--build-arg", "TARGETARCH=amd64", + "--build-arg", "AIOS_CLAWHUB_FETCH_TIMEOUT_MS=$ClawHubFetchTimeoutMs", "--platform", "linux/amd64" ) diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index b8d1140..12d1e47 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -170,6 +170,7 @@ ARG MCPORTER_VERSION=0.12.0 ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.4.1 ARG AIOS_APPS_INVOKE_CLI_VERSION=0.0.1 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org +ARG AIOS_CLAWHUB_FETCH_TIMEOUT_MS=180000 RUN mkdir -p "${AIOS_TOOLCHAIN_DIR}" @@ -179,7 +180,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ && apt-get install -y --no-install-recommends build-essential COPY build-scripts/install-versioned-npm-global.sh /usr/local/bin/install-versioned-npm-global -RUN chmod +x /usr/local/bin/install-versioned-npm-global +COPY build-scripts/patch-clawhub-timeouts.cjs /usr/local/bin/patch-clawhub-timeouts +RUN chmod +x /usr/local/bin/install-versioned-npm-global /usr/local/bin/patch-clawhub-timeouts RUN --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=type=cache,target=/opt/aios-build-cache/npm-tools,sharing=locked \ @@ -205,6 +207,10 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=type=cache,target=/opt/aios-build-cache/npm-tools,sharing=locked \ install-versioned-npm-global "clawhub" "${CLAWHUB_VERSION}" +RUN OPENCLAW_CLAWHUB_FETCH_TIMEOUT_MS="${AIOS_CLAWHUB_FETCH_TIMEOUT_MS}" \ + CLAWHUB_REQUEST_TIMEOUT_MS="${AIOS_CLAWHUB_FETCH_TIMEOUT_MS}" \ + node /usr/local/bin/patch-clawhub-timeouts + RUN --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=type=cache,target=/opt/aios-build-cache/npm-tools,sharing=locked \ install-versioned-npm-global "@tobilu/qmd" "${QMD_VERSION}" @@ -241,6 +247,8 @@ RUN --mount=type=bind,source=vendor,target=/tmp/aios-vendor-src,readonly \ FROM tool-builder AS seed-builder +ARG AIOS_CLAWHUB_FETCH_TIMEOUT_MS=180000 + ENV HOME=/opt/aios-seed/home ENV AIOS_OPENCLAW_HOME=/opt/aios-seed/home/.openclaw ENV AIOS_OPENCLAW_WORKSPACE_DIR=/opt/aios-seed/workspace-default @@ -254,6 +262,8 @@ ENV UV_PACKAGE_ROOT=/opt/aios-seed/uv-package ENV UV_TOOL_DIR=/opt/aios-seed/uv-package/tools ENV UV_TOOL_BIN_DIR=/opt/aios-seed/uv-package/bin ENV UV_CACHE_DIR=/opt/aios-seed/home/.cache/uv +ENV OPENCLAW_CLAWHUB_FETCH_TIMEOUT_MS=${AIOS_CLAWHUB_FETCH_TIMEOUT_MS} +ENV CLAWHUB_REQUEST_TIMEOUT_MS=${AIOS_CLAWHUB_FETCH_TIMEOUT_MS} RUN set -eux; \ mkdir -p "${HOME}" "${AIOS_OPENCLAW_HOME}" "${AIOS_OPENCLAW_WORKSPACE_DIR}" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" "${AIOS_OPENCLAW_HOME}/skills" "${UV_TOOL_DIR}" "${UV_TOOL_BIN_DIR}" "${UV_CACHE_DIR}" /opt/aios-seed/scripts /opt/aios-seed/workspace-templates/default; \ diff --git a/docker-images/kernal/build-linux-arm64.sh b/docker-images/kernal/build-linux-arm64.sh index 4cbc48d..6d51adb 100644 --- a/docker-images/kernal/build-linux-arm64.sh +++ b/docker-images/kernal/build-linux-arm64.sh @@ -3,6 +3,7 @@ set -euo pipefail IMAGE_NAME="${IMAGE_NAME:-aios-kernal-arm64}" IMAGE_TAG="${IMAGE_TAG:-arm64}" +CLAW_HUB_FETCH_TIMEOUT_MS="${CLAW_HUB_FETCH_TIMEOUT_MS:-180000}" NO_CACHE=false while [[ $# -gt 0 ]]; do @@ -15,6 +16,10 @@ while [[ $# -gt 0 ]]; do IMAGE_TAG="$2" shift 2 ;; + --clawhub-fetch-timeout-ms) + CLAW_HUB_FETCH_TIMEOUT_MS="$2" + shift 2 + ;; --no-cache) NO_CACHE=true shift @@ -26,6 +31,8 @@ Usage: build-linux-arm64.sh [options] Options: --image-name Docker image name, default: aios-kernal-arm64 --image-tag Docker image tag, default: arm64 + --clawhub-fetch-timeout-ms + ClawHub/OpenClaw request timeout, default: 180000 --no-cache Disable Docker build cache EOF exit 0 @@ -52,6 +59,7 @@ BUILD_ARGS=( --tag "${IMAGE_REF}" --no-cache-filter seed-builder --build-arg TARGETARCH=arm64 + --build-arg "AIOS_CLAWHUB_FETCH_TIMEOUT_MS=${CLAW_HUB_FETCH_TIMEOUT_MS}" --platform linux/arm64 ) diff --git a/docker-images/kernal/build-local.ps1 b/docker-images/kernal/build-local.ps1 index cfa978b..9deaf75 100644 --- a/docker-images/kernal/build-local.ps1 +++ b/docker-images/kernal/build-local.ps1 @@ -2,6 +2,7 @@ param( [string]$ImageName, [string]$ImageTag = "local", [string]$Platform, + [int]$ClawHubFetchTimeoutMs = 180000, [switch]$NoCache, [switch]$Pull ) @@ -45,7 +46,8 @@ $buildArgs = @( "--file", $dockerfile, "--tag", $imageRef, "--no-cache-filter", "seed-builder", - "--build-arg", "TARGETARCH=$targetArch" + "--build-arg", "TARGETARCH=$targetArch", + "--build-arg", "AIOS_CLAWHUB_FETCH_TIMEOUT_MS=$ClawHubFetchTimeoutMs" ) if ($Platform) { diff --git a/docker-images/kernal/build-scripts/patch-clawhub-timeouts.cjs b/docker-images/kernal/build-scripts/patch-clawhub-timeouts.cjs new file mode 100644 index 0000000..2566e6d --- /dev/null +++ b/docker-images/kernal/build-scripts/patch-clawhub-timeouts.cjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_TIMEOUT_MS = 180000; +const toolchainDir = process.env.AIOS_TOOLCHAIN_DIR || "/opt/aios-toolchain"; +const nodeModulesDir = path.join(toolchainDir, "lib", "node_modules"); + +function parsePositiveInteger(value) { + const parsed = Number.parseInt(value || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +const configuredTimeoutMs = + parsePositiveInteger(process.env.AIOS_CLAWHUB_FETCH_TIMEOUT_MS) || + parsePositiveInteger(process.env.OPENCLAW_CLAWHUB_FETCH_TIMEOUT_MS) || + parsePositiveInteger(process.env.CLAWHUB_REQUEST_TIMEOUT_MS) || + DEFAULT_TIMEOUT_MS; + +function replaceConstant(source, constantName, originalLiteral, replacement) { + const assignmentPattern = new RegExp( + `const ${constantName} = (?:${originalLiteral}|\\(\\(\\) => \\{[\\s\\S]*?\\}\\)\\(\\));`, + ); + + if (!assignmentPattern.test(source)) { + return undefined; + } + + return source.replace(assignmentPattern, replacement); +} + +function patchFile(filePath, constantName, originalLiteral, replacement) { + const source = fs.readFileSync(filePath, "utf8"); + const patched = replaceConstant(source, constantName, originalLiteral, replacement); + + if (patched === undefined) { + throw new Error(`Could not find ${constantName} assignment in ${filePath}`); + } + + if (patched !== source) { + fs.writeFileSync(filePath, patched, "utf8"); + } +} + +function patchOpenClaw() { + const distDir = path.join(nodeModulesDir, "openclaw", "dist"); + if (!fs.existsSync(distDir)) { + throw new Error(`OpenClaw dist directory not found: ${distDir}`); + } + + const replacement = `const DEFAULT_FETCH_TIMEOUT_MS = (() => { +\tconst value = Number.parseInt(process.env.OPENCLAW_CLAWHUB_FETCH_TIMEOUT_MS ?? process.env.AIOS_CLAWHUB_FETCH_TIMEOUT_MS ?? "", 10); +\treturn Number.isFinite(value) && value > 0 ? value : ${configuredTimeoutMs}; +})();`; + + const candidates = fs + .readdirSync(distDir) + .filter((name) => /^clawhub-.*\.js$/.test(name)) + .map((name) => path.join(distDir, name)) + .filter((filePath) => fs.readFileSync(filePath, "utf8").includes("DEFAULT_FETCH_TIMEOUT_MS")); + + if (candidates.length === 0) { + throw new Error(`No OpenClaw ClawHub bundle with DEFAULT_FETCH_TIMEOUT_MS found in ${distDir}`); + } + + for (const filePath of candidates) { + patchFile(filePath, "DEFAULT_FETCH_TIMEOUT_MS", "3e4", replacement); + console.log(`Patched ${filePath} ClawHub fetch timeout default to ${configuredTimeoutMs}ms`); + } +} + +function patchOpenClawSkillInstallTimeouts() { + const distDir = path.join(nodeModulesDir, "openclaw", "dist"); + const candidates = fs + .readdirSync(distDir) + .filter((name) => /^status-.*\.js$/.test(name)) + .map((name) => path.join(distDir, name)) + .filter((filePath) => { + const source = fs.readFileSync(filePath, "utf8"); + return source.includes("performClawHubSkillInstall") && source.includes("timeoutMs: 12e4"); + }); + + if (candidates.length === 0) { + throw new Error(`No OpenClaw skills lifecycle bundle with timeoutMs: 12e4 found in ${distDir}`); + } + + const timeoutConstant = `const OPENCLAW_CLAWHUB_INSTALL_TIMEOUT_MS = (() => { +\tconst value = Number.parseInt(process.env.OPENCLAW_CLAWHUB_FETCH_TIMEOUT_MS ?? process.env.AIOS_CLAWHUB_FETCH_TIMEOUT_MS ?? "", 10); +\treturn Number.isFinite(value) && value > 0 ? value : ${configuredTimeoutMs}; +})();`; + + for (const filePath of candidates) { + const source = fs.readFileSync(filePath, "utf8"); + if (!source.includes("const CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS = [")) { + throw new Error(`Could not find ClawHub skill archive marker declaration in ${filePath}`); + } + + let patched = source.includes("OPENCLAW_CLAWHUB_INSTALL_TIMEOUT_MS") + ? source + : source.replace("const CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS = [", `${timeoutConstant}\nconst CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS = [`); + + patched = patched + .replaceAll("timeoutMs: params.timeoutMs ?? 12e4", "timeoutMs: params.timeoutMs ?? OPENCLAW_CLAWHUB_INSTALL_TIMEOUT_MS") + .replaceAll("timeoutMs: 12e4", "timeoutMs: OPENCLAW_CLAWHUB_INSTALL_TIMEOUT_MS"); + + if (patched === source) { + throw new Error(`Could not patch OpenClaw skill install timeouts in ${filePath}`); + } + + fs.writeFileSync(filePath, patched, "utf8"); + console.log(`Patched ${filePath} skill install timeouts to ${configuredTimeoutMs}ms`); + } +} + +function patchStandaloneClawHub() { + const httpFile = path.join(nodeModulesDir, "clawhub", "dist", "http.js"); + if (!fs.existsSync(httpFile)) { + throw new Error(`ClawHub http module not found: ${httpFile}`); + } + + const replacement = `const REQUEST_TIMEOUT_MS = (() => { +\tconst value = Number.parseInt(process.env.CLAWHUB_REQUEST_TIMEOUT_MS ?? process.env.OPENCLAW_CLAWHUB_FETCH_TIMEOUT_MS ?? process.env.AIOS_CLAWHUB_FETCH_TIMEOUT_MS ?? "", 10); +\treturn Number.isFinite(value) && value > 0 ? value : ${configuredTimeoutMs}; +})();`; + + patchFile(httpFile, "REQUEST_TIMEOUT_MS", "15_000", replacement); + console.log(`Patched ${httpFile} request timeout default to ${configuredTimeoutMs}ms`); +} + +patchOpenClaw(); +patchOpenClawSkillInstallTimeouts(); +patchStandaloneClawHub(); -- Gitee From ca98a191c6a1860e05e4a0ff14d398984bd21179 Mon Sep 17 00:00:00 2001 From: NingWei Date: Wed, 17 Jun 2026 18:43:12 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E3=80=81=E4=BC=98=E5=8C=96=E5=90=AF=E5=8A=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../package-lock.json | 155 +++++++- .../package.json | 7 +- .../src/china-time.ts | 30 ++ apps/aios-app-invoke-proxy-service/src/cli.ts | 14 +- .../src/logger.ts | 107 ++++++ .../src/service.ts | 50 ++- apps/management-website/README.md | 2 + apps/management-website/docs/spec.md | 6 +- apps/management-website/package-lock.json | 156 +++++++- apps/management-website/package.json | 8 +- .../public/digital-employee-icon.png | Bin 0 -> 15340 bytes .../scripts/reset-password.js | 3 +- apps/management-website/server/index.js | 12 +- apps/management-website/server/src/app.js | 5 +- .../server/src/background/index.js | 40 +- .../server/src/config/env.js | 2 +- .../management-website/server/src/db/index.js | 5 +- .../src/infra/mqtt/management-rpc-client.js | 24 +- .../server/src/infra/s3/object-storage.js | 3 +- .../server/src/services/agent-service.js | 11 +- .../server/src/services/audit-log-service.js | 3 +- .../server/src/services/auth-service.js | 11 +- .../src/services/catalog-sync-service.js | 48 ++- .../server/src/services/external-service.js | 5 +- .../services/large-language-model-service.js | 7 +- .../server/src/services/portal-service.js | 51 +-- .../server/src/services/system-service.js | 15 +- .../server/src/services/topic-ping-service.js | 5 +- .../server/src/utils/china-time.js | 34 ++ .../server/src/utils/logger.js | 106 ++++++ .../server/test/background-services.test.js | 44 ++- .../server/test/catalog-sync-service.test.js | 20 +- .../server/test/env-config.test.js | 16 + .../test/portal-service-alignment.test.js | 63 +++- apps/management-website/src/app/App.jsx | 8 +- .../src/components/DigitalEmployeeIcon.jsx | 10 + .../src/components/LogoMark.jsx | 5 +- .../src/pages/AgentTestPanel.jsx | 8 +- .../src/pages/DashboardPage.jsx | 4 +- .../src/pages/SkillsPage.jsx | 66 ++-- apps/management-website/src/styles.css | 59 ++- .../test/integration/website-full.js | 34 +- docker-images/all-in-one/Dockerfile | 5 +- docker-images/all-in-one/README.md | 23 +- .../all-in-one/assets/scripts/startup.sh | 283 +++++++++----- docker-images/all-in-one/doc/spec.md | 18 +- .../all-in-one/docs/startup-steps.md | 50 +++ docker-images/apps/Dockerfile | 9 +- docker-images/apps/README.md | 17 +- docker-images/apps/assets/scripts/startup.sh | 241 ++++++++++-- docker-images/apps/docs/startup-steps.md | 33 ++ docker-images/kernal/Dockerfile | 11 +- docker-images/kernal/README.md | 13 +- .../kernal/assets/scripts/startup.sh | 347 +++++++++++++----- docker-images/kernal/docs/design.md | 4 +- docker-images/kernal/docs/startup-steps.md | 65 ++++ .../test/container-mqtt-integration.js | 2 +- .../kernal-tests/test/startup-config.test.ts | 218 ++++++++++- joint-test/dmz-mode/playwright-dev2-flow.js | 5 +- kernal/MANUAL-DEPLOY.md | 2 +- kernal/aios-apps-invoke-cli/package-lock.json | 155 +++++++- kernal/aios-apps-invoke-cli/package.json | 7 +- .../aios-apps-invoke-cli/src/commands/root.ts | 3 +- kernal/aios-apps-invoke-cli/src/logger.ts | 95 +++++ kernal/aios-apps-invoke-cli/test/root.test.ts | 12 +- kernal/aios-management-serivce/README.md | 2 +- .../docs/compatibility-list.md | 85 ++++- kernal/aios-management-serivce/docs/design.md | 2 + .../aios-management-serivce/package-lock.json | 155 +++++++- kernal/aios-management-serivce/package.json | 7 +- .../capabilities/agent-template/service.ts | 3 +- .../capabilities/agent/lifecycle-service.ts | 5 +- .../src/capabilities/agent/service.ts | 11 +- .../src/capabilities/agent/skills-service.ts | 1 - .../capabilities/agent/workspace-service.ts | 3 +- .../src/capabilities/apps/service.ts | 7 +- .../src/capabilities/catalog/snapshot.ts | 39 +- .../src/capabilities/llm/service.ts | 3 +- .../src/capabilities/version/list.ts | 127 +++++-- .../aios-management-serivce/src/china-time.ts | 34 ++ kernal/aios-management-serivce/src/cli.ts | 15 +- kernal/aios-management-serivce/src/logger.ts | 107 ++++++ kernal/aios-management-serivce/src/service.ts | 33 +- kernal/aios-management-serivce/src/types.ts | 1 - .../test/capabilities-registry.test.ts | 222 +++++++++++ .../test/openclaw-manager.test.ts | 4 +- .../aios-make-chart-image/package-lock.json | 158 ++++++++ .../aios-make-chart-image/package.json | 3 + .../scripts/make_chart_image.mjs | 98 ++++- .../aios-transfer-file/package-lock.json | 160 +++++++- .../aios-transfer-file/package.json | 5 +- .../scripts/transfer_s3.mjs | 99 ++++- .../aios-mqtt-channel/package-lock.json | 153 +++++++- .../aios-mqtt-channel/package.json | 5 +- .../aios-mqtt-channel/src/channel-runtime.ts | 44 ++- .../aios-mqtt-channel/src/channel.test.ts | 99 ++++- .../aios-mqtt-channel/src/chat-log.ts | 111 ++++++ 97 files changed, 4077 insertions(+), 604 deletions(-) create mode 100644 apps/aios-app-invoke-proxy-service/src/china-time.ts create mode 100644 apps/aios-app-invoke-proxy-service/src/logger.ts create mode 100644 apps/management-website/public/digital-employee-icon.png create mode 100644 apps/management-website/server/src/utils/china-time.js create mode 100644 apps/management-website/server/src/utils/logger.js create mode 100644 apps/management-website/src/components/DigitalEmployeeIcon.jsx create mode 100644 docker-images/all-in-one/docs/startup-steps.md create mode 100644 docker-images/apps/docs/startup-steps.md create mode 100644 docker-images/kernal/docs/startup-steps.md create mode 100644 kernal/aios-apps-invoke-cli/src/logger.ts create mode 100644 kernal/aios-management-serivce/src/china-time.ts create mode 100644 kernal/aios-management-serivce/src/logger.ts create mode 100644 kernal/openclaw-plugins/aios-mqtt-channel/src/chat-log.ts diff --git a/apps/aios-app-invoke-proxy-service/package-lock.json b/apps/aios-app-invoke-proxy-service/package-lock.json index 67709e5..ae81d60 100644 --- a/apps/aios-app-invoke-proxy-service/package-lock.json +++ b/apps/aios-app-invoke-proxy-service/package-lock.json @@ -1,16 +1,19 @@ { "name": "aios-app-invoke-proxy-service", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-app-invoke-proxy-service", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { + "dayjs": "^1.11.21", "huozige-app-integration": "^0.1.5", - "mqtt": "^5.15.1" + "mqtt": "^5.15.1", + "pino": "^10.3.1", + "rotating-file-stream": "^3.2.9" }, "bin": { "aios-app-invoke-proxy-service": "dist/cli.js" @@ -495,6 +498,12 @@ "node": ">=12" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -578,6 +587,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -739,6 +757,12 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1111,6 +1135,15 @@ "js-sdsl": "4.3.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -1145,6 +1178,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -1160,6 +1230,28 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -1176,12 +1268,33 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rotating-file-stream": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.9.tgz", + "integrity": "sha512-i9i0KkHh12ryl4xtELg+0gyoFre2PJ9RcQQLzquWsiqygyYsrZLckrqqYrthhnJZGZb4g+KUHtcoWYVq34gaug==", + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1202,6 +1315,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1262,6 +1384,15 @@ "npm": ">= 3.0.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1384,6 +1515,24 @@ "node": ">=8" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/apps/aios-app-invoke-proxy-service/package.json b/apps/aios-app-invoke-proxy-service/package.json index df3eba8..4f6c08b 100644 --- a/apps/aios-app-invoke-proxy-service/package.json +++ b/apps/aios-app-invoke-proxy-service/package.json @@ -1,6 +1,6 @@ { "name": "aios-app-invoke-proxy-service", - "version": "0.1.3", + "version": "0.1.4", "description": "MQTT service proxy for AIOS application invocation.", "type": "module", "private": false, @@ -57,8 +57,11 @@ "access": "public" }, "dependencies": { + "dayjs": "^1.11.21", "huozige-app-integration": "^0.1.5", - "mqtt": "^5.15.1" + "mqtt": "^5.15.1", + "pino": "^10.3.1", + "rotating-file-stream": "^3.2.9" }, "devDependencies": { "@types/jasmine": "^5.1.13", diff --git a/apps/aios-app-invoke-proxy-service/src/china-time.ts b/apps/aios-app-invoke-proxy-service/src/china-time.ts new file mode 100644 index 0000000..1183b64 --- /dev/null +++ b/apps/aios-app-invoke-proxy-service/src/china-time.ts @@ -0,0 +1,30 @@ +const CHINA_TIME_OFFSET_MINUTES = 8 * 60; + +function pad(value: number, length = 2): string { + return String(value).padStart(length, "0"); +} + +export function toChinaISOString(value: Date | string | number = new Date()): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error("Invalid date value"); + } + + const chinaTime = new Date(date.getTime() + CHINA_TIME_OFFSET_MINUTES * 60 * 1000); + return [ + chinaTime.getUTCFullYear(), + "-", + pad(chinaTime.getUTCMonth() + 1), + "-", + pad(chinaTime.getUTCDate()), + "T", + pad(chinaTime.getUTCHours()), + ":", + pad(chinaTime.getUTCMinutes()), + ":", + pad(chinaTime.getUTCSeconds()), + ".", + pad(chinaTime.getUTCMilliseconds(), 3), + "+08:00" + ].join(""); +} diff --git a/apps/aios-app-invoke-proxy-service/src/cli.ts b/apps/aios-app-invoke-proxy-service/src/cli.ts index 9854e19..7110ea9 100644 --- a/apps/aios-app-invoke-proxy-service/src/cli.ts +++ b/apps/aios-app-invoke-proxy-service/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createAppInvokeProxyService } from "./service.js"; +import { createLogger, errorDetails } from "./logger.js"; function printUsage(): void { process.stdout.write( @@ -35,7 +36,11 @@ async function main(): Promise { } await service.start(); - process.stdout.write("aios-app-invoke-proxy-service started\n"); + createLogger("app-invoke-proxy-cli", 1).info({ + command, + service: service.info.name, + version: service.info.version + }, "aios-app-invoke-proxy-service started"); let shuttingDown = false; const shutdown = async () => { @@ -44,7 +49,9 @@ async function main(): Promise { } shuttingDown = true; + createLogger("app-invoke-proxy-cli").info({ signal: "shutdown" }, "aios-app-invoke-proxy-service stopping"); await service.stop(); + createLogger("app-invoke-proxy-cli").info({ signal: "shutdown" }, "aios-app-invoke-proxy-service stopped"); process.exit(0); }; @@ -53,7 +60,8 @@ async function main(): Promise { } main().catch((error: unknown) => { - const message = error instanceof Error ? error.stack ?? error.message : String(error); - process.stderr.write(`${message}\n`); + createLogger("app-invoke-proxy-cli").error({ + error: errorDetails(error) + }, "aios-app-invoke-proxy-service failed"); process.exit(1); }); diff --git a/apps/aios-app-invoke-proxy-service/src/logger.ts b/apps/aios-app-invoke-proxy-service/src/logger.ts new file mode 100644 index 0000000..07b283e --- /dev/null +++ b/apps/aios-app-invoke-proxy-service/src/logger.ts @@ -0,0 +1,107 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; + +import pino, { type DestinationStream, type Logger } from "pino"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc.js"; +import timezone from "dayjs/plugin/timezone.js"; +import { createStream, type RotatingFileStream } from "rotating-file-stream"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const PACKAGE_NAME = "aios-app-invoke-proxy-service"; +let fileStream: RotatingFileStream | undefined; +let fileStreamDir = ""; +const warnedLogDirs = new Set(); + +function beijingTime(): string { + return dayjs().tz("Asia/Shanghai").format("YYYY-MM-DDTHH:mm:ss.SSSZ"); +} + +function resolveLogDir(): string | undefined { + const configured = process.env.AIOS_LOG_DIR?.trim(); + if (configured) { + return configured; + } + + const root = process.env.AIOS_ROOT?.trim(); + if (root) { + return path.join(root, "logs"); + } + + const dataDir = process.env.AIOS_DATA_DIR?.trim() || process.env.AIOS_KERNEL_DATA_DIR?.trim(); + if (dataDir) { + return path.join(path.dirname(dataDir), "logs"); + } + + return undefined; +} + +function getFileStream(): RotatingFileStream | undefined { + const logDir = resolveLogDir(); + if (!logDir) { + return undefined; + } + + if (fileStream && fileStreamDir === logDir) { + return fileStream; + } + + try { + mkdirSync(logDir, { recursive: true }); + fileStreamDir = logDir; + fileStream = createStream((time) => { + const current = time ? dayjs(time).tz("Asia/Shanghai") : dayjs().tz("Asia/Shanghai"); + return `${PACKAGE_NAME}-${current.format("YYYYMMDD")}.log`; + }, { + path: logDir, + interval: "1d", + intervalBoundary: true, + intervalUTC: false, + mode: 0o660 + }); + return fileStream; + } catch (error) { + if (!warnedLogDirs.has(logDir)) { + warnedLogDirs.add(logDir); + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[logger] ${beijingTime()} failed to initialize daily log file in ${logDir}: ${message}\n`); + } + return undefined; + } +} + +function createDestination(fd: 1 | 2): DestinationStream { + const consoleStream = pino.destination({ fd, sync: true }); + const dailyFileStream = getFileStream(); + + return { + write(line: string) { + consoleStream.write(line); + dailyFileStream?.write(line); + } + }; +} + +export function createLogger(name: string, fd: 1 | 2 = 2): Logger { + return pino({ + name, + level: process.env.AIOS_LOG_LEVEL || "info", + timestamp: () => `,"ts":"${beijingTime()}"`, + base: null + }, createDestination(fd)); +} + +export function errorDetails(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack + }; + } + + return { + message: String(error) + }; +} diff --git a/apps/aios-app-invoke-proxy-service/src/service.ts b/apps/aios-app-invoke-proxy-service/src/service.ts index d2022e9..7a4b88a 100644 --- a/apps/aios-app-invoke-proxy-service/src/service.ts +++ b/apps/aios-app-invoke-proxy-service/src/service.ts @@ -5,6 +5,8 @@ import { connect, type IClientOptions, type MqttClient } from "mqtt"; import { badRequest, notFound, resolveErrorMessage, resolveErrorStatusCode } from "./errors.js"; import { loadEnvironmentConfig } from "./env.js"; +import { toChinaISOString } from "./china-time.js"; +import { createLogger, errorDetails } from "./logger.js"; import { HzgProviderClient, normalizeBindingCommandName, UNSUPPORTED_BINDING_COMMANDS } from "./hzg-provider-client.js"; import { normalizeBrokerUrl } from "./mqtt.js"; import { newErrorResponse, newSuccessResponse } from "./protocol.js"; @@ -23,6 +25,7 @@ import { WebApiClient } from "./web-api-client.js"; const require = createRequire(import.meta.url); const { version: SERVICE_VERSION } = require("../package.json") as { version: string }; const SERVICE_NAME = "aios-app-invoke-proxy-service"; +const logger = createLogger(SERVICE_NAME); const DEFAULT_MQTT_MAX_CONNECT_ATTEMPTS = 120; const DEFAULT_MQTT_RETRY_DELAY_MS = 5_000; @@ -200,7 +203,8 @@ export function createAppInvokeProxyService( function markMqttError(message: string): void { mqttState.lastError = message; - mqttState.lastErrorAt = new Date().toISOString(); + mqttState.lastErrorAt = toChinaISOString(); + logger.error({ message }, "MQTT runtime error recorded"); } async function publishResponse(traceId: string, payload: AppInvokeResponse): Promise { @@ -265,7 +269,7 @@ export function createAppInvokeProxyService( try { await recordInvocation(log); } catch (error: unknown) { - process.stderr.write(`Failed to record invocation via web API: ${String(error)}\n`); + logger.error({ error: errorDetails(error) }, "Failed to record invocation via web API"); } } @@ -372,7 +376,11 @@ export function createAppInvokeProxyService( const handleConnect = (): void => { mqttState.connected = true; - mqttState.lastConnectedAt = new Date().toISOString(); + mqttState.lastConnectedAt = toChinaISOString(); + logger.info({ + brokerUrl: env.mqttBrokerUrl, + requestTopic: env.mqttRequestTopic + }, "MQTT connected; subscribing request topic"); client.subscribe(env.mqttRequestTopic, { qos: 1 }, (error: Error | null) => { if (error) { const message = `Failed to subscribe to ${env.mqttRequestTopic}: ${error.message}`; @@ -382,7 +390,8 @@ export function createAppInvokeProxyService( } mqttState.subscribed = true; - mqttState.lastSubscribedAt = new Date().toISOString(); + mqttState.lastSubscribedAt = toChinaISOString(); + logger.info({ requestTopic: env.mqttRequestTopic }, "MQTT request topic subscribed"); settleSuccess(); }); }; @@ -406,15 +415,31 @@ export function createAppInvokeProxyService( for (let attempt = 1; attempt <= mqttMaxConnectAttempts; attempt += 1) { const client = mqttConnect(brokerUrl, mqttOptions); + logger.info({ + attempt, + maxAttempts: mqttMaxConnectAttempts, + brokerUrl + }, "MQTT startup connection attempt"); try { await waitForInitialSubscription(client); + logger.info({ + attempt, + brokerUrl, + requestTopic: env.mqttRequestTopic + }, "MQTT startup connection succeeded"); return client; } catch (error: unknown) { lastError = error instanceof Error ? error : new Error(String(error)); mqttState.connected = false; mqttState.subscribed = false; - mqttState.lastDisconnectedAt = new Date().toISOString(); + mqttState.lastDisconnectedAt = toChinaISOString(); + logger.warn({ + attempt, + maxAttempts: mqttMaxConnectAttempts, + retryDelayMs: attempt < mqttMaxConnectAttempts ? mqttRetryDelayMs : 0, + error: errorDetails(lastError) + }, "MQTT startup connection attempt failed"); await endMqttClient(client, true); if (attempt < mqttMaxConnectAttempts) { @@ -461,6 +486,7 @@ export function createAppInvokeProxyService( }, async start(): Promise { if (configIssues.length > 0) { + logger.error({ issues: configIssues }, "app invoke proxy configuration invalid"); throw new Error(configIssues.join("; ")); } @@ -480,24 +506,31 @@ export function createAppInvokeProxyService( } mqttClient = await connectAndSubscribeWithRetry(normalizedBroker.brokerUrl, mqttOptions); + logger.info({ + brokerUrl: normalizedBroker.brokerUrl, + requestTopic: env.mqttRequestTopic, + responseTopic: env.mqttResponseTopic, + clientId: env.mqttClientId + }, "app invoke proxy service started"); mqttClient.on("reconnect", () => { mqttState.connected = false; mqttState.subscribed = false; + logger.warn("MQTT reconnecting"); }); mqttClient.on("close", () => { mqttState.connected = false; mqttState.subscribed = false; - mqttState.lastDisconnectedAt = new Date().toISOString(); + mqttState.lastDisconnectedAt = toChinaISOString(); + logger.warn({ lastDisconnectedAt: mqttState.lastDisconnectedAt }, "MQTT connection closed"); }); mqttClient.on("error", (error: Error) => { const message = `MQTT error: ${error.message}`; markMqttError(message); - process.stderr.write(`${message}\n`); }); mqttClient.on("message", (_topic: string, message: Buffer) => { void handleMqttMessage(message).catch((error: unknown) => { - process.stderr.write(`Failed to handle MQTT message: ${String(error)}\n`); + logger.error({ error: errorDetails(error) }, "Failed to handle MQTT message"); }); }); @@ -511,6 +544,7 @@ export function createAppInvokeProxyService( await new Promise((resolve) => { client.end(false, {}, () => resolve()); }); + logger.info("app invoke proxy service stopped"); } } }; diff --git a/apps/management-website/README.md b/apps/management-website/README.md index 3f9904b..20ddeb7 100644 --- a/apps/management-website/README.md +++ b/apps/management-website/README.md @@ -118,6 +118,8 @@ npm run test:full 设置页“同步数据”按钮会先执行 `catalog.snapshot`,再执行 `agent.usage.list`。同步过程会显示蒙版,完成后用 toast 提示结果。 +`catalog.snapshot` 采用紧凑目录协议,控制台只消费同步落库所需字段:Skill 只读取 `id` / `builtIn`,Template 只读取 `id` / `builtIn`,Agent 不从快照读取 workspace 和 Token 用量,Token 用量统一由 `agent.usage.list` 刷新。为了降低本地缓存体积,`agents.remote_state_json` 只保存 `inboundTopic` / `outboundTopic`,`agent_templates.remote_result_json` 只保存 `builtIn` 标记。 + 同步状态写入这些表: - `agent_sync_state` diff --git a/apps/management-website/docs/spec.md b/apps/management-website/docs/spec.md index ca441c8..1fd9edb 100644 --- a/apps/management-website/docs/spec.md +++ b/apps/management-website/docs/spec.md @@ -46,6 +46,8 @@ Token 用量由 `agent.usage.list` 同步到 `agents.usage_snapshot_json`。超 当前只管理全局 Skill。上传 zip 顶层必须包含符合 AgentSkills 规范的 `SKILL.md`,frontmatter 至少包含 `name` 和 `description`。安装和删除通过 `skills.global.install.local` / `skills.global.delete` 执行。 +技能目录同步和页面卡片不再读取或展示 Skill 描述。技能卡片只展示技能名称、内建标记、上传者和上传时间;内建技能的上传者和上传时间显示为 `-`。 + ### LLM Provider 表示 OpenAI 兼容模型服务的 `baseUrl + SK`。Model 归属 Provider,运行时引用格式为 `provider_id/model_id`。 @@ -71,10 +73,12 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `catalog.snaps 行为: - 发送 `catalog.snapshot` 到 `aios-management-serivce` -- 一次性获取 LLM、Agent、Template、Skill、System、Ontology 目录 +- 一次性获取 LLM、Agent、Template、Skill、System、Ontology 的紧凑目录 - 写入本地 SQLite - 更新目录同步状态表 +`catalog.snapshot` 只携带同步落库所需字段,避免 MQTT 单条消息过大:Skill 只读取 `id` / `builtIn`,Template 只读取 `id` / `builtIn`,Agent 不携带 workspace 和 Token 用量,Provider 不携带显示名和 `secret.configured`。本地缓存也只保留必要远端状态:`agents.remote_state_json` 只保存 `inboundTopic` / `outboundTopic`,`agent_templates.remote_result_json` 只保存 `builtIn` 标记。 + ### Token 用量同步 触发时机: diff --git a/apps/management-website/package-lock.json b/apps/management-website/package-lock.json index 040ef61..f17c174 100644 --- a/apps/management-website/package-lock.json +++ b/apps/management-website/package-lock.json @@ -1,24 +1,26 @@ { "name": "aios-management-web", - "version": "0.2.8", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-management-web", - "version": "0.2.8", + "version": "0.3.6", "dependencies": { "@ant-design/icons": "^6.0.0", "@ant-design/x": "^1.6.1", "@aws-sdk/client-s3": "^3.907.0", "adm-zip": "^0.5.16", "antd": "^5.27.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.21", "express": "^5.1.0", "mqtt": "^5.15.1", "multer": "^2.0.2", + "pino": "^10.3.1", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "rotating-file-stream": "^3.2.9" }, "devDependencies": { "@vitejs/plugin-react": "^5.0.1", @@ -1509,6 +1511,12 @@ ], "license": "MIT" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2481,6 +2489,15 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2873,9 +2890,9 @@ "license": "MIT" }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { @@ -3843,6 +3860,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3959,6 +3985,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/playwright-core": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", @@ -4016,6 +4079,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4044,6 +4123,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4727,6 +4812,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -4784,6 +4878,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/rotating-file-stream": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.9.tgz", + "integrity": "sha512-i9i0KkHh12ryl4xtELg+0gyoFre2PJ9RcQQLzquWsiqygyYsrZLckrqqYrthhnJZGZb4g+KUHtcoWYVq34gaug==", + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4820,6 +4926,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5034,6 +5149,15 @@ "npm": ">= 3.0.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5207,6 +5331,24 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", diff --git a/apps/management-website/package.json b/apps/management-website/package.json index a3808f9..40ec8d4 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.3.6", + "version": "0.3.7", "type": "module", "files": [ "dist", @@ -30,12 +30,14 @@ "@aws-sdk/client-s3": "^3.907.0", "adm-zip": "^0.5.16", "antd": "^5.27.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.21", "express": "^5.1.0", "mqtt": "^5.15.1", "multer": "^2.0.2", + "pino": "^10.3.1", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "rotating-file-stream": "^3.2.9" }, "devDependencies": { "@vitejs/plugin-react": "^5.0.1", diff --git a/apps/management-website/public/digital-employee-icon.png b/apps/management-website/public/digital-employee-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e8159a73aff2dd6923e04dc4d041144a8803f40d GIT binary patch literal 15340 zcmeIZS5%ZsuqgZi29zkMRo@#e4P~;6^cNurB2!hlr2|2P zph*Z3p9BAX`wSg|e-|FB7c+J1@t$riZrl1I+il@n z>{pVr2t4l+UCEA-z8f1we&dR#!QHQHI&Vc)=pRwuh-SN_s&F$aMydT;k_e4EwxoNd z;a~>iz2#hrHFGpuuN+y&`pDuWn$j4QRX4{=?&hkYk!34+8c?tHLMLm`eKe z5(-j=(gv4oJ;M(}rEfD8d1;sGbzc=EZ<19->TQ%XUC2EC2837^b&II zbgh)ajw^!aMB9;&=tIt`OzD57+1;$dncSG%(m%}DQU2Ep5DS`Rv1?t0uAdcUn`-eKjT7!HRs&ueLd*7!K4T@TS@Il;$z z&V^Sm<7)qDDZRUY~~Vd`6~4TYB!2Fr^!zSt>Kj9m&FY&Kj{tkXR}D%DZYN<@e1J%gOIkfaZU!f z>A})5*Mj~lxPzNUP8jS4_^J2SAOts+@fyhPI$raWBfGIyt!}vu&OFzTem&OkuA#le z$q4IJ{R(6HK^Y*y#<9oM-3_n&^y_%(W;`ykjC7tTt#w}dVg@7rVGcdD^IR3k7>&D` zJ=ywGuBNlwWox}<=3SJbNDDJfSQ`@h{WW=z$=TQ%HA#KG=(#BOHj z@V7>^+K@LEAp+P13wKX}`QvJ|;C5X14~CY0-imz&EM*o7-Ln zeMWrCQ<0JT?xd9A@hJ4t4F-eQBzj$7ZeuTx>E045Rh6aaXOR z^p8i0Vk?j4O4F(foZCx&x{aJ=2AX>N+nG(bvSGjU0@v>LYWMh)>dQE>f7rk{H$>6q zC1J5fDxBv(pOgzy&K22p*D6V}5?6wio@ZpY(L%sZ9A60f9kHbHw*@+DK|sVfWi?Eq zsj%Ga72IC)KI&4V0hy`lj>)zK%Vn^MElY*c9oj=nhwPH_E| z+}bFwSLDw2TAJ{_NwwU-qS2b}8dWYmztcI?YtAg0mS$eG#I2rn;W&d2Hwdvp|i8{7UonI9-Q+Sd%1kT{mHMogeaIOj!h$GwA+ak+*ct>wxP+K%9m9XgC#|16 z46gfTjYsc~{X8yYwK4KJHK`m;!afqQ&n)7%8ndW&$+3p(M_E|aERXDVg*_g1E7nhn z?;k90o{8|VtAZI{(gV^E>)1Ip8aX?)V+yS!Db^K4P!-2$Bs177w*}ds2lHh$QUth0<`-w=C0j{EEM2tw`~SCW!smbjJo4Y+dH6F=|KOv3#N9S<=DR z5YN!0bWX+>*vr4_?=9>besO#M7%xMufK*9}594`&S=74Fw5UN_?jp2{Ze7@aoa4qA z=fikC5v-U}L@c@_`uF+B#Y3!jBOb4hrQD_5%DbE|&4^18CjA?dZtn=`DE$JOr%m;wKigFk>r!TTV*OGkYWMo)jXu_ zR!wSuP6)OJ`=aFSt+%8LKEAKq4AFgpSMM!PU0T1&i_V+lq>8j2CS52yzP>G~1xRWI zGtMUS_1}|?fB~gxyy#iU?0ZM97HrepgA=+*u3Ti&GuDuF zpJLGWVUCcYdCDWwp9FaR8=Mw8rtYS9cjV4DX*@F#ZKkE{=zaw=`#mNix*TfSXx8=B zEqN}r=u&OK?+~zh&#~g^yqJ6m<)Ud9Z1*1ZtEHllDvkza6@kE@dx2us8xpFA z3$@z#`JFkUMC$FdA>@92A@-b9FqgtLe?&24xMb}eWfamV2r*1h*cMI3ER2|*%HG24 znk?eQJFG8~H|o+NE$z`8KdNpB+mU+kb1nOg7(seeS0K5e%FcANuoz2nnfd)M_~Sc0 zAKNlS-42egLPd&dg9%M;dx>dQ1CBu?VO=)F*P|lXmDDf#FkHF(%NDNJ0~pIP-QVu1 z#FE!>`mhwW^RiPC5ZoBoSqrgd227i*O(u1-7$ zrFH7PXNlI7tbuvd4&Xeg)vXImMNP(~>7X4wv!5gwA$Rq+EjYqfQeGqoI&SLI3qkY7;soY^uV1iulZ?JB?23vhmP>7eJ5w$*y|^0Nd2YT@{CW^rn+RvEuiHH`X}@m!aIvX*TD!h+E48ETpG*EXkfgVMz!OW zD+m1V$z$p&(tpiG3IWRI6}jh_1+e{QYo7B8>A$R5;?tu~I>e8W7RM{pa`V6`=sua7 z(w^qW_PiTRqg;`Tyxx*B>^#^%#fn0AoB`{%RScVsuJ!*KImFQxxTzH{Hhgxl^jM)P zX1|~5DY*XcH_PAKs8OybHF8I3#W}YHHfWXH&VtkDq@`K7qZBiZ1~;sKAw+)1!3pxd z9$vJo7BCGJ zJx{ytTcO-8l%YNwD|_J3E~GwhHi(_vy|bsYM_ToHAII&{t{iKXx3>fiEAku(uq)n_O`4wYsdaj*wR8N0_G<{ySUjUS-= z?V%6!V?nPB8Is0y(?-`;AYyquc|_xoUYOVjdoYJkK_q$Onlj zlAm<-i-7=@m`4cR<|HB3xF2YKxgXFl(#C2NvjxW?TC(6x$(WTp-vIKP;o)~ciY(Z{ z%csHnfdQAh0j5txod&@C&A*b@5rSJ$iJl_4KBYEXj7Kc3y;sb2lFr@c27CXYRNtEf zD57CyYwf~3u2$zVhVbIUnwTjzz~7-O+d*m@=g0F3PH?s&cYpdC9@xzS3T*dXX(L?7W^(0irYxVt=owM~X;iQOyw3iJW3)61XlES97ykli z%_eu-|29_b>)T;#1xKHLP5sTYlD=_ISgNyIBl4%TES+|9Y^yq z7xq7~0DIJkB8D*4@AlVDxiyk69J~Y{yTk?3S(g?A+X6Kz++a=5HcYXR`zuaW@%#Pf zAs;xh?#{6X@`*tRa)pBgLnvI?Eo1k_)rq<%V9jz~fd$gKC=*^4Tu9)hHv$)^cno8$ ztO<2>iQ5!vnc0OP&I*`)eF3};hNj);a;f!#v1Yy;;+#(?9R7Z8=q!{t>DcD|;)lem zA-yYr_S4+`CBmxts5%Jxjx?NRmoF!q(4^07H}`A)EdSG)YC+D+3%J(y*j>GEN+Q} zg3ga4pvdrN>B(g?=0MyRx%Vu-FP58Stpi^Q45hexGR9LpEFam13?gk1^jD9yTcH~w zffD%iBy0bj1v%1{`MxxXHDNGWayj{cu364d_NSNF%Lec`bO#;q?nSGnV z9;F=v{b(iEuUFq$+P}5%c*$H*3C8p}qHAaNlH++O%(kDj^NLLT{@k1Pxprhwu)K^9 zQ|w~gEo%rOt~{z z=U`I%87R?(x+|d|VAB(fSa>C1F?B4skO)zK!^tr+TRR$8Je^td`jqQ47Z6Lqpl9n|Kl$J{?3G{ z<2;nOIUd7ZDf}I7=an#S@;hylG%btBzG3Ge3OV0TrX!HLB9m|W*rPX-_}|GxDk=S zF#K)Je{FTpKhY6TsLzz|rR8{8P(19;pHUNge5(yz)53nMwyA}~0c%NT z!)Pcf+MWDY8-{aDaot1Q!_*|SA~~6V^y6%#VYCsrSc>p(!Ai> zLDj^)UIp7?mB@3f;4N@r0QzXjQ;>eM83# ztlDH6E`QYSQdRHt{6K(TK|eGE=T{x{m+@Tw;IxN@8Y=!DjjN2a-?K{g6mCiy37-G6 zvjzga-kN-EG5zdbZmfO0Qu72BB|SR3No9Bk{IC^;2!J67ItRj~DS;O-Ub%0lMO-jt z@P3bc0O(!EG0d&Jk9!0Wexiwj$P@#MKb$a^k^6^Ff2~g3@AtOX*BkA5V15)6JN#u{ zVOH$#*2db*z7-%f3P!BF{_^5w&ePy03eg{_WO$q4eU7*OY>30vf%jMPIJU0fd|_?g zc9IGHJ(F#LGmd}tb!azPwpz;NUFg{sc!G3R`2GfE?_%Ki7I!=v{6_)5L#NjR?Z$o^ zT&YJ*@5s$->Uj71Ts=zJ4Sr4B_CKtaa-VrvSbJT;dH!-Y4L(?hqY|yRJb`zv2*P+& z?+JcJ_9}5V3?8f1xqg{hkDg?(zGr^6R!;+a#otHM-@X5G^fL+8^rb$Cd|I(zs_9_To?Q0Tip5-(*UI+qq8w!)IDF0NvZ%9>mU=-9_blc>B-@ zaaIfU1+Z_M=iWzLb}jg@n65J-b(sdd+xLtj&i#0N0cUD#e$kIOZY6Zm#wknwx|#AG z?;gdfM#l{~Q7HCk|I98Euh-7Z_L|wC^C#mccg@fNrfU9%X{&laP)^rR*5kfzURvQ1 zMbAB0(QwK{uzIjGeaw22BIq5x`NkhH_oTCH=@Dd0?$2=t?qoC3t8(0j!y>}$eL{8-yJ95iYI4$zC7Kq z_m@%i4%P271L9$)-U^ zpjCH>ZE(x=utkYTFsYY22}RdRPH)ViG(DoYc#`Jxj@UC{c|(Z%QJ5*arGfve@%uXg zWn&5_-M8}UZ&!&zy4KvcH-Q~_p11qXia_5$0i-WXiZcijd!EJKLkE|4+xLeU)=$?Z z?xy@0x#S3ItNGA3@N?{4I&JSP9WyG?4=%RbZtLuOno2V8W<*hGqY0l`Jsn+8UGY{vQjTuQ|c7!G{?{e zcL80mL6K?=nMgt|qnl+00kNIyhzVB`>VmuR2O&Oz>9iYC?m8cYe9BE2Jmk=rPq(bg zOk!PoL|LPXTZyR6pFK(zrdr|`s zMt#lQnudrr;+>O}Pqrrw)R26YTr~t4qSS_46=-^GJ~+pIg$a|EwGKeR@aKBYqD5N7e6f=#yM&ppDhxWEzU0vr2p9C z|7rYYvd!}ibVH$gc-{bzVAA135_x|PUHi`JJ!@ZwSLKT_>)Va6e!+OGNO*R;$v?V| zwFG5ja}TX0A~IqfajZ)dXLQ{kL0^^*H~i8_t$j6f%7>m@L%rtc?=6!p{!lI}*J9}K z{VCA{DT8)d+3Pk|8#Sek%Z#_1fx`zT)*SE3~FWh~B+BjPb`P6LbHPs(M%Mo(jRzLm1MKTbPuKZ%6(44#M`I7`7anzF) zkK7)0;bv#KvnDc|ze#&8B(+=B*yYG?|4zPVoli#AG*!-V@LHa9J%h``-+n79-3@EUj~^|Uk1ZYCr>H4kX^x!w{9QLv&-+$tZoZV@uAx)qD(9Pa z_xQ!}9zzcLpyqP2K`;7s(_Jwqs*R~lQ;N->){h`A%$r}U0i9#;;e~;?(QCA!*~_kh zJSBwVndt_PX}hmpXt%;?H4a$vRlO&mWj^|1b(FxISZW!&?_YWmP6Y%RXAne-T&wnC z>`!2iT=Iyp?rLt3>X5xVE-yIyQxfrlJ)*6me!=ff%>FO4v<@qHww0JDh3~95GZk6WPtzN^ z%)(-i2r1Bo#S%cLQZl87Sp#pJuyoqKmz90wvH4Xw!t18+PhH@l>YZL7C zJF4x~G?kAPyHOnfBi=4Axe@YXl4u3x#Iu!XI{C4t_)z(Wp@42F?EQ)HuYB{YMo*sICeveMP_WZc#YHH4WR?cLkcZ;_LG$R&e)p!rCO0Z@2ic$tyWY^ zfi*n@j?XS5$23pm%(y}CFeE!&p6HkjZ(Vi}-jz@+>V2501fB#-UEd%-|5suYSMI^z zj}auTF}! zf}AO3d0S{V`%`JKN;#vIYB)93x-OA_=}JJ$FX`GJ2QGj9;g)2+6&vF>-pd4eqWSC7f;2GjU<2#Z?9IpJ?&tnoJQ-S<&ninppPAU861$ z`%|Hk>4Rpom_v}AR{0h-Ug&7q*gN>gSV2C4q|BZo>NYUiH}OjWOkoI;m`u?JFPJRw z!^&v@hysWo2ktXK7g)Ru0w`z)`0B0ZzzqdBkQTUagT_L^jqw^R(^p`UDe-3J7hYOl z;HX)_z>a1HbGlAJrue_i{#T9vR~w}B1AfcO!+g2I@kZdjF43>aA)<1cB+coR!d=3pKqb!@pdsu^v^MR9y1qukV;d3V~@n%CC)`FpF84|lp4`56v*BF zXgi5oXcL7+|E5+xJr&smV{$E$%s%BT%RWNA|NJ?p;JPQt3l$Dqct$fN(h+LEsqLsT z(Ul2~gZS_OY2Y6vafpcd7<)=<4Ng>+wKyK@4a~vM?;gk>C_j}!b=FgR z&`%a*cBmkJ6YJrQO{GE1ot13%e5%5Y^=-xTPYr{-DCAfWnYSx4B+ZAOz=^q9cF0^66{}`xz9_3&M=?q1 zZIc&?Kh?3B1fPt*C?sg;1?tz><+-L#HC_sw>6-jfm30#QAHq+#6ZOem2Vly(Ho8Z z&xG7r$rgUt@JfB2YR%RB3Yt|ETIIVwU0Eifm7; zTq~d>Yx;E$PTrsQ#xGMh+8-D-Q<8j%Uc{(nO_ymQTDSlDd-DghM+1Q`rA=SW#yD*) zZFR@ZT3rH;+K;Jx{b=81;Hd4c2xP~D$CS?u>zrm|#di)1ZN-fyMv_A3VNR;}%HSMv z5X5Re$RkZGeVu%--av5W=JP|~*(6n(Xi!{8+xK-Zd5UZeM4S;6kmy3 z-FUz!_8vHOBEu~?U{~xn7{;>9cb^zMP8}*->Vew?LOy(cKprkPw8lL+eY;F?c{@WsGHMfp^|Evkz08kv4sNJw0RkT%>=Xj;l5 zuBv%^?tQnpaJ%12jZKd1NuS}Tz(cHDxE9OAgNDL%heigGk;oOohZT3+X!f|bdyVH6 zX#8&x4Tu%*cG2#Nb)A{KQ<%QQpcajP2X28k<(YD`(Q1l0&p~IE-w2xhryrlBsAwj* zi_1sBuzjnfeTnqp6+h4?p~)%VXF@XPHHF4gcox`HaTIV;tYHFaI};f2&$>5a4fq9ga7mfmI=9)ZRhlR(H#tnu3l@8}0vwYno}k95L> z8(^0mOkayNvqTXHH{Q3dNOt-|oHgD=yPyRB0Ka(S$L9Vx=>W>T3OzscG zN$+X%Z>2Zht(m>PT0T%q(}75GPU~SFH0%4h?&<|7j(f#)x$t=jG#{)zT1{TY6sl)C ze%QGAojDVQjG$-}Fq`bNfj{m`$*SMmbR3i;*n#QvD-7ma)j;y9$D`$uhlev2jYkVZ zeS=s($r056ktL*|SHX|U?D{FYm8w8*^tDv3>6&M1+)>smFc4$8syC`3IBT;xl@CfL z-@bJwu8?`$kR{JK?beNM%9c>U2xgJlnkd(Cec60z;aPnp3oie{cM64bsv5@yE+6Qg z9F?YE8*8(lct07-d`uCudFyt4vSrOZa}MmRuZ2cLwmhdj)zUHZ2_!Q!29p~2T@`lZ?S+P&bD_DM5=DgMfTBbd#}K`@n? zxi?Yq){Q)kddD~}N5#LLZC-EFS;Voy_o*i-ZN0sHa7-i5|As9ir)gYdMl>mm&L};l zuV3>FGuAxDp&kAw_Sx&AP>Chrm$ywGVyYiKQk-MP{ZAm42^H`gR$$U@O09Ia_liUO zlNEj+oY`>1AM!(OhzdJy;uFiFcWhayW17ZZK7-Haao+gn|GyarEp8e5@60*Y-2Hod z#P&LYC{~tw_qI$w|GzQl!*S|8RHw-%Gb>0$`d0Dkd3~8bt(u5G&U(cC-Zni~KK>0WLVk}s?}mSq6s%PbhjRw8HoqJ_ zUsINvmPQ!V-^GaZSzIe$W$!J~5Ns0^1hp^MZ2iGRn) zioYVqJqTD0kS^mr%c83ghjM)u2;4sfgGvwzIYxdlf{u8o>_{&6A4PrKl_IwBZKe-4@pD#}H zl2qGZpRRG-)mwFWN#fQNhdU`6_lnw_tee)UdG@sANeBNV=i%*(i8LR>|0oN*K>w~W z^zqIAf^oJb;kvzqJBhc^@dJU<2%#AojRdR~ALJw-`w_ds@ehTpg1cDoQKNN*FSGnYRWs?}U~ zFURVH&#^9ZoF;=eZgs^D|ChrZdjwLZJ>ULK692QOshWN?#7@4T_5~Pcge*RRe06o^ z$;`spppjw@Uw2Qy2Gqq6qBHzP2bMxpJcYwS7I{jUQ&XcyGG`IYeX-_d0?b7oftRdV zBU<*rG-ulepTC}^uk&;Bu*6H2W1_kvOVm^WibPLS+cKnP4f}IIhrf0$K`y*65T8A- z0=e*G#;DW$d0&ETt#%zZ`O`=VdXP3UQrwxMvMR49V18!_*wiBL?<{d&=$HdS2S?CV zgz$W^RfIaXE_WqhIO$(7fDsb`o$NUwX7tAF-#`lgdoj1KZtsO450G6OxN-BUke>Y2 zaxakeZjAD)mHkF*3bNs2Tjz^yK1^jWr#}K2{PRfdon;b`4*+#CZ#IM<^L$^*3#lT4 zaw^kJa9qBX=2;V=pxDPslkroWeT(&T0@gIsNNidx@33FA1MDrFfG$-CU*o_vmkU8E zbMFS_qG5LEko#~qP;>F1kbopv+Aja~Y2k#l%ns&U7lWg?eyA246!mmI5LzW)?@GgR z7tuh1HLuk$Z8y9%{wunO_0Q#ZMD$$5S8}AKp$<8=ySS`J#?&^?+Ejd~gU`I{rL6b+ zxOZlc5UT6-wxCHL`)*z+eH3U85Pr*Tcgd|4TaOS!KgV8gCA7jON%IToL61$OThCt4 zPhV>TLV3|U;ZTqZ^`7N?0&mu{@F~!ZX4>wRAH*V-+1Zsv=cuwDJBb70#C+$P`s` z0xN)LhfNjeQ{sHPK#{$2hl)}8EYjtJTPAG2d|tr-qWq$<4KJ9uU8lQu zH{Ab{-`o9u120e%^+Rh_8Ab7v*MVg3-YT>EqMSD**fF?vuojpt)7r7=wRSW4(@G#E zD3&Vw^aSIN=J$HpgeFVPiO^HfI?q04Fw8xS9-L0@VkT7($H1aoU_CfhZ_*;n5z&kB zgJUn6@BKq`F*qS)xqHZdboX-`Acug+`BW)q*=9S? z75y!A_%?Nq1e?$<-8RnTIyeqz;aPFJK^Jv2%mq%idK=2r17<-Yj+YG~rC68lgT!&Y z1*d5a)%7e}P9$i*y@b@a5vFE`8*t&lA|+CXuE-cqAGv-|**yC@XybyU3i0X=%iN{D z%%YqC`E3*x70e61S`TFTUUr?`*MUdq!K@fjxM-K@H>^i~t zB_O9Aj_fvOna>&fIOpFHpf0hD`vQV9EBoUZ#K%5u9c%BS%2O`mjv_D4Zo_S5CQOm! z1yA4D514byDbA`o7o;AL_3>EWM`bjumPU(i($6a8gU@ICY5Qz~2S6@b} zcP$`J(st^9gY9bs-n#RBP=a+w_9oMZ&8s>$9h@K^Qy33VO&)gW?L4kVn;%0+Z=Fzz zVi{Kp{w-A{2EpzJzLmZid@tT(sM^788&{u)fD?pQwA?_km>sFFvnU-$*7RSUU(a*3sNE)pyACo$lDmU8=}ZMQa<+c z41c2jJnil6ovcVQi2ERVtlVT5HH&C0*v&f956qh|bLO0Wjn$dQ&2&(1o`s^#_*cKz zoO{2Oa(t2_PL1Gn0{rjReL5GR47v#g#uX0 zu{rn2F0m;1W{kBWo*vAKAQ61WqG*H9@QNRpr9XXtp@mDK34D#DXj6sXLPV4=Put?B zZBYKCXd@;LtrsfT(Haz#bx~Z8It7X0yQ4rAJm?mM#GH0y48|7EU+%^8X^?=bySB1O ztyQi7esXhoVL<2)v^&4z`{e;Wl9Xe}W*Og;P9J0}e!wqqvQEx=O56Vt1V}Z3!dgX} z>{F4n&R__GI`29|SGDUefPt66mlLcNR186TV7;M>cx4%>l0o>M?$e&riCzbLMA2sE z6#n};zHR<*8yE^j8)mYm!!KM4klg9$KK$+n74Z1cjATvUPJ2KVIwihtVurLd`bKq^ z>v>R5TY3dj#;+yF2p!Uc8To*xfTE2k^c`ps`u>Rqx`mHV0r~M2=Ruv1pgN$lCSXyZ z=&Bj$aSTgPIUZ@mou~;6um<0AgcP-%G6&WF0IqWir#^+hJB8bVZx~oBCQkJiays$k zf7{?D^Kc9T!YP8A2a4Xy z$Da#G;>@#wxSJh;n6i$Al7?tz=ldv zd#6?W!2b>&0KTY%s0=j+EzaUOgE~`4Wf4-P28z$auPA$HfrZ)>eiCAMMB}?@Y$E+j zYwym6orgXE(Nua$&iI*eO;Y2PJq*ry=OX9Y6FL}9Te(2&Vjcq&^BBOKoc99PY&^s3 qZcgV5=qeXdT+~Dn4FusR4&kpEw7qWHfKPTI)!Q1kN|YWv|Nj6B9;0Re literal 0 HcmV?d00001 diff --git a/apps/management-website/scripts/reset-password.js b/apps/management-website/scripts/reset-password.js index a5d8b36..e695793 100644 --- a/apps/management-website/scripts/reset-password.js +++ b/apps/management-website/scripts/reset-password.js @@ -1,5 +1,6 @@ import { db } from "../server/src/db/index.js"; import { hashPassword } from "../server/src/utils/security.js"; +import { toChinaISOString } from "../server/src/utils/china-time.js"; const [, , username, password] = process.argv; @@ -12,7 +13,7 @@ const result = db.prepare(` UPDATE users SET password_hash = ?, must_change_password = 1, updated_at = ? WHERE username = ? AND role = 'admin' -`).run(hashPassword(password), new Date().toISOString(), username); +`).run(hashPassword(password), toChinaISOString(), username); if (!result.changes) { console.error(`Admin user not found: ${username}`); diff --git a/apps/management-website/server/index.js b/apps/management-website/server/index.js index 8e79dc7..52ebfe4 100644 --- a/apps/management-website/server/index.js +++ b/apps/management-website/server/index.js @@ -1,21 +1,29 @@ import { createServerApp } from "./src/app.js"; import { startBackgroundServices } from "./src/background/index.js"; +import { createLogger, errorDetails } from "./src/utils/logger.js"; + +const logger = createLogger("management-web"); process.on("unhandledRejection", (error) => { - console.error("Unhandled promise rejection", error); + logger.error({ error: errorDetails(error) }, "Unhandled promise rejection"); }); const { app, env, services } = createServerApp(); const background = startBackgroundServices({ env, services }); const server = app.listen(env.port, () => { - console.log(`AIOS management console listening on http://localhost:${env.port}`); + logger.info({ + port: env.port, + url: `http://localhost:${env.port}` + }, "AIOS management console started"); }); for (const signal of ["SIGINT", "SIGTERM"]) { process.on(signal, async () => { + logger.info({ signal }, "AIOS management console stopping"); server.close(); await background.stop(); + logger.info({ signal }, "AIOS management console stopped"); process.exit(0); }); } diff --git a/apps/management-website/server/src/app.js b/apps/management-website/server/src/app.js index 6c9cb01..56f1f6e 100644 --- a/apps/management-website/server/src/app.js +++ b/apps/management-website/server/src/app.js @@ -19,6 +19,9 @@ import { PortalService } from "./services/portal-service.js"; import { SystemService } from "./services/system-service.js"; import { TopicPingService } from "./services/topic-ping-service.js"; import { AppError } from "./utils/errors.js"; +import { createLogger, errorDetails } from "./utils/logger.js"; + +const logger = createLogger("management-web-app"); export function createServerApp() { const env = loadEnv(); @@ -80,7 +83,7 @@ export function createServerApp() { return; } - console.error(error); + logger.error({ error: errorDetails(error) }, "HTTP request failed"); res.status(500).json({ code: "internal_error", message: error?.message || "服务器内部错误" diff --git a/apps/management-website/server/src/background/index.js b/apps/management-website/server/src/background/index.js index fcd211e..51cab67 100644 --- a/apps/management-website/server/src/background/index.js +++ b/apps/management-website/server/src/background/index.js @@ -1,6 +1,10 @@ const DEFAULT_STARTUP_SYNC_READY_TIMEOUT_MS = 240000; const DEFAULT_STARTUP_SYNC_PING_TIMEOUT_MS = 1000; -const DEFAULT_STARTUP_SYNC_POLL_INTERVAL_MS = 1000; +const DEFAULT_STARTUP_SYNC_POLL_INTERVAL_MS = 10000; + +import { createLogger, errorDetails } from "../utils/logger.js"; + +const logger = createLogger("management-web-background"); function normalizePositiveNumber(value, fallback) { const parsed = Number(value); @@ -26,6 +30,7 @@ export async function waitForManagementRpcReady({ pollIntervalMs = DEFAULT_STARTUP_SYNC_POLL_INTERVAL_MS }) { if (typeof services.rpcClient.isConfigured === "function" && !services.rpcClient.isConfigured()) { + logger.info("Management RPC is not configured; startup readiness check skipped"); return; } @@ -39,12 +44,19 @@ export async function waitForManagementRpcReady({ try { const result = await services.rpcClient.call("service.ping", {}, readyPingTimeoutMs); if (result?.pong === true) { + logger.info({ + elapsedMs: Date.now() - startedAt + }, "Management RPC readiness check succeeded"); return; } throw new Error("Management RPC ping returned an invalid response"); } catch (error) { lastError = error; + logger.warn({ + elapsedMs: Date.now() - startedAt, + error: errorDetails(error) + }, "Management RPC readiness check attempt failed"); } const remainingMs = readyTimeoutMs - (Date.now() - startedAt); @@ -75,9 +87,11 @@ export function startBackgroundServices({ env, services }) { catalogTimer = setTimeout(async () => { catalogTimer = null; try { + logger.info({ trigger: "scheduled" }, "Scheduled catalog sync starting"); await services.catalogSyncService.syncCatalogTask({ trigger: "scheduled" }); + logger.info({ trigger: "scheduled" }, "Scheduled catalog sync finished"); } catch (error) { - console.error("Scheduled catalog sync failed", error); + logger.error({ error: errorDetails(error) }, "Scheduled catalog sync failed"); } finally { scheduleCatalogSync(scheduleDelayMs); } @@ -89,16 +103,22 @@ export function startBackgroundServices({ env, services }) { usageTimer = setTimeout(async () => { usageTimer = null; try { + logger.info({ trigger: "scheduled" }, "Scheduled agent usage sync starting"); await services.catalogSyncService.refreshAgentUsage({ trigger: "scheduled" }); + logger.info({ trigger: "scheduled" }, "Scheduled agent usage sync finished"); } catch (error) { - console.error("Scheduled agent usage sync failed", error); + logger.error({ error: errorDetails(error) }, "Scheduled agent usage sync failed"); } finally { scheduleUsageSync(scheduleDelayMs); } }, delayMs); } - const rpcStarter = (async () => services.rpcClient.start())(); + const rpcStarter = (async () => { + logger.info("Management RPC client starting"); + await services.rpcClient.start(); + logger.info("Management RPC client started"); + })(); function markStartupSyncFailed(error) { services.catalogSyncService.markStartupSyncFailed?.( @@ -116,30 +136,34 @@ export function startBackgroundServices({ env, services }) { pollIntervalMs: env?.startupSyncPollIntervalMs }); } catch (error) { - console.error("Management RPC readiness check failed", error); + logger.error({ error: errorDetails(error) }, "Management RPC readiness check failed"); markStartupSyncFailed(error); return; } try { + logger.info({ trigger: "startup" }, "Startup version sync starting"); await services.portalService.refreshVersionInfoCache?.({ trigger: "startup" }); + logger.info({ trigger: "startup" }, "Startup version sync finished"); } catch (error) { - console.error("Startup version sync failed", error); + logger.error({ error: errorDetails(error) }, "Startup version sync failed"); } try { + logger.info({ trigger: "startup" }, "Startup catalog sync starting"); const result = await services.catalogSyncService.syncStartupCatalogTask({ trigger: "startup" }); + logger.info({ trigger: "startup", status: result?.status }, "Startup catalog sync finished"); if (result?.status === "success") { scheduleUsageSync(usageStartupDelayMs); } } catch (error) { - console.error("Startup catalog sync failed", error); + logger.error({ error: errorDetails(error) }, "Startup catalog sync failed"); } finally { scheduleCatalogSync(scheduleDelayMs); } }) .catch((error) => { - console.error("Background service failed to start", error); + logger.error({ error: errorDetails(error) }, "Background service failed to start"); markStartupSyncFailed(error); }); diff --git a/apps/management-website/server/src/config/env.js b/apps/management-website/server/src/config/env.js index 8d2d168..66246ce 100644 --- a/apps/management-website/server/src/config/env.js +++ b/apps/management-website/server/src/config/env.js @@ -62,7 +62,7 @@ export function loadEnv() { 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), + startupSyncPollIntervalMs: readEnvNumber("AIOS_STARTUP_SYNC_POLL_INTERVAL_MS", 10000), mqtt: { brokerUrl: readEnvValue("AIOS_MQTT_CHANNEL_BROKER"), username: readEnvValue("AIOS_MQTT_CHANNEL_USERNAME"), diff --git a/apps/management-website/server/src/db/index.js b/apps/management-website/server/src/db/index.js index 1d2cbb4..632fa35 100644 --- a/apps/management-website/server/src/db/index.js +++ b/apps/management-website/server/src/db/index.js @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import crypto from "node:crypto"; +import { toChinaISOString } from "../utils/china-time.js"; import { loadEnv } from "../config/env.js"; import { hashPassword } from "../utils/security.js"; @@ -560,7 +561,7 @@ function seedStateRow(tableName, now) { } function seed() { - const now = new Date().toISOString(); + const now = toChinaISOString(); db.prepare(` INSERT INTO settings ( @@ -611,7 +612,7 @@ function seed() { } function ensureCoreRows() { - const now = new Date().toISOString(); + const now = toChinaISOString(); db.prepare(` INSERT INTO settings ( diff --git a/apps/management-website/server/src/infra/mqtt/management-rpc-client.js b/apps/management-website/server/src/infra/mqtt/management-rpc-client.js index 1da3b10..b01ba65 100644 --- a/apps/management-website/server/src/infra/mqtt/management-rpc-client.js +++ b/apps/management-website/server/src/infra/mqtt/management-rpc-client.js @@ -1,9 +1,13 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; +import { toChinaISOString } from "../../utils/china-time.js"; import mqtt from "mqtt"; import { serviceUnavailable } from "../../utils/errors.js"; +import { createLogger, errorDetails } from "../../utils/logger.js"; + +const logger = createLogger("management-rpc-client"); export class ManagementRpcClient extends EventEmitter { constructor({ env, db, mqttFactory = mqtt }) { @@ -25,14 +29,21 @@ export class ManagementRpcClient extends EventEmitter { async start() { if (!this.isConfigured()) { + logger.info("Management RPC client is not configured; startup skipped"); return; } if (this.client) { + logger.info("Management RPC client already started"); return; } const connect = this.mqttFactory.connect || this.mqttFactory; + logger.info({ + brokerUrl: this.env.mqtt.brokerUrl, + inboundTopic: this.env.mqtt.adminInboundTopic, + outboundTopic: this.env.mqtt.adminOutboundTopic + }, "Management RPC MQTT connecting"); this.client = connect(this.env.mqtt.brokerUrl, { clientId: `aios-web-admin-${randomUUID().slice(0, 8)}`, username: this.env.mqtt.username || undefined, @@ -49,11 +60,13 @@ export class ManagementRpcClient extends EventEmitter { const onConnect = () => { cleanup(); + logger.info("Management RPC MQTT connected"); resolve(); }; const onError = (error) => { cleanup(); + logger.error({ error: errorDetails(error) }, "Management RPC MQTT connection failed"); reject(error); }; @@ -64,10 +77,12 @@ export class ManagementRpcClient extends EventEmitter { await new Promise((resolve, reject) => { this.client.subscribe(this.env.mqtt.adminOutboundTopic, { qos: 1 }, (error) => { if (error) { + logger.error({ error: errorDetails(error), topic: this.env.mqtt.adminOutboundTopic }, "Management RPC MQTT subscribe failed"); reject(error); return; } + logger.info({ topic: this.env.mqtt.adminOutboundTopic }, "Management RPC MQTT subscribed"); resolve(); }); }); @@ -104,7 +119,7 @@ export class ManagementRpcClient extends EventEmitter { serviceUnavailable(message.error?.message || "Management CLI returned an error", message.error) ); } catch (error) { - console.error("Failed to process management response", error); + logger.error({ error: errorDetails(error) }, "Failed to process management response"); } }); } @@ -124,6 +139,7 @@ export class ManagementRpcClient extends EventEmitter { await new Promise((resolve) => { this.client.end(false, {}, () => resolve()); }); + logger.info("Management RPC MQTT stopped"); this.client = null; } @@ -132,7 +148,7 @@ export class ManagementRpcClient extends EventEmitter { INSERT INTO management_requests ( request_id, action, params_json, created_at ) VALUES (?, ?, ?, ?) - `).run(requestId, action, JSON.stringify(params ?? {}), new Date().toISOString()); + `).run(requestId, action, JSON.stringify(params ?? {}), toChinaISOString()); } finishLog(response) { @@ -144,7 +160,7 @@ export class ManagementRpcClient extends EventEmitter { response.ok ? 1 : 0, response.result === undefined ? null : JSON.stringify(response.result), response.error === undefined ? null : JSON.stringify(response.error), - new Date().toISOString(), + toChinaISOString(), response.requestId ); } @@ -156,7 +172,7 @@ export class ManagementRpcClient extends EventEmitter { WHERE request_id = ? `).run( JSON.stringify(error ?? { message: "Management RPC failed" }), - new Date().toISOString(), + toChinaISOString(), requestId ); } diff --git a/apps/management-website/server/src/infra/s3/object-storage.js b/apps/management-website/server/src/infra/s3/object-storage.js index 7ccfd86..76511e6 100644 --- a/apps/management-website/server/src/infra/s3/object-storage.js +++ b/apps/management-website/server/src/infra/s3/object-storage.js @@ -1,6 +1,7 @@ import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; +import { chinaDateKey } from "../../utils/china-time.js"; import { serviceUnavailable } from "../../utils/errors.js"; @@ -68,7 +69,7 @@ export class ObjectStorage { } async uploadAdminArtifact({ kind, file }) { - const objectKey = `${kind}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}-${sanitizeFileName(file.originalname)}`; + const objectKey = `${kind}/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(file.originalname)}`; await this.getClient().send( new PutObjectCommand({ Bucket: this.env.s3.adminInboxBucket, diff --git a/apps/management-website/server/src/services/agent-service.js b/apps/management-website/server/src/services/agent-service.js index 58261d1..5e9685b 100644 --- a/apps/management-website/server/src/services/agent-service.js +++ b/apps/management-website/server/src/services/agent-service.js @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { toChinaISOString, chinaDateKey } from "../utils/china-time.js"; import { jsonParse, jsonStringify } from "../db/index.js"; import { badRequest, conflict, notFound, serviceUnavailable } from "../utils/errors.js"; @@ -55,7 +56,7 @@ function sanitizeFileName(fileName) { } function buildWorkspaceExportObjectKey(agentSlug) { - return `agent-workspace/${new Date().toISOString().slice(0, 10)}/${randomUUID()}-${sanitizeFileName(agentSlug)}.zip`; + return `agent-workspace/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(agentSlug)}.zip`; } function buildModelRef(providerId, modelId) { @@ -320,7 +321,7 @@ export class AgentService { UPDATE agents SET agent_name = ?, status = ?, remote_state_json = ?, updated_at = ? WHERE id = ? - `).run(nextAgentName, nextStatus, nextRemoteStateJson, new Date().toISOString(), row.id); + `).run(nextAgentName, nextStatus, nextRemoteStateJson, toChinaISOString(), row.id); return true; } @@ -464,7 +465,7 @@ export class AgentService { ON CONFLICT(username) DO UPDATE SET updated_at = excluded.updated_at `); - const now = new Date().toISOString(); + const now = toChinaISOString(); for (const username of usernames) { insert.run(username, now, now); } @@ -692,7 +693,7 @@ export class AgentService { let agentId; try { agentId = runInTransaction(this.db, () => { - const now = new Date().toISOString(); + const now = toChinaISOString(); const existingAgent = this.db.prepare("SELECT * FROM agents WHERE slug = ?").get(slug); const localRemoteState = this.buildLocalRemoteState(slug, agentName, templateName); const remoteState = existingAgent @@ -872,7 +873,7 @@ export class AgentService { llmSelection.providerId, llmSelection.modelId, llmSelection.modelRef, - new Date().toISOString(), + toChinaISOString(), agentId ); diff --git a/apps/management-website/server/src/services/audit-log-service.js b/apps/management-website/server/src/services/audit-log-service.js index ac47ede..1329827 100644 --- a/apps/management-website/server/src/services/audit-log-service.js +++ b/apps/management-website/server/src/services/audit-log-service.js @@ -1,3 +1,4 @@ +import { toChinaISOString } from "../utils/china-time.js"; export class AuditLogService { constructor({ db }) { this.db = db; @@ -13,7 +14,7 @@ export class AuditLogService { username || "", action, detail || "", - new Date().toISOString() + toChinaISOString() ); } diff --git a/apps/management-website/server/src/services/auth-service.js b/apps/management-website/server/src/services/auth-service.js index f9c706a..10450bf 100644 --- a/apps/management-website/server/src/services/auth-service.js +++ b/apps/management-website/server/src/services/auth-service.js @@ -1,6 +1,7 @@ import { jsonParse } from "../db/index.js"; import { badRequest, conflict, forbidden, notFound, unauthorized } from "../utils/errors.js"; import { hashPassword, newToken, verifyPassword } from "../utils/security.js"; +import { toChinaISOString } from "../utils/china-time.js"; const ADMIN_ROLE = "aios-admin"; @@ -58,12 +59,12 @@ export class AuthService { this.db.prepare(` INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?) - `).run(token, userId, expiresAt.toISOString(), now.toISOString()); + `).run(token, userId, toChinaISOString(expiresAt), toChinaISOString(now)); return token; } cleanupExpiredSessions() { - this.db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(new Date().toISOString()); + this.db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(toChinaISOString()); } login({ username, password }) { @@ -91,7 +92,7 @@ export class AuthService { FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at >= ? - `).get(token, new Date().toISOString()); + `).get(token, toChinaISOString()); if (!row) { throw unauthorized(); @@ -128,7 +129,7 @@ export class AuthService { UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = ? WHERE id = ? - `).run(hashPassword(nextPassword), new Date().toISOString(), userId); + `).run(hashPassword(nextPassword), toChinaISOString(), userId); return { ok: true }; } @@ -146,7 +147,7 @@ export class AuthService { UPDATE users SET password_hash = ?, must_change_password = 1, updated_at = ? WHERE id = ? - `).run(hashPassword(nextPassword), new Date().toISOString(), targetUserId); + `).run(hashPassword(nextPassword), toChinaISOString(), targetUserId); return { ok: true }; } 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 a5f8fc1..fe6c7aa 100644 --- a/apps/management-website/server/src/services/catalog-sync-service.js +++ b/apps/management-website/server/src/services/catalog-sync-service.js @@ -1,4 +1,5 @@ import { jsonParse, withSerializedTransaction } from "../db/index.js"; +import { toChinaISOString } from "../utils/china-time.js"; import { normalizeAgentStatus, normalizeUsage, @@ -118,7 +119,7 @@ function normalizeRemoteModelRef(value) { } function normalizeAgentRemoteState(item) { - const remote = item && typeof item === "object" ? { ...item } : {}; + const remote = item && typeof item === "object" ? item : {}; const topics = remote.topics && typeof remote.topics === "object" ? remote.topics : {}; const inboundTopic = firstText( remote.inboundTopic, @@ -134,15 +135,16 @@ function normalizeAgentRemoteState(item) { topics.outboundTopic, topics["outbound-topic"] ); + const normalized = {}; if (inboundTopic) { - remote.inboundTopic = inboundTopic; + normalized.inboundTopic = inboundTopic; } if (outboundTopic) { - remote.outboundTopic = outboundTopic; + normalized.outboundTopic = outboundTopic; } - return remote; + return normalized; } function listRpcItems(payload, label) { @@ -181,7 +183,7 @@ function normalizeUsageItemPayload(item) { agentId, raw: item, usage: parseUsagePayload(item), - captured_at: new Date().toISOString() + captured_at: toChinaISOString() }; } @@ -248,7 +250,7 @@ export class CatalogSyncService { } markStartupSyncFailed(errorMessage = "Management RPC was not ready") { - const now = new Date().toISOString(); + const now = toChinaISOString(); for (const tableName of STARTUP_SYNC_STATE_TABLES) { this.updateState(tableName, { status: "failed", @@ -301,7 +303,7 @@ export class CatalogSyncService { } async runCatalogSnapshotSync(trigger) { - const startedAt = new Date().toISOString(); + const startedAt = toChinaISOString(); if (!this.rpcClient.isConfigured()) { for (const tableName of STARTUP_SYNC_STATE_TABLES) { @@ -338,7 +340,7 @@ export class CatalogSyncService { try { const remote = await this.callManagement("catalog.snapshot"); - const now = new Date().toISOString(); + const now = toChinaISOString(); const llmItems = readCatalogItems(remote, "llmProviders"); const templateItems = readCatalogItems(remote, "templates"); const agentItems = readCatalogItems(remote, "agents"); @@ -370,7 +372,7 @@ export class CatalogSyncService { this.syncSkills(skillItems, now); this.syncSystems(systemItems, now, builtInOntologyNames(ontologyItems)); - const finishedAt = new Date().toISOString(); + const finishedAt = toChinaISOString(); for (const item of CATALOG_SYNC_STATES) { this.updateState(item.tableName, { status: "success", @@ -399,7 +401,7 @@ export class CatalogSyncService { } }; } catch (error) { - const finishedAt = new Date().toISOString(); + const finishedAt = toChinaISOString(); const errorMessage = error instanceof Error ? error.message : String(error); for (const tableName of STARTUP_SYNC_STATE_TABLES) { this.updateState(tableName, { @@ -449,7 +451,7 @@ export class CatalogSyncService { } async runUsageRefresh(trigger) { - const startedAt = new Date().toISOString(); + const startedAt = toChinaISOString(); if (!this.rpcClient.isConfigured()) { this.updateState("usage_refresh_state", { @@ -497,8 +499,7 @@ export class CatalogSyncService { const remoteItems = agentRows.map((row) => ({ agentId: row.slug })); - const now = new Date(); - const capturedAt = now.toISOString(); + const capturedAt = toChinaISOString(); const updateAgent = this.db.prepare(` UPDATE agents SET usage_snapshot_json = ?, status = ?, updated_at = ? @@ -521,7 +522,7 @@ export class CatalogSyncService { refreshedAgents += 1; } - const finishedAt = new Date().toISOString(); + const finishedAt = toChinaISOString(); this.updateState("usage_refresh_state", { status: "success", triggerSource: trigger, @@ -538,7 +539,7 @@ export class CatalogSyncService { return this.getUsageRefreshStatus(); } catch (error) { - const finishedAt = new Date().toISOString(); + const finishedAt = toChinaISOString(); this.updateState("usage_refresh_state", { status: "failed", triggerSource: trigger, @@ -751,8 +752,8 @@ export class CatalogSyncService { kind: "template", originalName: firstText(item?.path, `${templateName}.remote`) }); - const remoteResultJson = JSON.stringify(item ?? {}); const isBuiltin = isBuiltInItem(item) ? 1 : 0; + const remoteResultJson = JSON.stringify({ builtIn: Boolean(isBuiltin) }); if (existing) { update.run( @@ -804,7 +805,7 @@ export class CatalogSyncService { `); const update = this.db.prepare(` UPDATE skills - SET description = ?, artifact_id = ?, remote_status = ?, is_builtin = ?, updated_at = ? + SET artifact_id = ?, remote_status = ?, is_builtin = ?, updated_at = ? WHERE id = ? `); const remove = this.db.prepare("DELETE FROM skills WHERE id = ?"); @@ -817,13 +818,10 @@ export class CatalogSyncService { seen.add(slug); const existing = existingMap.get(slug); - const origin = item?.origin && typeof item.origin === "object" ? item.origin : {}; - const description = firstText(item?.description, origin.description, existing?.description); const isBuiltin = isBuiltInItem(item) ? 1 : 0; if (existing) { update.run( - description, existing.artifact_id ?? null, "installed", isBuiltin, @@ -835,7 +833,7 @@ export class CatalogSyncService { insert.run( slug, - description, + "", null, "installed", isBuiltin, @@ -851,7 +849,7 @@ export class CatalogSyncService { } } - syncSystems(items, now = new Date().toISOString(), builtInOntologies = new Set()) { + syncSystems(items, now = toChinaISOString(), builtInOntologies = new Set()) { const existingRows = this.db.prepare("SELECT * FROM business_systems").all(); const existingMap = new Map(existingRows.map((row) => [row.application_name, row])); const seen = new Set(); @@ -908,7 +906,7 @@ export class CatalogSyncService { "application/octet-stream", 0, null, - new Date().toISOString() + toChinaISOString() ); return result.lastInsertRowid; @@ -937,7 +935,7 @@ export class CatalogSyncService { updateState(tableName, { status, triggerSource, startedAt, finishedAt, lastSuccessAt, errorMessage, summary }) { const current = this.db.prepare(`SELECT * FROM ${tableName} WHERE id = 1`).get(); - const createdAt = current?.created_at || new Date().toISOString(); + const createdAt = current?.created_at || toChinaISOString(); const nextLastSuccessAt = lastSuccessAt ?? current?.last_success_at ?? null; this.db.prepare(` @@ -963,7 +961,7 @@ export class CatalogSyncService { errorMessage || null, JSON.stringify(summary || {}), createdAt, - new Date().toISOString() + toChinaISOString() ); } } diff --git a/apps/management-website/server/src/services/external-service.js b/apps/management-website/server/src/services/external-service.js index 383da45..63e564f 100644 --- a/apps/management-website/server/src/services/external-service.js +++ b/apps/management-website/server/src/services/external-service.js @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { toChinaISOString } from "../utils/china-time.js"; import { badRequest, internalError, notFound } from "../utils/errors.js"; import { normalizeUsage, statusAfterUsageRefresh } from "./agent-quota.js"; @@ -97,7 +98,7 @@ export class ExternalService { } upsertSessionCookie(sessionId, provider, cookie) { - const now = new Date().toISOString(); + const now = toChinaISOString(); const normalizedCookie = normalizeCookie(cookie); this.db.prepare(` INSERT INTO external_session_cookies ( @@ -179,7 +180,7 @@ export class ExternalService { } const sessionId = `s-${randomUUID()}`; - const now = new Date().toISOString(); + const now = toChinaISOString(); this.db.prepare(` INSERT INTO external_sessions ( session_id, aios_user_id, agent_id, created_at, updated_at diff --git a/apps/management-website/server/src/services/large-language-model-service.js b/apps/management-website/server/src/services/large-language-model-service.js index aaeecbc..2d625dc 100644 --- a/apps/management-website/server/src/services/large-language-model-service.js +++ b/apps/management-website/server/src/services/large-language-model-service.js @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { toChinaISOString } from "../utils/china-time.js"; import { jsonParse, jsonStringify } from "../db/index.js"; import { badRequest, conflict, notFound } from "../utils/errors.js"; @@ -215,7 +216,7 @@ export class LargeLanguageModelService { } upsertProvider(remoteProvider, fallback = {}) { - const now = new Date().toISOString(); + const now = toChinaISOString(); const provider = normalizeRemoteProvider(remoteProvider, fallback); this.db.prepare(` INSERT INTO llm_providers ( @@ -243,7 +244,7 @@ export class LargeLanguageModelService { } upsertModel(remoteModel, fallback = {}) { - const now = new Date().toISOString(); + const now = toChinaISOString(); const model = normalizeRemoteModel(remoteModel, fallback); this.db.prepare(` INSERT INTO llm_models ( @@ -346,7 +347,7 @@ export class LargeLanguageModelService { const normalizedModelId = normalizeModelId(modelId); const provider = this.getProvider(normalizedProviderId); const model = this.getModel(normalizedProviderId, normalizedModelId); - const testedAt = new Date().toISOString(); + const testedAt = toChinaISOString(); const startedAt = Date.now(); const timeoutMs = payload.timeout_ms || payload.timeoutMs || 30000; diff --git a/apps/management-website/server/src/services/portal-service.js b/apps/management-website/server/src/services/portal-service.js index d46dedc..3b3d430 100644 --- a/apps/management-website/server/src/services/portal-service.js +++ b/apps/management-website/server/src/services/portal-service.js @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { toChinaISOString } from "../utils/china-time.js"; import AdmZip from "adm-zip"; @@ -354,7 +355,7 @@ export class PortalService { } writeVersionInfoCache(items, source) { - const now = new Date().toISOString(); + const now = toChinaISOString(); const normalizedItems = Array.isArray(items) ? items.map((item) => normalizeVersionItem(item)).filter((item) => item.name && item.version) : []; @@ -438,7 +439,7 @@ export class PortalService { } createAccessToken() { - const now = new Date().toISOString(); + const now = toChinaISOString(); let token = ""; do { @@ -483,7 +484,7 @@ export class PortalService { const next = { ...current, ...payload, - updated_at: new Date().toISOString() + updated_at: toChinaISOString() }; this.db.prepare(` UPDATE settings @@ -515,7 +516,7 @@ export class PortalService { updateLogo(file) { const logo = normalizeLogoFile(file); - const now = new Date().toISOString(); + const now = toChinaISOString(); this.db.prepare(` UPDATE settings SET logo_mime_type = ?, logo_data = ?, updated_at = ? @@ -525,7 +526,7 @@ export class PortalService { } clearLogo() { - const now = new Date().toISOString(); + const now = toChinaISOString(); this.db.prepare(` UPDATE settings SET logo_mime_type = '', logo_data = NULL, updated_at = ? @@ -582,7 +583,7 @@ export class PortalService { } const passwordHash = payload.role === "aios-admin" ? hashPassword(payload.password || "123456") : ""; - const now = new Date().toISOString(); + const now = toChinaISOString(); const result = this.db.prepare(` INSERT INTO users ( role, username, display_name, status, password_hash, @@ -617,7 +618,7 @@ export class PortalService { ...payload, status: normalizeUserStatus(payload.status ?? current.status), tags_json: jsonStringify(payload.tags ?? jsonParse(current.tags_json)), - updated_at: new Date().toISOString() + updated_at: toChinaISOString() }; this.db.prepare(` UPDATE users @@ -664,7 +665,7 @@ export class PortalService { file.mimetype || "application/octet-stream", file.size, createdBy, - new Date().toISOString() + toChinaISOString() ); return { id: result.lastInsertRowid, @@ -766,7 +767,7 @@ export class PortalService { objectKey: artifact.objectKey, replace: true }); - const now = new Date().toISOString(); + const now = toChinaISOString(); this.db.prepare(` INSERT INTO agent_templates ( template_name, artifact_id, remote_status, remote_result_json, @@ -814,7 +815,7 @@ export class PortalService { defaultSelection.providerId, defaultSelection.modelId, defaultSelection.modelRef, - new Date().toISOString(), + toChinaISOString(), normalizedTemplateName ); @@ -843,10 +844,21 @@ export class PortalService { listSkills() { return this.db.prepare(` SELECT - s.*, - a.original_name AS artifact_name + s.id, + s.slug, + s.artifact_id, + s.remote_status, + s.is_builtin, + s.created_at, + s.updated_at, + a.original_name AS artifact_name, + CASE WHEN s.is_builtin <> 0 THEN NULL ELSE a.created_at END AS uploaded_at, + CASE WHEN s.is_builtin <> 0 THEN NULL ELSE a.created_by END AS uploader_user_id, + CASE WHEN s.is_builtin <> 0 THEN NULL ELSE u.username END AS uploader_username, + CASE WHEN s.is_builtin <> 0 THEN NULL ELSE u.display_name END AS uploader_display_name FROM skills s LEFT JOIN artifacts a ON a.id = s.artifact_id + LEFT JOIN users u ON u.id = a.created_by ORDER BY CASE WHEN lower(s.slug) LIKE 'aios-%' THEN 0 ELSE 1 END, lower(s.slug) ASC @@ -861,23 +873,22 @@ export class PortalService { const slug = normalizedArchive.metadata.slug; const artifact = await this.persistArtifact({ kind: "skill", file: normalizedArchive.file, createdBy }); - const remoteResult = await this.rpcClient.call("skills.global.install.local", { + await this.rpcClient.call("skills.global.install.local", { slug, bucket: artifact.bucket, objectKey: artifact.objectKey, force: false }); const remoteStatus = "installed"; - const description = firstText(remoteResult?.description, normalizedArchive.metadata.description); - const now = new Date().toISOString(); + const now = toChinaISOString(); const result = this.db.prepare(` INSERT INTO skills ( slug, description, artifact_id, remote_status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) `).run( slug, - description, + "", artifact.id, remoteStatus, now, @@ -904,24 +915,23 @@ export class PortalService { ...current, ...payload, artifact_id: artifact.id, - updated_at: new Date().toISOString() + updated_at: toChinaISOString() }; - const remoteResult = await this.rpcClient.call("skills.global.install.local", { + await this.rpcClient.call("skills.global.install.local", { slug, bucket: artifact.bucket, objectKey: artifact.objectKey, force: true }); const remoteStatus = "installed"; - const description = firstText(remoteResult?.description, normalizedArchive.metadata.description); this.db.prepare(` UPDATE skills SET description = ?, artifact_id = ?, remote_status = ?, updated_at = ? WHERE id = ? `).run( - description, + "", next.artifact_id, remoteStatus, next.updated_at, @@ -935,7 +945,6 @@ export class PortalService { return { name: normalizedArchive.metadata.name, slug: normalizedArchive.metadata.slug, - description: normalizedArchive.metadata.description, normalized_name: normalizedArchive.metadata.slug, name_changed: normalizedArchive.metadata.name !== normalizedArchive.metadata.slug }; diff --git a/apps/management-website/server/src/services/system-service.js b/apps/management-website/server/src/services/system-service.js index e9bc60a..31fe039 100644 --- a/apps/management-website/server/src/services/system-service.js +++ b/apps/management-website/server/src/services/system-service.js @@ -1,4 +1,5 @@ import AdmZip from "adm-zip"; +import { toChinaISOString } from "../utils/china-time.js"; import { jsonParse } from "../db/index.js"; import { badRequest, conflict, notFound } from "../utils/errors.js"; @@ -286,7 +287,7 @@ export class SystemService { replaceOntology: Boolean(bucket && objectKey) }); - const now = new Date().toISOString(); + const now = toChinaISOString(); const result = this.db.prepare(` INSERT INTO business_systems ( provider, application_name, description, ontology_artifact_id, @@ -338,7 +339,7 @@ export class SystemService { file.mimetype || "application/octet-stream", file.size, createdBy, - new Date().toISOString() + toChinaISOString() ); return { @@ -405,7 +406,7 @@ export class SystemService { normalized.host, normalized.port, normalized.status, - new Date().toISOString(), + toChinaISOString(), id ); @@ -430,7 +431,7 @@ export class SystemService { UPDATE business_systems SET status = ?, updated_at = ? WHERE id = ? - `).run(normalizedStatus, new Date().toISOString(), id); + `).run(normalizedStatus, toChinaISOString(), id); return this.getSystemById(id); } @@ -463,14 +464,14 @@ export class SystemService { ok, baseUrl, service: { status: response.status, url: baseUrl }, - tested_at: new Date().toISOString() + tested_at: toChinaISOString() }; this.db.prepare(` UPDATE business_systems SET last_connectivity_test_status = ?, last_connectivity_test_result_json = ?, updated_at = ? WHERE id = ? - `).run(ok ? "ok" : "failed", JSON.stringify(result), new Date().toISOString(), id); + `).run(ok ? "ok" : "failed", JSON.stringify(result), toChinaISOString(), id); return result; } @@ -502,7 +503,7 @@ export class SystemService { log.response_time_ms, log.success ? 1 : 0, log.error_message || null, - log.created_at || new Date().toISOString() + log.created_at || toChinaISOString() ); try { 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 4ed8e91..67573fe 100644 --- a/apps/management-website/server/src/services/topic-ping-service.js +++ b/apps/management-website/server/src/services/topic-ping-service.js @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { toChinaISOString } from "../utils/china-time.js"; import mqtt from "mqtt"; @@ -329,7 +330,7 @@ export class TopicPingService { replyMessage, replyMessages, timings, - tested_at: new Date().toISOString() + tested_at: toChinaISOString() }; } catch (error) { timings.total_ms = Date.now() - startedAt; @@ -343,7 +344,7 @@ export class TopicPingService { replyMessage: null, replyMessages, timings, - tested_at: new Date().toISOString(), + tested_at: toChinaISOString(), error_message: error.message || "数字员工测试失败", error_details: error.details || null }; diff --git a/apps/management-website/server/src/utils/china-time.js b/apps/management-website/server/src/utils/china-time.js new file mode 100644 index 0000000..d94606c --- /dev/null +++ b/apps/management-website/server/src/utils/china-time.js @@ -0,0 +1,34 @@ +const CHINA_TIME_OFFSET_MINUTES = 8 * 60; + +function pad(value, length = 2) { + return String(value).padStart(length, "0"); +} + +export function toChinaISOString(value = new Date()) { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error("Invalid date value"); + } + + const chinaTime = new Date(date.getTime() + CHINA_TIME_OFFSET_MINUTES * 60 * 1000); + return [ + chinaTime.getUTCFullYear(), + "-", + pad(chinaTime.getUTCMonth() + 1), + "-", + pad(chinaTime.getUTCDate()), + "T", + pad(chinaTime.getUTCHours()), + ":", + pad(chinaTime.getUTCMinutes()), + ":", + pad(chinaTime.getUTCSeconds()), + ".", + pad(chinaTime.getUTCMilliseconds(), 3), + "+08:00" + ].join(""); +} + +export function chinaDateKey(value = new Date()) { + return toChinaISOString(value).slice(0, 10); +} diff --git a/apps/management-website/server/src/utils/logger.js b/apps/management-website/server/src/utils/logger.js new file mode 100644 index 0000000..c28e66b --- /dev/null +++ b/apps/management-website/server/src/utils/logger.js @@ -0,0 +1,106 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; + +import pino from "pino"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc.js"; +import timezone from "dayjs/plugin/timezone.js"; +import { createStream } from "rotating-file-stream"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const PACKAGE_NAME = "aios-management-web"; +let fileStream; +let fileStreamDir = ""; +const warnedLogDirs = new Set(); + +function beijingTime() { + return dayjs().tz("Asia/Shanghai").format("YYYY-MM-DDTHH:mm:ss.SSSZ"); +} + +function resolveLogDir() { + const configured = process.env.AIOS_LOG_DIR?.trim(); + if (configured) { + return configured; + } + + const root = process.env.AIOS_ROOT?.trim(); + if (root) { + return path.join(root, "logs"); + } + + const dataDir = process.env.AIOS_DATA_DIR?.trim() || process.env.AIOS_KERNEL_DATA_DIR?.trim(); + if (dataDir) { + return path.join(path.dirname(dataDir), "logs"); + } + + return undefined; +} + +function getFileStream() { + const logDir = resolveLogDir(); + if (!logDir) { + return undefined; + } + + if (fileStream && fileStreamDir === logDir) { + return fileStream; + } + + try { + mkdirSync(logDir, { recursive: true }); + fileStreamDir = logDir; + fileStream = createStream((time) => { + const current = time ? dayjs(time).tz("Asia/Shanghai") : dayjs().tz("Asia/Shanghai"); + return `${PACKAGE_NAME}-${current.format("YYYYMMDD")}.log`; + }, { + path: logDir, + interval: "1d", + intervalBoundary: true, + intervalUTC: false, + mode: 0o660 + }); + return fileStream; + } catch (error) { + if (!warnedLogDirs.has(logDir)) { + warnedLogDirs.add(logDir); + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[logger] ${beijingTime()} failed to initialize daily log file in ${logDir}: ${message}\n`); + } + return undefined; + } +} + +function createDestination() { + const dailyFileStream = getFileStream(); + + return { + write(line) { + process.stderr.write(line); + dailyFileStream?.write(line); + } + }; +} + +export function createLogger(name) { + return pino({ + name, + level: process.env.AIOS_LOG_LEVEL || "info", + timestamp: () => `,"ts":"${beijingTime()}"`, + base: null + }, createDestination()); +} + +export function errorDetails(error) { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack + }; + } + + return { + message: String(error) + }; +} diff --git a/apps/management-website/server/test/background-services.test.js b/apps/management-website/server/test/background-services.test.js index 4f02b32..7ab6296 100644 --- a/apps/management-website/server/test/background-services.test.js +++ b/apps/management-website/server/test/background-services.test.js @@ -281,6 +281,39 @@ it("retries management RPC readiness before startup sync failure is marked", asy } }); +it("uses a ten second default service ping retry interval before startup sync", async () => { + const timers = installFakeTimers(); + const calls = []; + const taskFactory = (label) => async ({ trigger }) => { + calls.push(`${label}:${trigger}`); + return { status: "success" }; + }; + + const background = startBackgroundServices({ + env: {}, + services: createServices({ + calls, + rpcStart: async () => { + calls.push("rpc.start"); + }, + rpcCall: async (action) => { + calls.push(`rpc.call:${action}`); + throw new Error("not ready yet"); + }, + taskFactory + }) + }); + + try { + await flushMicrotasks(); + expect(calls).toEqual(["rpc.start", "rpc.call:service.ping"]); + expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([10 * 1000]); + } finally { + await background.stop(); + timers.restore(); + } +}); + it("times out while waiting for management RPC readiness", async () => { const calls = []; try { @@ -311,10 +344,9 @@ it("times out while waiting for management RPC readiness", async () => { }); it("does not register scheduled sync timers when RPC startup throws", async () => { - spyOn(console, "error").and.stub(); - const timers = installFakeTimers(); const calls = []; + const startupFailures = []; const taskFactory = (label) => async ({ trigger }) => { calls.push(`${label}:${trigger}`); return { status: "success" }; @@ -330,17 +362,17 @@ it("does not register scheduled sync timers when RPC startup throws", async () = calls.push("rpc.start"); throw new Error("rpc failed"); }, + markStartupSyncFailed: (message) => { + startupFailures.push(message); + }, taskFactory }) }); try { await flushMicrotasks(); - expect(console.error).toHaveBeenCalledWith( - "Background service failed to start", - jasmine.any(Error) - ); expect(calls).toEqual(["rpc.start"]); + expect(startupFailures).toEqual(["rpc failed"]); expect(timers.activeTimeouts()).toEqual([]); } finally { await background.stop(); 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 8976c25..abc2294 100644 --- a/apps/management-website/server/test/catalog-sync-service.test.js +++ b/apps/management-website/server/test/catalog-sync-service.test.js @@ -435,7 +435,6 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { }], templates: [{ id: "default", - path: "/var/aios/workspace-templates/default", builtIn: true }], agents: [{ @@ -456,7 +455,6 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { }], systems: [{ id: "crm", - name: "CRM", provider: "phx", status: "disabled", endpoint: { @@ -467,7 +465,6 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { }], ontologies: [{ id: "crm", - name: "crm", builtIn: true }] }); @@ -500,22 +497,20 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { 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" - }, + expect(JSON.parse(syncedAgent.remote_state_json)).toEqual({ 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 }) ]); + const syncedTemplate = db.prepare("SELECT remote_result_json FROM agent_templates WHERE template_name = ?").get("default"); + expect(JSON.parse(syncedTemplate.remote_result_json)).toEqual({ builtIn: true }); 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" }) + jasmine.objectContaining({ slug: "global-skill", description: "" }) ]); 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 }) @@ -733,11 +728,10 @@ it("keeps renamed agent name from kernel catalog sync for management and externa const agent = db.prepare("SELECT agent_name, remote_state_json FROM agents WHERE slug = ?").get("renamed-agent"); expect(agent.agent_name).toBe("Kernel Synced Name"); - expect(JSON.parse(agent.remote_state_json)).toEqual(jasmine.objectContaining({ - name: "Kernel Synced Name", + expect(JSON.parse(agent.remote_state_json)).toEqual({ inboundTopic: "aios/agent/renamed-agent/inbound", outboundTopic: "aios/agent/renamed-agent/outbound" - })); + }); }); it("refreshes agent usage snapshots with kernel stock values", async () => { diff --git a/apps/management-website/server/test/env-config.test.js b/apps/management-website/server/test/env-config.test.js index 7628e3d..82654e6 100644 --- a/apps/management-website/server/test/env-config.test.js +++ b/apps/management-website/server/test/env-config.test.js @@ -67,6 +67,22 @@ it("reads management website timeout from environment in seconds", () => { } }); +it("defaults service ping retry interval to ten seconds", () => { + const originalPollInterval = process.env.AIOS_STARTUP_SYNC_POLL_INTERVAL_MS; + delete process.env.AIOS_STARTUP_SYNC_POLL_INTERVAL_MS; + + try { + const env = loadEnv(); + expect(env.startupSyncPollIntervalMs).toBe(10000); + } finally { + if (originalPollInterval === undefined) { + delete process.env.AIOS_STARTUP_SYNC_POLL_INTERVAL_MS; + } else { + process.env.AIOS_STARTUP_SYNC_POLL_INTERVAL_MS = originalPollInterval; + } + } +}); + it("reads management website port from environment", () => { const originalPort = process.env.AIOS_WEB_PORT; process.env.AIOS_WEB_PORT = "3040"; 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 8c4c60a..2ba84a7 100644 --- a/apps/management-website/server/test/portal-service-alignment.test.js +++ b/apps/management-website/server/test/portal-service-alignment.test.js @@ -189,7 +189,7 @@ it("returns component version info from the local cache", () => { }]); }); -it("refreshes component version cache from local packages and version.list", async () => { +it("refreshes component version cache from local apps packages and management-service version.list", async () => { const db = createVersionDb(); const calls = []; const service = new PortalService({ @@ -230,6 +230,22 @@ it("refreshes component version cache from local packages and version.list", asy "aios-mqtt-channel", "core" ]); + expect(versionInfo.items.filter((item) => item.source === "local").map((item) => ({ + group: item.group, + package_name: item.package_name + }))).toEqual([ + { group: "apps", package_name: "aios-management-web" }, + { group: "apps", package_name: "aios-app-invoke-proxy-service" } + ]); + expect(versionInfo.items.filter((item) => item.source === "management-service").map((item) => ({ + group: item.group, + package_name: item.package_name + }))).toEqual([ + { group: "kernal", package_name: "aios-apps-invoke-cli" }, + { group: "kernal", package_name: "aios-management-serivce" }, + { group: "kernal", package_name: "aios-mqtt-channel" }, + { group: "kernal", package_name: "openclaw" } + ]); expect(versionInfo.items.find((item) => item.name === "core")?.package_name).toBe("openclaw"); }); @@ -343,9 +359,17 @@ it("updates and clears uploaded portal logo", () => { it("lists aios skills first and sorts each group alphabetically", () => { const db = new DatabaseSync(":memory:"); db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT NOT NULL + ); + CREATE TABLE artifacts ( id INTEGER PRIMARY KEY, - original_name TEXT NOT NULL + original_name TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL ); CREATE TABLE skills ( @@ -359,15 +383,25 @@ it("lists aios skills first and sorts each group alphabetically", () => { updated_at TEXT NOT NULL ); `); + db.prepare(` + INSERT INTO users ( + id, username, display_name + ) VALUES (1, 'admin', '管理员') + `).run(); + db.prepare(` + INSERT INTO artifacts ( + id, original_name, created_by, created_at + ) VALUES (1, 'browser.zip', 1, '2026-05-26T01:30:00.000Z') + `).run(); const insert = db.prepare(` INSERT INTO skills ( id, slug, description, artifact_id, remote_status, is_builtin, created_at, updated_at - ) VALUES (?, ?, '', NULL, 'installed', 1, ?, ?) + ) VALUES (?, ?, '', ?, 'installed', ?, ?, ?) `); - 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"); + insert.run(1, "pdf", null, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T04:00:00.000Z"); + insert.run(2, "aios-transfer-file", null, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T03:00:00.000Z"); + insert.run(3, "browser", 1, 0, "2026-05-26T00:00:00.000Z", "2026-05-26T02:00:00.000Z"); + insert.run(4, "aios-call-app-service", 1, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T01:00:00.000Z"); const service = new PortalService({ db, objectStorage: {}, @@ -381,6 +415,15 @@ it("lists aios skills first and sorts each group alphabetically", () => { "browser", "pdf" ]); + const browserSkill = service.listSkills().find((item) => item.slug === "browser"); + expect(browserSkill.description).toBeUndefined(); + expect(browserSkill.uploader_username).toBe("admin"); + expect(browserSkill.uploader_display_name).toBe("管理员"); + expect(browserSkill.uploaded_at).toBe("2026-05-26T01:30:00.000Z"); + const builtInSkill = service.listSkills().find((item) => item.slug === "aios-call-app-service"); + expect(builtInSkill.uploader_username).toBeNull(); + expect(builtInSkill.uploader_display_name).toBeNull(); + expect(builtInSkill.uploaded_at).toBeNull(); db.close(); }); @@ -453,7 +496,7 @@ it("installs uploaded global skill through skills.global.install.local", async ( }); service.listSkills = () => { const lastStatement = db.statements[db.statements.length - 1]; - return [{ id: 1, description: lastStatement?.args?.[1] }]; + return [{ id: 1, slug: lastStatement?.args?.[0], description: undefined }]; }; const zipBuffer = createSkillZip({ @@ -480,8 +523,8 @@ it("installs uploaded global skill through skills.global.install.local", async ( expect(preview.name).toBe("财务助手"); expect(preview.slug).toMatch(/^skill-[a-f0-9]{10}$/); - expect(preview.description).toBe("Description from package"); - expect(result).toEqual({ id: 1, description: "Description from kernel" }); + expect(preview.description).toBeUndefined(); + expect(result).toEqual({ id: 1, slug: preview.slug, description: undefined }); expect(rpcCalls.length).toBe(1); expect(rpcCalls[0].action).toBe("skills.global.install.local"); expect(rpcCalls[0].params).toEqual({ diff --git a/apps/management-website/src/app/App.jsx b/apps/management-website/src/app/App.jsx index 3793c99..81c2f56 100644 --- a/apps/management-website/src/app/App.jsx +++ b/apps/management-website/src/app/App.jsx @@ -5,7 +5,6 @@ import { DashboardOutlined, FileTextOutlined, LockOutlined, - RobotOutlined, SettingOutlined, TeamOutlined, ToolOutlined @@ -15,6 +14,7 @@ import zhCN from "antd/locale/zh_CN"; import { api, authTokenStore } from "./api-client.js"; import { AppShell } from "../components/AppShell.jsx"; +import { DigitalEmployeeIcon } from "../components/DigitalEmployeeIcon.jsx"; import { AgentsPage } from "../pages/AgentsPage.jsx"; import { DashboardPage } from "../pages/DashboardPage.jsx"; import { LargeLanguageModelsPage } from "../pages/LargeLanguageModelsPage.jsx"; @@ -62,7 +62,11 @@ const menuItems = [ label: "员工中心", type: "group", children: [ - { key: "agents", label: "数字员工", icon: }, + { + key: "agents", + label: "数字员工", + icon: + }, { key: "templates", label: "员工模板", icon: } ] }, diff --git a/apps/management-website/src/components/DigitalEmployeeIcon.jsx b/apps/management-website/src/components/DigitalEmployeeIcon.jsx new file mode 100644 index 0000000..737a7f5 --- /dev/null +++ b/apps/management-website/src/components/DigitalEmployeeIcon.jsx @@ -0,0 +1,10 @@ +import React from "react"; + +export function DigitalEmployeeIcon({ className = "", ariaLabel = "", style }) { + const accessibilityProps = ariaLabel + ? { role: "img", "aria-label": ariaLabel } + : { "aria-hidden": true }; + const classes = ["digital-employee-icon", className].filter(Boolean).join(" "); + + return ; +} diff --git a/apps/management-website/src/components/LogoMark.jsx b/apps/management-website/src/components/LogoMark.jsx index a1422e4..65e5081 100644 --- a/apps/management-website/src/components/LogoMark.jsx +++ b/apps/management-website/src/components/LogoMark.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; -import { RobotOutlined } from "@ant-design/icons"; + +import { DigitalEmployeeIcon } from "./DigitalEmployeeIcon.jsx"; export function LogoMark({ src, className }) { const normalizedSrc = typeof src === "string" ? src.trim() : ""; @@ -14,7 +15,7 @@ export function LogoMark({ src, className }) { {normalizedSrc && !failed ? ( Logo setFailed(true)} /> ) : ( - + )} ); diff --git a/apps/management-website/src/pages/AgentTestPanel.jsx b/apps/management-website/src/pages/AgentTestPanel.jsx index c1b1a8b..6e2aa35 100644 --- a/apps/management-website/src/pages/AgentTestPanel.jsx +++ b/apps/management-website/src/pages/AgentTestPanel.jsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from "react"; -import { RobotOutlined, UserOutlined } from "@ant-design/icons"; +import { UserOutlined } from "@ant-design/icons"; import { Bubble, Sender } from "@ant-design/x"; import { Card, @@ -12,6 +12,7 @@ import { } from "antd"; import { api } from "../app/api-client.js"; +import { DigitalEmployeeIcon } from "../components/DigitalEmployeeIcon.jsx"; import { formatDateTime } from "../utils/format.js"; const { Text } = Typography; @@ -76,7 +77,10 @@ export function AgentTestPanel({ agent }) { }, assistant: { placement: "start", - avatar: { icon: , style: { background: "var(--portal-theme-color, #07c160)" } } + avatar: { + icon: , + style: { background: "var(--portal-theme-color, #07c160)" } + } } }), []); diff --git a/apps/management-website/src/pages/DashboardPage.jsx b/apps/management-website/src/pages/DashboardPage.jsx index 0fe86e9..f3ead5e 100644 --- a/apps/management-website/src/pages/DashboardPage.jsx +++ b/apps/management-website/src/pages/DashboardPage.jsx @@ -2,13 +2,13 @@ import React, { useEffect, useState } from "react"; import { ApiOutlined, FileTextOutlined, - RobotOutlined, ThunderboltOutlined, ToolOutlined } from "@ant-design/icons"; import { Card, Space, Spin, Table, Tag, Typography } from "antd"; import { api } from "../app/api-client.js"; +import { DigitalEmployeeIcon } from "../components/DigitalEmployeeIcon.jsx"; import { formatQuota, formatTokenCount, @@ -33,7 +33,7 @@ const metricDescriptions = { }; const metricIcons = { - agents: , + agents: , templates: , skills: , systems: diff --git a/apps/management-website/src/pages/SkillsPage.jsx b/apps/management-website/src/pages/SkillsPage.jsx index 166e9ee..db29cfe 100644 --- a/apps/management-website/src/pages/SkillsPage.jsx +++ b/apps/management-website/src/pages/SkillsPage.jsx @@ -1,27 +1,29 @@ import React, { useEffect, useState } from "react"; -import { Alert, Button, Card, Descriptions, Empty, Form, List, Modal, Space, Spin, Tag, Tooltip, Typography, Upload, message } from "antd"; +import { Alert, Button, Card, Descriptions, Empty, Form, List, Modal, Space, Spin, Tag, Typography, Upload, message } from "antd"; import { api } from "../app/api-client.js"; import { CardTitleWithReload } from "../components/CardTitleWithReload.jsx"; import { DeleteActionButton } from "../components/DeleteActionButton.jsx"; +import { formatDateTime } from "../utils/format.js"; -const { Paragraph, Text } = Typography; +const { Text } = Typography; -function getRemoteStatusLabel(status) { - switch (status) { - case "installed": - return "已安装"; - case "cataloged": - return "已归档"; - case "ready": - return "就绪"; - default: - return status || "-"; +function isBuiltinSkill(skill) { + return skill?.is_builtin === true || Number(skill?.is_builtin) === 1; +} + +function skillUploader(skill) { + if (isBuiltinSkill(skill)) { + return "-"; } + return skill?.uploader_display_name || skill?.uploader_username || "-"; } -function getSkillDescription(skill) { - return String(skill?.description || "").trim(); +function skillUploadedAt(skill) { + if (isBuiltinSkill(skill)) { + return "-"; + } + return formatDateTime(skill?.uploaded_at); } export function SkillsPage() { @@ -146,7 +148,7 @@ export function SkillsPage() { grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }} dataSource={items} renderItem={(skill) => { - const description = getSkillDescription(skill); + const builtin = isBuiltinSkill(skill); return (
@@ -155,25 +157,26 @@ export function SkillsPage() { {skill.slug} - {skill.is_builtin ? 内置 : 自定义} - {getRemoteStatusLabel(skill.remote_status)} + {builtin ? 内建 : null} -
- 描述 - {description ? ( - - - {description} - - - ) : ( - 暂无描述 - )} +
+
+ 上传者 + + {skillUploader(skill)} + +
+
+ 上传时间 + + {skillUploadedAt(skill)} + +