@zhin.js/host-http 1.1.13 → 1.1.15
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/lib/console-rest-pages-helpers.d.ts +23 -0
- package/lib/console-rest-pages-helpers.js +570 -0
- package/lib/console-rest-pages.js +10 -570
- package/lib/console-rpc-handlers.d.ts +21 -0
- package/lib/console-rpc-handlers.js +659 -0
- package/lib/console-rpc.d.ts +9 -5
- package/lib/console-rpc.js +91 -584
- package/lib/http-host.js +3 -2
- package/package.json +4 -4
- package/src/console-rest-pages-helpers.ts +619 -0
- package/src/console-rest-pages.ts +12 -612
- package/src/console-rpc-handlers.ts +608 -0
- package/src/console-rpc.ts +82 -556
- package/src/http-host.ts +3 -2
|
@@ -6,14 +6,23 @@
|
|
|
6
6
|
* 数据源不可用时降级:读操作返回空数组 + `note` 说明,session tree 返回 503;
|
|
7
7
|
* 写操作要求 `ctx.fullScope && authScope === 'full'`,否则 403。
|
|
8
8
|
*/
|
|
9
|
-
import type { ServerResponse } from 'node:http';
|
|
10
9
|
import { readFile } from 'node:fs/promises';
|
|
11
10
|
import { join } from 'node:path';
|
|
12
11
|
import type { HttpHost } from './http-host.js';
|
|
13
|
-
import {
|
|
12
|
+
import { readJsonBody } from './json-body.js';
|
|
14
13
|
import { MarketplaceRegistry } from './marketplace-registry.js';
|
|
15
|
-
import type { AuthScope } from './token-registry.js';
|
|
16
14
|
import type { ConsoleMessageEventData } from '@zhin.js/console-protocol';
|
|
15
|
+
import {
|
|
16
|
+
clampPositiveInt,
|
|
17
|
+
decodePathParam,
|
|
18
|
+
normalizeBase,
|
|
19
|
+
parsePositiveInt,
|
|
20
|
+
registerAgentSessionRoutes,
|
|
21
|
+
registerIntrospectionRoutes,
|
|
22
|
+
requireWriteScope,
|
|
23
|
+
trimTrailingSlashes,
|
|
24
|
+
writeJson,
|
|
25
|
+
} from './console-rest-pages-helpers.js';
|
|
17
26
|
|
|
18
27
|
/** 与 basic/cli 接线方对齐的上下文约定。 */
|
|
19
28
|
export interface ConsoleRestCtx {
|
|
@@ -125,19 +134,6 @@ type LogRow = {
|
|
|
125
134
|
timestamp?: string | Date;
|
|
126
135
|
};
|
|
127
136
|
|
|
128
|
-
const INTROSPECTION_PAGE_SIZES = {
|
|
129
|
-
commands: 25,
|
|
130
|
-
middlewares: 30,
|
|
131
|
-
components: 30,
|
|
132
|
-
tools: 15,
|
|
133
|
-
'prompt-sections': 20,
|
|
134
|
-
endpoints: 30,
|
|
135
|
-
bindings: 30,
|
|
136
|
-
mcp: 30,
|
|
137
|
-
} as const;
|
|
138
|
-
|
|
139
|
-
type IntrospectionKind = keyof typeof INTROSPECTION_PAGE_SIZES;
|
|
140
|
-
|
|
141
137
|
const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
|
|
142
138
|
|
|
143
139
|
/**
|
|
@@ -576,599 +572,3 @@ async function listInstalledPluginPackages(
|
|
|
576
572
|
}
|
|
577
573
|
}));
|
|
578
574
|
}
|
|
579
|
-
|
|
580
|
-
// ---------------------------------------------------------------------------
|
|
581
|
-
// Runtime snapshot introspection
|
|
582
|
-
// ---------------------------------------------------------------------------
|
|
583
|
-
|
|
584
|
-
function registerIntrospectionRoutes(
|
|
585
|
-
route: HttpHost['route'],
|
|
586
|
-
base: string,
|
|
587
|
-
ctx: ConsoleRestCtx,
|
|
588
|
-
options: ConsoleRestPagesOptions,
|
|
589
|
-
): void {
|
|
590
|
-
const collectors: Array<{
|
|
591
|
-
kind: IntrospectionKind;
|
|
592
|
-
collect: (introspection: ConsoleAgentIntrospection | undefined) => { rows: readonly unknown[]; note?: string };
|
|
593
|
-
fields: Array<(item: unknown) => string | undefined>;
|
|
594
|
-
}> = [
|
|
595
|
-
{
|
|
596
|
-
kind: 'commands',
|
|
597
|
-
collect: (value) => ({ rows: value?.commands?.() ?? [] }),
|
|
598
|
-
fields: [
|
|
599
|
-
(c) => stringProp(c, 'pattern'),
|
|
600
|
-
(c) => stringProp(c, 'desc'),
|
|
601
|
-
(c) => stringProp(c, 'plugin'),
|
|
602
|
-
],
|
|
603
|
-
},
|
|
604
|
-
{
|
|
605
|
-
kind: 'middlewares',
|
|
606
|
-
collect: (value) => ({ rows: value?.middlewares?.() ?? [] }),
|
|
607
|
-
fields: [
|
|
608
|
-
(m) => stringProp(m, 'name'),
|
|
609
|
-
(m) => stringProp(m, 'owner'),
|
|
610
|
-
(m) => stringProp(m, 'phase'),
|
|
611
|
-
(m) => stringProp(m, 'target'),
|
|
612
|
-
(m) => stringProp(m, 'source'),
|
|
613
|
-
],
|
|
614
|
-
},
|
|
615
|
-
{
|
|
616
|
-
kind: 'components',
|
|
617
|
-
collect: (value) => ({ rows: value?.components?.() ?? [] }),
|
|
618
|
-
fields: [
|
|
619
|
-
(c) => stringProp(c, 'name'),
|
|
620
|
-
(c) => stringProp(c, 'owner'),
|
|
621
|
-
(c) => stringProp(c, 'source'),
|
|
622
|
-
],
|
|
623
|
-
},
|
|
624
|
-
{
|
|
625
|
-
kind: 'endpoints',
|
|
626
|
-
collect: () => ({
|
|
627
|
-
rows: (ctx.getEndpoints?.() ?? []).map((endpoint) => ({
|
|
628
|
-
adapter: endpoint.adapter,
|
|
629
|
-
name: endpoint.name,
|
|
630
|
-
online: endpoint.connected,
|
|
631
|
-
status: endpoint.status,
|
|
632
|
-
})),
|
|
633
|
-
}),
|
|
634
|
-
fields: [(b) => stringProp(b, 'adapter'), (b) => stringProp(b, 'name')],
|
|
635
|
-
},
|
|
636
|
-
{
|
|
637
|
-
kind: 'bindings',
|
|
638
|
-
collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
|
|
639
|
-
fields: [
|
|
640
|
-
(a) => stringProp(a, 'name'),
|
|
641
|
-
(a) => stringProp(a, 'provider'),
|
|
642
|
-
(a) => stringProp(a, 'model'),
|
|
643
|
-
],
|
|
644
|
-
},
|
|
645
|
-
{
|
|
646
|
-
kind: 'tools',
|
|
647
|
-
collect: (value) => ({ rows: value?.tools?.() ?? [] }),
|
|
648
|
-
fields: [
|
|
649
|
-
(t) => stringProp(t, 'name'),
|
|
650
|
-
(t) => stringProp(t, 'source'),
|
|
651
|
-
(t) => stringProp(t, 'description'),
|
|
652
|
-
],
|
|
653
|
-
},
|
|
654
|
-
{
|
|
655
|
-
kind: 'prompt-sections',
|
|
656
|
-
collect: (value) => ({ rows: value?.promptSections?.() ?? [] }),
|
|
657
|
-
fields: [
|
|
658
|
-
(section) => stringProp(section, 'name'),
|
|
659
|
-
(section) => stringProp(section, 'qualifiedName'),
|
|
660
|
-
(section) => stringProp(section, 'title'),
|
|
661
|
-
(section) => stringProp(section, 'owner'),
|
|
662
|
-
(section) => stringProp(section, 'layer'),
|
|
663
|
-
(section) => stringProp(section, 'retention'),
|
|
664
|
-
(section) => stringProp(section, 'source'),
|
|
665
|
-
],
|
|
666
|
-
},
|
|
667
|
-
{
|
|
668
|
-
kind: 'mcp',
|
|
669
|
-
collect: (value) => {
|
|
670
|
-
const result = value?.mcp?.();
|
|
671
|
-
if (result == null) return { rows: [] as readonly unknown[] };
|
|
672
|
-
// readonly 数组不能用 Array.isArray 收窄,按对象形状判断
|
|
673
|
-
if (typeof result === 'object' && 'rows' in result) {
|
|
674
|
-
return { rows: result.rows, note: result.note };
|
|
675
|
-
}
|
|
676
|
-
return { rows: result as readonly unknown[] };
|
|
677
|
-
},
|
|
678
|
-
fields: [(s) => stringProp(s, 'name')],
|
|
679
|
-
},
|
|
680
|
-
];
|
|
681
|
-
|
|
682
|
-
for (const { kind, collect, fields } of collectors) {
|
|
683
|
-
route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
|
|
684
|
-
const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
|
|
685
|
-
try {
|
|
686
|
-
const introspection = lease?.value.introspection;
|
|
687
|
-
const { rows, note } = collect(introspection);
|
|
688
|
-
const query = url.searchParams;
|
|
689
|
-
const filter = query.get('filter')?.trim() || undefined;
|
|
690
|
-
const filtered = filterByFields([...rows], filter, fields);
|
|
691
|
-
const slice = paginateItems(
|
|
692
|
-
filtered,
|
|
693
|
-
parsePositiveInt(query.get('page'), 1),
|
|
694
|
-
parsePositiveInt(query.get('pageSize'), INTROSPECTION_PAGE_SIZES[kind]),
|
|
695
|
-
);
|
|
696
|
-
const missing = kind === 'endpoints'
|
|
697
|
-
? !ctx.getEndpoints
|
|
698
|
-
: !introspection;
|
|
699
|
-
const degradedNote = note ?? (missing
|
|
700
|
-
? kind === 'endpoints'
|
|
701
|
-
? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
|
|
702
|
-
: 'Agent runtime 未装配(basic/cli 未接线 acquireAgentRuntime)'
|
|
703
|
-
: undefined);
|
|
704
|
-
writeJson(response, 200, {
|
|
705
|
-
success: true,
|
|
706
|
-
data: { ...slice, filter, note: degradedNote },
|
|
707
|
-
});
|
|
708
|
-
} catch (err) {
|
|
709
|
-
writeJson(response, 503, {
|
|
710
|
-
success: false,
|
|
711
|
-
data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
|
|
712
|
-
error: err instanceof Error ? err.message : String(err),
|
|
713
|
-
});
|
|
714
|
-
} finally {
|
|
715
|
-
lease?.release();
|
|
716
|
-
}
|
|
717
|
-
}, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
let activeComponentPreviews = 0;
|
|
721
|
-
route('POST', `${base}/introspection/components/render`, async (request, response, _url, authScope) => {
|
|
722
|
-
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
723
|
-
const body = (await readJsonBody<Record<string, unknown>>(request)) ?? {};
|
|
724
|
-
const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
|
|
725
|
-
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
726
|
-
if (!requester || !name || requester.length > 256 || name.length > 160) {
|
|
727
|
-
writeJson(response, 400, { success: false, error: '请提供有效的 requester 与 component name' });
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
|
|
731
|
-
if (activeComponentPreviews >= maxConcurrent) {
|
|
732
|
-
writeJson(response, 503, { success: false, error: 'Component preview 正忙,请稍后重试' });
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
const lease = ctx.acquireAgentRuntime?.();
|
|
736
|
-
const render = lease?.value.introspection?.renderComponent;
|
|
737
|
-
const previewDelivery = lease?.value.introspection?.previewComponentDelivery;
|
|
738
|
-
if (!lease || !render) {
|
|
739
|
-
lease?.release();
|
|
740
|
-
writeJson(response, 503, { success: false, error: 'Component Runtime 未就绪' });
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
const controller = new AbortController();
|
|
744
|
-
const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
|
|
745
|
-
const timeout = setTimeout(() => controller.abort(
|
|
746
|
-
new ComponentPreviewTimeoutError(`Component preview 超过 ${timeoutMs}ms`),
|
|
747
|
-
), timeoutMs);
|
|
748
|
-
activeComponentPreviews += 1;
|
|
749
|
-
let released = false;
|
|
750
|
-
const release = () => {
|
|
751
|
-
if (released) return;
|
|
752
|
-
released = true;
|
|
753
|
-
activeComponentPreviews -= 1;
|
|
754
|
-
lease.release();
|
|
755
|
-
};
|
|
756
|
-
const renderPromise = Promise.resolve().then(() => {
|
|
757
|
-
const input = { requester, name, props: body.props ?? {}, signal: controller.signal };
|
|
758
|
-
if (body.target === undefined) return render(input).then(output => ({ output }));
|
|
759
|
-
if (!previewDelivery) throw new Error('Target-aware Component preview 未就绪');
|
|
760
|
-
return previewDelivery({ ...input, target: body.target });
|
|
761
|
-
});
|
|
762
|
-
// A timed-out implementation may ignore AbortSignal. Keep its generation
|
|
763
|
-
// lease until the real operation settles; the bounded concurrency gate
|
|
764
|
-
// prevents non-cooperative previews from pinning unbounded generations.
|
|
765
|
-
void renderPromise.then(release, release);
|
|
766
|
-
try {
|
|
767
|
-
const preview = await raceWithAbort(
|
|
768
|
-
renderPromise,
|
|
769
|
-
controller.signal,
|
|
770
|
-
);
|
|
771
|
-
assertPreviewOutput(preview, options.componentPreviewMaxBytes ?? 256 * 1024);
|
|
772
|
-
writeJson(response, 200, { success: true, data: { requester, name, ...preview } });
|
|
773
|
-
} catch (error) {
|
|
774
|
-
const status = error instanceof ComponentPreviewTimeoutError
|
|
775
|
-
? 408
|
|
776
|
-
: error instanceof ComponentPreviewLimitError ? 413 : 422;
|
|
777
|
-
writeJson(response, status, {
|
|
778
|
-
success: false,
|
|
779
|
-
error: error instanceof Error ? error.message : String(error),
|
|
780
|
-
});
|
|
781
|
-
} finally {
|
|
782
|
-
clearTimeout(timeout);
|
|
783
|
-
if (!controller.signal.aborted) controller.abort();
|
|
784
|
-
}
|
|
785
|
-
}, { summary: 'Render a Component preview', tags: ['console', 'introspection', 'component'] });
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
class ComponentPreviewTimeoutError extends Error {
|
|
789
|
-
override readonly name = 'ComponentPreviewTimeoutError';
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
class ComponentPreviewLimitError extends Error {
|
|
793
|
-
override readonly name = 'ComponentPreviewLimitError';
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function clampPositiveInt(value: number | undefined, fallback: number, ceiling: number): number {
|
|
797
|
-
if (!Number.isFinite(value) || !value || value < 1) return fallback;
|
|
798
|
-
return Math.min(Math.floor(value), ceiling);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
async function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
802
|
-
if (signal.aborted) throw abortReason(signal);
|
|
803
|
-
return await new Promise<T>((resolve, reject) => {
|
|
804
|
-
const onAbort = () => reject(abortReason(signal));
|
|
805
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
806
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function abortReason(signal: AbortSignal): Error {
|
|
811
|
-
return signal.reason instanceof Error
|
|
812
|
-
? signal.reason
|
|
813
|
-
: new DOMException('Component preview aborted', 'AbortError');
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function assertPreviewOutput(output: unknown, maxBytes: number): void {
|
|
817
|
-
const byteLimit = clampPositiveInt(maxBytes, 256 * 1024, 1024 * 1024);
|
|
818
|
-
const seen = new WeakSet<object>();
|
|
819
|
-
let nodes = 0;
|
|
820
|
-
const visit = (value: unknown, depth: number): void => {
|
|
821
|
-
nodes += 1;
|
|
822
|
-
if (nodes > 10_000) throw new ComponentPreviewLimitError('Component preview 节点数超过 10000');
|
|
823
|
-
if (depth > 24) throw new ComponentPreviewLimitError('Component preview 嵌套深度超过 24');
|
|
824
|
-
if (!value || typeof value !== 'object') return;
|
|
825
|
-
if (seen.has(value)) throw new ComponentPreviewLimitError('Component preview 不能包含循环引用');
|
|
826
|
-
seen.add(value);
|
|
827
|
-
if (Array.isArray(value)) {
|
|
828
|
-
for (const item of value) visit(item, depth + 1);
|
|
829
|
-
} else {
|
|
830
|
-
for (const item of Object.values(value as Record<string, unknown>)) visit(item, depth + 1);
|
|
831
|
-
}
|
|
832
|
-
};
|
|
833
|
-
visit(output, 0);
|
|
834
|
-
let serialized: string;
|
|
835
|
-
try {
|
|
836
|
-
serialized = JSON.stringify(output);
|
|
837
|
-
} catch (error) {
|
|
838
|
-
throw new ComponentPreviewLimitError(
|
|
839
|
-
`Component preview 无法序列化: ${error instanceof Error ? error.message : String(error)}`,
|
|
840
|
-
);
|
|
841
|
-
}
|
|
842
|
-
if (Buffer.byteLength(serialized ?? '') > byteLimit) {
|
|
843
|
-
throw new ComponentPreviewLimitError(`Component preview 超过 ${byteLimit} bytes`);
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// ---------------------------------------------------------------------------
|
|
848
|
-
// agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
|
|
849
|
-
// ---------------------------------------------------------------------------
|
|
850
|
-
|
|
851
|
-
function registerAgentSessionRoutes(
|
|
852
|
-
route: HttpHost['route'],
|
|
853
|
-
base: string,
|
|
854
|
-
ctx: ConsoleRestCtx,
|
|
855
|
-
): void {
|
|
856
|
-
const prefix = `${base}/agent/sessions/`;
|
|
857
|
-
const acquireSessionTree = (): {
|
|
858
|
-
readonly tree: ConsoleAgentSessionTree;
|
|
859
|
-
release(): void;
|
|
860
|
-
} | null => {
|
|
861
|
-
const lease = ctx.acquireAgentRuntime?.();
|
|
862
|
-
const tree = lease?.value.sessionTree;
|
|
863
|
-
if (!lease || !tree || typeof tree.resolveActiveSessionId !== 'function') {
|
|
864
|
-
lease?.release();
|
|
865
|
-
return null;
|
|
866
|
-
}
|
|
867
|
-
return { tree, release: () => lease.release() };
|
|
868
|
-
};
|
|
869
|
-
const unavailable = (response: ServerResponse) => {
|
|
870
|
-
writeJson(response, 503, {
|
|
871
|
-
success: false,
|
|
872
|
-
error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
|
|
873
|
-
});
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
|
|
877
|
-
route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
|
|
878
|
-
const messageAction = parseSessionAction(url.pathname, prefix, 'message');
|
|
879
|
-
if (messageAction) response.setHeader('Cache-Control', 'no-store');
|
|
880
|
-
const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
|
|
881
|
-
if (!parsed) {
|
|
882
|
-
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
const messageId = Number(url.searchParams.get('messageId'));
|
|
886
|
-
const expectedSessionId = url.searchParams.get('sessionId');
|
|
887
|
-
if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
|
|
888
|
-
writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
const lease = acquireSessionTree();
|
|
892
|
-
if (!lease) {
|
|
893
|
-
unavailable(response);
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
const runtime = lease.tree;
|
|
897
|
-
try {
|
|
898
|
-
if (messageAction && !runtime.readBranchMessage) {
|
|
899
|
-
writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
903
|
-
if (messageAction) {
|
|
904
|
-
if (!sessionId) {
|
|
905
|
-
writeJson(response, 404, { success: false, error: '未找到活跃会话' });
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
if (sessionId !== expectedSessionId) {
|
|
909
|
-
writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
const text = await runtime.readBranchMessage!(sessionId, messageId);
|
|
913
|
-
if (text === null) {
|
|
914
|
-
writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
writeJson(response, 200, { success: true, data: { messageId, text } });
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
if (!sessionId) {
|
|
921
|
-
if (!isRuntimeImSessionKey(parsed.sessionKey)) {
|
|
922
|
-
writeJson(response, 404, {
|
|
923
|
-
success: false,
|
|
924
|
-
error: `未找到会话:${parsed.sessionKey}`,
|
|
925
|
-
});
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
const knownConversation = await ctx.isKnownConversationSession?.(parsed.sessionKey);
|
|
929
|
-
if (knownConversation === false) {
|
|
930
|
-
writeJson(response, 404, {
|
|
931
|
-
success: false,
|
|
932
|
-
error: `未找到会话:${parsed.sessionKey}`,
|
|
933
|
-
});
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
// 渠道会话与 Agent 会话的生命周期不同:用户可以先在 Console
|
|
937
|
-
// 选中一个真实渠道会话,而该会话尚未触发过 Agent。树查询是只读操作,
|
|
938
|
-
// 因而将这种正常状态返回为可渲染空态,而不是误报资源不存在。
|
|
939
|
-
writeJson(response, 200, {
|
|
940
|
-
success: true,
|
|
941
|
-
data: {
|
|
942
|
-
state: 'not_started',
|
|
943
|
-
sessionKey: parsed.sessionKey,
|
|
944
|
-
sessionId: null,
|
|
945
|
-
activeLeafMessageId: null,
|
|
946
|
-
points: [],
|
|
947
|
-
},
|
|
948
|
-
});
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
953
|
-
const points = await runtime.listBranchPoints(sessionId);
|
|
954
|
-
writeJson(response, 200, {
|
|
955
|
-
success: true,
|
|
956
|
-
data: {
|
|
957
|
-
state: 'active',
|
|
958
|
-
sessionKey: parsed.sessionKey,
|
|
959
|
-
sessionId,
|
|
960
|
-
activeLeafMessageId,
|
|
961
|
-
points,
|
|
962
|
-
},
|
|
963
|
-
});
|
|
964
|
-
} finally {
|
|
965
|
-
lease.release();
|
|
966
|
-
}
|
|
967
|
-
}, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
|
|
968
|
-
|
|
969
|
-
route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
|
|
970
|
-
const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
|
|
971
|
-
if (!parsed) {
|
|
972
|
-
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
976
|
-
let body: Record<string, unknown>;
|
|
977
|
-
try {
|
|
978
|
-
body = (await readJsonBody(request) ?? {}) as Record<string, unknown>;
|
|
979
|
-
} catch (error) {
|
|
980
|
-
if (error instanceof HttpBodyError) {
|
|
981
|
-
writeJson(response, error.statusCode, { success: false, error: error.message });
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
throw error;
|
|
985
|
-
}
|
|
986
|
-
const messageIdRaw = body.messageId;
|
|
987
|
-
const indexRaw = body.index;
|
|
988
|
-
let messageId: number | undefined;
|
|
989
|
-
let branchIndex: number | undefined;
|
|
990
|
-
if (messageIdRaw != null && messageIdRaw !== '') {
|
|
991
|
-
const value = Number(messageIdRaw);
|
|
992
|
-
if (!Number.isFinite(value) || value < 1) {
|
|
993
|
-
writeJson(response, 400, { success: false, error: 'messageId 须为正整数' });
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
messageId = value;
|
|
997
|
-
} else if (indexRaw != null && indexRaw !== '') {
|
|
998
|
-
const value = Number(indexRaw);
|
|
999
|
-
if (!Number.isFinite(value) || value < 1) {
|
|
1000
|
-
writeJson(response, 400, { success: false, error: 'index 须为正整数' });
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
branchIndex = value;
|
|
1004
|
-
} else {
|
|
1005
|
-
writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
const lease = acquireSessionTree();
|
|
1010
|
-
if (!lease) {
|
|
1011
|
-
unavailable(response);
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
const runtime = lease.tree;
|
|
1015
|
-
try {
|
|
1016
|
-
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
1017
|
-
if (!sessionId) {
|
|
1018
|
-
writeJson(response, 404, {
|
|
1019
|
-
success: false,
|
|
1020
|
-
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
1021
|
-
});
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
if (branchIndex !== undefined) {
|
|
1026
|
-
const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
|
|
1027
|
-
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
1028
|
-
writeJson(response, result.ok ? 200 : 400, {
|
|
1029
|
-
success: result.ok,
|
|
1030
|
-
message: result.message,
|
|
1031
|
-
data: {
|
|
1032
|
-
sessionKey: parsed.sessionKey,
|
|
1033
|
-
sessionId,
|
|
1034
|
-
activeLeafMessageId,
|
|
1035
|
-
},
|
|
1036
|
-
});
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
const ok = await runtime.switchActiveLeaf(sessionId, messageId!);
|
|
1041
|
-
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
1042
|
-
writeJson(response, ok ? 200 : 400, {
|
|
1043
|
-
success: ok,
|
|
1044
|
-
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
1045
|
-
data: {
|
|
1046
|
-
sessionKey: parsed.sessionKey,
|
|
1047
|
-
sessionId,
|
|
1048
|
-
activeLeafMessageId,
|
|
1049
|
-
},
|
|
1050
|
-
});
|
|
1051
|
-
} finally {
|
|
1052
|
-
lease.release();
|
|
1053
|
-
}
|
|
1054
|
-
}, { summary: 'Switch agent session active leaf', tags: ['console', 'agent'] });
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
/** Runtime IM session keys are `<platform>:<endpoint>:<scope>:<sceneId>`. */
|
|
1058
|
-
function isRuntimeImSessionKey(value: string): boolean {
|
|
1059
|
-
const first = value.indexOf(':');
|
|
1060
|
-
const second = value.indexOf(':', first + 1);
|
|
1061
|
-
const third = value.indexOf(':', second + 1);
|
|
1062
|
-
return first > 0
|
|
1063
|
-
&& second > first + 1
|
|
1064
|
-
&& third > second + 1
|
|
1065
|
-
&& third < value.length - 1;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
function parseSessionAction(
|
|
1069
|
-
pathname: string,
|
|
1070
|
-
prefix: string,
|
|
1071
|
-
action: 'tree' | 'message' | 'leaf',
|
|
1072
|
-
): { sessionKey: string } | null {
|
|
1073
|
-
if (!pathname.startsWith(prefix)) return null;
|
|
1074
|
-
const rest = pathname.slice(prefix.length).replace(/\/+$/u, '');
|
|
1075
|
-
const suffix = `/${action}`;
|
|
1076
|
-
if (!rest.endsWith(suffix)) return null;
|
|
1077
|
-
const rawKey = rest.slice(0, -suffix.length);
|
|
1078
|
-
if (!rawKey) return null;
|
|
1079
|
-
return { sessionKey: decodePathParam(rawKey) };
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// ---------------------------------------------------------------------------
|
|
1083
|
-
// 内部分页 / 过滤
|
|
1084
|
-
// ---------------------------------------------------------------------------
|
|
1085
|
-
|
|
1086
|
-
function paginateItems<T>(
|
|
1087
|
-
all: T[],
|
|
1088
|
-
page: number,
|
|
1089
|
-
pageSize: number,
|
|
1090
|
-
): { items: T[]; page: number; pageSize: number; total: number; totalPages: number } {
|
|
1091
|
-
const total = all.length;
|
|
1092
|
-
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
|
1093
|
-
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
1094
|
-
const start = (safePage - 1) * pageSize;
|
|
1095
|
-
return {
|
|
1096
|
-
items: all.slice(start, start + pageSize),
|
|
1097
|
-
page: safePage,
|
|
1098
|
-
pageSize,
|
|
1099
|
-
total,
|
|
1100
|
-
totalPages,
|
|
1101
|
-
};
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
function filterByFields<T>(
|
|
1105
|
-
items: T[],
|
|
1106
|
-
filter: string | undefined,
|
|
1107
|
-
fields: Array<(item: T) => string | undefined>,
|
|
1108
|
-
): T[] {
|
|
1109
|
-
if (!filter?.trim()) return items;
|
|
1110
|
-
return items.filter((item) => {
|
|
1111
|
-
const blob = fields
|
|
1112
|
-
.map((f) => f(item) ?? '')
|
|
1113
|
-
.join(' ')
|
|
1114
|
-
.toLowerCase();
|
|
1115
|
-
return blob.includes(filter.trim().toLowerCase());
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
function stringProp(value: unknown, key: string): string | undefined {
|
|
1120
|
-
if (!value || typeof value !== 'object') return undefined;
|
|
1121
|
-
const prop = (value as Record<string, unknown>)[key];
|
|
1122
|
-
return typeof prop === 'string' ? prop : undefined;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function parsePositiveInt(raw: string | null, fallback: number): number {
|
|
1126
|
-
if (raw && /^\d+$/u.test(raw)) return Math.max(1, parseInt(raw, 10));
|
|
1127
|
-
return fallback;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// ---------------------------------------------------------------------------
|
|
1131
|
-
// helpers
|
|
1132
|
-
// ---------------------------------------------------------------------------
|
|
1133
|
-
|
|
1134
|
-
/** 写操作门禁:demo 部署(ctx.fullScope=false)或 demo token 均 403。 */
|
|
1135
|
-
function requireWriteScope(
|
|
1136
|
-
response: ServerResponse,
|
|
1137
|
-
ctx: ConsoleRestCtx,
|
|
1138
|
-
authScope: AuthScope,
|
|
1139
|
-
): boolean {
|
|
1140
|
-
if (ctx.fullScope && authScope === 'full') return true;
|
|
1141
|
-
writeJson(response, 403, {
|
|
1142
|
-
success: false,
|
|
1143
|
-
error: 'Demo scope: 只读访问,写操作被拒绝',
|
|
1144
|
-
});
|
|
1145
|
-
return false;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
function decodePathParam(raw: string): string {
|
|
1149
|
-
try {
|
|
1150
|
-
return decodeURIComponent(raw);
|
|
1151
|
-
} catch {
|
|
1152
|
-
return raw;
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
function trimTrailingSlashes(s: string): string {
|
|
1157
|
-
let i = s.length;
|
|
1158
|
-
while (i > 0 && s[i - 1] === '/') i--;
|
|
1159
|
-
return s.slice(0, i);
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
function normalizeBase(value: string): string {
|
|
1163
|
-
if (!value.startsWith('/')) return `/${value}`;
|
|
1164
|
-
return trimTrailingSlashes(value) || '/api';
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
function writeJson(response: ServerResponse, status: number, body: unknown): void {
|
|
1168
|
-
const payload = JSON.stringify(body);
|
|
1169
|
-
response.writeHead(status, {
|
|
1170
|
-
'content-type': 'application/json; charset=utf-8',
|
|
1171
|
-
'content-length': Buffer.byteLength(payload),
|
|
1172
|
-
});
|
|
1173
|
-
response.end(payload);
|
|
1174
|
-
}
|