@zhin.js/host-http 1.0.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.
@@ -0,0 +1,680 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { HttpBodyError, readJsonBody } from './json-body.js';
4
+ const INTROSPECTION_PAGE_SIZES = {
5
+ commands: 25,
6
+ tools: 15,
7
+ endpoints: 30,
8
+ bindings: 30,
9
+ mcp: 30,
10
+ };
11
+ let pluginsCache = null;
12
+ const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
13
+ /**
14
+ * 注册 Console 页面 REST 路由。返回注销函数(注销全部已注册路由)。
15
+ */
16
+ export function registerConsoleRestPages(host, ctx, options = {}) {
17
+ const base = normalizeBase(options.apiBase ?? '/api');
18
+ const fetchFn = options.fetchFn ?? fetch;
19
+ const pluginRegistryUrl = options.pluginRegistryUrl ?? 'https://zhin.js.org/plugins.json';
20
+ const npmRegistryUrl = (options.npmRegistryUrl ?? 'https://registry.npmmirror.com')
21
+ .replace(/\/+$/u, '');
22
+ const disposers = [];
23
+ const route = (method, path, handler, meta) => {
24
+ const dispose = host.route(method, path, handler, meta);
25
+ disposers.push(dispose);
26
+ return dispose;
27
+ };
28
+ registerLogsRoutes(route, base, ctx);
29
+ registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl, npmRegistryUrl);
30
+ registerIntrospectionRoutes(route, base, ctx);
31
+ registerAgentSessionRoutes(route, base, ctx);
32
+ return () => {
33
+ for (const dispose of disposers.splice(0))
34
+ dispose();
35
+ };
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // logs(legacy logs-rest-api.ts;数据源 SystemLog 模型)
39
+ // ---------------------------------------------------------------------------
40
+ function registerLogsRoutes(route, base, ctx) {
41
+ const getModel = () => {
42
+ const host = ctx.databaseHost;
43
+ if (!host?.started)
44
+ return null;
45
+ try {
46
+ const model = host.models.get('SystemLog');
47
+ return model && typeof model.select === 'function' ? model : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ };
53
+ const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
54
+ route('GET', `${base}/logs`, async (_request, response, url) => {
55
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') ?? '100', 10) || 100, 1), 1000);
56
+ const level = url.searchParams.get('level') ?? undefined;
57
+ const LogModel = getModel();
58
+ if (!LogModel) {
59
+ writeJson(response, 200, { success: true, data: [], total: 0, note: unavailableNote });
60
+ return;
61
+ }
62
+ let selection = LogModel.select();
63
+ if (level && level !== 'all') {
64
+ selection = selection.where({ level });
65
+ }
66
+ const logs = await selection.orderBy('timestamp', 'DESC').limit(limit);
67
+ writeJson(response, 200, {
68
+ success: true,
69
+ data: logs.map((log) => ({
70
+ level: log.level,
71
+ name: log.name,
72
+ message: log.message,
73
+ source: log.source,
74
+ timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
75
+ })),
76
+ total: logs.length,
77
+ });
78
+ }, { summary: 'List system logs', tags: ['console', 'logs'] });
79
+ route('DELETE', `${base}/logs`, async (_request, response, _url, authScope) => {
80
+ if (!requireWriteScope(response, ctx, authScope))
81
+ return;
82
+ const LogModel = getModel();
83
+ if (LogModel) {
84
+ await LogModel.delete({});
85
+ }
86
+ writeJson(response, 200, { success: true, message: '日志已清空' });
87
+ }, { summary: 'Clear system logs', tags: ['console', 'logs'] });
88
+ route('GET', `${base}/logs/stats`, async (_request, response) => {
89
+ const LogModel = getModel();
90
+ if (!LogModel) {
91
+ writeJson(response, 200, {
92
+ success: true,
93
+ data: {
94
+ total: 0,
95
+ byLevel: { info: 0, warn: 0, error: 0 },
96
+ oldestTimestamp: null,
97
+ },
98
+ note: unavailableNote,
99
+ });
100
+ return;
101
+ }
102
+ const total = await LogModel.select();
103
+ const levels = ['info', 'warn', 'error'];
104
+ const levelCounts = {};
105
+ for (const level of levels) {
106
+ const count = await LogModel.select().where({ level });
107
+ levelCounts[level] = count.length;
108
+ }
109
+ const oldestLog = await LogModel.select('timestamp')
110
+ .orderBy('timestamp', 'ASC')
111
+ .limit(1);
112
+ const oldestTimestamp = oldestLog.length > 0
113
+ ? oldestLog[0].timestamp instanceof Date
114
+ ? oldestLog[0].timestamp.toISOString()
115
+ : (oldestLog[0].timestamp ?? null)
116
+ : null;
117
+ writeJson(response, 200, {
118
+ success: true,
119
+ data: { total: total.length, byLevel: levelCounts, oldestTimestamp },
120
+ });
121
+ }, { summary: 'System log stats', tags: ['console', 'logs'] });
122
+ route('POST', `${base}/logs/cleanup`, async (request, response, _url, authScope) => {
123
+ if (!requireWriteScope(response, ctx, authScope))
124
+ return;
125
+ const LogModel = getModel();
126
+ if (!LogModel) {
127
+ writeJson(response, 200, {
128
+ success: true,
129
+ message: '已清理 0 条日志',
130
+ deletedCount: 0,
131
+ note: unavailableNote,
132
+ });
133
+ return;
134
+ }
135
+ const body = (await readJsonBody(request) ?? {});
136
+ const { days, maxRecords } = body;
137
+ let deletedCount = 0;
138
+ if (days && typeof days === 'number' && days > 0) {
139
+ const cutoffDate = new Date();
140
+ cutoffDate.setDate(cutoffDate.getDate() - days);
141
+ const deleted = await LogModel.delete({ timestamp: { $lt: cutoffDate } });
142
+ deletedCount += typeof deleted === 'number' ? deleted : deleted?.length || 0;
143
+ }
144
+ if (maxRecords && typeof maxRecords === 'number' && maxRecords > 0) {
145
+ const totalLogs = await LogModel.select();
146
+ if (totalLogs.length > maxRecords) {
147
+ const excessCount = totalLogs.length - maxRecords;
148
+ const oldestLogs = await LogModel.select('id', 'timestamp')
149
+ .orderBy('timestamp', 'ASC')
150
+ .limit(excessCount);
151
+ const idsToDelete = oldestLogs.map((log) => log.id);
152
+ if (idsToDelete.length > 0) {
153
+ const deleted = await LogModel.delete({ id: { $in: idsToDelete } });
154
+ deletedCount += typeof deleted === 'number' ? deleted : deleted?.length || 0;
155
+ }
156
+ }
157
+ }
158
+ writeJson(response, 200, {
159
+ success: true,
160
+ message: `已清理 ${deletedCount} 条日志`,
161
+ deletedCount,
162
+ });
163
+ }, { summary: 'Cleanup old system logs', tags: ['console', 'logs'] });
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // marketplace(legacy marketplace-rest-api.ts;plugins.json + npmmirror)
167
+ // ---------------------------------------------------------------------------
168
+ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl, npmRegistryUrl) {
169
+ const fetchPluginRegistry = async () => {
170
+ if (pluginsCache && Date.now() - pluginsCache.ts < PLUGINS_CACHE_TTL) {
171
+ return pluginsCache.data;
172
+ }
173
+ const resp = await fetchFn(pluginRegistryUrl, {
174
+ signal: AbortSignal.timeout(30_000),
175
+ });
176
+ if (!resp.ok)
177
+ throw new Error(`plugins.json fetch failed: ${resp.status}`);
178
+ const json = (await resp.json());
179
+ const list = json.plugins || [];
180
+ pluginsCache = { data: list, ts: Date.now() };
181
+ return list;
182
+ };
183
+ route('GET', '/pub/marketplace/search', async (_request, response, url) => {
184
+ const q = url.searchParams.get('q') ?? url.searchParams.get('keyword') ?? '';
185
+ const pageNum = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
186
+ const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20));
187
+ const category = url.searchParams.get('category') ?? undefined;
188
+ const official = url.searchParams.get('official') ?? undefined;
189
+ const searchKeyword = q.trim().toLowerCase();
190
+ try {
191
+ const allPlugins = (await fetchPluginRegistry());
192
+ let plugins = allPlugins.map((p) => ({
193
+ name: p.name,
194
+ displayName: p.displayName || '',
195
+ version: p.version || '',
196
+ description: p.description || '',
197
+ author: p.author || '',
198
+ isOfficial: !!p.isOfficial,
199
+ official: !!p.isOfficial,
200
+ category: p.category || 'util',
201
+ keywords: p.tags || [],
202
+ npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
203
+ date: p.lastUpdate || '',
204
+ downloads: p.downloads || { weekly: 0, monthly: 0 },
205
+ readme: p.readme || '',
206
+ license: p.license || '',
207
+ }));
208
+ if (searchKeyword) {
209
+ plugins = plugins.filter((p) => {
210
+ const haystack = [p.name, p.displayName, p.description, ...(p.keywords || [])]
211
+ .join(' ')
212
+ .toLowerCase();
213
+ return haystack.includes(searchKeyword);
214
+ });
215
+ }
216
+ if (category)
217
+ plugins = plugins.filter((p) => p.category === category);
218
+ if (official === 'true')
219
+ plugins = plugins.filter((p) => p.official);
220
+ if (official === 'false')
221
+ plugins = plugins.filter((p) => !p.official);
222
+ const total = plugins.length;
223
+ const start = (pageNum - 1) * pageSize;
224
+ const items = plugins.slice(start, start + pageSize);
225
+ writeJson(response, 200, {
226
+ success: true,
227
+ data: items,
228
+ total,
229
+ page: pageNum,
230
+ size: pageSize,
231
+ });
232
+ }
233
+ catch (err) {
234
+ writeJson(response, 502, {
235
+ success: false,
236
+ error: err instanceof Error ? err.message : 'Search failed',
237
+ });
238
+ }
239
+ }, { summary: 'Search plugin marketplace', tags: ['pub', 'marketplace'] });
240
+ route('GET', '/pub/marketplace/detail/*', async (_request, response, url) => {
241
+ const prefix = '/pub/marketplace/detail/';
242
+ const rawName = url.pathname.startsWith(prefix)
243
+ ? url.pathname.slice(prefix.length).replace(/\/+$/u, '')
244
+ : '';
245
+ if (!rawName) {
246
+ writeJson(response, 404, { success: false, error: 'Package name is required' });
247
+ return;
248
+ }
249
+ const pkgName = decodePathParam(rawName);
250
+ try {
251
+ let cachedDownloads = { weekly: 0, monthly: 0 };
252
+ try {
253
+ const registry = (await fetchPluginRegistry());
254
+ const cached = registry.find((p) => p.name === pkgName);
255
+ if (cached?.downloads && typeof cached.downloads === 'object') {
256
+ cachedDownloads = cached.downloads;
257
+ }
258
+ }
259
+ catch {
260
+ /* ignore cache miss */
261
+ }
262
+ const metaResp = await fetchFn(`${npmRegistryUrl}/${encodeURIComponent(pkgName)}`, { signal: AbortSignal.timeout(30_000) });
263
+ if (!metaResp.ok)
264
+ throw new Error(`Package not found: ${metaResp.status}`);
265
+ const meta = (await metaResp.json());
266
+ const latest = meta['dist-tags']?.latest;
267
+ const versions = meta.versions;
268
+ const latestInfo = latest && versions ? versions[latest] : undefined;
269
+ const time = meta.time;
270
+ writeJson(response, 200, {
271
+ success: true,
272
+ data: {
273
+ name: meta.name,
274
+ version: latest,
275
+ description: meta.description || '',
276
+ readme: meta.readme || '',
277
+ license: meta.license || latestInfo?.license || '',
278
+ homepage: meta.homepage || latestInfo?.homepage || '',
279
+ repository: meta.repository?.url ||
280
+ latestInfo?.repository?.url ||
281
+ '',
282
+ author: typeof meta.author === 'string'
283
+ ? meta.author
284
+ : meta.author?.name || '',
285
+ keywords: latestInfo?.keywords || [],
286
+ engines: latestInfo?.engines || {},
287
+ peerDependencies: latestInfo?.peerDependencies || {},
288
+ downloads: cachedDownloads,
289
+ versions: Object.keys(versions || {}),
290
+ lastPublish: (latest && time?.[latest]) || '',
291
+ },
292
+ });
293
+ }
294
+ catch (err) {
295
+ writeJson(response, 502, {
296
+ success: false,
297
+ error: err instanceof Error ? err.message : 'Detail fetch failed',
298
+ });
299
+ }
300
+ }, { summary: 'Plugin marketplace detail', tags: ['pub', 'marketplace'] });
301
+ route('GET', `${base}/marketplace/updates`, async (_request, response) => {
302
+ try {
303
+ const installed = await listInstalledPluginPackages(ctx.projectRoot);
304
+ if (!installed.length) {
305
+ writeJson(response, 200, { success: true, data: [] });
306
+ return;
307
+ }
308
+ const updates = await Promise.all(installed.map(async ({ name, current }) => {
309
+ try {
310
+ const resp = await fetchFn(`${npmRegistryUrl}/${encodeURIComponent(name)}/latest`, { signal: AbortSignal.timeout(15_000) });
311
+ if (!resp.ok)
312
+ return null;
313
+ const pkg = (await resp.json());
314
+ return {
315
+ name,
316
+ latest: pkg.version,
317
+ description: pkg.description || '',
318
+ // 本地已装版本对照:读不到 package.json 时省略该字段。
319
+ ...(current ? { current } : {}),
320
+ };
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }));
326
+ writeJson(response, 200, { success: true, data: updates.filter(Boolean) });
327
+ }
328
+ catch (err) {
329
+ writeJson(response, 500, {
330
+ success: false,
331
+ error: err instanceof Error ? err.message : 'Update check failed',
332
+ });
333
+ }
334
+ }, { summary: 'Check installed plugin updates', tags: ['console', 'marketplace'] });
335
+ }
336
+ /**
337
+ * 本地已装插件包:projectRoot/package.json 的 `zhin.plugins`({package, instanceKey}[]),
338
+ * 按包名去重;版本读 `node_modules/<pkg>/package.json`(读不到则省略)。
339
+ */
340
+ async function listInstalledPluginPackages(projectRoot) {
341
+ const names = new Set();
342
+ try {
343
+ const pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
344
+ const list = pkg.zhin?.plugins;
345
+ if (Array.isArray(list)) {
346
+ for (const item of list) {
347
+ if (typeof item === 'string' && item) {
348
+ names.add(item);
349
+ }
350
+ else if (item && typeof item === 'object') {
351
+ const entry = item.package;
352
+ if (typeof entry === 'string' && entry)
353
+ names.add(entry);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ catch {
359
+ // 无 package.json 或格式不符 — 返回空列表
360
+ }
361
+ return Promise.all([...names].map(async (name) => {
362
+ try {
363
+ const installed = JSON.parse(await readFile(join(projectRoot, 'node_modules', name, 'package.json'), 'utf8'));
364
+ const current = typeof installed.version === 'string' ? installed.version : undefined;
365
+ return current ? { name, current } : { name };
366
+ }
367
+ catch {
368
+ return { name };
369
+ }
370
+ }));
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // introspection(legacy introspection-rest-api.ts;分页形状对齐 IntrospectionJsonResponse)
374
+ // ---------------------------------------------------------------------------
375
+ function registerIntrospectionRoutes(route, base, ctx) {
376
+ const collectors = [
377
+ {
378
+ kind: 'commands',
379
+ collect: () => ({ rows: agentIntrospection(ctx)?.commands?.() ?? [] }),
380
+ fields: [
381
+ (c) => stringProp(c, 'pattern'),
382
+ (c) => stringProp(c, 'desc'),
383
+ (c) => stringProp(c, 'plugin'),
384
+ ],
385
+ },
386
+ {
387
+ kind: 'endpoints',
388
+ collect: () => ({
389
+ rows: (ctx.getEndpoints?.() ?? []).map((endpoint) => ({
390
+ adapter: endpoint.adapter,
391
+ name: endpoint.name,
392
+ online: endpoint.connected,
393
+ status: endpoint.status,
394
+ })),
395
+ }),
396
+ fields: [(b) => stringProp(b, 'adapter'), (b) => stringProp(b, 'name')],
397
+ },
398
+ {
399
+ kind: 'bindings',
400
+ collect: () => ({ rows: agentIntrospection(ctx)?.bindings?.() ?? [] }),
401
+ fields: [
402
+ (a) => stringProp(a, 'name'),
403
+ (a) => stringProp(a, 'provider'),
404
+ (a) => stringProp(a, 'model'),
405
+ ],
406
+ },
407
+ {
408
+ kind: 'tools',
409
+ collect: () => ({ rows: agentIntrospection(ctx)?.tools?.() ?? [] }),
410
+ fields: [
411
+ (t) => stringProp(t, 'name'),
412
+ (t) => stringProp(t, 'source'),
413
+ (t) => stringProp(t, 'description'),
414
+ ],
415
+ },
416
+ {
417
+ kind: 'mcp',
418
+ collect: () => {
419
+ const result = agentIntrospection(ctx)?.mcp?.();
420
+ if (result == null)
421
+ return { rows: [] };
422
+ // readonly 数组不能用 Array.isArray 收窄,按对象形状判断
423
+ if (typeof result === 'object' && 'rows' in result) {
424
+ return { rows: result.rows, note: result.note };
425
+ }
426
+ return { rows: result };
427
+ },
428
+ fields: [(s) => stringProp(s, 'name')],
429
+ },
430
+ ];
431
+ for (const { kind, collect, fields } of collectors) {
432
+ route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
433
+ try {
434
+ const { rows, note } = collect();
435
+ const query = url.searchParams;
436
+ const filter = query.get('filter')?.trim() || undefined;
437
+ const filtered = filterByFields([...rows], filter, fields);
438
+ const slice = paginateItems(filtered, parsePositiveInt(query.get('page'), 1), parsePositiveInt(query.get('pageSize'), INTROSPECTION_PAGE_SIZES[kind]));
439
+ const missing = kind === 'endpoints'
440
+ ? !ctx.getEndpoints
441
+ : !agentIntrospection(ctx);
442
+ const degradedNote = note ?? (missing
443
+ ? kind === 'endpoints'
444
+ ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
445
+ : 'Agent runtime 未装配(basic/cli 未接线 getAgentRuntime)'
446
+ : undefined);
447
+ writeJson(response, 200, {
448
+ success: true,
449
+ data: { ...slice, filter, note: degradedNote },
450
+ });
451
+ }
452
+ catch (err) {
453
+ writeJson(response, 503, {
454
+ success: false,
455
+ data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
456
+ error: err instanceof Error ? err.message : String(err),
457
+ });
458
+ }
459
+ }, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
460
+ }
461
+ }
462
+ function agentIntrospection(ctx) {
463
+ const runtime = ctx.getAgentRuntime?.();
464
+ return runtime && typeof runtime === 'object' ? runtime.introspection : undefined;
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
468
+ // ---------------------------------------------------------------------------
469
+ function registerAgentSessionRoutes(route, base, ctx) {
470
+ const prefix = `${base}/agent/sessions/`;
471
+ const getSessionTree = () => {
472
+ const runtime = ctx.getAgentRuntime?.();
473
+ const tree = runtime && typeof runtime === 'object' ? runtime.sessionTree : undefined;
474
+ if (!tree)
475
+ return null;
476
+ if (typeof tree.resolveActiveSessionId !== 'function')
477
+ return null;
478
+ return tree;
479
+ };
480
+ const unavailable = (response) => {
481
+ writeJson(response, 503, {
482
+ success: false,
483
+ error: 'Agent session tree runtime 未就绪(新 Runtime 需经 getAgentRuntime 装配 sessionTree)',
484
+ });
485
+ };
486
+ // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
487
+ route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
488
+ const parsed = parseSessionAction(url.pathname, prefix, 'tree');
489
+ if (!parsed) {
490
+ writeJson(response, 404, { success: false, error: 'Not found' });
491
+ return;
492
+ }
493
+ const runtime = getSessionTree();
494
+ if (!runtime) {
495
+ unavailable(response);
496
+ return;
497
+ }
498
+ const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
499
+ if (!sessionId) {
500
+ writeJson(response, 404, {
501
+ success: false,
502
+ error: `未找到活跃会话:${parsed.sessionKey}`,
503
+ });
504
+ return;
505
+ }
506
+ const session = await runtime.agentSessionStore.getBySessionId(sessionId);
507
+ const points = await runtime.listBranchPoints(sessionId);
508
+ writeJson(response, 200, {
509
+ success: true,
510
+ data: {
511
+ sessionKey: parsed.sessionKey,
512
+ sessionId,
513
+ activeLeafMessageId: session?.active_leaf_message_id ?? null,
514
+ points,
515
+ },
516
+ });
517
+ }, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
518
+ route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
519
+ const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
520
+ if (!parsed) {
521
+ writeJson(response, 404, { success: false, error: 'Not found' });
522
+ return;
523
+ }
524
+ if (!requireWriteScope(response, ctx, authScope))
525
+ return;
526
+ const runtime = getSessionTree();
527
+ if (!runtime) {
528
+ unavailable(response);
529
+ return;
530
+ }
531
+ const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
532
+ if (!sessionId) {
533
+ writeJson(response, 404, {
534
+ success: false,
535
+ error: `未找到活跃会话:${parsed.sessionKey}`,
536
+ });
537
+ return;
538
+ }
539
+ let body;
540
+ try {
541
+ body = (await readJsonBody(request) ?? {});
542
+ }
543
+ catch (error) {
544
+ if (error instanceof HttpBodyError) {
545
+ writeJson(response, error.statusCode, { success: false, error: error.message });
546
+ return;
547
+ }
548
+ throw error;
549
+ }
550
+ const messageIdRaw = body.messageId;
551
+ const indexRaw = body.index;
552
+ let messageId;
553
+ if (messageIdRaw != null && messageIdRaw !== '') {
554
+ const n = Number(messageIdRaw);
555
+ if (!Number.isFinite(n) || n < 1) {
556
+ writeJson(response, 400, { success: false, error: 'messageId 须为正整数' });
557
+ return;
558
+ }
559
+ messageId = n;
560
+ }
561
+ else if (indexRaw != null && indexRaw !== '') {
562
+ const index = Number(indexRaw);
563
+ if (!Number.isFinite(index) || index < 1) {
564
+ writeJson(response, 400, { success: false, error: 'index 须为正整数' });
565
+ return;
566
+ }
567
+ const result = await runtime.jumpToBranchIndex(sessionId, index);
568
+ const session = await runtime.agentSessionStore.getBySessionId(sessionId);
569
+ writeJson(response, result.ok ? 200 : 400, {
570
+ success: result.ok,
571
+ message: result.message,
572
+ data: {
573
+ sessionKey: parsed.sessionKey,
574
+ sessionId,
575
+ activeLeafMessageId: session?.active_leaf_message_id ?? null,
576
+ },
577
+ });
578
+ return;
579
+ }
580
+ else {
581
+ writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
582
+ return;
583
+ }
584
+ const ok = await runtime.switchActiveLeaf(sessionId, messageId);
585
+ const session = await runtime.agentSessionStore.getBySessionId(sessionId);
586
+ writeJson(response, ok ? 200 : 400, {
587
+ success: ok,
588
+ message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
589
+ data: {
590
+ sessionKey: parsed.sessionKey,
591
+ sessionId,
592
+ activeLeafMessageId: session?.active_leaf_message_id ?? null,
593
+ },
594
+ });
595
+ }, { summary: 'Switch agent session active leaf', tags: ['console', 'agent'] });
596
+ }
597
+ function parseSessionAction(pathname, prefix, action) {
598
+ if (!pathname.startsWith(prefix))
599
+ return null;
600
+ const rest = pathname.slice(prefix.length).replace(/\/+$/u, '');
601
+ const suffix = `/${action}`;
602
+ if (!rest.endsWith(suffix))
603
+ return null;
604
+ const rawKey = rest.slice(0, -suffix.length);
605
+ if (!rawKey)
606
+ return null;
607
+ return { sessionKey: decodePathParam(rawKey) };
608
+ }
609
+ // ---------------------------------------------------------------------------
610
+ // 内部分页 / 过滤(语义对齐 agent 包 introspection-pagination.ts)
611
+ // ---------------------------------------------------------------------------
612
+ function paginateItems(all, page, pageSize) {
613
+ const total = all.length;
614
+ const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
615
+ const safePage = Math.min(Math.max(1, page), totalPages);
616
+ const start = (safePage - 1) * pageSize;
617
+ return {
618
+ items: all.slice(start, start + pageSize),
619
+ page: safePage,
620
+ pageSize,
621
+ total,
622
+ totalPages,
623
+ };
624
+ }
625
+ function filterByFields(items, filter, fields) {
626
+ if (!filter?.trim())
627
+ return items;
628
+ return items.filter((item) => {
629
+ const blob = fields
630
+ .map((f) => f(item) ?? '')
631
+ .join(' ')
632
+ .toLowerCase();
633
+ return blob.includes(filter.trim().toLowerCase());
634
+ });
635
+ }
636
+ function stringProp(value, key) {
637
+ if (!value || typeof value !== 'object')
638
+ return undefined;
639
+ const prop = value[key];
640
+ return typeof prop === 'string' ? prop : undefined;
641
+ }
642
+ function parsePositiveInt(raw, fallback) {
643
+ if (raw && /^\d+$/u.test(raw))
644
+ return Math.max(1, parseInt(raw, 10));
645
+ return fallback;
646
+ }
647
+ // ---------------------------------------------------------------------------
648
+ // helpers
649
+ // ---------------------------------------------------------------------------
650
+ /** 写操作门禁:demo 部署(ctx.fullScope=false)或 demo token 均 403。 */
651
+ function requireWriteScope(response, ctx, authScope) {
652
+ if (ctx.fullScope && authScope === 'full')
653
+ return true;
654
+ writeJson(response, 403, {
655
+ success: false,
656
+ error: 'Demo scope: 只读访问,写操作被拒绝',
657
+ });
658
+ return false;
659
+ }
660
+ function decodePathParam(raw) {
661
+ try {
662
+ return decodeURIComponent(raw);
663
+ }
664
+ catch {
665
+ return raw;
666
+ }
667
+ }
668
+ function normalizeBase(value) {
669
+ if (!value.startsWith('/'))
670
+ return `/${value}`;
671
+ return value.replace(/\/+$/u, '') || '/api';
672
+ }
673
+ function writeJson(response, status, body) {
674
+ const payload = JSON.stringify(body);
675
+ response.writeHead(status, {
676
+ 'content-type': 'application/json; charset=utf-8',
677
+ 'content-length': Buffer.byteLength(payload),
678
+ });
679
+ response.end(payload);
680
+ }