aios-management-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.json +21 -0
- package/README.md +257 -0
- package/data/management-console.db +0 -0
- package/data/management-console.db-shm +0 -0
- package/data/management-console.db-wal +0 -0
- package/dist/assets/index-CV_wjCAG.js +464 -0
- package/dist/assets/index-DfMPB0eV.css +1 -0
- package/dist/index.html +13 -0
- package/docs/spec.md +199 -0
- package/index.html +12 -0
- package/package.json +37 -0
- package/scripts/reset-kernel.js +59 -0
- package/scripts/reset-password.js +22 -0
- package/server/fakes.js +57 -0
- package/server/index.js +21 -0
- package/server/src/api/middleware/auth.js +29 -0
- package/server/src/api/middleware/internal.js +44 -0
- package/server/src/api/routes/index.js +677 -0
- package/server/src/app.js +90 -0
- package/server/src/background/index.js +106 -0
- package/server/src/background/protocol.js +15 -0
- package/server/src/config/env.js +90 -0
- package/server/src/db/index.js +501 -0
- package/server/src/infra/mqtt/management-rpc-client.js +213 -0
- package/server/src/infra/providers/hzg-provider-client.js +39 -0
- package/server/src/infra/s3/object-storage.js +97 -0
- package/server/src/services/agent-quota.js +54 -0
- package/server/src/services/agent-service.js +696 -0
- package/server/src/services/agent-status-sync-service.js +132 -0
- package/server/src/services/audit-log-service.js +39 -0
- package/server/src/services/auth-service.js +153 -0
- package/server/src/services/catalog-sync-service.js +712 -0
- package/server/src/services/external-service.js +308 -0
- package/server/src/services/kernel-reset-service.js +86 -0
- package/server/src/services/portal-service.js +555 -0
- package/server/src/services/system-service.js +580 -0
- package/server/src/services/topic-ping-service.js +282 -0
- package/server/src/utils/errors.js +36 -0
- package/server/src/utils/security.js +22 -0
- package/server/test/agent-service-alignment.test.js +316 -0
- package/server/test/agent-service-create.test.js +662 -0
- package/server/test/agent-status-sync-service.test.js +167 -0
- package/server/test/agent-update-audit.test.js +63 -0
- package/server/test/auth-middleware.test.js +71 -0
- package/server/test/background-services.test.js +160 -0
- package/server/test/catalog-sync-service.test.js +920 -0
- package/server/test/db-reset-migration.test.js +123 -0
- package/server/test/env-config.test.js +68 -0
- package/server/test/external-service.test.js +380 -0
- package/server/test/hzg-provider-client.test.js +50 -0
- package/server/test/internal-auth-middleware.test.js +66 -0
- package/server/test/kernel-reset-service.test.js +112 -0
- package/server/test/management-rpc-client.test.js +105 -0
- package/server/test/portal-service-access-tokens.test.js +121 -0
- package/server/test/portal-service-alignment.test.js +318 -0
- package/server/test/portal-service-management-logs.test.js +114 -0
- package/server/test/reset-kernel-cli.test.js +23 -0
- package/server/test/service-api-auth-middleware.test.js +59 -0
- package/server/test/system-service-alignment.test.js +265 -0
- package/server/test/topic-ping-service.test.js +182 -0
- package/server/test/usage-refresh-audit-route.test.js +82 -0
- package/src/App.jsx +1 -0
- package/src/api.js +1 -0
- package/src/app/App.jsx +346 -0
- package/src/app/api-client.js +112 -0
- package/src/components/AppShell.jsx +117 -0
- package/src/components/CardTitleWithReload.jsx +20 -0
- package/src/components/DeleteActionButton.jsx +31 -0
- package/src/main.jsx +14 -0
- package/src/pages/AgentsPage.jsx +647 -0
- package/src/pages/AiosUsersPage.jsx +151 -0
- package/src/pages/DashboardPage.jsx +72 -0
- package/src/pages/LoginPage.jsx +41 -0
- package/src/pages/SettingsPage.jsx +431 -0
- package/src/pages/SkillsPage.jsx +175 -0
- package/src/pages/SystemLogsPage.jsx +349 -0
- package/src/pages/SystemsPage.jsx +498 -0
- package/src/pages/TemplatesPage.jsx +207 -0
- package/src/pages/UserManagementPage.jsx +25 -0
- package/src/pages/UsersPage.jsx +192 -0
- package/src/pages/system-logs/SystemLogsTabs.jsx +362 -0
- package/src/styles.css +222 -0
- package/src/utils/format.js +63 -0
- package/test/.reports/fast-2026-05-25T08-32-39-420Z.json +299 -0
- package/test/integration/common.js +208 -0
- package/test/integration/fast.js +135 -0
- package/test/integration/full.js +306 -0
- package/test/run-browser-e2e.js +212 -0
- package/test/run-jasmine.js +21 -0
- package/test/setup.js +1 -0
- package/vite.config.js +12 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import AdmZip from "adm-zip";
|
|
2
|
+
|
|
3
|
+
import { jsonParse, jsonStringify, newAccessToken } from "../db/index.js";
|
|
4
|
+
import { badRequest, conflict, notFound } from "../utils/errors.js";
|
|
5
|
+
import { hashPassword } from "../utils/security.js";
|
|
6
|
+
|
|
7
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
8
|
+
|
|
9
|
+
function ensureZipContains(buffer, requiredName) {
|
|
10
|
+
const zip = new AdmZip(buffer);
|
|
11
|
+
const names = zip
|
|
12
|
+
.getEntries()
|
|
13
|
+
.filter((entry) => !entry.isDirectory)
|
|
14
|
+
.map((entry) => entry.entryName.replace(/^\/+/, ""));
|
|
15
|
+
|
|
16
|
+
if (!names.includes(requiredName)) {
|
|
17
|
+
throw badRequest(`Zip 包中必须包含顶层文件 ${requiredName}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseJsonOrFallback(value, fallback = null) {
|
|
22
|
+
if (value === undefined || value === null || value === "") {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return jsonParse(value, fallback);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getDurationMs(startedAt, completedAt) {
|
|
30
|
+
if (!startedAt || !completedAt) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const started = new Date(startedAt);
|
|
35
|
+
const completed = new Date(completedAt);
|
|
36
|
+
if (Number.isNaN(started.getTime()) || Number.isNaN(completed.getTime())) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Math.max(0, completed.getTime() - started.getTime());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateSlug(value, label) {
|
|
44
|
+
const normalized = String(value || "").trim();
|
|
45
|
+
if (!normalized) {
|
|
46
|
+
throw badRequest(`${label}不能为空`);
|
|
47
|
+
}
|
|
48
|
+
if (!SLUG_PATTERN.test(normalized)) {
|
|
49
|
+
throw badRequest(`${label}需符合 slug 规则:仅允许小写字母、数字和中划线,且不能以中划线开头或结尾`);
|
|
50
|
+
}
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateUbuntuDirName(value, label) {
|
|
55
|
+
const normalized = String(value || "").trim();
|
|
56
|
+
if (!normalized) {
|
|
57
|
+
throw badRequest(`${label}不能为空`);
|
|
58
|
+
}
|
|
59
|
+
if (!SLUG_PATTERN.test(normalized)) {
|
|
60
|
+
throw badRequest(`${label}需符合 Ubuntu 目录名规则:仅允许小写字母、数字和中划线,且不能以中划线开头或结尾`);
|
|
61
|
+
}
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeUserStatus(value) {
|
|
66
|
+
const status = String(value || "active").trim();
|
|
67
|
+
if (status !== "active" && status !== "disabled") {
|
|
68
|
+
throw badRequest("用户状态只能是 active 或 disabled");
|
|
69
|
+
}
|
|
70
|
+
return status;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class PortalService {
|
|
74
|
+
constructor({ db, objectStorage, rpcClient, authService }) {
|
|
75
|
+
this.db = db;
|
|
76
|
+
this.objectStorage = objectStorage;
|
|
77
|
+
this.rpcClient = rpcClient;
|
|
78
|
+
this.authService = authService;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getSettings() {
|
|
82
|
+
return this.db.prepare("SELECT * FROM settings WHERE id = 1").get();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
listAccessTokens() {
|
|
86
|
+
return this.db.prepare(`
|
|
87
|
+
SELECT token, created_at, updated_at
|
|
88
|
+
FROM access_tokens
|
|
89
|
+
ORDER BY created_at DESC, token DESC
|
|
90
|
+
`).all();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
hasAccessToken(token) {
|
|
94
|
+
if (!token) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const row = this.db.prepare("SELECT token FROM access_tokens WHERE token = ?").get(token);
|
|
99
|
+
return Boolean(row);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
createAccessToken() {
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
let token = "";
|
|
105
|
+
|
|
106
|
+
do {
|
|
107
|
+
token = newAccessToken();
|
|
108
|
+
} while (this.hasAccessToken(token));
|
|
109
|
+
|
|
110
|
+
this.db.prepare(`
|
|
111
|
+
INSERT INTO access_tokens (
|
|
112
|
+
token, created_at, updated_at
|
|
113
|
+
) VALUES (?, ?, ?)
|
|
114
|
+
`).run(token, now, now);
|
|
115
|
+
|
|
116
|
+
return this.db.prepare(`
|
|
117
|
+
SELECT token, created_at, updated_at
|
|
118
|
+
FROM access_tokens
|
|
119
|
+
WHERE token = ?
|
|
120
|
+
`).get(token);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
deleteAccessToken(token) {
|
|
124
|
+
const normalizedToken = String(token || "").trim();
|
|
125
|
+
if (!normalizedToken) {
|
|
126
|
+
throw badRequest("缺少访问令牌");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const current = this.db.prepare("SELECT token FROM access_tokens WHERE token = ?").get(normalizedToken);
|
|
130
|
+
if (!current) {
|
|
131
|
+
throw notFound("访问令牌不存在");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const count = Number(this.db.prepare("SELECT COUNT(*) AS count FROM access_tokens").get()?.count || 0);
|
|
135
|
+
if (count <= 1) {
|
|
136
|
+
throw conflict("至少需要保留一个访问令牌");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.db.prepare("DELETE FROM access_tokens WHERE token = ?").run(normalizedToken);
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
updateSettings(payload) {
|
|
144
|
+
const current = this.getSettings();
|
|
145
|
+
const next = {
|
|
146
|
+
...current,
|
|
147
|
+
...payload,
|
|
148
|
+
updated_at: new Date().toISOString()
|
|
149
|
+
};
|
|
150
|
+
this.db.prepare(`
|
|
151
|
+
UPDATE settings
|
|
152
|
+
SET portal_name = ?, brand_subtitle = ?, theme_color = ?, updated_at = ?
|
|
153
|
+
WHERE id = 1
|
|
154
|
+
`).run(
|
|
155
|
+
next.portal_name,
|
|
156
|
+
next.brand_subtitle,
|
|
157
|
+
next.theme_color,
|
|
158
|
+
next.updated_at
|
|
159
|
+
);
|
|
160
|
+
return this.getSettings();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
listManagementRequests({ page = 1, pageSize = 50 } = {}) {
|
|
164
|
+
const safePage = Math.max(1, Number(page) || 1);
|
|
165
|
+
const safePageSize = Math.max(1, Math.min(200, Number(pageSize) || 50));
|
|
166
|
+
const offset = (safePage - 1) * safePageSize;
|
|
167
|
+
const total = Number(
|
|
168
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM management_requests").get()?.count || 0
|
|
169
|
+
);
|
|
170
|
+
const items = this.db.prepare(`
|
|
171
|
+
SELECT *
|
|
172
|
+
FROM management_requests
|
|
173
|
+
ORDER BY created_at DESC, id DESC
|
|
174
|
+
LIMIT ? OFFSET ?
|
|
175
|
+
`).all(safePageSize, offset).map((row) => ({
|
|
176
|
+
...row,
|
|
177
|
+
ok: row.ok === null || row.ok === undefined ? null : Boolean(row.ok),
|
|
178
|
+
params: parseJsonOrFallback(row.params_json, {}),
|
|
179
|
+
result: parseJsonOrFallback(row.result_json, null),
|
|
180
|
+
error: parseJsonOrFallback(row.error_json, null),
|
|
181
|
+
response_time_ms: getDurationMs(row.created_at, row.completed_at)
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
items,
|
|
186
|
+
total,
|
|
187
|
+
page: safePage,
|
|
188
|
+
pageSize: safePageSize
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
listUsers(role) {
|
|
193
|
+
const rows = role
|
|
194
|
+
? this.db.prepare("SELECT * FROM users WHERE role = ? ORDER BY updated_at DESC").all(role)
|
|
195
|
+
: this.db.prepare("SELECT * FROM users ORDER BY role, updated_at DESC").all();
|
|
196
|
+
return rows.map((row) => this.authService.mapUser(row));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
assertUserEditable(row) {
|
|
200
|
+
if (row.is_builtin || row.username === "aios") {
|
|
201
|
+
throw conflict("默认管理员不支持编辑");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
createUser(payload) {
|
|
206
|
+
if (!payload.username || !payload.display_name || !payload.role) {
|
|
207
|
+
throw badRequest("缺少必要字段:用户名、显示名或角色");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const passwordHash = payload.role === "aios-admin" ? hashPassword(payload.password || "123456") : "";
|
|
211
|
+
const now = new Date().toISOString();
|
|
212
|
+
const result = this.db.prepare(`
|
|
213
|
+
INSERT INTO users (
|
|
214
|
+
role, username, display_name, status, password_hash,
|
|
215
|
+
must_change_password, is_builtin, tags_json, created_at, updated_at
|
|
216
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
217
|
+
`).run(
|
|
218
|
+
payload.role,
|
|
219
|
+
payload.username,
|
|
220
|
+
payload.display_name,
|
|
221
|
+
normalizeUserStatus(payload.status),
|
|
222
|
+
passwordHash,
|
|
223
|
+
payload.role === "aios-admin" ? 1 : 0,
|
|
224
|
+
0,
|
|
225
|
+
jsonStringify(payload.tags),
|
|
226
|
+
now,
|
|
227
|
+
now
|
|
228
|
+
);
|
|
229
|
+
return this.authService.mapUser(
|
|
230
|
+
this.db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
updateUser(userId, payload) {
|
|
235
|
+
const current = this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
|
236
|
+
if (!current) {
|
|
237
|
+
throw notFound("用户不存在");
|
|
238
|
+
}
|
|
239
|
+
this.assertUserEditable(current);
|
|
240
|
+
|
|
241
|
+
const next = {
|
|
242
|
+
...current,
|
|
243
|
+
...payload,
|
|
244
|
+
status: normalizeUserStatus(payload.status ?? current.status),
|
|
245
|
+
tags_json: jsonStringify(payload.tags ?? jsonParse(current.tags_json)),
|
|
246
|
+
updated_at: new Date().toISOString()
|
|
247
|
+
};
|
|
248
|
+
this.db.prepare(`
|
|
249
|
+
UPDATE users
|
|
250
|
+
SET display_name = ?, status = ?, tags_json = ?, updated_at = ?
|
|
251
|
+
WHERE id = ?
|
|
252
|
+
`).run(
|
|
253
|
+
next.display_name,
|
|
254
|
+
next.status,
|
|
255
|
+
next.tags_json,
|
|
256
|
+
next.updated_at,
|
|
257
|
+
userId
|
|
258
|
+
);
|
|
259
|
+
return this.authService.mapUser(this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
deleteUser(userId, operatorUserId) {
|
|
263
|
+
const current = this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
|
264
|
+
if (!current) {
|
|
265
|
+
throw notFound("用户不存在");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (Number(userId) === Number(operatorUserId)) {
|
|
269
|
+
throw conflict("不允许删除当前登录用户");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (current.is_builtin || current.username === "aios") {
|
|
273
|
+
throw conflict("默认管理员不支持删除");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async persistArtifact({ kind, file, createdBy }) {
|
|
280
|
+
const upload = await this.objectStorage.uploadAdminArtifact({ kind, file });
|
|
281
|
+
const result = this.db.prepare(`
|
|
282
|
+
INSERT INTO artifacts (
|
|
283
|
+
kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at
|
|
284
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
285
|
+
`).run(
|
|
286
|
+
kind,
|
|
287
|
+
upload.bucket,
|
|
288
|
+
upload.objectKey,
|
|
289
|
+
file.originalname,
|
|
290
|
+
file.mimetype || "application/octet-stream",
|
|
291
|
+
file.size,
|
|
292
|
+
createdBy,
|
|
293
|
+
new Date().toISOString()
|
|
294
|
+
);
|
|
295
|
+
return {
|
|
296
|
+
id: result.lastInsertRowid,
|
|
297
|
+
...upload
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
listTemplates() {
|
|
302
|
+
return this.db.prepare(`
|
|
303
|
+
SELECT
|
|
304
|
+
t.*,
|
|
305
|
+
a.original_name AS artifact_name
|
|
306
|
+
FROM agent_templates t
|
|
307
|
+
JOIN artifacts a ON a.id = t.artifact_id
|
|
308
|
+
ORDER BY t.updated_at DESC
|
|
309
|
+
`).all().map((row) => ({
|
|
310
|
+
...row,
|
|
311
|
+
is_builtin: Boolean(row.is_builtin),
|
|
312
|
+
agent_slugs: this.db.prepare(`
|
|
313
|
+
SELECT slug
|
|
314
|
+
FROM agents
|
|
315
|
+
WHERE template_name = ?
|
|
316
|
+
ORDER BY slug
|
|
317
|
+
`).all(row.template_name).map((item) => item.slug),
|
|
318
|
+
remote_result: jsonParse(row.remote_result_json, {})
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async createTemplate({ templateName, description, file, createdBy }) {
|
|
323
|
+
templateName = validateUbuntuDirName(templateName, "模板名称");
|
|
324
|
+
if (!file) {
|
|
325
|
+
throw badRequest("模板名称和模板文件不能为空");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
ensureZipContains(file.buffer, "AGENTS.md");
|
|
329
|
+
const artifact = await this.persistArtifact({ kind: "template", file, createdBy });
|
|
330
|
+
const remoteResult = await this.rpcClient.call("agent.template.create", {
|
|
331
|
+
templateName,
|
|
332
|
+
bucket: artifact.bucket,
|
|
333
|
+
objectKey: artifact.objectKey,
|
|
334
|
+
replace: true
|
|
335
|
+
});
|
|
336
|
+
const now = new Date().toISOString();
|
|
337
|
+
this.db.prepare(`
|
|
338
|
+
INSERT INTO agent_templates (
|
|
339
|
+
template_name, description, artifact_id, remote_status, remote_result_json, created_at, updated_at
|
|
340
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
341
|
+
ON CONFLICT(template_name) DO UPDATE SET
|
|
342
|
+
description = excluded.description,
|
|
343
|
+
artifact_id = excluded.artifact_id,
|
|
344
|
+
remote_status = excluded.remote_status,
|
|
345
|
+
remote_result_json = excluded.remote_result_json,
|
|
346
|
+
updated_at = excluded.updated_at
|
|
347
|
+
`).run(
|
|
348
|
+
templateName,
|
|
349
|
+
description || "",
|
|
350
|
+
artifact.id,
|
|
351
|
+
"ready",
|
|
352
|
+
JSON.stringify(remoteResult ?? {}),
|
|
353
|
+
now,
|
|
354
|
+
now
|
|
355
|
+
);
|
|
356
|
+
return this.listTemplates().find((item) => item.template_name === templateName);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async deleteTemplate(templateName) {
|
|
360
|
+
const existing = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(templateName);
|
|
361
|
+
if (!existing) {
|
|
362
|
+
throw notFound("模板不存在");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (existing.is_builtin) {
|
|
366
|
+
throw conflict("内置模板不允许删除");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const usageCount = Number(this.db.prepare(`
|
|
370
|
+
SELECT COUNT(*) AS count
|
|
371
|
+
FROM agents
|
|
372
|
+
WHERE template_name = ?
|
|
373
|
+
`).get(templateName)?.count || 0);
|
|
374
|
+
if (usageCount > 0) {
|
|
375
|
+
throw conflict("模板仍被数字员工使用,无法删除");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await this.rpcClient.call("agent.template.delete", { templateName });
|
|
379
|
+
this.db.prepare("DELETE FROM agent_templates WHERE template_name = ?").run(templateName);
|
|
380
|
+
return { ok: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
listSkills() {
|
|
384
|
+
return this.db.prepare(`
|
|
385
|
+
SELECT
|
|
386
|
+
s.*,
|
|
387
|
+
a.original_name AS artifact_name
|
|
388
|
+
FROM skills s
|
|
389
|
+
LEFT JOIN artifacts a ON a.id = s.artifact_id
|
|
390
|
+
ORDER BY s.updated_at DESC
|
|
391
|
+
`).all().map((row) => ({
|
|
392
|
+
...row,
|
|
393
|
+
is_builtin: Boolean(row.is_builtin),
|
|
394
|
+
agent_slugs: this.db.prepare(`
|
|
395
|
+
SELECT ag.slug
|
|
396
|
+
FROM agent_skill_bindings b
|
|
397
|
+
JOIN agents ag ON ag.id = b.agent_id
|
|
398
|
+
WHERE b.skill_id = ?
|
|
399
|
+
ORDER BY ag.slug
|
|
400
|
+
`).all(row.id).map((item) => item.slug)
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async createSkill({ payload, file, createdBy }) {
|
|
405
|
+
const slug = validateSlug(payload.slug, "技能ID");
|
|
406
|
+
if (!file) {
|
|
407
|
+
throw badRequest("请上传技能 zip 文件");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
ensureZipContains(file.buffer, "SKILL.md");
|
|
411
|
+
const artifact = await this.persistArtifact({ kind: "skill", file, createdBy });
|
|
412
|
+
|
|
413
|
+
await this.rpcClient.call("skills.global.install.local", {
|
|
414
|
+
slug,
|
|
415
|
+
bucket: artifact.bucket,
|
|
416
|
+
objectKey: artifact.objectKey,
|
|
417
|
+
force: false
|
|
418
|
+
});
|
|
419
|
+
const remoteStatus = "installed";
|
|
420
|
+
|
|
421
|
+
const now = new Date().toISOString();
|
|
422
|
+
const result = this.db.prepare(`
|
|
423
|
+
INSERT INTO skills (
|
|
424
|
+
slug, description, artifact_id, remote_status, created_at, updated_at
|
|
425
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
426
|
+
`).run(
|
|
427
|
+
slug,
|
|
428
|
+
payload.description || "",
|
|
429
|
+
artifact.id,
|
|
430
|
+
remoteStatus,
|
|
431
|
+
now,
|
|
432
|
+
now
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const skillId = result.lastInsertRowid;
|
|
436
|
+
return this.listSkills().find((item) => item.id === skillId);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async updateSkill(skillId, { payload, file, createdBy }) {
|
|
440
|
+
const current = this.db.prepare("SELECT * FROM skills WHERE id = ?").get(skillId);
|
|
441
|
+
if (!current) {
|
|
442
|
+
throw notFound("技能不存在");
|
|
443
|
+
}
|
|
444
|
+
const slug = validateSlug(current.slug, "技能ID");
|
|
445
|
+
if (!file) {
|
|
446
|
+
throw badRequest("请上传技能 zip 文件");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
ensureZipContains(file.buffer, "SKILL.md");
|
|
450
|
+
const artifact = await this.persistArtifact({ kind: "skill", file, createdBy });
|
|
451
|
+
|
|
452
|
+
const next = {
|
|
453
|
+
...current,
|
|
454
|
+
...payload,
|
|
455
|
+
artifact_id: artifact.id,
|
|
456
|
+
updated_at: new Date().toISOString()
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
await this.rpcClient.call("skills.global.install.local", {
|
|
460
|
+
slug,
|
|
461
|
+
bucket: artifact.bucket,
|
|
462
|
+
objectKey: artifact.objectKey,
|
|
463
|
+
force: true
|
|
464
|
+
});
|
|
465
|
+
const remoteStatus = "installed";
|
|
466
|
+
|
|
467
|
+
this.db.prepare(`
|
|
468
|
+
UPDATE skills
|
|
469
|
+
SET description = ?, artifact_id = ?, remote_status = ?, updated_at = ?
|
|
470
|
+
WHERE id = ?
|
|
471
|
+
`).run(
|
|
472
|
+
next.description ?? current.description,
|
|
473
|
+
next.artifact_id,
|
|
474
|
+
remoteStatus,
|
|
475
|
+
next.updated_at,
|
|
476
|
+
skillId
|
|
477
|
+
);
|
|
478
|
+
return this.listSkills().find((item) => item.id === skillId);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
replaceSkillBindings(skillId, agentSlugs) {
|
|
482
|
+
this.db.prepare("DELETE FROM agent_skill_bindings WHERE skill_id = ?").run(skillId);
|
|
483
|
+
const insert = this.db.prepare(`
|
|
484
|
+
INSERT INTO agent_skill_bindings (agent_id, skill_id)
|
|
485
|
+
VALUES (?, ?)
|
|
486
|
+
`);
|
|
487
|
+
for (const slug of agentSlugs) {
|
|
488
|
+
const agent = this.db.prepare("SELECT id FROM agents WHERE slug = ?").get(slug);
|
|
489
|
+
if (agent) {
|
|
490
|
+
insert.run(agent.id, skillId);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async deleteSkill(skillId) {
|
|
496
|
+
const current = this.db.prepare("SELECT * FROM skills WHERE id = ?").get(skillId);
|
|
497
|
+
if (!current) {
|
|
498
|
+
throw notFound("技能不存在");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (current.is_builtin) {
|
|
502
|
+
throw conflict("内置技能不允许删除");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (current.remote_status === "installed") {
|
|
506
|
+
await this.rpcClient.call("skills.global.delete", { slug: current.slug });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
this.db.prepare("DELETE FROM skills WHERE id = ?").run(skillId);
|
|
510
|
+
return { ok: true };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
getDashboard() {
|
|
514
|
+
const agentSyncState = this.db.prepare(`
|
|
515
|
+
SELECT status, last_success_at
|
|
516
|
+
FROM agent_sync_state
|
|
517
|
+
WHERE id = 1
|
|
518
|
+
`).get();
|
|
519
|
+
const usageRefreshState = this.db.prepare(`
|
|
520
|
+
SELECT status, last_success_at
|
|
521
|
+
FROM usage_refresh_state
|
|
522
|
+
WHERE id = 1
|
|
523
|
+
`).get();
|
|
524
|
+
const agentStatsReady = Boolean(agentSyncState?.last_success_at) && agentSyncState?.status === "success";
|
|
525
|
+
const usageStatsReady = Boolean(usageRefreshState?.last_success_at) && usageRefreshState?.status === "success";
|
|
526
|
+
const agentCount = this.db.prepare("SELECT COUNT(*) AS count FROM agents").get().count;
|
|
527
|
+
const templateCount = this.db.prepare("SELECT COUNT(*) AS count FROM agent_templates").get().count;
|
|
528
|
+
const skillCount = this.db.prepare("SELECT COUNT(*) AS count FROM skills").get().count;
|
|
529
|
+
const systemCount = this.db.prepare("SELECT COUNT(*) AS count FROM business_systems").get().count;
|
|
530
|
+
const totalTokenUsage = Number(this.db.prepare(`
|
|
531
|
+
SELECT COALESCE(SUM(COALESCE(json_extract(usage_snapshot_json, '$.usage.daily'), 0)), 0) AS total
|
|
532
|
+
FROM agents
|
|
533
|
+
`).get()?.total || 0);
|
|
534
|
+
const recentInvocations = this.db.prepare(`
|
|
535
|
+
SELECT * FROM system_invocation_logs
|
|
536
|
+
ORDER BY created_at DESC
|
|
537
|
+
LIMIT 8
|
|
538
|
+
`).all().map((row) => ({
|
|
539
|
+
...row,
|
|
540
|
+
request_payload: jsonParse(row.request_payload_json, {}),
|
|
541
|
+
response_payload: jsonParse(row.response_payload_json, {})
|
|
542
|
+
}));
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
stats: {
|
|
546
|
+
agents: agentStatsReady ? agentCount : null,
|
|
547
|
+
templates: templateCount,
|
|
548
|
+
skills: skillCount,
|
|
549
|
+
systems: systemCount,
|
|
550
|
+
today_tokens: usageStatsReady ? totalTokenUsage : null
|
|
551
|
+
},
|
|
552
|
+
recentInvocations
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|