@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/README.md +67 -13
- package/config.ts +14 -0
- package/constants.ts +4 -0
- package/docs/README.zh-CN.md +58 -10
- package/index.ts +352 -46
- package/package.json +4 -1
- package/queue.ts +118 -0
- package/slash.ts +261 -31
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
|
|
15
|
-
* inside the `/qq` bridge command
|
|
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:
|
|
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
|
-
/**
|
|
112
|
-
|
|
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().
|
|
149
|
+
const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
|
|
137
150
|
if (known) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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 =
|
|
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()`
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
491
|
+
available.length > 0
|
|
492
|
+
? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}`
|
|
493
|
+
: "没有可用模型",
|
|
264
494
|
].join("\n"),
|
|
265
495
|
);
|
|
266
496
|
return;
|