@zxh126/pi-qqbot 1.0.0 → 1.2.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/queue.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * QQ 侧任务队列。
3
+ *
4
+ * pi 的单会话是串行执行的:一轮 agent run 结束(agent_settled)后才可能开始下一轮。
5
+ * 于是忙碌期间收到的消息不再丢弃/拒绝,而是排队;当前任务结束后由 index.ts 的
6
+ * `drainQueue()` 逐条取出执行。
7
+ *
8
+ * 设计要点:
9
+ * - 全局单队列(本扩展是单会话设计),每条任务记住自己的 openid,回复发给原主人。
10
+ * - FIFO,编号单调递增不复用,方便用户用 `/queue drop <编号>` 定点删除。
11
+ * - `/cancel` 不走队列(立即生效),并且会清空队列。
12
+ */
13
+
14
+ export interface QueuedJob {
15
+ /** 队列编号,从 1 开始单调递增 */
16
+ id: number;
17
+ /** 任务来自哪个 QQ 用户 */
18
+ openid: string;
19
+ /** 交给 pi 的原始文本(普通对话 / 斜杠命令 / 提示模板 / skill 命令) */
20
+ text: string;
21
+ /** 入队时间戳(毫秒) */
22
+ enqueuedAt: number;
23
+ /** 派发失败的重试次数,由 index.ts 维护 */
24
+ attempts: number;
25
+ }
26
+
27
+ export type PushResult =
28
+ | { ok: true; job: QueuedJob; ahead: number }
29
+ | { ok: false; reason: "full" };
30
+
31
+ export const DEFAULT_QUEUE_MAX = 20;
32
+
33
+ /** 单条预览的最大字符数 */
34
+ const PREVIEW_MAX = 48;
35
+
36
+ export function previewText(text: string, max = PREVIEW_MAX): string {
37
+ const flat = text.replace(/\s+/g, " ").trim();
38
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat || "(空消息)";
39
+ }
40
+
41
+ /** 距离入队多久:`12s 前` / `3m 前` / `1h 前` */
42
+ export function agoLabel(at: number, now = Date.now()): string {
43
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
44
+ if (seconds < 60) return `${seconds}s 前`;
45
+ const minutes = Math.floor(seconds / 60);
46
+ if (minutes < 60) return `${minutes}m 前`;
47
+ return `${Math.floor(minutes / 60)}h 前`;
48
+ }
49
+
50
+ export class MessageQueue {
51
+ private jobs: QueuedJob[] = [];
52
+ private nextId = 1;
53
+ private limit: number;
54
+
55
+ /** `limit = 0` 表示关闭排队(忙碌时直接拒绝新消息) */
56
+ constructor(limit = DEFAULT_QUEUE_MAX) {
57
+ this.limit = Math.max(0, Math.floor(limit) || 0);
58
+ }
59
+
60
+ /** 按配置调整上限;已有的任务不会被丢弃 */
61
+ setLimit(limit: number) {
62
+ this.limit = Math.max(0, Math.floor(limit) || 0);
63
+ }
64
+
65
+ get size(): number {
66
+ return this.jobs.length;
67
+ }
68
+
69
+ get max(): number {
70
+ return this.limit;
71
+ }
72
+
73
+ /** 只读快照(保持插入顺序) */
74
+ list(): readonly QueuedJob[] {
75
+ return this.jobs;
76
+ }
77
+
78
+ push(openid: string, text: string): PushResult {
79
+ if (this.limit === 0 || this.jobs.length >= this.limit) return { ok: false, reason: "full" };
80
+ const job: QueuedJob = {
81
+ id: this.nextId++,
82
+ openid,
83
+ text,
84
+ enqueuedAt: Date.now(),
85
+ attempts: 0,
86
+ };
87
+ this.jobs.push(job);
88
+ return { ok: true, job, ahead: this.jobs.length - 1 };
89
+ }
90
+
91
+ /** 手动构造一条不入队的任务(直接执行 / 失败重试时用) */
92
+ mint(openid: string, text: string): QueuedJob {
93
+ return { id: 0, openid, text, enqueuedAt: Date.now(), attempts: 0 };
94
+ }
95
+
96
+ shift(): QueuedJob | undefined {
97
+ return this.jobs.shift();
98
+ }
99
+
100
+ /** 派发失败时放回队首重试 */
101
+ unshift(job: QueuedJob): void {
102
+ this.jobs.unshift(job);
103
+ }
104
+
105
+ /** 按编号删除一条 */
106
+ remove(id: number): QueuedJob | undefined {
107
+ const index = this.jobs.findIndex((job) => job.id === id);
108
+ if (index === -1) return undefined;
109
+ return this.jobs.splice(index, 1)[0];
110
+ }
111
+
112
+ /** 清空并返回被清掉的任务 */
113
+ clear(): QueuedJob[] {
114
+ const dropped = this.jobs;
115
+ this.jobs = [];
116
+ return dropped;
117
+ }
118
+ }
package/slash.ts CHANGED
@@ -11,14 +11,21 @@
11
11
  * 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
