@zxh126/pi-qqbot 2.3.0 → 2.4.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/CHANGELOG.md +370 -314
- package/README.md +518 -439
- package/approver.ts +174 -174
- package/attachment.ts +502 -502
- package/billing.ts +579 -0
- package/config.ts +281 -280
- package/constants.ts +51 -51
- package/docs/README.zh-CN.md +477 -410
- package/index.ts +1856 -1571
- package/package.json +60 -60
- package/qq/api.ts +107 -107
- package/qq/gateway.ts +265 -265
- package/qq/upload.ts +288 -288
- package/remote.ts +81 -81
- package/selfupdate.ts +212 -0
- package/slash.ts +862 -826
package/slash.ts
CHANGED
|
@@ -1,826 +1,862 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Slash-command routing for pi-qqbot.
|
|
3
|
-
*
|
|
4
|
-
* QQ messages that start with `/` are handled in this order:
|
|
5
|
-
*
|
|
6
|
-
* 1. **pi extension commands / prompt templates / skill commands** — anything the
|
|
7
|
-
* running pi instance reports in `pi.getCommands()` (source: extension | prompt |
|
|
8
|
-
* skill). These are dispatched verbatim with
|
|
9
|
-
* `pi.sendUserMessage(text, { expandPromptTemplates: true })`, the only path pi
|
|
10
|
-
* uses to execute extension commands from a message.
|
|
11
|
-
* 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
|
|
12
|
-
* built-ins from a message (only the TUI/RPC layer does), so these are
|
|
13
|
-
* re-implemented through the extension API. The command-only APIs
|
|
14
|
-
* (`reload`, `newSession`, `fork`, `navigateTree`, `switchSession`) need a fresh
|
|
15
|
-
* `ExtensionCommandContext`, so they run inside the `/qq` bridge command
|
|
16
|
-
* registered by this extension.
|
|
17
|
-
* 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
|
|
18
|
-
* 4. **anything else** — treated as ordinary chat text (the caller forwards it).
|
|
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
|
-
*
|
|
23
|
-
* Everything is opted in conservatively through `commands` in the config file.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import fs from "node:fs";
|
|
27
|
-
import path from "node:path";
|
|
28
|
-
|
|
29
|
-
import { PI_AGENT_DIR } from "./constants";
|
|
30
|
-
|
|
31
|
-
export interface SlashCommandText {
|
|
32
|
-
/** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
|
|
33
|
-
name: string;
|
|
34
|
-
/** raw text after the command name */
|
|
35
|
-
args: string;
|
|
36
|
-
/** the original message */
|
|
37
|
-
raw: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const SLASH = /^\/([A-Za-z0-9:_-]+)(?:\s+([\s\S]*))?$/;
|
|
41
|
-
|
|
42
|
-
export function parseSlash(text: string): SlashCommandText | null {
|
|
43
|
-
const match = SLASH.exec(text.trim());
|
|
44
|
-
if (!match) return null;
|
|
45
|
-
return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type BridgeSub =
|
|
49
|
-
| "reload"
|
|
50
|
-
| "new"
|
|
51
|
-
| "compact"
|
|
52
|
-
| "quit"
|
|
53
|
-
| "session"
|
|
54
|
-
| "name"
|
|
55
|
-
| "model"
|
|
56
|
-
| "thinking"
|
|
57
|
-
| "tree"
|
|
58
|
-
| "fork"
|
|
59
|
-
| "clone"
|
|
60
|
-
| "resume"
|
|
61
|
-
| "sendfile";
|
|
62
|
-
|
|
63
|
-
interface Takeover {
|
|
64
|
-
sub: string;
|
|
65
|
-
/** the command tears down the current runtime/session: reply before dispatching */
|
|
66
|
-
selfDestroying?: boolean;
|
|
67
|
-
/** unsafe to run while a task is in progress */
|
|
68
|
-
needsIdle?: boolean;
|
|
69
|
-
needsArgs?: boolean;
|
|
70
|
-
hint: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* pi built-ins that can be re-implemented with extension APIs.
|
|
75
|
-
* `dist/core/slash-commands.d.ts` marks the full built-in list, but pi does not
|
|
76
|
-
* export it, so this table is maintained here on purpose.
|
|
77
|
-
*/
|
|
78
|
-
export const TAKEOVER: Readonly<Record<string, Takeover>> = {
|
|
79
|
-
reload: { sub: "reload", selfDestroying: true, needsIdle: true, hint: "重新加载扩展/技能/模板/主题" },
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return cfg.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
].
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
*/
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return { code, symbol, rate:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (
|
|
701
|
-
reply(
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
case "
|
|
808
|
-
if (!arg) {
|
|
809
|
-
reply(
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command routing for pi-qqbot.
|
|
3
|
+
*
|
|
4
|
+
* QQ messages that start with `/` are handled in this order:
|
|
5
|
+
*
|
|
6
|
+
* 1. **pi extension commands / prompt templates / skill commands** — anything the
|
|
7
|
+
* running pi instance reports in `pi.getCommands()` (source: extension | prompt |
|
|
8
|
+
* skill). These are dispatched verbatim with
|
|
9
|
+
* `pi.sendUserMessage(text, { expandPromptTemplates: true })`, the only path pi
|
|
10
|
+
* uses to execute extension commands from a message.
|
|
11
|
+
* 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
|
|
12
|
+
* built-ins from a message (only the TUI/RPC layer does), so these are
|
|
13
|
+
* re-implemented through the extension API. The command-only APIs
|
|
14
|
+
* (`reload`, `newSession`, `fork`, `navigateTree`, `switchSession`) need a fresh
|
|
15
|
+
* `ExtensionCommandContext`, so they run inside the `/qq` bridge command
|
|
16
|
+
* registered by this extension.
|
|
17
|
+
* 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
|
|
18
|
+
* 4. **anything else** — treated as ordinary chat text (the caller forwards it).
|
|
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
|
+
*
|
|
23
|
+
* Everything is opted in conservatively through `commands` in the config file.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { PI_AGENT_DIR } from "./constants";
|
|
30
|
+
|
|
31
|
+
export interface SlashCommandText {
|
|
32
|
+
/** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
|
|
33
|
+
name: string;
|
|
34
|
+
/** raw text after the command name */
|
|
35
|
+
args: string;
|
|
36
|
+
/** the original message */
|
|
37
|
+
raw: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SLASH = /^\/([A-Za-z0-9:_-]+)(?:\s+([\s\S]*))?$/;
|
|
41
|
+
|
|
42
|
+
export function parseSlash(text: string): SlashCommandText | null {
|
|
43
|
+
const match = SLASH.exec(text.trim());
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type BridgeSub =
|
|
49
|
+
| "reload"
|
|
50
|
+
| "new"
|
|
51
|
+
| "compact"
|
|
52
|
+
| "quit"
|
|
53
|
+
| "session"
|
|
54
|
+
| "name"
|
|
55
|
+
| "model"
|
|
56
|
+
| "thinking"
|
|
57
|
+
| "tree"
|
|
58
|
+
| "fork"
|
|
59
|
+
| "clone"
|
|
60
|
+
| "resume"
|
|
61
|
+
| "sendfile";
|
|
62
|
+
|
|
63
|
+
interface Takeover {
|
|
64
|
+
sub: string;
|
|
65
|
+
/** the command tears down the current runtime/session: reply before dispatching */
|
|
66
|
+
selfDestroying?: boolean;
|
|
67
|
+
/** unsafe to run while a task is in progress */
|
|
68
|
+
needsIdle?: boolean;
|
|
69
|
+
needsArgs?: boolean;
|
|
70
|
+
hint: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* pi built-ins that can be re-implemented with extension APIs.
|
|
75
|
+
* `dist/core/slash-commands.d.ts` marks the full built-in list, but pi does not
|
|
76
|
+
* export it, so this table is maintained here on purpose.
|
|
77
|
+
*/
|
|
78
|
+
export const TAKEOVER: Readonly<Record<string, Takeover>> = {
|
|
79
|
+
reload: { sub: "reload", selfDestroying: true, needsIdle: true, hint: "重新加载扩展/技能/模板/主题" },
|
|
80
|
+
update: { sub: "update", needsIdle: true, hint: "在线更新 pi-qqbot 并重载(npm + pi update)" },
|
|
81
|
+
new: { sub: "new", selfDestroying: true, needsIdle: true, hint: "开启新的 pi 会话" },
|
|
82
|
+
compact: { sub: "compact", needsIdle: true, hint: "压缩上下文(可带自定义指令)" },
|
|
83
|
+
quit: { sub: "quit", selfDestroying: true, needsIdle: true, hint: "退出主机上的 pi" },
|
|
84
|
+
session: { sub: "session", hint: "查看当前会话信息与上下文用量" },
|
|
85
|
+
name: { sub: "name", hint: "设置或查看会话名称" },
|
|
86
|
+
model: { sub: "model", hint: "查看/切换模型(provider/model)" },
|
|
87
|
+
thinking: { sub: "thinking", hint: "查看/切换思考等级" },
|
|
88
|
+
tree: { sub: "list tree", needsIdle: true, hint: "在会话树上跳转(回编号选择)" },
|
|
89
|
+
fork: { sub: "list fork", needsIdle: true, hint: "从某条历史用户消息分叉(回编号选择)" },
|
|
90
|
+
resume: { sub: "list resume", needsIdle: true, hint: "切换到本项目的另一个会话(回编号选择)" },
|
|
91
|
+
clone: { sub: "clone", selfDestroying: true, needsIdle: true, hint: "在当前叶子位置复制会话" },
|
|
92
|
+
sendfile: { sub: "sendfile", needsArgs: true, hint: "把本机文件发送到你的 QQ" },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** pi built-ins that the extension API cannot reach at all. */
|
|
96
|
+
export const HOST_ONLY: ReadonlySet<string> = new Set([
|
|
97
|
+
"settings",
|
|
98
|
+
"hotkeys",
|
|
99
|
+
"changelog",
|
|
100
|
+
"trust",
|
|
101
|
+
"login",
|
|
102
|
+
"logout",
|
|
103
|
+
"scoped-models",
|
|
104
|
+
"llama",
|
|
105
|
+
"import",
|
|
106
|
+
"export",
|
|
107
|
+
"share",
|
|
108
|
+
"bug",
|
|
109
|
+
"copy",
|
|
110
|
+
"privacy",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
export interface CommandConfig {
|
|
114
|
+
/** forward pi extension commands / prompt templates / skill commands */
|
|
115
|
+
passthrough: boolean;
|
|
116
|
+
/** allow /reload (restarts this bot too) */
|
|
117
|
+
allowReload: boolean;
|
|
118
|
+
/** allow /quit (stops the host pi process) */
|
|
119
|
+
allowQuit: boolean;
|
|
120
|
+
/** allow /update (npm + pi update + reload:在线升级 pi-qqbot 自己) */
|
|
121
|
+
allowUpdate: boolean;
|
|
122
|
+
/** allow session-level commands: /new, /compact, /session, /name, /model, /thinking, /tree, /fork, /clone, /resume */
|
|
123
|
+
allowSession: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const DEFAULT_COMMANDS: CommandConfig = {
|
|
127
|
+
passthrough: true,
|
|
128
|
+
allowReload: false,
|
|
129
|
+
allowQuit: false,
|
|
130
|
+
allowUpdate: false,
|
|
131
|
+
allowSession: true,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export interface RouteDeps {
|
|
135
|
+
/** commands pi currently knows about (extension/prompt/skill) */
|
|
136
|
+
listPiCommands(): readonly { name: string; source?: string }[];
|
|
137
|
+
/**
|
|
138
|
+
* deliver a pi command through pi's own dispatcher.
|
|
139
|
+
* `runsTurn` marks commands that start an agent run (prompt templates, `/skill:name`):
|
|
140
|
+
* those are serialized through the bot's queue, everything else runs immediately.
|
|
141
|
+
* Returns "queued" when the bot deferred it, "rejected" when the queue was full.
|
|
142
|
+
*/
|
|
143
|
+
dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
|
|
144
|
+
/** answer the QQ user */
|
|
145
|
+
/** 回用户一条消息(返回值忽略,允许实现返回是否送达) */
|
|
146
|
+
reply(text: string): Promise<unknown>;
|
|
147
|
+
busy: boolean;
|
|
148
|
+
cfg: CommandConfig;
|
|
149
|
+
log(message: string, ...rest: unknown[]): void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type DispatchResult = "dispatched" | "queued" | "rejected";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 输出只走 `ctx.ui.*`(TUI / RPC 通道)的扩展命令。
|
|
156
|
+
*
|
|
157
|
+
* 这类命令在 QQ 里发了不会有任何回复:它们的结果是通过 `ctx.ui.notify()` 渲染的,
|
|
158
|
+
* 而本桥接只把 assistant 文本(`message_update.text_delta`)转发到 QQ。
|
|
159
|
+
*
|
|
160
|
+
* - 带 `bridge`:改派 `/qq <bridge> <args>`,由桥接命令重新执行原命令并读 session 里
|
|
161
|
+
* 的 custom entry,自行渲染回 QQ(用户用法不变,仍发 `/usage today`)。
|
|
162
|
+
* - 不带 `bridge`:需要主机 TUI 的交互(选择框/浏览器登录),直接回执说清楚,不能沉默。
|
|
163
|
+
*
|
|
164
|
+
* 必须在 passthrough **之前**判断:这些命令确实在 `pi.getCommands()` 里,
|
|
165
|
+
* 一旦先走 passthrough 就会被派发到黑洞。
|
|
166
|
+
*/
|
|
167
|
+
export const UI_ONLY: Readonly<Record<string, { bridge?: string; hint: string }>> = {
|
|
168
|
+
usage: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
|
|
169
|
+
cost: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
|
|
170
|
+
mcp: { hint: "MCP 管理需要主机 TUI 的选择框与浏览器登录" },
|
|
171
|
+
llama: { hint: "本地 llama 管理需要主机 TUI" },
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export type RouteResult = "dispatched" | "handled" | "text";
|
|
175
|
+
|
|
176
|
+
const allowed = (cfg: CommandConfig, name: string): boolean => {
|
|
177
|
+
if (name === "reload") return cfg.allowReload;
|
|
178
|
+
if (name === "quit") return cfg.allowQuit;
|
|
179
|
+
if (name === "update") return cfg.allowUpdate;
|
|
180
|
+
// 发文件不属于会话操作:由 files.enabled + QQ 审批把关
|
|
181
|
+
if (name === "sendfile") return true;
|
|
182
|
+
return cfg.allowSession;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Route one slash message. Returns "text" when it should be treated as chat. */
|
|
186
|
+
export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
|
|
187
|
+
const parsed = parseSlash(text);
|
|
188
|
+
if (!parsed) return "text";
|
|
189
|
+
const { name } = parsed;
|
|
190
|
+
|
|
191
|
+
// 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
|
|
192
|
+
const uiOnly = UI_ONLY[name];
|
|
193
|
+
if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
|
|
194
|
+
if (uiOnly.bridge) {
|
|
195
|
+
const bridge = `/qq ${uiOnly.bridge}${parsed.args ? ` ${parsed.args}` : ""}`;
|
|
196
|
+
deps.log("[cmd] 桥接 UI-only 命令:", parsed.raw.slice(0, 60), "->", bridge.slice(0, 60));
|
|
197
|
+
deps.dispatch(bridge);
|
|
198
|
+
return "dispatched";
|
|
199
|
+
}
|
|
200
|
+
await deps.reply(`/${name} ${uiOnly.hint},QQ 侧无法执行。`);
|
|
201
|
+
return "handled";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 1. pi extension commands, prompt templates and skill commands
|
|
205
|
+
if (deps.cfg.passthrough) {
|
|
206
|
+
const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
|
|
207
|
+
if (known) {
|
|
208
|
+
// 提示模板与 /skill: 会开启一轮 agent run,交给队列串行;扩展命令自己管理 LLM 交互
|
|
209
|
+
const runsTurn = known.source === "prompt" || known.source === "skill";
|
|
210
|
+
deps.log("[cmd] 派发 pi 命令:", parsed.raw.slice(0, 80), "runsTurn=", runsTurn);
|
|
211
|
+
const result = deps.dispatch(parsed.raw, { runsTurn });
|
|
212
|
+
if (result === "queued") {
|
|
213
|
+
await deps.reply(`已排队 /${name}:当前任务结束后执行(发 /queue 查看队列)。`);
|
|
214
|
+
} else if (result === "rejected") {
|
|
215
|
+
await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
|
|
216
|
+
} else {
|
|
217
|
+
await deps.reply(`已派发 /${name} 给 pi。`);
|
|
218
|
+
}
|
|
219
|
+
return "dispatched";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. built-ins we can take over
|
|
224
|
+
const takeover = TAKEOVER[name];
|
|
225
|
+
if (takeover) {
|
|
226
|
+
if (!allowed(deps.cfg, name)) {
|
|
227
|
+
const gate = name === "reload" ? "allowReload" : name === "quit" ? "allowQuit" : "allowSession";
|
|
228
|
+
await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
|
|
229
|
+
return "handled";
|
|
230
|
+
}
|
|
231
|
+
if (takeover.needsIdle && deps.busy) {
|
|
232
|
+
await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
|
|
233
|
+
return "handled";
|
|
234
|
+
}
|
|
235
|
+
const bridge = `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
|
|
236
|
+
deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
|
|
237
|
+
if (takeover.selfDestroying) {
|
|
238
|
+
// the command tears the runtime down; answer before it lands
|
|
239
|
+
await deps.reply(`已请求 /${name}(${takeover.hint})…`);
|
|
240
|
+
deps.dispatch(bridge);
|
|
241
|
+
return "dispatched";
|
|
242
|
+
}
|
|
243
|
+
deps.dispatch(bridge);
|
|
244
|
+
return "dispatched";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 3. built-ins only the host TUI can run
|
|
248
|
+
if (HOST_ONLY.has(name)) {
|
|
249
|
+
await deps.reply(`/${name} 只能在主机上的 pi 里执行(扩展 API 没有对应能力)。`);
|
|
250
|
+
return "handled";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 4. not a command we know: ordinary text
|
|
254
|
+
return "text";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const MAX_LIST = 12;
|
|
258
|
+
|
|
259
|
+
function formatTokens(value: number | null | undefined): string {
|
|
260
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "?";
|
|
261
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
|
|
262
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
|
263
|
+
return String(value);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// pickers (tree / fork / resume / model)
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
export type PickerKind = "tree" | "fork" | "resume" | "model";
|
|
271
|
+
|
|
272
|
+
export interface PickerItem {
|
|
273
|
+
/** entry id (tree/fork) */
|
|
274
|
+
id?: string;
|
|
275
|
+
/** session file (resume) */
|
|
276
|
+
path?: string;
|
|
277
|
+
/** what the user sees next to the number */
|
|
278
|
+
label: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface PickerState {
|
|
282
|
+
kind: PickerKind;
|
|
283
|
+
items: PickerItem[];
|
|
284
|
+
/** only this QQ user may answer with a number */
|
|
285
|
+
openid: string | null;
|
|
286
|
+
createdAt: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Shared mutable state between the router (index.ts) and the bridge command. */
|
|
290
|
+
export interface BridgeStore {
|
|
291
|
+
picker: PickerState | null;
|
|
292
|
+
/** set by the router right before dispatching a `list` command */
|
|
293
|
+
pickerOwner: string | null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const PICKER_TTL_MS = 10 * 60_000;
|
|
297
|
+
export const PICKER_MAX_ITEMS = 10;
|
|
298
|
+
|
|
299
|
+
const KIND_LABEL: Record<PickerKind, string> = {
|
|
300
|
+
tree: "会话树节点",
|
|
301
|
+
fork: "可分叉的用户消息",
|
|
302
|
+
resume: "本项目的其他会话",
|
|
303
|
+
model: "新接入服务的模型",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
function previewText(content: unknown): string {
|
|
307
|
+
const parts = Array.isArray(content) ? content : [];
|
|
308
|
+
const text = parts
|
|
309
|
+
.map((part: any) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
|
|
310
|
+
.join(" ")
|
|
311
|
+
.replace(/\s+/g, " ")
|
|
312
|
+
.trim();
|
|
313
|
+
return text.length > 50 ? `${text.slice(0, 50)}…` : text || "(无文本)";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const stamp = (value: unknown): string => {
|
|
317
|
+
const date = typeof value === "string" || typeof value === "number" ? new Date(value) : new Date();
|
|
318
|
+
if (Number.isNaN(date.getTime())) return "?";
|
|
319
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
320
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function branchItems(ctx: any): PickerItem[] {
|
|
324
|
+
let branch: any[] = [];
|
|
325
|
+
try {
|
|
326
|
+
branch = ctx.sessionManager.getBranch() ?? [];
|
|
327
|
+
} catch {
|
|
328
|
+
branch = [];
|
|
329
|
+
}
|
|
330
|
+
return branch
|
|
331
|
+
.filter((entry) => entry?.type === "message" && entry?.message?.role === "user")
|
|
332
|
+
.slice(-PICKER_MAX_ITEMS)
|
|
333
|
+
.map((entry) => ({
|
|
334
|
+
id: entry.id,
|
|
335
|
+
label: `${stamp(entry.timestamp)} ${previewText(entry.message.content)}`,
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function resumeItems(ctx: any): PickerItem[] {
|
|
340
|
+
let dir: string | undefined;
|
|
341
|
+
let current: string | undefined;
|
|
342
|
+
try {
|
|
343
|
+
dir = ctx.sessionManager.getSessionDir?.();
|
|
344
|
+
current = ctx.sessionManager.getSessionFile?.();
|
|
345
|
+
} catch {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
if (!dir) return [];
|
|
349
|
+
let names: string[] = [];
|
|
350
|
+
try {
|
|
351
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
|
|
352
|
+
} catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
return names
|
|
356
|
+
.map((name) => {
|
|
357
|
+
const full = path.join(dir!, name);
|
|
358
|
+
let mtime = 0;
|
|
359
|
+
let size = 0;
|
|
360
|
+
try {
|
|
361
|
+
const stat = fs.statSync(full);
|
|
362
|
+
mtime = stat.mtimeMs;
|
|
363
|
+
size = stat.size;
|
|
364
|
+
} catch {
|
|
365
|
+
// unreadable: keep it out of the ranking
|
|
366
|
+
}
|
|
367
|
+
return { full, name, mtime, size };
|
|
368
|
+
})
|
|
369
|
+
.filter((row) => row.mtime > 0 && row.full !== current)
|
|
370
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
371
|
+
.slice(0, PICKER_MAX_ITEMS)
|
|
372
|
+
.map((row) => ({
|
|
373
|
+
path: row.full,
|
|
374
|
+
label: `${stamp(row.mtime)} ${Math.max(1, Math.round(row.size / 1024))}KB ${row.name.replace(/\.jsonl$/, "").slice(0, 44)}`,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function buildPickerItems(kind: PickerKind, ctx: any): PickerItem[] {
|
|
379
|
+
if (kind === "resume") return resumeItems(ctx);
|
|
380
|
+
return branchItems(ctx);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function formatPicker(picker: PickerState): string {
|
|
384
|
+
const header = `选择${KIND_LABEL[picker.kind]}(${picker.items.length} 项):`;
|
|
385
|
+
const rows = picker.items.map((item, index) => ` ${index + 1}. ${item.label}`);
|
|
386
|
+
return [
|
|
387
|
+
header,
|
|
388
|
+
...rows,
|
|
389
|
+
`回复编号执行,或回复「取消」放弃(${Math.round(PICKER_TTL_MS / 60000)} 分钟内有效)。`,
|
|
390
|
+
].join("\n");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Parse a bare "3" reply that answers the pending picker. */
|
|
394
|
+
export function parsePickIndex(text: string): number | null {
|
|
395
|
+
const match = /^\s*(\d{1,3})\s*$/.exec(text);
|
|
396
|
+
if (!match) return null;
|
|
397
|
+
const value = Number(match[1]);
|
|
398
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function pickerExpired(picker: PickerState | null): boolean {
|
|
402
|
+
return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
|
|
406
|
+
const USAGE_ENTRY_TYPE = "usage-report";
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* appendEntry 的写入比 `sendUserMessage` 的 resolve 晚(实测 promise 183ms 返回,
|
|
410
|
+
* entry 在之后 ~100ms 才出现在 getEntries 里),所以必须轮询而不是只读一次。
|
|
411
|
+
*/
|
|
412
|
+
const USAGE_WAIT_TIMEOUT_MS = 2000;
|
|
413
|
+
const USAGE_POLL_INTERVAL_MS = 100;
|
|
414
|
+
|
|
415
|
+
/** pi-usage-report 的配置/汇率缓存(读回来确认:命令真的生效了,而不是口头应下) */
|
|
416
|
+
const USAGE_SETTINGS_FILE = "pi-usage-report.json";
|
|
417
|
+
const USAGE_RATE_FILE = "pi-usage-report-rate.json";
|
|
418
|
+
|
|
419
|
+
const USAGE_HELP = [
|
|
420
|
+
"/usage(默认 today)",
|
|
421
|
+
"/usage today | yesterday | week | lastweek | month | lastmonth | all",
|
|
422
|
+
"/usage 7d | 12h | 2w | 3m | 10天 滚动窗口",
|
|
423
|
+
"/usage 2026-09-01..2026-09-30 本地日期区间(含首尾)",
|
|
424
|
+
"/usage 今天 | 昨天 | 本周 | 上周 | 本月 | 上月 | 全部",
|
|
425
|
+
"/usage rate <值> | rate auto 固定 USD→本币汇率,或恢复自动(会真的写配置)",
|
|
426
|
+
].join("\n");
|
|
427
|
+
|
|
428
|
+
function readAgentJson(file: string): any {
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(fs.readFileSync(path.join(PI_AGENT_DIR, file), "utf8"));
|
|
431
|
+
} catch {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const CURRENCY_SYMBOLS: Record<string, string> = { CNY: "¥", USD: "$", EUR: "€", JPY: "¥", GBP: "£", HKD: "HK$" };
|
|
437
|
+
|
|
438
|
+
/** 复刻 pi-usage-report 的 readSettings 优先级:fixed(usdRate) > 缓存的发布汇率 > 无 */
|
|
439
|
+
function usageMoney(): { code: string; symbol: string; rate: number | null; source: string } {
|
|
440
|
+
const raw = readAgentJson(USAGE_SETTINGS_FILE) ?? {};
|
|
441
|
+
const code = String(raw.currency ?? "CNY").toUpperCase();
|
|
442
|
+
const symbol = typeof raw.symbol === "string" ? raw.symbol : (CURRENCY_SYMBOLS[code] ?? code);
|
|
443
|
+
const pinned = typeof raw.usdRate === "number" && raw.usdRate > 0 ? raw.usdRate : undefined;
|
|
444
|
+
const fixed = raw.rateMode === "auto" ? undefined : pinned;
|
|
445
|
+
if (fixed !== undefined) return { code, symbol, rate: fixed, source: "fixed,/usage rate 手动固定" };
|
|
446
|
+
const cache = readAgentJson(USAGE_RATE_FILE);
|
|
447
|
+
if (cache && cache.code === code && typeof cache.rate === "number" && cache.rate > 0) {
|
|
448
|
+
const ageMinutes = Math.max(0, Math.round((Date.now() - Number(cache.fetchedAt ?? 0)) / 60000));
|
|
449
|
+
return { code, symbol, rate: cache.rate, source: `cache,${ageMinutes} 分钟前抓取` };
|
|
450
|
+
}
|
|
451
|
+
return { code, symbol, rate: null, source: "无,可用 /usage rate <值>" };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function moneyText(money: { code: string; rate: number | null; source: string }): string {
|
|
455
|
+
return money.rate === null
|
|
456
|
+
? `USD/${money.code}:无可用汇率(${money.source})`
|
|
457
|
+
: `USD/${money.code} = ${money.rate}(${money.source})`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function sessionEntries(ctx: any): any[] {
|
|
461
|
+
try {
|
|
462
|
+
return ctx?.sessionManager?.getEntries?.() ?? [];
|
|
463
|
+
} catch {
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function sessionFile(ctx: any): string | undefined {
|
|
469
|
+
try {
|
|
470
|
+
return ctx?.sessionManager?.getSessionFile?.() ?? undefined;
|
|
471
|
+
} catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 兼容三种时间形态:毫秒数字(pi.appendEntry 实际写的就是它)/ ISO 字符串 / Date */
|
|
477
|
+
function entryTime(value: unknown): number {
|
|
478
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
|
|
479
|
+
if (value instanceof Date) return value.getTime();
|
|
480
|
+
if (typeof value === "string" && value) return Date.parse(value);
|
|
481
|
+
return NaN;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 从会话文件尾部找最近一条 usage-report(generatedAt 不早于 since)。
|
|
486
|
+
*
|
|
487
|
+
* 为什么需要这条路:appendEntry 是**另一个扩展**写的,命令上下文里的
|
|
488
|
+
* `sessionManager.getEntries()` 不一定即时反映(实测 `/usage today` 已经写入了
|
|
489
|
+
* 会话文件,但 getEntries 看不到新增项)。数据库不会骗人,文件会。
|
|
490
|
+
*/
|
|
491
|
+
function readUsageEntryFromFile(file: string | undefined, since: number): any | undefined {
|
|
492
|
+
if (!file) return undefined;
|
|
493
|
+
try {
|
|
494
|
+
const size = fs.statSync(file).size;
|
|
495
|
+
const chunk = Math.min(size, 512 * 1024);
|
|
496
|
+
if (chunk <= 0) return undefined;
|
|
497
|
+
const fd = fs.openSync(file, "r");
|
|
498
|
+
let text = "";
|
|
499
|
+
try {
|
|
500
|
+
const buf = Buffer.alloc(chunk);
|
|
501
|
+
fs.readSync(fd, buf, 0, chunk, size - chunk);
|
|
502
|
+
text = buf.toString("utf8");
|
|
503
|
+
} finally {
|
|
504
|
+
fs.closeSync(fd);
|
|
505
|
+
}
|
|
506
|
+
const lines = text.split("\n");
|
|
507
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
508
|
+
const line = lines[i].trim();
|
|
509
|
+
if (!line.startsWith("{")) continue;
|
|
510
|
+
let entry: any;
|
|
511
|
+
try {
|
|
512
|
+
entry = JSON.parse(line);
|
|
513
|
+
} catch {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (!isUsageEntry(entry)) continue;
|
|
517
|
+
const when = entryTime(entry?.data?.generatedAt ?? entry?.timestamp);
|
|
518
|
+
return Number.isFinite(when) && when >= since ? entry : undefined;
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const intText = (value: unknown): string =>
|
|
527
|
+
typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
|
|
528
|
+
|
|
529
|
+
function localStamp(value: unknown): string {
|
|
530
|
+
if (typeof value !== "string") return "?";
|
|
531
|
+
const date = new Date(value);
|
|
532
|
+
if (Number.isNaN(date.getTime())) return "?";
|
|
533
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
534
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const isUsageEntry = (entry: any): boolean =>
|
|
538
|
+
entry?.type === "custom" && entry?.customType === USAGE_ENTRY_TYPE;
|
|
539
|
+
|
|
540
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 等本次 /usage 写的报告出现:每 100ms 查一次 getEntries(最多 2s),
|
|
544
|
+
* 全程未命中再试一次会话文件(custom entry 可能很晚才落盘)。
|
|
545
|
+
*/
|
|
546
|
+
async function waitForUsageEntry(
|
|
547
|
+
ctx: any,
|
|
548
|
+
opts: { before: Set<unknown>; since: number; file?: string },
|
|
549
|
+
): Promise<any | undefined> {
|
|
550
|
+
const deadline = Date.now() + USAGE_WAIT_TIMEOUT_MS;
|
|
551
|
+
while (Date.now() < deadline) {
|
|
552
|
+
const fresh = [...sessionEntries(ctx)].reverse().find((entry: any) => isUsageEntry(entry) && !opts.before.has(entry?.id));
|
|
553
|
+
if (fresh) return fresh;
|
|
554
|
+
await sleep(USAGE_POLL_INTERVAL_MS);
|
|
555
|
+
}
|
|
556
|
+
return readUsageEntryFromFile(opts.file, opts.since);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
|
|
560
|
+
function formatUsage(data: any): string {
|
|
561
|
+
if (!data?.totals) return "用量报告为空。";
|
|
562
|
+
const t = data.totals;
|
|
563
|
+
const money = data.money ?? {};
|
|
564
|
+
const usd = Number(t.costUsd ?? 0);
|
|
565
|
+
const rate = typeof money.rate === "number" ? money.rate : null;
|
|
566
|
+
const symbol = money.symbol ?? "$";
|
|
567
|
+
const costText = rate !== null ? `${symbol}${(usd * rate).toFixed(2)}(USD ${usd.toFixed(4)},汇率 ${rate})` : `USD ${usd.toFixed(4)}(无汇率)`;
|
|
568
|
+
const totalTokens = Number(t.totalTokens ?? 0);
|
|
569
|
+
const cachePct = totalTokens > 0 ? ((Number(t.cacheRead ?? 0) / totalTokens) * 100).toFixed(1) : "0.0";
|
|
570
|
+
const lines = [
|
|
571
|
+
`用量统计:${data.rangeLabel ?? "?"} ${localStamp(data.from)} → ${localStamp(data.to)}`,
|
|
572
|
+
`请求 ${intText(t.requests)} 次 合计 ${formatTokens(totalTokens)} tokens 费用 ${costText}`,
|
|
573
|
+
`输入 ${intText(t.input)} 输出 ${intText(t.output)} 缓存读 ${intText(t.cacheRead)}(${cachePct}%)`,
|
|
574
|
+
`缓存写 ${intText(t.cacheWrite)} 推理 ${intText(t.reasoning)}`,
|
|
575
|
+
];
|
|
576
|
+
const models: any[] = Array.isArray(data.byModel) ? data.byModel : [];
|
|
577
|
+
if (models.length > 0) {
|
|
578
|
+
lines.push("按模型:");
|
|
579
|
+
for (const row of models.slice(0, 5)) {
|
|
580
|
+
const bucket = row?.bucket ?? {};
|
|
581
|
+
const rowUsd = Number(bucket.costUsd ?? 0);
|
|
582
|
+
const rowCost = rate !== null ? `${symbol}${(rowUsd * rate).toFixed(2)}` : `USD ${rowUsd.toFixed(4)}`;
|
|
583
|
+
lines.push(
|
|
584
|
+
` ${row?.model ?? "unknown"} ${intText(bucket.requests)} 次 ${formatTokens(Number(bucket.totalTokens ?? 0))} tokens ${rowCost}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const sessions: any[] = Array.isArray(data.bySession) ? data.bySession : [];
|
|
589
|
+
if (sessions.length > 1) lines.push(`会话数:${sessions.length} 个`);
|
|
590
|
+
if (t.unpriced) lines.push(`注:${intText(t.unpriced)} 次请求没有计价。`);
|
|
591
|
+
return lines.join("\n");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
|
|
596
|
+
* which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
|
|
597
|
+
* `switchSession()` are legal.
|
|
598
|
+
*
|
|
599
|
+
* Session-replacing actions (`reload`, `new`, `tree`/`fork`/`resume` picks, `clone`)
|
|
600
|
+
* tear this runtime down; the caller answers the QQ user before dispatching them.
|
|
601
|
+
*/
|
|
602
|
+
export async function runBridge(
|
|
603
|
+
sub: string,
|
|
604
|
+
arg: string,
|
|
605
|
+
ctx: any,
|
|
606
|
+
pi: any,
|
|
607
|
+
reply: (text: string) => void,
|
|
608
|
+
store?: BridgeStore,
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const level = String(sub ?? "").toLowerCase();
|
|
611
|
+
const first = arg.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
612
|
+
|
|
613
|
+
switch (level) {
|
|
614
|
+
case "reload": {
|
|
615
|
+
await ctx.reload();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
case "update": {
|
|
619
|
+
// 完整流程在 selfupdate.ts:检查 npm → pi update → 校验版本 → ctx.reload()
|
|
620
|
+
const { runSelfUpdate, installedVersion, checkLatestVersion, applyUpdate } = await import("./selfupdate");
|
|
621
|
+
await runSelfUpdate({
|
|
622
|
+
currentVersion: installedVersion(),
|
|
623
|
+
checkLatest: () => checkLatestVersion(),
|
|
624
|
+
applyUpdate: () => applyUpdate(),
|
|
625
|
+
versionAfterUpdate: () => installedVersion(),
|
|
626
|
+
reply: (text) => Promise.resolve(reply(text)),
|
|
627
|
+
reload: () => ctx.reload(),
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
case "new": {
|
|
632
|
+
await ctx.newSession();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
case "quit": {
|
|
636
|
+
reply("正在退出主机上的 pi…");
|
|
637
|
+
ctx.shutdown();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
case "compact": {
|
|
641
|
+
reply(arg ? `开始压缩上下文(指令:${arg.slice(0, 80)})…` : "开始压缩上下文…");
|
|
642
|
+
ctx.compact({
|
|
643
|
+
customInstructions: arg || undefined,
|
|
644
|
+
onComplete: () => reply("上下文压缩完成。"),
|
|
645
|
+
onError: (error: Error) => reply(`上下文压缩失败:${String(error?.message ?? error).slice(0, 200)}`),
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
case "list": {
|
|
650
|
+
const kind = first as PickerKind;
|
|
651
|
+
if (kind !== "tree" && kind !== "fork" && kind !== "resume") {
|
|
652
|
+
reply(`未知列表类型:${first || "(空)"}`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const items = buildPickerItems(kind, ctx);
|
|
656
|
+
if (items.length === 0) {
|
|
657
|
+
reply(`没有可用的${KIND_LABEL[kind]}。`);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
|
|
661
|
+
if (store) store.picker = picker;
|
|
662
|
+
reply(formatPicker(picker));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
case "pick": {
|
|
666
|
+
const index = Number.parseInt(first, 10);
|
|
667
|
+
const picker = store?.picker ?? null;
|
|
668
|
+
if (!picker || !Number.isFinite(index)) {
|
|
669
|
+
reply("选择列表已失效,请重新发送命令。");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const item = picker.items[index - 1];
|
|
673
|
+
if (!item) {
|
|
674
|
+
reply(`编号 ${first} 超出范围(1-${picker.items.length})。`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (store) store.picker = null;
|
|
678
|
+
// 欠费接手:清单是「provider/model」,选了就切过去
|
|
679
|
+
if (picker.kind === "model") {
|
|
680
|
+
const [provider, ...rest] = String(item.id ?? "").split("/");
|
|
681
|
+
const modelId = rest.join("/");
|
|
682
|
+
const found = provider && modelId ? ctx.modelRegistry?.find?.(provider, modelId) : undefined;
|
|
683
|
+
if (!found) {
|
|
684
|
+
reply(`模型 ${item.id ?? "?"} 已不在目录里(models.json 可能改过),发 /model 重新看看。`);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const ok = await pi.setModel(found);
|
|
688
|
+
reply(
|
|
689
|
+
ok
|
|
690
|
+
? `已切换到 ${provider}/${modelId},直接发问题就行。`
|
|
691
|
+
: `切换到 ${provider}/${modelId} 失败(这个服务没有凭据?)`,
|
|
692
|
+
);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (picker.kind === "tree") {
|
|
696
|
+
reply(`正在跳转到第 ${index} 个节点…`);
|
|
697
|
+
await ctx.navigateTree(item.id);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (picker.kind === "fork") {
|
|
701
|
+
reply(`正在从第 ${index} 条消息分叉…`);
|
|
702
|
+
await ctx.fork(item.id);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
reply(`正在切换会话:${item.label.slice(0, 44)}…`);
|
|
706
|
+
await ctx.switchSession(item.path);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
case "clone": {
|
|
710
|
+
let leaf: string | null = null;
|
|
711
|
+
try {
|
|
712
|
+
leaf = ctx.sessionManager.getLeafId?.() ?? null;
|
|
713
|
+
} catch {
|
|
714
|
+
leaf = null;
|
|
715
|
+
}
|
|
716
|
+
if (!leaf) {
|
|
717
|
+
reply("当前会话还没有可复制的位置(先发一条消息)。");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
reply("正在当前叶子位置复制会话…");
|
|
721
|
+
await ctx.fork(leaf, { position: "at" });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
case "usage": {
|
|
725
|
+
// /qq usage [...]:重跑原 /usage 命令,再从 session 里读回它写的 custom entry
|
|
726
|
+
const rest = arg.slice(first.length).trim();
|
|
727
|
+
|
|
728
|
+
// help:不执行命令(它在 TUI 打文本),直接回本地帮助
|
|
729
|
+
if (first === "help" || first === "?") {
|
|
730
|
+
reply(USAGE_HELP);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const commands = (pi?.getCommands?.() ?? []) as { name: string }[];
|
|
735
|
+
const hasUsage = commands.some((c) => ["usage", "cost"].includes(String(c.name).toLowerCase()));
|
|
736
|
+
if (!hasUsage) {
|
|
737
|
+
reply("主机上没有 /usage 命令(需要安装 pi-usage-report),无法统计。");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// rate:会写配置/联网刷新汇率,必须真的执行,再读回状态确认(不能只回一句“TUI 可见”)
|
|
742
|
+
if (first === "rate") {
|
|
743
|
+
if (!rest) {
|
|
744
|
+
reply(moneyText(usageMoney()));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const rateCommand = `/usage rate ${rest}`;
|
|
748
|
+
try {
|
|
749
|
+
await pi.sendUserMessage(rateCommand, { expandPromptTemplates: true });
|
|
750
|
+
} catch (error) {
|
|
751
|
+
reply(`执行 ${rateCommand} 失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const after = usageMoney();
|
|
755
|
+
reply(
|
|
756
|
+
`已执行 ${rateCommand}\n${moneyText(after)}\n(下一步 /usage today 的 ¥/USD 换算就用它;无效值会被忽略)`,
|
|
757
|
+
);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const command = `/usage${arg ? ` ${arg}` : ""}`;
|
|
762
|
+
const since = Date.now();
|
|
763
|
+
const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
|
|
764
|
+
const file = sessionFile(ctx);
|
|
765
|
+
reply(`正在统计用量(${command})…`);
|
|
766
|
+
try {
|
|
767
|
+
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
768
|
+
} catch (error) {
|
|
769
|
+
reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// 先轮询 getEntries 的增量(appendEntry 晚于 await 返回),超时再退回读会话文件
|
|
773
|
+
const entry = await waitForUsageEntry(ctx, { before, since, file });
|
|
774
|
+
if (!entry) {
|
|
775
|
+
reply("没拿到用量报告:/usage 已执行但找不到报告数据(可重试一次;若反复出现请把 pi-qqbot.log 尾部发我)。");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
reply(formatUsage(entry.data));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
case "session": {
|
|
782
|
+
const sm = ctx.sessionManager;
|
|
783
|
+
const usage = ctx.getContextUsage?.();
|
|
784
|
+
const model = ctx.model;
|
|
785
|
+
const entries = (() => {
|
|
786
|
+
try {
|
|
787
|
+
return sm.getEntries().length;
|
|
788
|
+
} catch {
|
|
789
|
+
return "?";
|
|
790
|
+
}
|
|
791
|
+
})();
|
|
792
|
+
reply(
|
|
793
|
+
[
|
|
794
|
+
`会话:${sm.getSessionName?.() || "(未命名)"}`,
|
|
795
|
+
`id:${sm.getSessionId?.() ?? "?"}`,
|
|
796
|
+
`cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
|
|
797
|
+
`模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
|
|
798
|
+
`条目:${entries} 上下文:${
|
|
799
|
+
usage && usage.tokens !== undefined
|
|
800
|
+
? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}`
|
|
801
|
+
: "未知"
|
|
802
|
+
}`,
|
|
803
|
+
].join("\n"),
|
|
804
|
+
);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
case "name": {
|
|
808
|
+
if (!arg) {
|
|
809
|
+
reply(`当前会话名:${ctx.sessionManager.getSessionName?.() || "(未命名)"}`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
pi.setSessionName(arg);
|
|
813
|
+
reply(`会话名已设为:${arg}`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
case "model": {
|
|
817
|
+
const registry = ctx.modelRegistry;
|
|
818
|
+
const available = registry?.getAvailable?.() ?? [];
|
|
819
|
+
const current = ctx.model;
|
|
820
|
+
if (!arg) {
|
|
821
|
+
const names = available.slice(0, MAX_LIST).map((m: any) => `${m.provider}/${m.id}`);
|
|
822
|
+
reply(
|
|
823
|
+
[
|
|
824
|
+
`当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
|
|
825
|
+
available.length > 0
|
|
826
|
+
? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
|
|
827
|
+
: "没有可用模型",
|
|
828
|
+
].join("\n"),
|
|
829
|
+
);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const wanted = arg.toLowerCase();
|
|
833
|
+
const found = available.find((m: any) => `${m.provider}/${m.id}`.toLowerCase() === wanted)
|
|
834
|
+
?? available.find((m: any) => String(m.id).toLowerCase() === wanted);
|
|
835
|
+
if (!found) {
|
|
836
|
+
reply(`找不到模型 ${arg}。发 /model 查看可用列表。`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const ok = await pi.setModel(found);
|
|
840
|
+
reply(ok ? `模型已切换为 ${found.provider}/${found.id}` : `切换到 ${found.provider}/${found.id} 失败(缺少凭据?)`);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
case "thinking": {
|
|
844
|
+
if (!arg) {
|
|
845
|
+
reply(`当前思考等级:${pi.getThinkingLevel()}`);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
849
|
+
const wanted = arg.toLowerCase();
|
|
850
|
+
if (!levels.includes(wanted)) {
|
|
851
|
+
reply(`思考等级只能是:${levels.join(" / ")}`);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
pi.setThinkingLevel(wanted);
|
|
855
|
+
reply(`思考等级已设为 ${wanted}`);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
default: {
|
|
859
|
+
reply(`未知的桥接命令:/qq ${arg ? `${level} ${arg}` : level}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|