ework-qq-bridge 0.5.0 → 0.5.2
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/package.json +1 -1
- package/src/chat.ts +48 -27
- package/src/config.ts +4 -0
- package/src/router.ts +7 -7
- package/tests/bridge.test.ts +105 -11
package/package.json
CHANGED
package/src/chat.ts
CHANGED
|
@@ -24,52 +24,69 @@ export function estimateTokens(text: string): number {
|
|
|
24
24
|
// Single-message hard cap so one pasted log cannot eat the whole budget.
|
|
25
25
|
export const CHAT_MSG_CHAR_CAP = 24000;
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Evict in bulk, never slide. A sliding window re-derives "last N" on every
|
|
28
|
+
// build, shifting the prompt prefix each request, missing the serving
|
|
29
|
+
// engine's prefix cache (radix cache) and forcing a full re-prefill of the
|
|
30
|
+
// whole context every turn once history is full. trimStored is instead
|
|
31
|
+
// STICKY: it is a no-op while under the limits and, on crossing one, drops
|
|
32
|
+
// the oldest turns down to TRIM_FLOOR_RATIO in a single shot. Between
|
|
33
|
+
// evictions the stored history only ever grows append-only, so the prompt
|
|
34
|
+
// prefix (system + history) stays byte-identical and cached prefixes keep
|
|
35
|
+
// hitting; only the first request after each eviction re-fills.
|
|
36
|
+
export const TRIM_FLOOR_RATIO = 0.7;
|
|
37
|
+
|
|
38
|
+
export function capContent(text: string): string {
|
|
28
39
|
return text.length > CHAT_MSG_CHAR_CAP ? text.slice(0, CHAT_MSG_CHAR_CAP) + "…(已截断)" : text;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
):
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
function historyTokens(history: ChatTurn[]): number {
|
|
43
|
+
return history.reduce((n, t) => n + estimateTokens(t.content), 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Headroom for the fixed prompt furniture (system + one max-size question).
|
|
47
|
+
function reserveTokens(): number {
|
|
48
|
+
return estimateTokens(CHAT_SYSTEM_PROMPT) + estimateTokens("x".repeat(CHAT_MSG_CHAR_CAP));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function trimStored(history: ChatTurn[], maxHistory: number, maxContextTokens: number): ChatTurn[] {
|
|
52
|
+
const countLimit = Math.max(2, maxHistory * 2);
|
|
53
|
+
const tokenLimit = Math.max(0, maxContextTokens - reserveTokens());
|
|
54
|
+
if (history.length <= countLimit && historyTokens(history) <= tokenLimit) return history;
|
|
55
|
+
const countFloor = Math.max(2, Math.floor(countLimit * TRIM_FLOOR_RATIO));
|
|
56
|
+
const tokenFloor = Math.floor(tokenLimit * TRIM_FLOOR_RATIO);
|
|
57
|
+
let start = 0;
|
|
58
|
+
let total = historyTokens(history);
|
|
59
|
+
while (start < history.length && (history.length - start > countFloor || total > tokenFloor)) {
|
|
60
|
+
const turn = history[start];
|
|
61
|
+
if (!turn) break;
|
|
62
|
+
total -= estimateTokens(turn.content);
|
|
63
|
+
start++;
|
|
47
64
|
}
|
|
48
|
-
return
|
|
65
|
+
return history.slice(start);
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
export function buildChatMessages(
|
|
52
69
|
history: ChatTurn[],
|
|
53
70
|
question: ChatTurn,
|
|
54
|
-
maxHistory: number,
|
|
55
|
-
maxContextTokens: number,
|
|
56
71
|
): { role: string; name?: string; content: string }[] {
|
|
57
|
-
const system = { role: "system", content: CHAT_SYSTEM_PROMPT };
|
|
58
|
-
const recent = history.slice(-maxHistory);
|
|
59
|
-
const kept = trimToContext(recent, maxContextTokens, estimateTokens(CHAT_SYSTEM_PROMPT) + estimateTokens(question.content));
|
|
60
72
|
return [
|
|
61
|
-
system,
|
|
62
|
-
...
|
|
73
|
+
{ role: "system", content: CHAT_SYSTEM_PROMPT },
|
|
74
|
+
...history.map((t) => ({ role: t.role, name: t.name, content: capContent(t.content) })),
|
|
63
75
|
{ role: question.role, name: question.name, content: capContent(question.content) },
|
|
64
76
|
];
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
export function stripThink(text: string): string {
|
|
80
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
export async function chatComplete(
|
|
68
84
|
apiBase: string,
|
|
69
85
|
apiKey: string,
|
|
70
86
|
model: string,
|
|
71
87
|
messages: { role: string; name?: string; content: string }[],
|
|
72
88
|
timeoutMs: number,
|
|
89
|
+
noThink = true,
|
|
73
90
|
): Promise<string> {
|
|
74
91
|
const ctrl = new AbortController();
|
|
75
92
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
@@ -77,14 +94,18 @@ export async function chatComplete(
|
|
|
77
94
|
const res = await fetch(`${apiBase.replace(/\/+$/, "")}/chat/completions`, {
|
|
78
95
|
method: "POST",
|
|
79
96
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
80
|
-
body: JSON.stringify(
|
|
97
|
+
body: JSON.stringify(
|
|
98
|
+
noThink
|
|
99
|
+
? { model, messages, max_tokens: 1024, temperature: 0.4, chat_template_kwargs: { enable_thinking: false } }
|
|
100
|
+
: { model, messages, max_tokens: 1024, temperature: 0.4 },
|
|
101
|
+
),
|
|
81
102
|
signal: ctrl.signal,
|
|
82
103
|
});
|
|
83
104
|
if (!res.ok) {
|
|
84
105
|
throw new Error(`LLM ${res.status}: ${(await res.text()).slice(0, 120)}`);
|
|
85
106
|
}
|
|
86
107
|
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
87
|
-
const text = data.choices?.[0]?.message?.content
|
|
108
|
+
const text = stripThink(data.choices?.[0]?.message?.content ?? "");
|
|
88
109
|
if (!text) throw new Error("LLM returned empty content");
|
|
89
110
|
return text;
|
|
90
111
|
} finally {
|
package/src/config.ts
CHANGED
|
@@ -35,6 +35,10 @@ const Schema = z.object({
|
|
|
35
35
|
// Token budget (heuristic estimate) for one chat request; when history
|
|
36
36
|
// exceeds it the oldest turns are dropped ("满了就清理").
|
|
37
37
|
WORK_CHAT_MAX_CONTEXT: z.coerce.number().int().default(50000),
|
|
38
|
+
// Disable the model's thinking pass for QQ chat replies only (default on:
|
|
39
|
+
// "仅仅这个机器人关掉"); "0" restores thinking. Daemon/opencode sessions
|
|
40
|
+
// are untouched — they use their own runtime.
|
|
41
|
+
WORK_CHAT_NO_THINK: z.preprocess((v) => (v === undefined || v === "" ? "1" : v), z.string()).transform((v) => v !== "0"),
|
|
38
42
|
|
|
39
43
|
// QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
|
|
40
44
|
// other members are logged and ignored — same trust model as the GitHub
|
package/src/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { EworkClient } from "./ework";
|
|
|
3
3
|
import type { GroupMessageEvent } from "./onebot";
|
|
4
4
|
import type { BridgeStore } from "./db";
|
|
5
5
|
import type { BindingStore } from "./bindings";
|
|
6
|
-
import { buildChatMessages, chatComplete, splitForQQ, type ChatTurn } from "./chat";
|
|
6
|
+
import { buildChatMessages, capContent, chatComplete, splitForQQ, trimStored, type ChatTurn } from "./chat";
|
|
7
7
|
|
|
8
8
|
const HELP_TEXT = [
|
|
9
9
|
"用法:",
|
|
@@ -52,12 +52,12 @@ export function createRouter(deps: RouterDeps) {
|
|
|
52
52
|
|
|
53
53
|
async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
|
|
54
54
|
try {
|
|
55
|
-
const turn: ChatTurn = { role: "user", name: ev.nickname, content: question };
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
chatHistory.set(ev.groupId,
|
|
55
|
+
const turn: ChatTurn = { role: "user", name: ev.nickname, content: capContent(question) };
|
|
56
|
+
const stored = trimStored(chatHistory.get(ev.groupId) ?? [], cfg.WORK_CHAT_MAX_HISTORY, cfg.WORK_CHAT_MAX_CONTEXT);
|
|
57
|
+
chatHistory.set(ev.groupId, stored);
|
|
58
|
+
const messages = buildChatMessages(stored, turn);
|
|
59
|
+
const answer = await chatComplete(cfg.WORK_CHAT_API, cfg.WORK_CHAT_API_KEY, cfg.WORK_CHAT_MODEL, messages, cfg.WORK_CHAT_TIMEOUT_MS, cfg.WORK_CHAT_NO_THINK);
|
|
60
|
+
chatHistory.set(ev.groupId, [...stored, turn, { role: "assistant", name: "bot", content: capContent(answer) }]);
|
|
61
61
|
for (const part of splitForQQ(answer)) {
|
|
62
62
|
await deps.reply(ev.groupId, part);
|
|
63
63
|
}
|
package/tests/bridge.test.ts
CHANGED
|
@@ -231,7 +231,7 @@ describe("issue pinning", () => {
|
|
|
231
231
|
});
|
|
232
232
|
|
|
233
233
|
describe("pure-API chat", () => {
|
|
234
|
-
const { estimateTokens,
|
|
234
|
+
const { estimateTokens, trimStored, buildChatMessages, CHAT_SYSTEM_PROMPT, CHAT_MSG_CHAR_CAP } = require("../src/chat");
|
|
235
235
|
const turn = (content: string, role: "user" | "assistant" = "user") => ({ role, name: "u", content });
|
|
236
236
|
|
|
237
237
|
test("estimateTokens grows with length", () => {
|
|
@@ -239,27 +239,71 @@ describe("pure-API chat", () => {
|
|
|
239
239
|
expect(estimateTokens("abcd".repeat(100))).toBeGreaterThan(estimateTokens("abcd"));
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
test("
|
|
243
|
-
const hist = [turn("
|
|
244
|
-
|
|
245
|
-
const kept = trimToContext(hist, budget, 0);
|
|
246
|
-
expect(kept.map((t: { content: string }) => t.content[0])).toEqual(["c", "d"]);
|
|
242
|
+
test("trimStored is a no-op under both limits", () => {
|
|
243
|
+
const hist = [turn("hi"), turn("yo"), turn("lo")];
|
|
244
|
+
expect(trimStored(hist, 20, 50000)).toBe(hist);
|
|
247
245
|
});
|
|
248
246
|
|
|
249
|
-
test("
|
|
250
|
-
const
|
|
251
|
-
|
|
247
|
+
test("trimStored evicts in bulk on count overflow and is sticky", () => {
|
|
248
|
+
const mk = (n: number) => Array.from({ length: n }, (_, i) => turn("m" + i));
|
|
249
|
+
const over = trimStored(mk(41), 20, 5000000);
|
|
250
|
+
expect(over.length).toBe(Math.max(2, Math.floor(40 * 0.7)));
|
|
251
|
+
expect(over[over.length - 1].content).toBe("m40");
|
|
252
|
+
expect(trimStored(over, 20, 5000000)).toBe(over);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("trimStored evicts in bulk on token overflow", () => {
|
|
256
|
+
const hist = Array.from({ length: 10 }, (_, i) => turn("x".repeat(1000) + i));
|
|
257
|
+
const per = estimateTokens("x".repeat(1000));
|
|
258
|
+
const reserve = estimateTokens(CHAT_SYSTEM_PROMPT) + estimateTokens("x".repeat(CHAT_MSG_CHAR_CAP));
|
|
259
|
+
const budget = reserve + per * 6;
|
|
260
|
+
const kept = trimStored(hist, 100, budget);
|
|
261
|
+
expect(kept.length).toBeLessThan(10);
|
|
262
|
+
const keptTotal = kept.reduce((n: number, t: { content: string }) => n + estimateTokens(t.content), 0);
|
|
263
|
+
const tokenFloor = Math.floor(per * 6 * 0.7);
|
|
264
|
+
expect(keptTotal).toBeLessThanOrEqual(tokenFloor + per);
|
|
265
|
+
expect(kept[kept.length - 1].content.endsWith("9")).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("count cap evicts in bulk too (no sliding window)", () => {
|
|
269
|
+
const mk = (n: number) => Array.from({ length: n }, (_, i) => turn("m" + i));
|
|
270
|
+
expect(buildChatMessages(mk(40), turn("q")).length).toBe(42);
|
|
271
|
+
const over = trimStored(mk(41), 20, 5000000);
|
|
272
|
+
expect(buildChatMessages(over, turn("q")).length).toBe(Math.floor(40 * 0.7) + 2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("prompt grows append-only between evictions (prefix cache friendly)", () => {
|
|
276
|
+
const maxHistory = 3;
|
|
277
|
+
let hist: { role: "user" | "assistant"; name: string; content: string }[] = [];
|
|
278
|
+
let prev: { role: string; content: string }[] | null = null;
|
|
279
|
+
let evictions = 0;
|
|
280
|
+
for (let i = 0; i < 12; i++) {
|
|
281
|
+
const turnU = { role: "user" as const, name: "u", content: "q" + i };
|
|
282
|
+
const trimmed = trimStored(hist, maxHistory, 5000000);
|
|
283
|
+
if (trimmed !== hist) evictions++;
|
|
284
|
+
hist = trimmed;
|
|
285
|
+
const msgs = buildChatMessages(hist, turnU).map((m: { role: string; content: string }) => ({ role: m.role, content: m.content }));
|
|
286
|
+
if (prev) {
|
|
287
|
+
const prefixIntact = prev.every((pm, idx) => msgs[idx] && msgs[idx].role === pm.role && msgs[idx].content === pm.content);
|
|
288
|
+
const grewAppendOnly = msgs.length >= prev.length;
|
|
289
|
+
if (!grewAppendOnly) evictions++;
|
|
290
|
+
expect(prefixIntact || !grewAppendOnly).toBe(true);
|
|
291
|
+
}
|
|
292
|
+
prev = msgs;
|
|
293
|
+
hist = [...hist, turnU, { role: "assistant", name: "bot", content: "a" + i }];
|
|
294
|
+
}
|
|
295
|
+
expect(evictions).toBeGreaterThan(0);
|
|
252
296
|
});
|
|
253
297
|
|
|
254
298
|
test("buildChatMessages truncates oversized single message", () => {
|
|
255
|
-
const msgs = buildChatMessages([], turn("x".repeat(CHAT_MSG_CHAR_CAP + 500))
|
|
299
|
+
const msgs = buildChatMessages([], turn("x".repeat(CHAT_MSG_CHAR_CAP + 500)));
|
|
256
300
|
expect(msgs[msgs.length - 1].content.length).toBeLessThanOrEqual(CHAT_MSG_CHAR_CAP + 10);
|
|
257
301
|
expect(msgs[msgs.length - 1].content).toContain("已截断");
|
|
258
302
|
});
|
|
259
303
|
|
|
260
304
|
test("buildChatMessages keeps system prompt first and reserves question budget", () => {
|
|
261
305
|
const hist = [turn("a".repeat(40000)), turn("b")];
|
|
262
|
-
const msgs = buildChatMessages(hist, turn("q")
|
|
306
|
+
const msgs = buildChatMessages(trimStored(hist, 20, 50000), turn("q"));
|
|
263
307
|
expect(msgs[0].role).toBe("system");
|
|
264
308
|
expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
|
|
265
309
|
const total = msgs.reduce((n: number, m: { content: string }) => n + estimateTokens(m.content), 0);
|
|
@@ -317,3 +361,53 @@ describe("pure-API chat", () => {
|
|
|
317
361
|
}
|
|
318
362
|
});
|
|
319
363
|
});
|
|
364
|
+
|
|
365
|
+
describe("no-think", () => {
|
|
366
|
+
const { stripThink } = require("../src/chat");
|
|
367
|
+
|
|
368
|
+
test("stripThink removes inline think blocks", () => {
|
|
369
|
+
expect(stripThink("<think>internal</think>答案")).toBe("答案");
|
|
370
|
+
expect(stripThink("<think>a</think>前<think>b</think>后")).toBe("前后");
|
|
371
|
+
expect(stripThink("普通回复")).toBe("普通回复");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("chat request carries enable_thinking:false by default", async () => {
|
|
375
|
+
const origFetch = globalThis.fetch;
|
|
376
|
+
let sent: Record<string, unknown> = {};
|
|
377
|
+
globalThis.fetch = (async (_u: unknown, init?: { body?: string }) => {
|
|
378
|
+
sent = JSON.parse(String(init?.body));
|
|
379
|
+
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 });
|
|
380
|
+
}) as typeof fetch;
|
|
381
|
+
try {
|
|
382
|
+
const { chatComplete } = require("../src/chat");
|
|
383
|
+
const out = await chatComplete("http://x/v1", "k", "m", [{ role: "user", content: "q" }], 1000);
|
|
384
|
+
expect(out).toBe("ok");
|
|
385
|
+
expect((sent.chat_template_kwargs as { enable_thinking?: boolean })?.enable_thinking).toBe(false);
|
|
386
|
+
await chatComplete("http://x/v1", "k", "m", [{ role: "user", content: "q" }], 1000, false);
|
|
387
|
+
expect(sent.chat_template_kwargs).toBeUndefined();
|
|
388
|
+
} finally {
|
|
389
|
+
globalThis.fetch = origFetch;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("thinking payload is stripped before replying", async () => {
|
|
394
|
+
const origFetch = globalThis.fetch;
|
|
395
|
+
globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: "<think>隐藏推理</think>可见答案" } }] }), { status: 200 })) as typeof fetch;
|
|
396
|
+
try {
|
|
397
|
+
const { createRouter } = require("../src/router");
|
|
398
|
+
const replies: string[] = [];
|
|
399
|
+
const router = createRouter({
|
|
400
|
+
cfg: { VERBOSE: false, WORK_CHAT_API: "http://x/v1", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20, WORK_CHAT_MAX_CONTEXT: 50000, WORK_CHAT_NO_THINK: true },
|
|
401
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
|
|
402
|
+
wakeList: new Set(["1"]),
|
|
403
|
+
ework: { createIssue: async () => 9, addComment: async () => {} },
|
|
404
|
+
store: { seenPost: () => false },
|
|
405
|
+
reply: async (_g: number, x: string) => { replies.push(x); },
|
|
406
|
+
});
|
|
407
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "nt1", rawMessage: "[CQ:at,qq=2661222094] 问" });
|
|
408
|
+
expect(replies).toEqual(["可见答案"]);
|
|
409
|
+
} finally {
|
|
410
|
+
globalThis.fetch = origFetch;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
});
|