12
12
  * built-ins from a message (only the TUI/RPC layer does), so these are
13
13
  * re-implemented through the extension API. The command-only APIs
14
- * (`reload`, `newSession`) need a fresh `ExtensionCommandContext`, so they run
15
- * inside the `/qq` bridge command registered by this extension.
14
+ * (`reload`, `newSession`, `fork`, `navigateTree`, `switchSession`) need a fresh
15
+ * `ExtensionCommandContext`, so they run inside the `/qq` bridge command
16
+ * registered by this extension.
16
17
  * 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
17
18
  * 4. **anything else** — treated as ordinary chat text (the caller forwards it).
18
19
  *
20
+ * Selection-style commands (`/tree`, `/fork`, `/resume`) answer with a numbered list
21
+ * and accept the number as the next message; the pending list lives in `BridgeStore`.
22
+ *
19
23
  * Everything is opted in conservatively through `commands` in the config file.
20
24
  */
21
25
 
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
22
29
  export interface SlashCommandText {
23
30
  /** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
24
31
  name: string;
@@ -36,12 +43,12 @@ export function parseSlash(text: string): SlashCommandText | null {
36
43
  return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
37
44
  }
38
45
 
39
- export type BridgeSub = "reload" | "new" | "compact" | "quit" | "session" | "name" | "model" | "thinking";
46
+ export type BridgeSub = "reload" | "new" | "compact" | "quit" | "session" | "name" | "model" | "thinking" | "tree" | "fork" | "clone" | "resume";
40
47
 
41
48
  interface Takeover {
42
- sub: BridgeSub;
49
+ sub: string;
43
50
  /** the command tears down the current runtime/session: reply before dispatching */
44
- selfDestroying?: boolean;
51
+ selfDestroying?: boolean;
45
52
  /** unsafe to run while a task is in progress */
46
53
  needsIdle?: boolean;
47
54
  needsArgs?: boolean;
@@ -62,6 +69,10 @@ export const TAKEOVER: Readonly<Record<string, Takeover>> = {
62
69
  name: { sub: "name", hint: "设置或查看会话名称" },
63
70
  model: { sub: "model", hint: "查看/切换模型(provider/model)" },
64
71
  thinking: { sub: "thinking", hint: "查看/切换思考等级" },
72
+ tree: { sub: "list tree", needsIdle: true, hint: "在会话树上跳转(回编号选择)" },
73
+ fork: { sub: "list fork", needsIdle: true, hint: "从某条历史用户消息分叉(回编号选择)" },
74
+ resume: { sub: "list resume", needsIdle: true, hint: "切换到本项目的另一个会话(回编号选择)" },
75
+ clone: { sub: "clone", selfDestroying: true, needsIdle: true, hint: "在当前叶子位置复制会话" },
65
76
  };
66
77
 
67
78
  /** pi built-ins that the extension API cannot reach at all. */
@@ -74,17 +85,12 @@ export const HOST_ONLY: ReadonlySet<string> = new Set([
74
85
  "logout",
75
86
  "scoped-models",
76
87
  "llama",
77
- "tree",
78
- "fork",
79
- "clone",
80
- "resume",
81
88
  "import",
82
89
  "export",
83
90
  "share",
84
91
  "bug",
85
92
  "copy",
86
93
  "privacy",
87
- "handoff",
88
94
  ]);
89
95
 
90
96
  export interface CommandConfig {
@@ -94,7 +100,7 @@ export interface CommandConfig {
94
100
  allowReload: boolean;
95
101
  /** allow /quit (stops the host pi process) */
96
102
  allowQuit: boolean;
97
- /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking */
103
+ /** allow session-level commands: /new, /compact, /session, /name, /model, /thinking, /tree, /fork, /clone, /resume */
98
104
  allowSession: boolean;
99
105
  }
100
106
 
@@ -108,8 +114,13 @@ export const DEFAULT_COMMANDS: CommandConfig = {
108
114
  export interface RouteDeps {
109
115
  /** commands pi currently knows about (extension/prompt/skill) */
110
116
  listPiCommands(): readonly { name: string; source?: string }[];
111
- /** deliver a pi command through pi's own dispatcher */
112
- dispatch(text: string): void;
117
+ /**
118
+ * deliver a pi command through pi's own dispatcher.
119
+ * `runsTurn` marks commands that start an agent run (prompt templates, `/skill:name`):
120
+ * those are serialized through the bot's queue, everything else runs immediately.
121
+ * Returns "queued" when the bot deferred it, "rejected" when the queue was full.
122
+ */
123
+ dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
113
124
  /** answer the QQ user */
114
125
  reply(text: string): Promise<void>;
115
126
  busy: boolean;
@@ -117,6 +128,8 @@ export interface RouteDeps {
117
128
  log(message: string, ...rest: unknown[]): void;
118
129
  }
119
130
 
131
+ export type DispatchResult = "dispatched" | "queued" | "rejected";
132
+
120
133
  export type RouteResult = "dispatched" | "handled" | "text";
121
134
 
122
135
  const allowed = (cfg: CommandConfig, name: string): boolean => {
@@ -133,11 +146,19 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
133
146
 
134
147
  // 1. pi extension commands, prompt templates and skill commands
135
148
  if (deps.cfg.passthrough) {
136
- const known = deps.listPiCommands().some((c) => c.name.toLowerCase() === name);
149
+ const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
137
150
  if (known) {
138
- deps.log("[cmd] 派发 pi 扩展命令:", parsed.raw.slice(0, 80));
139
- deps.dispatch(parsed.raw);
140
- await deps.reply(`已派发 /${name} 给 pi。`);
151
+ // 提示模板与 /skill: 会开启一轮 agent run,交给队列串行;扩展命令自己管理 LLM 交互
152
+ const runsTurn = known.source === "prompt" || known.source === "skill";
153
+ deps.log("[cmd] 派发 pi 命令:", parsed.raw.slice(0, 80), "runsTurn=", runsTurn);
154
+ const result = deps.dispatch(parsed.raw, { runsTurn });
155
+ if (result === "queued") {
156
+ await deps.reply(`已排队 /${name}:当前任务结束后执行(发 /queue 查看队列)。`);
157
+ } else if (result === "rejected") {
158
+ await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
159
+ } else {
160
+ await deps.reply(`已派发 /${name} 给 pi。`);
161
+ }
141
162
  return "dispatched";
142
163
  }
143
164
  }
@@ -146,16 +167,15 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
146
167
  const takeover = TAKEOVER[name];
147
168
  if (takeover) {
148
169
  if (!allowed(deps.cfg, name)) {
149
- await deps.reply(
150
- `/${name} 在 QQ 侧被禁用(配置 commands.allow${name === "reload" ? "Reload" : name === "quit" ? "Quit" : "Session"})。`,
151
- );
170
+ const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
171
+ await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
152
172
  return "handled";
153
173
  }
154
174
  if (takeover.needsIdle && deps.busy) {
155
175
  await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
156
176
  return "handled";
157
177
  }
158
- const bridge = takeover.needsArgs && !parsed.args ? `/qq ${takeover.sub}` : `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
178
+ const bridge = `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
159
179
  deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
160
180
  if (takeover.selfDestroying) {
161
181
  // the command tears the runtime down; answer before it lands
@@ -186,9 +206,151 @@ function formatTokens(value: number | null | undefined): string {
186
206
  return String(value);
187
207
  }
188
208
 
209
+ // ---------------------------------------------------------------------------
210
+ // pickers (tree / fork / resume)
211
+ // ---------------------------------------------------------------------------
212
+
213
+ export type PickerKind = "tree" | "fork" | "resume";
214
+
215
+ export interface PickerItem {
216
+ /** entry id (tree/fork) */
217
+ id?: string;
218
+ /** session file (resume) */
219
+ path?: string;
220
+ /** what the user sees next to the number */
221
+ label: string;
222
+ }
223
+
224
+ export interface PickerState {
225
+ kind: PickerKind;
226
+ items: PickerItem[];
227
+ /** only this QQ user may answer with a number */
228
+ openid: string | null;
229
+ createdAt: number;
230
+ }
231
+
232
+ /** Shared mutable state between the router (index.ts) and the bridge command. */
233
+ export interface BridgeStore {
234
+ picker: PickerState | null;
235
+ /** set by the router right before dispatching a `list` command */
236
+ pickerOwner: string | null;
237
+ }
238
+
239
+ export const PICKER_TTL_MS = 10 * 60_000;
240
+ export const PICKER_MAX_ITEMS = 10;
241
+
242
+ const KIND_LABEL: Record<PickerKind, string> = {
243
+ tree: "会话树节点",
244
+ fork: "可分叉的用户消息",
245
+ resume: "本项目的其他会话",
246
+ };
247
+
248
+ function previewText(content: unknown): string {
249
+ const parts = Array.isArray(content) ? content : [];
250
+ const text = parts
251
+ .map((part: any) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
252
+ .join(" ")
253
+ .replace(/\s+/g, " ")
254
+ .trim();
255
+ return text.length > 50 ? `${text.slice(0, 50)}…` : text || "(无文本)";
256
+ }
257
+
258
+ const stamp = (value: unknown): string => {
259
+ const date = typeof value === "string" || typeof value === "number" ? new Date(value) : new Date();
260
+ if (Number.isNaN(date.getTime())) return "?";
261
+ const pad = (n: number) => String(n).padStart(2, "0");
262
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
263
+ };
264
+
265
+ function branchItems(ctx: any): PickerItem[] {
266
+ let branch: any[] = [];
267
+ try {
268
+ branch = ctx.sessionManager.getBranch() ?? [];
269
+ } catch {
270
+ branch = [];
271
+ }
272
+ return branch
273
+ .filter((entry) => entry?.type === "message" && entry?.message?.role === "user")
274
+ .slice(-PICKER_MAX_ITEMS)
275
+ .map((entry) => ({
276
+ id: entry.id,
277
+ label: `${stamp(entry.timestamp)} ${previewText(entry.message.content)}`,
278
+ }));
279
+ }
280
+
281
+ function resumeItems(ctx: any): PickerItem[] {
282
+ let dir: string | undefined;
283
+ let current: string | undefined;
284
+ try {
285
+ dir = ctx.sessionManager.getSessionDir?.();
286
+ current = ctx.sessionManager.getSessionFile?.();
287
+ } catch {
288
+ return [];
289
+ }
290
+ if (!dir) return [];
291
+ let names: string[] = [];
292
+ try {
293
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
294
+ } catch {
295
+ return [];
296
+ }
297
+ return names
298
+ .map((name) => {
299
+ const full = path.join(dir!, name);
300
+ let mtime = 0;
301
+ let size = 0;
302
+ try {
303
+ const stat = fs.statSync(full);
304
+ mtime = stat.mtimeMs;
305
+ size = stat.size;
306
+ } catch {
307
+ // unreadable: keep it out of the ranking
308
+ }
309
+ return { full, name, mtime, size };
310
+ })
311
+ .filter((row) => row.mtime > 0 && row.full !== current)
312
+ .sort((a, b) => b.mtime - a.mtime)
313
+ .slice(0, PICKER_MAX_ITEMS)
314
+ .map((row) => ({
315
+ path: row.full,
316
+ label: `${stamp(row.mtime)} ${Math.max(1, Math.round(row.size / 1024))}KB ${row.name.replace(/\.jsonl$/, "").slice(0, 44)}`,
317
+ }));
318
+ }
319
+
320
+ function buildPickerItems(kind: PickerKind, ctx: any): PickerItem[] {
321
+ if (kind === "resume") return resumeItems(ctx);
322
+ return branchItems(ctx);
323
+ }
324
+
325
+ function formatPicker(picker: PickerState): string {
326
+ const header = `选择${KIND_LABEL[picker.kind]}(${picker.items.length} 项):`;
327
+ const rows = picker.items.map((item, index) => ` ${index + 1}. ${item.label}`);
328
+ return [
329
+ header,
330
+ ...rows,
331
+ `回复编号执行,或回复「取消」放弃(${Math.round(PICKER_TTL_MS / 60000)} 分钟内有效)。`,
332
+ ].join("\n");
333
+ }
334
+
335
+ /** Parse a bare "3" reply that answers the pending picker. */
336
+ export function parsePickIndex(text: string): number | null {
337
+ const match = /^\s*(\d{1,3})\s*$/.exec(text);
338
+ if (!match) return null;
339
+ const value = Number(match[1]);
340
+ return Number.isFinite(value) && value > 0 ? value : null;
341
+ }
342
+
343
+ export function pickerExpired(picker: PickerState | null): boolean {
344
+ return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
345
+ }
346
+
189
347
  /**
190
348
  * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
191
- * which is the only place `reload()` / `newSession()` are legal.
349
+ * which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
350
+ * `switchSession()` are legal.
351
+ *
352
+ * Session-replacing actions (`reload`, `new`, `tree`/`fork`/`resume` picks, `clone`)
353
+ * tear this runtime down; the caller answers the QQ user before dispatching them.
192
354
  */
193
355
  export async function runBridge(
194
356
  sub: string,
@@ -196,8 +358,11 @@ export async function runBridge(
196
358
  ctx: any,
197
359
  pi: any,
198
360
  reply: (text: string) => void,
361
+ store?: BridgeStore,
199
362
  ): Promise<void> {
200
363
  const level = String(sub ?? "").toLowerCase();
364
+ const first = arg.split(/\s+/)[0]?.toLowerCase() ?? "";
365
+
201
366
  switch (level) {
202
367
  case "reload": {
203
368
  await ctx.reload();
@@ -221,6 +386,64 @@ export async function runBridge(
221
386
  });
222
387
  return;
223
388
  }
389
+ case "list": {
390
+ const kind = first as PickerKind;
391
+ if (kind !== "tree" && kind !== "fork" && kind !== "resume") {
392
+ reply(`未知列表类型:${first || "(空)"}`);
393
+ return;
394
+ }
395
+ const items = buildPickerItems(kind, ctx);
396
+ if (items.length === 0) {
397
+ reply(`没有可用的${KIND_LABEL[kind]}。`);
398
+ return;
399
+ }
400
+ const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
401
+ if (store) store.picker = picker;
402
+ reply(formatPicker(picker));
403
+ return;
404
+ }
405
+ case "pick": {
406
+ const index = Number.parseInt(first, 10);
407
+ const picker = store?.picker ?? null;
408
+ if (!picker || !Number.isFinite(index)) {
409
+ reply("选择列表已失效,请重新发送命令。");
410
+ return;
411
+ }
412
+ const item = picker.items[index - 1];
413
+ if (!item) {
414
+ reply(`编号 ${first} 超出范围(1-${picker.items.length})。`);
415
+ return;
416
+ }
417
+ if (store) store.picker = null;
418
+ if (picker.kind === "tree") {
419
+ reply(`正在跳转到第 ${index} 个节点…`);
420
+ await ctx.navigateTree(item.id);
421
+ return;
422
+ }
423
+ if (picker.kind === "fork") {
424
+ reply(`正在从第 ${index} 条消息分叉…`);
425
+ await ctx.fork(item.id);
426
+ return;
427
+ }
428
+ reply(`正在切换会话:${item.label.slice(0, 44)}…`);
429
+ await ctx.switchSession(item.path);
430
+ return;
431
+ }
432
+ case "clone": {
433
+ let leaf: string | null = null;
434
+ try {
435
+ leaf = ctx.sessionManager.getLeafId?.() ?? null;
436
+ } catch {
437
+ leaf = null;
438
+ }
439
+ if (!leaf) {
440
+ reply("当前会话还没有可复制的位置(先发一条消息)。");
441
+ return;
442
+ }
443
+ reply("正在当前叶子位置复制会话…");
444
+ await ctx.fork(leaf, { position: "at" });
445
+ return;
446
+ }
224
447
  case "session": {
225
448
  const sm = ctx.sessionManager;
226
449
  const usage = ctx.getContextUsage?.();
@@ -232,14 +455,19 @@ export async function runBridge(
232
455
  return "?";
233
456
  }
234
457
  })();
235
- const lines = [
236
- `会话:${sm.getSessionName?.() || "(未命名)"}`,
237
- `id:${sm.getSessionId?.() ?? "?"}`,
238
- `cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
239
- `模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
240
- `条目:${entries} 上下文:${usage && usage.tokens !== undefined ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}` : "未知"}`,
241
- ];
242
- reply(lines.join("\n"));
458
+ reply(
459
+ [
460
+ `会话:${sm.getSessionName?.() || "(未命名)"}`,
461
+ `id:${sm.getSessionId?.() ?? "?"}`,
462
+ `cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
463
+ `模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
464
+ `条目:${entries} 上下文:${
465
+ usage && usage.tokens !== undefined
466
+ ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}`
467
+ : "未知"
468
+ }`,
469
+ ].join("\n"),
470
+ );
243
471
  return;
244
472
  }
245
473
  case "name": {
@@ -260,7 +488,9 @@ export async function runBridge(
260
488
  reply(
261
489
  [
262
490
  `当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
263
- available.length > 0 ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}` : "没有可用模型",
491
+ available.length > 0
492
+ ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
493
+ : "没有可用模型",
264
494
  ].join("\n"),
265
495
  );
266
496
  return;