diff --git a/README.md b/README.md index ce7f4080d2fe572313ac1092a80652794489d4a0..661dbee3f7d2222cd73d2d7215e73e8f5d501567 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,14 @@ sudo sh ./get-docker.sh 执行以下命令: ```bash -docker run -d --restart unless-stopped --name aios-all-in-one --cap-add NET_ADMIN --network bridge -p 3030:3030 -p 1883:1883 -p 9000:9000 -v /var/aios_root/:/var/aios-all-in-one crpi-4auaoyyj6r36p6lb.cn-hangzhou.personal.cr.aliyuncs.com/huozige_lab/aios-all-x64:[版本号] +docker run -d --restart unless-stopped --name aios-all-in-one --cap-add NET_ADMIN -p 3030:3030 -p 1883:1883 -p 9000:9000 -v /var/aios_root/:/var/aios-all-in-one crpi-4auaoyyj6r36p6lb.cn-hangzhou.personal.cr.aliyuncs.com/huozige_lab/aios-all-x64:[版本号] ``` 其中的 `[版本号]` 请参考本项目的Release,如 `20260604.04`。默认的启动参数可适配大多数场景,如需定制,请 [点击查看详细说明](/docker-images/all-in-one/README.md)。 ### 3、配置 AIOS -容器启动后,等待1分钟左右,使用浏览器访问宿主机的3030端口(如`http://docker-host-ip:3030`),打开 AIOS 的管理控制台。 +容器启动后,等待3分钟左右(Docker log 中出现 `management console should be available ...` )后,使用浏览器访问宿主机的3030端口(如`http://docker-host-ip:3030`),打开 AIOS 的管理控制台。 #### 3.1 添加大语言模型 diff --git a/apps/aios-app-invoke-proxy-service/package-lock.json b/apps/aios-app-invoke-proxy-service/package-lock.json index 67709e5c219e6c7ff448daca1256c09215381d9d..967061205a50ad880f84b1ecae55e0b5ad4287aa 100644 --- a/apps/aios-app-invoke-proxy-service/package-lock.json +++ b/apps/aios-app-invoke-proxy-service/package-lock.json @@ -1,16 +1,18 @@ { "name": "aios-app-invoke-proxy-service", - "version": "0.1.2", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-app-invoke-proxy-service", - "version": "0.1.2", + "version": "0.1.4", "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" }, "bin": { "aios-app-invoke-proxy-service": "dist/cli.js" @@ -495,6 +497,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 +586,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 +756,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 +1134,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 +1177,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 +1229,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,6 +1267,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/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -1202,6 +1302,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 +1371,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 +1502,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 df3eba8844528f1efdc0cf2433f3fa648163cfff..d6539ac3df5b6e047d2d48ec8900e5d08e39c8e0 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.6", "description": "MQTT service proxy for AIOS application invocation.", "type": "module", "private": false, @@ -57,8 +57,10 @@ "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" }, "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 0000000000000000000000000000000000000000..1183b641eda2a6daf386e1cfb0489abfbe5529f6 --- /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 9854e19d44b029c53643ac206091e2df7c365141..7110ea9c9f52d3a9f5a02a64402f9c583e2e3051 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 0000000000000000000000000000000000000000..5c78ea7afe69f30a652b7359d6a73890ab57e386 --- /dev/null +++ b/apps/aios-app-invoke-proxy-service/src/logger.ts @@ -0,0 +1,96 @@ +import { appendFileSync, chmodSync, existsSync, mkdirSync, writeFileSync } 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"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const PACKAGE_NAME = "aios-app-invoke-proxy-service"; +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 getDailyLogFile(): string | undefined { + const logDir = resolveLogDir(); + if (!logDir) { + return undefined; + } + + try { + mkdirSync(logDir, { recursive: true }); + const logFile = path.join(logDir, `${PACKAGE_NAME}-${dayjs().tz("Asia/Shanghai").format("YYYYMMDD")}.log`); + if (!existsSync(logFile)) { + writeFileSync(logFile, "", { mode: 0o660 }); + chmodSync(logFile, 0o660); + } + return logFile; + } 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 }); + + return { + write(line: string) { + consoleStream.write(line); + const logFile = getDailyLogFile(); + if (logFile) { + appendFileSync(logFile, line, { mode: 0o660 }); + } + } + }; +} + +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 d2022e95bc5abb7b82265cb64e215c30e211db67..7a4b88ada01956f00d0b6e9d151b3aaadb306565 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 5d2ec0e85617ee7a07cbced431fde774c64e9b43..20ddeb73743c2b125a4bf460caf4006077fd1c76 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,13 +110,16 @@ 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 提示结果。 +`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` @@ -124,6 +128,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 b96550db5687ed8487d3bb5ecba2eabdac452fd7..1fd9edb1b6fb3d22befa597969cb644b83b3ee59 100644 --- a/apps/management-website/docs/spec.md +++ b/apps/management-website/docs/spec.md @@ -40,12 +40,14 @@ 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 当前只管理全局 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 040ef612e5f766b1b45106af918b9bd65649d037..19968c3fb50f6111e04ca0f864c7b16aae899ef6 100644 --- a/apps/management-website/package-lock.json +++ b/apps/management-website/package-lock.json @@ -1,22 +1,23 @@ { "name": "aios-management-web", - "version": "0.2.8", + "version": "0.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-management-web", - "version": "0.2.8", + "version": "0.3.7", "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" }, @@ -1509,6 +1510,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 +2488,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 +2889,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 +3859,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 +3984,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 +4078,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 +4122,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 +4811,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", @@ -4820,6 +4913,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 +5136,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 +5318,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 51f51c252a0026a8d622621337a5b6065b4e8cd6..def4c5b589b23a4d974f59056111c50964d2e5a4 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.4.0", "type": "module", "files": [ "dist", @@ -30,10 +30,11 @@ "@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" }, 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 Binary files /dev/null and b/apps/management-website/public/digital-employee-icon.png differ diff --git a/apps/management-website/scripts/reset-password.js b/apps/management-website/scripts/reset-password.js index a5d8b36a47938a6c1d606e0c290d0bcb521b8448..e69579337284e047eed0a850adfb7c56150369bc 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 8e79dc73e0175fb134bb4d7def206732ceaf9497..52ebfe42e947abfbe576e47c62d72337b980817d 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/api/routes/index.js b/apps/management-website/server/src/api/routes/index.js index 747029589b5d6470de15691863ce0678384482b1..de27211243b65d34cd6c5d883e4bdadc40aab189 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( @@ -482,17 +499,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 +529,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/app.js b/apps/management-website/server/src/app.js index ca22ab7dd00ff4b02049bcdf40bc6f2cc9c9f379..56f1f6e7b411e8a00a41b1c20c482b5f8edb1b65 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(); @@ -29,6 +32,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 }); @@ -79,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 c93542027e3cc84a2ad59391fdab87585af839cd..51cab670ff910fc26221cee978d67b6a6a820966 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,24 +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) { + 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 8d2d168e85905dacfa99cbdf008234cd21708ccf..66246ce960b74d0ccdca4b9d4477f876ca4e0ab6 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 3a79bc4a246213d70644aaa370c64bb35b2c1d27..632fa35f4d23a8578eb6fa981f81d9f1856e7a2f 100644 --- a/apps/management-website/server/src/db/index.js +++ b/apps/management-website/server/src/db/index.js @@ -2,12 +2,13 @@ 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"; 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 +289,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 +354,8 @@ function createSchema() { updated_at TEXT NOT NULL ); + ${createComponentVersionsTable()} + ${createStateTable("usage_refresh_state")} ${createStateTable("agent_sync_state")} ${createStateTable("llm_sync_state")} @@ -538,7 +561,7 @@ function seedStateRow(tableName, now) { } function seed() { - const now = new Date().toISOString(); + const now = toChinaISOString(); db.prepare(` INSERT INTO settings ( @@ -589,7 +612,7 @@ function seed() { } function ensureCoreRows() { - const now = new Date().toISOString(); + const now = toChinaISOString(); db.prepare(` INSERT INTO settings ( @@ -676,6 +699,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -695,6 +721,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -711,6 +740,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -724,6 +756,9 @@ function migrate() { if (SCHEMA_VERSION >= 6) { migrateV5ToV6(); } + if (SCHEMA_VERSION >= 7) { + migrateV6ToV7(); + } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); } catch (error) { @@ -734,6 +769,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 +806,7 @@ function migrate() { } addAgentCreatorColumn(); + migrateV6ToV7(); ensureCoreRows(); } 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 1da3b10d9d45b5fb11ee5ca0c7094f7e816dc22d..b01ba65e844fb7eb9f821d661da862a575443120 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 7ccfd86ee0d70f8cace6b05bcadb633f841ac75a..76511e6b3c0c89582c563bb4007ea5c619e18a7a 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 f39a2ac14afa8aa08c2132ae4b3f278318e3a1f0..5e9685bf4f1e220134e67e09dde0f9136949ec68 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 @@ -839,6 +840,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, @@ -864,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 ac47ede24b8e3340ab8109c4f7d7a0d57b61f1f9..1329827c38b1aa38ff6550b7b57ec39198512f8b 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 f9c706a729cb86e5244cdf2ff39d7bc222937815..10450bf7537b2d438f12ddc10beb34babbaabd69 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 5bb76b25a251e180ad6ff1dd93de215ccfedc995..fe6c7aaaba0458f40560038cd7abd65a5a162f03 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() }; } @@ -196,6 +198,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; @@ -237,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", @@ -290,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) { @@ -327,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"); @@ -359,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", @@ -388,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, { @@ -438,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", { @@ -486,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 = ? @@ -510,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, @@ -527,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, @@ -581,9 +593,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, @@ -742,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( @@ -795,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 = ?"); @@ -808,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, @@ -826,7 +833,7 @@ export class CatalogSyncService { insert.run( slug, - description, + "", null, "installed", isBuiltin, @@ -842,12 +849,13 @@ 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(); - 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 +867,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); } } } @@ -887,7 +906,7 @@ export class CatalogSyncService { "application/octet-stream", 0, null, - new Date().toISOString() + toChinaISOString() ); return result.lastInsertRowid; @@ -916,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(` @@ -942,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 383da45e58662d6352505d5cc092341d7819df30..15c211d6ef327c6cde05d54c82993b8f7debe709 100644 --- a/apps/management-website/server/src/services/external-service.js +++ b/apps/management-website/server/src/services/external-service.js @@ -1,9 +1,19 @@ -import { randomUUID } from "node:crypto"; +import { randomInt, 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"; const MAX_COOKIE_LENGTH = 16 * 1024; +const EXTERNAL_SESSION_DIGIT_LENGTH = 30; + +function generateExternalSessionId() { + let digits = ""; + for (let index = 0; index < EXTERNAL_SESSION_DIGIT_LENGTH; index += 1) { + digits += String(randomInt(1, 9)); + } + return `s-${digits}`; +} function normalizeProvider(value) { const provider = String(value || "").trim().toLowerCase(); @@ -97,7 +107,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 ( @@ -178,8 +188,8 @@ export class ExternalService { }; } - const sessionId = `s-${randomUUID()}`; - const now = new Date().toISOString(); + const sessionId = generateExternalSessionId(); + const now = toChinaISOString(); this.db.prepare(` INSERT INTO external_sessions ( session_id, aios_user_id, agent_id, created_at, updated_at @@ -233,7 +243,7 @@ export class ExternalService { WHERE es.session_id = ? `).get(normalizedSessionId); if (!session) { - throw notFound(`会话不存在:${normalizedSessionId}`); + throw notFound(`会话不存在:${normalizedSessionId} 。请确保格式正确。`); } if (normalizedProvider !== "hzg") { 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 6313038629994735c3274f5e0d2d61b8ec94f67a..2d625dcd9dee81aee78bc1cc15ec0b191f86f62d 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 ( @@ -333,24 +334,43 @@ export class LargeLanguageModelService { async testProvider(providerId, payload = {}) { const normalizedProviderId = normalizeProviderId(providerId); - const provider = this.getProvider(normalizedProviderId); const model = this.listModels(normalizedProviderId)[0] || null; - const testedAt = new Date().toISOString(); + 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 = toChinaISOString(); 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 +380,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 81fdbe01ba2d94dbf11824da8d5e084112eb1d78..209277f7be59fe8c6e42a110558e4c80d4b854fb 100644 --- a/apps/management-website/server/src/services/portal-service.js +++ b/apps/management-website/server/src/services/portal-service.js @@ -1,3 +1,9 @@ +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"; import { jsonParse, jsonStringify, newAccessToken } from "../db/index.js"; @@ -5,6 +11,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"], @@ -12,6 +19,129 @@ const LOGO_MIME_TYPES = new Map([ ["image/bmp", "bmp"], ["image/x-ms-bmp", "bmp"] ]); +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const APPS_ROOT = path.resolve(PACKAGE_ROOT, ".."); +const REPO_ROOT = path.resolve(PACKAGE_ROOT, "..", ".."); +const DEFAULT_APPS_HOME = "/opt/aios-apps"; + +function packageJsonInDir(dirPath) { + return dirPath ? path.join(dirPath, "package.json") : ""; +} + +function packageJsonInNodeModules(rootPath, packageName) { + return rootPath ? path.join(rootPath, "node_modules", packageName, "package.json") : ""; +} + +function packageJsonInChildDir(rootPath, childDir) { + return rootPath ? packageJsonInDir(path.join(rootPath, childDir)) : ""; +} + +function localVersionComponents() { + return [ + { + group: "apps", + fallbackName: "aios-management-web", + packagePaths: [ + firstText(process.env.AIOS_WEB_PACKAGE_JSON), + packageJsonInDir(firstText(process.env.AIOS_WEB_HOME)), + packageJsonInDir(PACKAGE_ROOT), + path.join(REPO_ROOT, "apps", "management-website", "package.json"), + packageJsonInNodeModules(APPS_ROOT, "aios-management-web") + ] + }, + { + group: "apps", + fallbackName: "aios-app-invoke-proxy-service", + packagePaths: [ + firstText(process.env.AIOS_APP_INVOKE_PROXY_PACKAGE_JSON, process.env.AIOS_PROXY_PACKAGE_JSON), + packageJsonInDir(firstText(process.env.AIOS_PROXY_HOME)), + packageJsonInChildDir(firstText(process.env.AIOS_APPS_HOME), "app-invoke-proxy"), + packageJsonInDir(path.join(APPS_ROOT, "app-invoke-proxy")), + packageJsonInDir(path.join(DEFAULT_APPS_HOME, "app-invoke-proxy")), + packageJsonInDir(path.join(APPS_ROOT, "aios-app-invoke-proxy-service")), + path.join(REPO_ROOT, "apps", "aios-app-invoke-proxy-service", "package.json"), + packageJsonInNodeModules(PACKAGE_ROOT, "aios-app-invoke-proxy-service"), + packageJsonInNodeModules(APPS_ROOT, "aios-app-invoke-proxy-service") + ] + } + ]; +} + +function readPackageMetadata(packagePaths, fallbackName) { + const safePackagePaths = Array.isArray(packagePaths) ? packagePaths.filter(Boolean) : []; + for (const packagePath of safePackagePaths) { + try { + const metadata = JSON.parse(fs.readFileSync(packagePath, "utf8")); + return { + name: firstText(metadata?.name, fallbackName), + version: firstText(metadata?.version, "unknown") + }; + } catch { + } + } + + return { + name: fallbackName, + 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(item) { + return { + group: item.group, + name: item.name, + package_name: item.package_name, + version: item.version, + source: item.source, + updated_at: item.updated_at + }; +} + +function sortVersionCacheEntries(left, right) { + const orderDiff = Number(left.display_order || 0) - Number(right.display_order || 0); + if (orderDiff !== 0) { + return orderDiff; + } + + return String(left.name || "").localeCompare(String(right.name || "")); +} + +function buildVersionInfoCache(entries) { + const sortedEntries = [...entries].sort(sortVersionCacheEntries); + const generatedAt = sortedEntries.reduce((latest, item) => ( + item.updated_at && item.updated_at > latest ? item.updated_at : latest + ), ""); + + return { + generated_at: generatedAt || null, + items: sortedEntries.map(toVersionCacheRow) + }; +} + +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); @@ -25,6 +155,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; @@ -114,6 +369,10 @@ export class PortalService { this.objectStorage = objectStorage; this.rpcClient = rpcClient; this.authService = authService; + this.versionInfoEntries = []; + this.versionInfoCache = buildVersionInfoCache(this.versionInfoEntries); + this.localVersionCacheLoaded = false; + this.kernalVersionCacheLoaded = false; } getSettings() { @@ -131,6 +390,91 @@ export class PortalService { }; } + getVersionInfo() { + return { + generated_at: this.versionInfoCache.generated_at, + items: this.versionInfoCache.items.map((item) => ({ ...item })) + }; + } + + localVersionItems() { + return localVersionComponents().map((component) => { + const metadata = readPackageMetadata(component.packagePaths, component.fallbackName); + return normalizeVersionItem({ + group: component.group, + name: component.displayName || metadata.name, + package_name: metadata.name, + version: metadata.version + }, component.group); + }); + } + + writeVersionInfoCache(items, source) { + const now = toChinaISOString(); + const normalizedItems = Array.isArray(items) + ? items.map((item) => normalizeVersionItem(item)).filter((item) => item.name && item.version) + : []; + const localComponentCount = localVersionComponents().length; + const entriesByKey = new Map( + this.versionInfoEntries + .filter((item) => item.source !== source) + .map((item) => [versionComponentKey(item), { ...item }]) + ); + + normalizedItems.forEach((item, index) => { + const entry = { + group: item.group, + name: item.name, + package_name: item.package_name, + version: item.version, + source, + display_order: source === "local" ? index : index + localComponentCount, + updated_at: now + }; + entriesByKey.set(versionComponentKey(entry), entry); + }); + this.versionInfoEntries = [...entriesByKey.values()].sort(sortVersionCacheEntries); + this.versionInfoCache = buildVersionInfoCache(this.versionInfoEntries); + } + + cacheLocalVersionInfo({ force = false } = {}) { + if (this.localVersionCacheLoaded && !force) { + return this.getVersionInfo(); + } + + this.writeVersionInfoCache(this.localVersionItems(), "local"); + this.localVersionCacheLoaded = true; + return this.getVersionInfo(); + } + + async refreshVersionInfoCache({ timeoutMs = 15000, force = false } = {}) { + if (this.kernalVersionCacheLoaded && !force) { + return this.getVersionInfo(); + } + + 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"); + this.kernalVersionCacheLoaded = true; + 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 @@ -149,7 +493,7 @@ export class PortalService { } createAccessToken() { - const now = new Date().toISOString(); + const now = toChinaISOString(); let token = ""; do { @@ -194,7 +538,7 @@ export class PortalService { const next = { ...current, ...payload, - updated_at: new Date().toISOString() + updated_at: toChinaISOString() }; this.db.prepare(` UPDATE settings @@ -226,7 +570,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 = ? @@ -236,7 +580,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 = ? @@ -293,7 +637,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, @@ -328,7 +672,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 @@ -375,7 +719,7 @@ export class PortalService { file.mimetype || "application/octet-stream", file.size, createdBy, - new Date().toISOString() + toChinaISOString() ); return { id: result.lastInsertRowid, @@ -477,7 +821,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, @@ -525,7 +869,7 @@ export class PortalService { defaultSelection.providerId, defaultSelection.modelId, defaultSelection.modelRef, - new Date().toISOString(), + toChinaISOString(), normalizedTemplateName ); @@ -554,10 +898,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 @@ -568,13 +923,9 @@ 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", { slug, @@ -584,14 +935,14 @@ export class PortalService { }); const remoteStatus = "installed"; - 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, - payload.description || "", + "", artifact.id, remoteStatus, now, @@ -608,18 +959,17 @@ 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, ...payload, artifact_id: artifact.id, - updated_at: new Date().toISOString() + updated_at: toChinaISOString() }; await this.rpcClient.call("skills.global.install.local", { @@ -635,7 +985,7 @@ export class PortalService { SET description = ?, artifact_id = ?, remote_status = ?, updated_at = ? WHERE id = ? `).run( - next.description ?? current.description, + "", next.artifact_id, remoteStatus, next.updated_at, @@ -644,6 +994,16 @@ 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, + 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/src/services/system-service.js b/apps/management-website/server/src/services/system-service.js index e9bc60ae07c0b972edae54013339810519e875c7..31fe03990eff09718e8a93d5d88ac6c432b74fc7 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 4ed8e91a8b28e06bbd9153ceeeccd0c33b21d054..67573fe94dafd3f90547f5fa3bc4d05813f53d65 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 0000000000000000000000000000000000000000..d94606c7a08aa2bfb9a0ce947c0bbbd09b8a4afa --- /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 0000000000000000000000000000000000000000..6de901024438617bd46cc09994b225e7585acef2 --- /dev/null +++ b/apps/management-website/server/src/utils/logger.js @@ -0,0 +1,94 @@ +import { appendFileSync, chmodSync, existsSync, mkdirSync, writeFileSync } 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"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const PACKAGE_NAME = "aios-management-web"; +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 getDailyLogFile() { + const logDir = resolveLogDir(); + if (!logDir) { + return undefined; + } + + try { + mkdirSync(logDir, { recursive: true }); + const logFile = path.join(logDir, `${PACKAGE_NAME}-${dayjs().tz("Asia/Shanghai").format("YYYYMMDD")}.log`); + if (!existsSync(logFile)) { + writeFileSync(logFile, "", { mode: 0o660 }); + chmodSync(logFile, 0o660); + } + return logFile; + } 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() { + return { + write(line) { + process.stderr.write(line); + const logFile = getDailyLogFile(); + if (logFile) { + appendFileSync(logFile, line, { mode: 0o660 }); + } + } + }; +} + +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/agent-service-create.test.js b/apps/management-website/server/test/agent-service-create.test.js index 3bce83bdf3e2a29a1b0fc66e91e5c3cd20f662cd..c7032359b0a1783cdb06b4c235e01449b310eab8 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/background-services.test.js b/apps/management-website/server/test/background-services.test.js index d3b764341c46973eae091eb3738ca36024747400..7ab62966f2ce99e41a879d8cfb28bdbbd0e959a6 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([]); @@ -274,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 { @@ -304,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" }; @@ -323,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 5829da9673a81fc3e9740eed8eaabafcd3442d88..abc22947d01155be3b72baa430365c26699a976d 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,11 +455,16 @@ it("syncs startup catalog snapshot with one management rpc call", async () => { }], systems: [{ id: "crm", - name: "CRM" + provider: "phx", + status: "disabled", + endpoint: { + scheme: "https", + host: "crm-kernel.example.com", + port: 8443 + } }], ontologies: [{ id: "crm", - name: "crm", builtIn: true }] }); @@ -493,26 +497,31 @@ 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 }) ]); + 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 +640,100 @@ 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({ + 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/db-reset-migration.test.js b/apps/management-website/server/test/db-reset-migration.test.js index 688c97edb4810f16f88b9e20b2d96c5067a30ead..c18b47008d4087b5b0189ec334271459c24ae3bc 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/env-config.test.js b/apps/management-website/server/test/env-config.test.js index 7628e3d6487612079485c7eed2c89be2445a9352..82654e6b01edcc336c6693d36d5a5833dbc65f5f 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/external-service.test.js b/apps/management-website/server/test/external-service.test.js index c0ec96079cf687b20b6ab7edd954f8014841deb4..85b2280723087d5ce5ffc6ca5b47e4af160a73c8 100644 --- a/apps/management-website/server/test/external-service.test.js +++ b/apps/management-website/server/test/external-service.test.js @@ -132,7 +132,7 @@ it("creates and reuses external session ids per user-agent pair and returns topi }); expect(first).toEqual(jasmine.objectContaining({ - sessionId: jasmine.stringMatching(/^s-/), + sessionId: jasmine.stringMatching(/^s-[1-9]{30}$/), inboundTopic: "aios/agent-a/inbound", outboundTopic: "aios/agent-a/outbound" })); 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 d203e0918107ad47ea10e71a1e8c2f8307149bd4..1b391729393b1ff2f91b405d8f2a2cd601c6bec5 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 48d92d0acbdbaa191d9a97b54cfcedd8d3b3b361..cfdfa0477e6852b32e07290827d4f507d68fb1e2 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 1d74bfd3436cf336895e872e0410e68c102660d3..b7794a59872077c0762395981c5df3e97f912d36 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,60 @@ 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 withPackageVersionEnv({ webPackagePath, proxyPackagePath }, fn) { + const previousWebPackagePath = process.env.AIOS_WEB_PACKAGE_JSON; + const previousProxyPackagePath = process.env.AIOS_APP_INVOKE_PROXY_PACKAGE_JSON; + process.env.AIOS_WEB_PACKAGE_JSON = webPackagePath; + process.env.AIOS_APP_INVOKE_PROXY_PACKAGE_JSON = proxyPackagePath; + + try { + return fn(); + } finally { + if (previousWebPackagePath === undefined) { + delete process.env.AIOS_WEB_PACKAGE_JSON; + } else { + process.env.AIOS_WEB_PACKAGE_JSON = previousWebPackagePath; + } + if (previousProxyPackagePath === undefined) { + delete process.env.AIOS_APP_INVOKE_PROXY_PACKAGE_JSON; + } else { + process.env.AIOS_APP_INVOKE_PROXY_PACKAGE_JSON = previousProxyPackagePath; + } + } +} + +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 }], @@ -118,6 +173,211 @@ it("returns dashboard metrics after agent and usage startup sync complete", () = }]); }); +it("returns component version info from the in-memory cache", () => { + const service = new PortalService({ + db: createVersionDb(), + objectStorage: {}, + rpcClient: {}, + authService: {} + }); + service.writeVersionInfoCache([ + { group: "kernal", name: "core", package_name: "openclaw", version: "2026.5.28" } + ], "management-service"); + + const versionInfo = service.getVersionInfo(); + versionInfo.items[0].version = "mutated"; + + expect(service.getVersionInfo().items).toEqual([{ + group: "kernal", + name: "core", + package_name: "openclaw", + version: "2026.5.28", + source: "management-service", + updated_at: versionInfo.generated_at + }]); + expect(versionInfo.items).toEqual([{ + group: "kernal", + name: "core", + package_name: "openclaw", + version: "mutated", + source: "management-service", + updated_at: versionInfo.generated_at + }]); +}); + +it("reads local app versions from configured package metadata paths", () => { + const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "aios-web-version-packages-")); + const webPackagePath = path.join(packageRoot, "web-package.json"); + const proxyPackagePath = path.join(packageRoot, "proxy-package.json"); + fs.writeFileSync(webPackagePath, JSON.stringify({ + name: "aios-management-web", + version: "9.8.7" + }), "utf8"); + fs.writeFileSync(proxyPackagePath, JSON.stringify({ + name: "aios-app-invoke-proxy-service", + version: "6.5.4" + }), "utf8"); + + withPackageVersionEnv({ webPackagePath, proxyPackagePath }, () => { + const service = new PortalService({ + db: createVersionDb(), + objectStorage: {}, + rpcClient: {}, + authService: {} + }); + + expect(service.localVersionItems()).toEqual([ + { + group: "apps", + name: "aios-management-web", + package_name: "aios-management-web", + version: "9.8.7" + }, + { + group: "apps", + name: "aios-app-invoke-proxy-service", + package_name: "aios-app-invoke-proxy-service", + version: "6.5.4" + } + ]); + }); +}); + +it("loads local package versions once and then serves them from memory", () => { + const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "aios-web-version-cache-")); + const webPackagePath = path.join(packageRoot, "web-package.json"); + const proxyPackagePath = path.join(packageRoot, "proxy-package.json"); + fs.writeFileSync(webPackagePath, JSON.stringify({ + name: "aios-management-web", + version: "1.0.0" + }), "utf8"); + fs.writeFileSync(proxyPackagePath, JSON.stringify({ + name: "aios-app-invoke-proxy-service", + version: "2.0.0" + }), "utf8"); + + withPackageVersionEnv({ webPackagePath, proxyPackagePath }, () => { + const service = new PortalService({ + db: createVersionDb(), + objectStorage: {}, + rpcClient: {}, + authService: {} + }); + + const initial = service.cacheLocalVersionInfo(); + fs.writeFileSync(webPackagePath, JSON.stringify({ + name: "aios-management-web", + version: "9.9.9" + }), "utf8"); + fs.writeFileSync(proxyPackagePath, JSON.stringify({ + name: "aios-app-invoke-proxy-service", + version: "8.8.8" + }), "utf8"); + + const cached = service.cacheLocalVersionInfo(); + const readOnly = service.getVersionInfo(); + + expect(initial.items.map((item) => item.version)).toEqual(["1.0.0", "2.0.0"]); + expect(cached.items.map((item) => item.version)).toEqual(["1.0.0", "2.0.0"]); + expect(readOnly.items.map((item) => item.version)).toEqual(["1.0.0", "2.0.0"]); + }); +}); + +it("refreshes component version cache from local apps packages and management-service 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: {} + }); + + service.cacheLocalVersionInfo(); + 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.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"); +}); + +it("loads kernal versions once after management service is ready", async () => { + const calls = []; + const service = new PortalService({ + db: createVersionDb(), + objectStorage: {}, + rpcClient: { + isConfigured() { + return true; + }, + async call(action, params, timeoutMs) { + calls.push({ action, params, timeoutMs }); + return { + version: 1, + items: [ + { group: "kernal", name: "core", packageName: "openclaw", version: "2026.5.28" } + ] + }; + } + }, + authService: {} + }); + + const initial = await service.refreshVersionInfoCache({ timeoutMs: 1000 }); + const cached = await service.refreshVersionInfoCache({ timeoutMs: 2000 }); + + expect(calls).toEqual([{ + action: "version.list", + params: {}, + timeoutMs: 1000 + }]); + expect(initial.items.map((item) => item.version)).toEqual(["2026.5.28"]); + expect(cached.items.map((item) => item.version)).toEqual(["2026.5.28"]); +}); + it("updates portal settings without touching uploaded logo data", () => { const calls = []; let settings = { @@ -228,9 +488,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 ( @@ -244,15 +512,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: {}, @@ -266,10 +544,19 @@ 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(); }); -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 +581,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 +592,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 +600,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 +615,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, slug: lastStatement?.args?.[0], description: undefined }]; + }; - 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).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({ - 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/app/App.jsx b/apps/management-website/src/app/App.jsx index c124be92906e3b76edfb1005f7db760418bef2bf..81c2f56a501eb1f003fa6d8ab46c41fcdbcc494a 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: } ] }, @@ -165,6 +169,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 +231,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 +309,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 ( ; +} diff --git a/apps/management-website/src/components/LogoMark.jsx b/apps/management-website/src/components/LogoMark.jsx index a1422e4de75ed284133f9ec86cce20fdbd5ec344..65e50818cc8b1e5e31e75386ec4ed6985f01d300 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 c1b1a8bca7e005d4364112046f78a74f42fecde6..6e2aa358f6760287ec0afd1782237bf9af491dcb 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 0fe86e9849d4222119a65cf1eada9fd84e992b59..f3ead5e6649629493cf363be383cbb32e94d69ea 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/LargeLanguageModelsPage.jsx b/apps/management-website/src/pages/LargeLanguageModelsPage.jsx index 62faaf74fa1af2b49e53f6186ca420142e577c99..757f5f85f77851e917f23d095638a02fbb45fd33 100644 --- a/apps/management-website/src/pages/LargeLanguageModelsPage.jsx +++ b/apps/management-website/src/pages/LargeLanguageModelsPage.jsx @@ -86,9 +86,9 @@ export function LargeLanguageModelsPage() { const [submitLoading, setSubmitLoading] = useState(false); const [submitMaskOpen, setSubmitMaskOpen] = useState(false); const [submitMaskText, setSubmitMaskText] = useState("正在处理,请稍候..."); - const [providerTestingId, setProviderTestingId] = useState(""); - const [providerTestResult, setProviderTestResult] = useState(null); - const [providerTestOpen, setProviderTestOpen] = useState(false); + const [modelTestingRef, setModelTestingRef] = useState(""); + const [modelTestResult, setModelTestResult] = useState(null); + const [modelTestOpen, setModelTestOpen] = useState(false); const [providerForm] = Form.useForm(); const [modelForm] = Form.useForm(); const [messageApi, contextHolder] = message.useMessage(); @@ -240,27 +240,32 @@ export function LargeLanguageModelsPage() { } }; - const handleProviderTest = async (row) => { - 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() { -