ework-qq-bridge 0.5.0 → 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-qq-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "QQ group <-> ework issue bridge (OneBot 11 reverse WebSocket)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/chat.ts CHANGED
@@ -24,42 +24,54 @@ 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
- function capContent(text: string): string {
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
- // Keep the newest turns that fit the token budget (oldest dropped first).
32
- export function trimToContext(
33
- history: ChatTurn[],
34
- maxContextTokens: number,
35
- reservedTokens: number,
36
- ): ChatTurn[] {
37
- const budget = Math.max(0, maxContextTokens - reservedTokens);
38
- const kept: ChatTurn[] = [];
39
- let used = 0;
40
- for (let i = history.length - 1; i >= 0; i--) {
41
- const turn = history[i];
42
- if (!turn) continue;
43
- const cost = estimateTokens(turn.content);
44
- if (used + cost > budget) break;
45
- used += cost;
46
- kept.unshift(turn);
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 kept;
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
- ...kept.map((t) => ({ role: t.role, name: t.name, content: capContent(t.content) })),
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
  }
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 history = chatHistory.get(ev.groupId) ?? [];
57
- const messages = buildChatMessages(history, turn, cfg.WORK_CHAT_MAX_HISTORY, cfg.WORK_CHAT_MAX_CONTEXT);
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);
58
59
  const answer = await chatComplete(cfg.WORK_CHAT_API, cfg.WORK_CHAT_API_KEY, cfg.WORK_CHAT_MODEL, messages, cfg.WORK_CHAT_TIMEOUT_MS);
59
- history.push(turn, { role: "assistant", name: "bot", content: answer });
60
- chatHistory.set(ev.groupId, history.slice(-cfg.WORK_CHAT_MAX_HISTORY * 2));
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
  }
@@ -231,7 +231,7 @@ describe("issue pinning", () => {
231
231
  });
232
232
 
233
233
  describe("pure-API chat", () => {
234
- const { estimateTokens, trimToContext, buildChatMessages, CHAT_SYSTEM_PROMPT, CHAT_MSG_CHAR_CAP } = require("../src/chat");
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("trimToContext drops oldest first when over budget", () => {
243
- const hist = [turn("a".repeat(1000)), turn("b".repeat(1000)), turn("c".repeat(1000)), turn("d".repeat(1000))];
244
- const budget = estimateTokens("c".repeat(1000)) + estimateTokens("d".repeat(1000));
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("trimToContext keeps everything under budget", () => {
250
- const hist = [turn("hi"), turn("yo")];
251
- expect(trimToContext(hist, 100000, 0).length).toBe(2);
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)), 20, 50000);
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"), 20, 50000);
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);