@zhin.js/command 1.1.7 → 1.1.8

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,134 @@
1
+ /** Internal composition contract. Not part of the command authoring facade. */
2
+ import { createToken } from '@zhin.js/plugin-runtime';
3
+ /** @internal Admission-only Root resource; no bind or finish authority is published. */
4
+ export const commandExecutionAdmissionToken = createToken('zhin.command.execution-admission');
5
+ /** @internal Root-instance-owned state shared by projections and draining retired generations. */
6
+ export class CommandExecutionBudget {
7
+ #entries = new Map();
8
+ #views = new WeakMap();
9
+ #clock;
10
+ #capacity;
11
+ admission;
12
+ constructor(options = {}) {
13
+ this.#clock = options.clock ?? (() => performance.now());
14
+ this.#capacity = options.capacity ?? 10_000;
15
+ if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1)
16
+ throw new TypeError('Invalid command budget capacity');
17
+ this.admission = Object.freeze({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot) });
18
+ }
19
+ openOperation(input) {
20
+ const c = input.conversation;
21
+ const values = [c.endpoint.adapter, c.endpoint.id, c.kind, c.id, c.parent?.kind ?? '', c.parent?.id ?? '', c.threadId ?? '', input.senderId ?? ''];
22
+ const valid = values.slice(0, 4).every(nonempty) && ['private', 'group', 'channel'].includes(c.kind) && nonempty(input.senderId)
23
+ && (c.parent === undefined || (nonempty(c.parent.kind) && nonempty(c.parent.id)))
24
+ && (c.threadId === undefined || nonempty(c.threadId)) && values.every(value => value.length <= 1024);
25
+ const operation = {
26
+ snapshot: input.snapshot, key: valid ? Object.freeze(values) : undefined,
27
+ active: true, progressStopped: false, admitted: false, signal: input.retirementSignal,
28
+ reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
29
+ };
30
+ const stop = () => {
31
+ operation.progressStopped = true;
32
+ for (const timer of operation.timers)
33
+ clearTimeout(timer);
34
+ operation.timers.clear();
35
+ };
36
+ input.retirementSignal.addEventListener('abort', stop, { once: true });
37
+ return Object.freeze({
38
+ bind: (view, owner) => { if (operation.active)
39
+ this.#views.set(view, { operation, owner }); },
40
+ trackSend: (send) => {
41
+ if (!operation.active)
42
+ return;
43
+ const settled = send.then(() => { operation.sends.delete(settled); }, () => { operation.sends.delete(settled); });
44
+ operation.sends.add(settled);
45
+ },
46
+ stopProgress: async () => { stop(); await Promise.all(operation.feedback); },
47
+ finish: async () => {
48
+ stop();
49
+ await Promise.all(operation.feedback);
50
+ // New sends may start while an earlier send is draining. Do not release from an initial snapshot alone.
51
+ if (operation.admitted)
52
+ while (operation.sends.size > 0)
53
+ await Promise.all(operation.sends);
54
+ if (!operation.active)
55
+ return;
56
+ operation.active = false;
57
+ operation.signal.removeEventListener('abort', stop);
58
+ for (const release of operation.releases)
59
+ release();
60
+ operation.releases.length = 0;
61
+ operation.feedback.length = 0;
62
+ operation.accepted.clear();
63
+ operation.sends.clear();
64
+ operation.reply = undefined;
65
+ operation.snapshot = undefined;
66
+ operation.key = undefined;
67
+ },
68
+ });
69
+ }
70
+ #admit(source, capability, owner, policy, snapshot) {
71
+ const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
72
+ const operation = binding?.operation;
73
+ if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted)
74
+ return limited('operation_unavailable');
75
+ if (!operation.key)
76
+ return limited('identity_unavailable');
77
+ const key = JSON.stringify([capability, ...operation.key]);
78
+ // Re-entering the same capability is a second attempt, rather than a second free execution.
79
+ const now = this.#clock();
80
+ let entry = this.#entries.get(key);
81
+ if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent)
82
+ return limited('busy');
83
+ if (entry && entry.cooldownUntil > now)
84
+ return Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) });
85
+ if (!entry) {
86
+ if (this.#entries.size >= this.#capacity) {
87
+ for (const [oldKey, old] of this.#entries)
88
+ if (old.active === 0 && old.cooldownUntil <= now)
89
+ this.#entries.delete(oldKey);
90
+ if (this.#entries.size >= this.#capacity)
91
+ return limited('capacity');
92
+ }
93
+ entry = { active: 0, cooldownUntil: 0 };
94
+ this.#entries.set(key, entry);
95
+ }
96
+ entry.active++;
97
+ operation.admitted = true;
98
+ entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
99
+ const acceptedEntry = entry;
100
+ operation.releases.push(() => {
101
+ acceptedEntry.active--;
102
+ if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock())
103
+ this.#entries.delete(key);
104
+ });
105
+ if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
106
+ const timer = setTimeout(() => {
107
+ operation.timers.delete(timer);
108
+ if (!operation.active || operation.progressStopped || operation.signal.aborted || !operation.reply)
109
+ return;
110
+ // Observe failure once. Never retry uncertain delivery and never expose the exception.
111
+ let send;
112
+ try {
113
+ send = Promise.resolve(operation.reply(owner, '正在处理中,请稍候。')).then(() => { }, () => { });
114
+ }
115
+ catch {
116
+ send = Promise.resolve();
117
+ }
118
+ operation.feedback.push(send);
119
+ }, policy.progressAfterMs);
120
+ operation.timers.add(timer);
121
+ }
122
+ operation.accepted.add(capability);
123
+ return undefined;
124
+ }
125
+ }
126
+ function nonempty(value) { return typeof value === 'string' && value.length > 0; }
127
+ function limited(reason) { return Object.freeze({ reason }); }
128
+ /** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
129
+ export function admitCommandExecution(source, capability, owner, policy, snapshot) {
130
+ if (!policy)
131
+ return undefined;
132
+ const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
133
+ return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
134
+ }
package/lib/index.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
+ export * from './permission-explanation.js';
9
10
  export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
10
11
  export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
11
12
  export { default } from './provider.js';
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
+ export * from './permission-explanation.js';
9
10
  export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
10
11
  export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
11
12
  export { default } from './provider.js';
@@ -0,0 +1,22 @@
1
+ import { type PermissionSubject, type PermissionConditionExplanation } from '@zhin.js/permission';
2
+ import type { CommandDescriptor } from './command-index.js';
3
+ /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
4
+ export declare function toCommandPermissionSubject(source: unknown): PermissionSubject;
5
+ /** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
6
+ export declare function commandAdapterMatches(command: Readonly<{
7
+ adapter?: string;
8
+ }>, source: unknown): boolean;
9
+ export type CommandPermissionHostStatus = 'present' | 'missing' | 'unverified';
10
+ /** Read-only hypothetical conditions. A match is not permission to execute the command. */
11
+ export declare function explainCommandPermissions(command: Pick<CommandDescriptor, 'adapter' | 'permit'>, options: Readonly<{
12
+ source?: unknown;
13
+ rolesKnown?: boolean;
14
+ permissionHost: CommandPermissionHostStatus;
15
+ }>): Readonly<{
16
+ permissionHost: CommandPermissionHostStatus;
17
+ adapter: Readonly<PermissionConditionExplanation>;
18
+ rules: readonly import("@zhin.js/permission").PermitRuleExplanation[];
19
+ summary: Readonly<PermissionConditionExplanation>;
20
+ unconstrained: readonly string[];
21
+ assumptions: readonly string[];
22
+ }>;
@@ -0,0 +1,54 @@
1
+ import { operationClientAdapter } from '@zhin.js/feature-kit';
2
+ import { explainPermitRules, toPermissionSubject } from '@zhin.js/permission';
3
+ import { resolveCommandSender, resolveCommandSession } from './definition.js';
4
+ /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
5
+ export function toCommandPermissionSubject(source) {
6
+ const projected = toPermissionSubject(source);
7
+ const session = resolveCommandSession(source);
8
+ return { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
9
+ }
10
+ /** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
11
+ export function commandAdapterMatches(command, source) {
12
+ return !command.adapter || operationClientAdapter(source) === command.adapter;
13
+ }
14
+ /** Read-only hypothetical conditions. A match is not permission to execute the command. */
15
+ export function explainCommandPermissions(command, options) {
16
+ const projected = toCommandPermissionSubject(options.source);
17
+ const inputSender = options.source && typeof options.source === 'object'
18
+ ? options.source.sender : undefined;
19
+ const structuredRoles = inputSender && typeof inputSender === 'object'
20
+ && Array.isArray(inputSender.role);
21
+ // A sender-only assumption has no synthetic conversation. Apply the real canonical
22
+ // sender defaults without inventing an Endpoint or treating unknown roles as known.
23
+ const senderOnly = options.source && typeof options.source === 'object' && !('conversation' in options.source) && !structuredRoles
24
+ ? resolveCommandSender(options.source, undefined) : undefined;
25
+ const subject = senderOnly ? { ...projected, sender: senderOnly } : projected;
26
+ const defaultedRoles = options.rolesKnown && Array.isArray(inputSender?.roles) && inputSender.roles.length === 0
27
+ && subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
28
+ const actualAdapter = operationClientAdapter(options.source);
29
+ const adapter = !command.adapter
30
+ ? { outcome: 'not_evaluated', reason: '命令未声明 adapter 约束' }
31
+ : !actualAdapter ? { outcome: 'unknown', reason: '未选择当前 Endpoint,无法判断命令的精确 adapter 约束' }
32
+ : { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态' };
33
+ const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown });
34
+ const conditions = [...(command.adapter ? [adapter] : []), ...rules];
35
+ let summary;
36
+ if (conditions.some(item => item.outcome === 'mismatch'))
37
+ summary = { outcome: 'mismatch', reason: '至少一个声明条件与假设输入不符;这不是实际准入检查' };
38
+ else if ((rules.length > 0 && options.permissionHost !== 'present') || conditions.some(item => item.outcome === 'unknown')) {
39
+ summary = { outcome: 'unknown', reason: rules.length > 0 && options.permissionHost !== 'present'
40
+ ? 'PermissionHost 缺失或无法确认;不能推断命令可执行' : '存在缺少依据的条件,不能推断命令可执行' };
41
+ }
42
+ else if (rules.length === 0)
43
+ summary = { outcome: 'not_evaluated', reason: '命令未声明 permit 限制;这不表示已获得执行权限' };
44
+ else if (conditions.some(item => item.outcome === 'not_evaluated'))
45
+ summary = { outcome: 'not_evaluated', reason: '存在未执行的平台或自定义条件,不能推断命令可执行' };
46
+ else
47
+ summary = { outcome: 'match', reason: '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行' };
48
+ return Object.freeze({ permissionHost: options.permissionHost, adapter: Object.freeze(adapter), rules, summary: Object.freeze(summary),
49
+ unconstrained: Object.freeze(['已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制']),
50
+ assumptions: Object.freeze(['身份与角色仅用于模拟,未向平台核实', '未执行命令、动态默认值、平台或自定义 checker',
51
+ ...(defaultedRoles
52
+ ? ['canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色'] : [])]),
53
+ });
54
+ }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
8
8
  "exports": {
9
+ "./internal/execution": {
10
+ "types": "./lib/execution-runtime.d.ts",
11
+ "import": "./lib/execution-runtime.js"
12
+ },
9
13
  ".": {
10
14
  "types": "./lib/index.d.ts",
11
15
  "import": "./lib/index.js"
@@ -17,10 +21,10 @@
17
21
  ],
18
22
  "dependencies": {
19
23
  "segment-matcher": "^1.0.5",
20
- "@zhin.js/feature-kit": "1.1.5",
21
- "@zhin.js/plugin-runtime": "1.1.14",
24
+ "@zhin.js/feature-kit": "1.1.6",
22
25
  "@zhin.js/interaction": "1.1.2",
23
- "@zhin.js/permission": "1.1.6"
26
+ "@zhin.js/permission": "1.1.7",
27
+ "@zhin.js/plugin-runtime": "1.1.15"
24
28
  },
25
29
  "devDependencies": {
26
30
  "@types/node": "^26.1.2",
@@ -3,19 +3,25 @@ import {
3
3
  TypeMatcherRegistry,
4
4
  type MessageSegment,
5
5
  } from 'segment-matcher';
6
- import type {
7
- CapabilitySlot,
8
- PluginId,
9
- RuntimeSnapshot,
6
+ import {
7
+ htmlRendererToken,
8
+ type CapabilityId,
9
+ type CapabilitySlot,
10
+ type PluginId,
11
+ type RuntimeSnapshot,
10
12
  } from '@zhin.js/plugin-runtime';
11
13
  import type { UserInteractionFactory } from '@zhin.js/interaction';
12
- import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
14
+ import { bindOperationInput } from '@zhin.js/feature-kit';
13
15
  import {
14
16
  permissionHostToken,
15
- toPermissionSubject,
16
17
  type PermissionHost,
17
18
  } from '@zhin.js/permission';
19
+ import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
18
20
  import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
21
+ import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
22
+ import { admitCommandExecution } from './execution-runtime.js';
23
+ import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
24
+ export { CommandExecutionLimitedError } from './execution-policy.js';
19
25
  import {
20
26
  createCommandContext,
21
27
  resolveCommandSession,
@@ -32,6 +38,8 @@ export interface CommandParameterDescriptor extends CommandParameterDefinition {
32
38
  }
33
39
 
34
40
  export interface CommandDescriptor {
41
+ readonly id: CapabilityId;
42
+ readonly owner: PluginId;
35
43
  readonly name: string;
36
44
  readonly description?: string;
37
45
  readonly adapter?: string;
@@ -41,18 +49,34 @@ export interface CommandDescriptor {
41
49
  readonly options: NonNullable<CommandDefinition['options']>;
42
50
  readonly alias?: readonly string[];
43
51
  readonly permit?: readonly string[];
52
+ readonly execution?: CommandDefinition['execution'];
44
53
  /** shortcut 触发键列表(不含预填 params)。 */
45
54
  readonly shortcut?: readonly string[];
46
55
  }
47
56
 
48
57
  export interface CommandDispatchResult {
49
58
  readonly matched: boolean;
50
- readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
59
+ readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
60
+ readonly limit?: CommandExecutionLimit;
51
61
  readonly issues?: readonly CommandValidationIssue[];
52
62
  readonly help?: string;
53
63
  readonly command?: string;
54
64
  readonly owner?: PluginId;
55
65
  readonly value?: unknown;
66
+ readonly menu?: CommandMenuNavigation;
67
+ }
68
+
69
+ /** Semantic menu navigation; the IM composition layer supplies platform controls. */
70
+ export interface CommandMenuNavigation {
71
+ readonly keyword: string;
72
+ readonly pluginKey?: string;
73
+ readonly page: number;
74
+ readonly pages: number;
75
+ }
76
+
77
+ interface CommandMenuResult {
78
+ readonly value: string | CommandMenuSegment;
79
+ readonly menu?: CommandMenuNavigation;
56
80
  }
57
81
 
58
82
  interface CommandRecord extends CommandDescriptor {
@@ -143,9 +167,13 @@ export class CommandIndex {
143
167
  ? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
144
168
  : undefined;
145
169
 
170
+ const execution = normalizeExecutionPolicy(slot.definition.execution);
146
171
  const record: CommandRecord = Object.freeze({
172
+ id: slot.id,
173
+ owner: slot.owner,
147
174
  name,
148
175
  description: slot.definition.description,
176
+ ...(execution ? { execution } : {}),
149
177
  ...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
150
178
  source: slot.source,
151
179
  usage: commandUsage(name, slot.definition),
@@ -240,6 +268,8 @@ export class CommandIndex {
240
268
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
241
269
  const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
242
270
  if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
271
+ const limit = admitCommandExecution(undefined, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
272
+ if (limit) throw new CommandExecutionLimitedError(limit);
243
273
  return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
244
274
  textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
245
275
  }
@@ -252,14 +282,14 @@ export class CommandIndex {
252
282
  operationSnapshot: RuntimeSnapshot = this.snapshot,
253
283
  ): Promise<CommandDispatchResult> {
254
284
  if (this.#menu) {
255
- const menuValue = this.#dispatchMenu(input, commandPrefix);
256
- if (menuValue !== undefined) {
285
+ const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
286
+ if (menuResult !== undefined) {
257
287
  return Object.freeze({
258
288
  matched: true,
259
289
  status: 'executed',
260
290
  command: this.#menu.keyword,
261
291
  owner: operationSnapshot.root,
262
- value: menuValue,
292
+ ...menuResult,
263
293
  });
264
294
  }
265
295
  }
@@ -289,6 +319,8 @@ export class CommandIndex {
289
319
  const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
290
320
  if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
291
321
  owner: match.command.slot.owner, issues: parsed.issues, help });
322
+ const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
323
+ if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
292
324
  const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
293
325
  textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
294
326
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
@@ -315,9 +347,7 @@ export class CommandIndex {
315
347
  if (!source || typeof source !== 'object') return false;
316
348
  const host = this.#resolveHost(operationSnapshot);
317
349
  if (!host) return false;
318
- const projected = toPermissionSubject(source);
319
- const session = resolveCommandSession(source);
320
- const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
350
+ const subject = toCommandPermissionSubject(source);
321
351
  if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
322
352
  return host.checkAll(permits, subject);
323
353
  }
@@ -404,50 +434,68 @@ export class CommandIndex {
404
434
  // 内置菜单命令
405
435
  // ==========================================================================
406
436
 
407
- #dispatchMenu(input: CommandMatchInput, commandPrefix = ''): string | undefined {
437
+ #dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot): CommandMenuResult | undefined {
408
438
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
409
439
  if (text === undefined) return undefined;
410
440
  const menu = this.#menu;
411
441
  if (!menu) return undefined;
412
442
  const keyword = menu.keyword;
413
- if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword);
443
+ if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
414
444
  if (text.startsWith(keyword + ' ')) {
415
445
  const key = text.slice(keyword.length + 1).trim();
416
- if (key) return this.#buildMenu(key, commandPrefix, keyword);
417
- return this.#buildMenu(undefined, commandPrefix, keyword);
446
+ const args = key.split(/\s+/u);
447
+ const flag = args.indexOf('--page');
448
+ if (flag !== -1) {
449
+ const pageText = args[flag + 1] ?? '';
450
+ if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
451
+ || !Number.isSafeInteger(Number(pageText))) {
452
+ return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
453
+ }
454
+ return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
455
+ }
456
+ if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot);
457
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
418
458
  }
419
459
  return undefined;
420
460
  }
421
461
 
422
- #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string): string {
423
- const root = this.snapshot.root;
462
+ #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, page = 1): CommandMenuResult {
463
+ const root = snapshot.root;
424
464
  const targetId = pluginKey
425
465
  ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
426
466
  : root;
427
467
 
428
- const node = this.snapshot.tree.get(targetId);
429
- if (!node) return `未找到插件: ${pluginKey}`;
468
+ const node = snapshot.tree.get(targetId);
469
+ if (!node) return { value: `未找到插件: ${pluginKey}` };
430
470
 
431
471
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
432
472
  const children = node.children
433
- .map((childId) => this.snapshot.tree.get(childId))
473
+ .map((childId) => snapshot.tree.get(childId))
434
474
  .filter((child): child is NonNullable<typeof child> => !!child);
435
475
 
476
+ const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
477
+ const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
478
+ if (page > pages) return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
479
+ const offset = (page - 1) * MENU_PAGE_SIZE;
480
+ const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
481
+ const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
482
+
436
483
  const displayKey = pluginKey ?? node.instanceKey;
437
484
  const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
485
+ if (pages > 1) lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
438
486
 
439
- if (directCommands.length > 0) {
487
+ if (pageCommands.length > 0) {
440
488
  lines.push('');
441
- for (const cmd of directCommands) {
489
+ for (const cmd of pageCommands) {
442
490
  const desc = cmd.description ? ` - ${cmd.description}` : '';
443
491
  lines.push(` ${commandPrefix}${cmd.name}${desc}`);
444
492
  }
445
493
  }
446
494
 
447
- if (children.length > 0) {
495
+ if (pageChildren.length > 0) {
448
496
  lines.push('');
449
497
  lines.push('子插件:');
450
- for (const child of children) {
498
+ for (const child of pageChildren) {
451
499
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
452
500
  const label = child.metadata?.displayName ?? child.instanceKey;
453
501
  lines.push(` ${childKey} (${label})`);
@@ -459,12 +507,27 @@ export class CommandIndex {
459
507
  lines.push('(暂无指令和子插件)');
460
508
  }
461
509
 
462
- if (children.length > 0) {
510
+ const hints: string[] = [];
511
+ if (children.length > 0) hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
512
+ if (page > 1) hints.push(`上一页:${invocation} --page ${page - 1}`);
513
+ if (page < pages) hints.push(`下一页:${invocation} --page ${page + 1}`);
514
+ const hint = hints.length ? hints.join('\n') : undefined;
515
+ if (hint) {
463
516
  lines.push('');
464
- lines.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
517
+ lines.push(hint);
465
518
  }
466
519
 
467
- return lines.join('\n');
520
+ const text = lines.join('\n');
521
+ const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
522
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
523
+ const config = snapshot.config.get(root) as Record<string, unknown> | undefined;
524
+ return { menu, value: renderCommandMenu(displayKey,
525
+ pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })),
526
+ pageChildren.map(child => ({
527
+ name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
528
+ description: child.metadata?.displayName ?? child.instanceKey,
529
+ })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
530
+ appearance: readMenuAppearance(config?.menuStyle) }) };
468
531
  }
469
532
  }
470
533
 
@@ -839,10 +902,6 @@ function toDescriptor({
839
902
  return descriptor;
840
903
  }
841
904
 
842
- function commandAdapterMatches(record: CommandRecord, source: unknown): boolean {
843
- return !record.adapter || operationClientAdapter(source) === record.adapter;
844
- }
845
-
846
905
  export class CommandParameterValueError extends TypeError {
847
906
  constructor(name: string, type: CommandParameterType, value: string) {
848
907
  super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
@@ -0,0 +1,112 @@
1
+ interface MenuEntry {
2
+ readonly name: string;
3
+ readonly description?: string;
4
+ }
5
+
6
+ interface MenuAppearance {
7
+ readonly title?: string;
8
+ readonly subtitle?: string;
9
+ readonly accentColor?: string;
10
+ readonly backgroundImage?: string;
11
+ }
12
+
13
+ export interface CommandMenuSegment {
14
+ readonly type: 'html';
15
+ readonly data: {
16
+ readonly html: string;
17
+ readonly text: string;
18
+ readonly width: number;
19
+ readonly fileName: string;
20
+ };
21
+ }
22
+
23
+ export const MENU_PAGE_SIZE = 30;
24
+
25
+ const escape = (value: string) => value.replace(/[&<>"']/gu, char => ({
26
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
27
+ })[char] ?? char);
28
+
29
+ /** Configuration is text/data, never an interpolated CSS or HTML fragment. */
30
+ export function readMenuAppearance(value: unknown): MenuAppearance {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
32
+ const input = value as Record<string, unknown>;
33
+ const result: { title?: string; subtitle?: string; accentColor?: string; backgroundImage?: string } = {};
34
+ if (typeof input.title === 'string') result.title = input.title.slice(0, 100);
35
+ if (typeof input.subtitle === 'string') result.subtitle = input.subtitle.slice(0, 160);
36
+ if (typeof input.accentColor === 'string' && /^#[\da-f]{6}$/iu.test(input.accentColor)) result.accentColor = input.accentColor;
37
+ if (typeof input.backgroundImage === 'string') {
38
+ try {
39
+ const url = new URL(input.backgroundImage);
40
+ if (url.protocol === 'https:' || url.protocol === 'http:'
41
+ || /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/u.test(url.href)) result.backgroundImage = url.href;
42
+ } catch { /* Invalid optional artwork falls back to the bundled illustration. */ }
43
+ }
44
+ return result;
45
+ }
46
+
47
+ // Self-contained artwork: no network requests or extra asset/package dependency.
48
+ const landscape = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1400" viewBox="0 0 1080 1400">
49
+ <defs><linearGradient id="sky" x2=".8" y2="1"><stop stop-color="#162635"/><stop offset=".48" stop-color="#344b57"/><stop offset="1" stop-color="#70837c"/></linearGradient><radialGradient id="glow"><stop stop-color="#eddbc0" stop-opacity=".3"/><stop offset="1" stop-color="#d6d7c3" stop-opacity="0"/></radialGradient><linearGradient id="hill" x2="0" y2="1"><stop stop-color="#b2c2b5" stop-opacity=".34"/><stop offset="1" stop-color="#213e45" stop-opacity=".1"/></linearGradient></defs>
50
+ <path fill="url(#sky)" d="M0 0h1080v1400H0z"/><circle cx="820" cy="184" r="290" fill="url(#glow)"/><circle cx="823" cy="167" r="92" fill="#e3dfc9"/><circle cx="802" cy="146" r="10" fill="#cbcdbb" opacity=".3"/><circle cx="854" cy="194" r="19" fill="#cbcdbb" opacity=".3"/>
51
+ <g fill="#e7d5ac" opacity=".7"><circle cx="113" cy="72" r="1.5"/><circle cx="360" cy="104" r="1"/><circle cx="624" cy="55" r="2"/><circle cx="713" cy="265" r="1.2"/><circle cx="982" cy="332" r="2"/><circle cx="463" cy="43" r="1.4"/><circle cx="540" cy="202" r="1.4"/><circle cx="96" cy="410" r="2"/></g>
52
+ <g fill="none" stroke="#abac8a" stroke-width="2" opacity=".6"><path d="M1080 0Q959 77 902 267M1012 45Q916 92 866 106M984 78Q1016 164 1004 229M946 146Q866 156 838 204M917 217Q947 257 945 311"/></g>
53
+ <g fill="#d1bc93" opacity=".7"><ellipse cx="916" cy="84" rx="8" ry="21" transform="rotate(60 916 84)"/><ellipse cx="975" cy="115" rx="8" ry="20" transform="rotate(-25 975 115)"/><ellipse cx="876" cy="172" rx="8" ry="20" transform="rotate(58 876 172)"/><ellipse cx="1006" cy="208" rx="8" ry="20"/><ellipse cx="932" cy="270" rx="8" ry="20" transform="rotate(-25 932 270)"/></g>
54
+ <path d="M0 615 171 375 293 517 441 315 619 564 796 417 1080 651V1400H0Z" fill="url(#hill)"/><path d="m0 867 263-305 194 263 173-167 216 142 234-183v783H0Z" fill="#243e48" opacity=".46"/><path d="M0 1155q260-270 540-160t540-125v530H0Z" fill="#17343d" opacity=".65"/>
55
+ <g fill="none" stroke="#b1c1b2" stroke-opacity=".14"><path d="M0 455q210-85 424-12t656-4M0 474q210-85 424-12t656-4M0 1095q310-83 600-9t480-30M0 1124q310-83 600-9t480-30"/></g></svg>`;
56
+
57
+ const symbols = [
58
+ '<path d="m16 3 4 9 9 4-9 4-4 9-4-9-9-4 9-4Z"/><path d="m16 10 2 6-2 6-2-6Z"/>',
59
+ '<circle cx="16" cy="16" r="11"/><path d="m10 19 6-12 6 12-6-3Z"/>',
60
+ '<path d="m16 3 12 7v12l-12 7-12-7V10Z"/><path d="m4 10 12 7 12-7M16 17v12"/>',
61
+ '<circle cx="16" cy="16" r="5"/><path d="m16 2 3 7 8-3-3 8 7 2-7 3 3 8-8-3-3 7-3-7-8 3 3-8-7-3 7-2-3-8 8 3Z"/>',
62
+ ];
63
+
64
+ function icon(name: string, plugin = false): string {
65
+ const hash = [...name].reduce((value, char) => (value + (char.codePointAt(0) ?? 0)) % symbols.length, 0);
66
+ return `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.15">${plugin ? symbols[2] : symbols[hash]}</svg>`;
67
+ }
68
+
69
+ /** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
70
+ export function renderCommandMenu(
71
+ title: string,
72
+ commands: readonly MenuEntry[],
73
+ children: readonly MenuEntry[],
74
+ hint: string | undefined,
75
+ text: string,
76
+ options: { readonly page?: number; readonly pages?: number; readonly commandTotal?: number; readonly pluginTotal?: number; readonly appearance?: MenuAppearance } = {},
77
+ ): CommandMenuSegment {
78
+ const appearance = options.appearance ?? {};
79
+ const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
80
+ // CSS string escaping additionally prevents an artwork URL from closing <style>.
81
+ const cssUrl = JSON.stringify(background).replace(/</gu, '\\3c ').replace(/>/gu, '\\3e ');
82
+ const entries = (items: readonly MenuEntry[], plugin: boolean) => items.map(item =>
83
+ `<div class="entry"><div class="icon">${icon(item.name, plugin)}</div><div class="copy"><div class="name">${escape(item.name)}</div>${item.description
84
+ ? `<div class="description">${escape(item.description)}</div>` : ''}</div></div>`).join('');
85
+ const section = (label: string, items: readonly MenuEntry[], total: number, plugin = false) => items.length === 0 ? ''
86
+ : `<section><div class="section-head"><h2>${label}</h2><span>${items.length} / ${total}</span></div><div class="grid">${entries(items, plugin)}</div></section>`;
87
+ const page = options.page ?? 1;
88
+ const pages = options.pages ?? 1;
89
+
90
+ return {
91
+ type: 'html',
92
+ data: {
93
+ width: 1080, fileName: 'command-menu.png', text,
94
+ html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
95
+ *{box-sizing:border-box}body{margin:0;background:#253b46;color:#f4f0e6;font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:18px;line-height:1.5;--accent:${appearance.accentColor ?? '#e7c993'}}
96
+ main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
97
+ header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
98
+ h1{margin:0 0 10px;font-family:"Songti SC","Noto Serif CJK SC",serif;font-size:55px;font-weight:800;letter-spacing:3px;text-shadow:0 2px 16px #0c202a66;max-width:760px;line-height:1.2}
99
+ .subtitle{font-size:17px;color:#e0e6e0;letter-spacing:2px;max-width:700px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.summary{margin:18px 0 0;display:flex;align-items:center;gap:16px;font-size:13px;letter-spacing:1px;color:#e0dfcf}.summary i{width:22px;height:1px;background:var(--accent)}
100
+ section{margin:0 0 20px;padding:5px;background:#dbe9e516;border:1px solid #f0e2c722;border-radius:19px;box-shadow:0 8px 25px #08192312}.section-head{padding:14px 18px;display:flex;justify-content:space-between;align-items:center;background:#142a34db;border-radius:13px 13px 0 0}
101
+ h2{margin:0;font-size:20px;font-weight:700;letter-spacing:1px;color:var(--accent)}.section-head span{font-size:12px;letter-spacing:2px;color:#b7c7c5}
102
+ .grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
103
+ .entry{display:flex;align-items:center;gap:12px;padding:19px 17px;min-width:0;min-height:106px;border-top:1px solid #e7e6d614;border-right:1px solid #e7e6d614;background:linear-gradient(120deg,#ffffff08,#ffffff00)}.entry:nth-child(3n){border-right:0}
104
+ .icon{width:35px;height:35px;flex:0 0 35px;color:var(--accent);filter:drop-shadow(0 1px 6px #d9ba7c22)}.icon svg{display:block;width:100%;height:100%}.copy{min-width:0}.name{font-size:18px;font-weight:700;color:var(--accent)}.description{margin:4px 0 0;color:#e1e8e5;font-size:15px;line-height:1.45}
105
+ .name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
106
+ footer{margin:6px 5px 0;padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:22px;background:#142a34da;border:1px solid #f0e2c71c;border-radius:13px}.hint{font-size:14px;color:#cfdbd7;white-space:pre-wrap;overflow-wrap:anywhere}.page{font-size:20px;color:var(--accent);white-space:nowrap;font-family:Georgia,serif}.signature{margin:16px 0 0;text-align:center;font-size:10px;letter-spacing:4px;color:#c4d0c899}.empty{margin:0 0 20px;padding:28px;background:#142a34da;border-radius:16px;color:#dbe4de}
107
+ </style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || `${title} · 指令指南`)}</h1><div class="subtitle">${escape(appearance.subtitle ?? '每一个灵感,都有回应。')}</div><div class="summary"><i></i>${options.commandTotal ?? commands.length} 条指令<span>·</span>${options.pluginTotal ?? children.length} 个子插件<span>·</span>${escape(title)}</div></header>
108
+ ${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
109
+ <footer><div class="hint">${escape(hint ?? '查看参数与用法:指令名 --help')}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
110
+ },
111
+ };
112
+ }