ework-qq-bridge 0.5.3 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-qq-bridge",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "QQ group <-> ework issue bridge (OneBot 11 reverse WebSocket)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -25,24 +25,12 @@ const Schema = z.object({
25
25
  // Runtime pin overrides written by the 绑定/解绑 commands (JSON, group -> issue).
26
26
  WORK_BINDINGS_FILE: z.string().default(""),
27
27
 
28
- // Pure-API chat (@bot Q&A): OpenAI-compatible endpoint, no tools/session.
29
- // Empty WORK_CHAT_API disables chat and falls back to usage guidance.
30
- WORK_CHAT_API: z.string().default(""),
31
- WORK_CHAT_API_KEY: z.string().default(""),
32
- WORK_CHAT_MODEL: z.string().default(""),
33
- WORK_CHAT_TIMEOUT_MS: z.coerce.number().int().default(90000),
34
- WORK_CHAT_MAX_HISTORY: z.coerce.number().int().default(20),
35
- // Token budget (heuristic estimate) for one chat request; when history
36
- // exceeds it the oldest turns are dropped ("满了就清理").
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"),
42
-
43
- // Where @bot chat history is persisted (JSON, group -> turns). Restarting
44
- // the bridge no longer clears conversations.
45
- WORK_CHAT_HISTORY_FILE: z.string().default(""),
28
+ // Pure chat (@bot Q&A) is delegated to the standalone ework-chat service
29
+ // (chat-only LLM + bili transparent compression + JSONL per-turn disk
30
+ // persistence). Empty WORK_CHAT_URL disables chat and falls back to usage
31
+ // guidance.
32
+ WORK_CHAT_URL: z.string().default(""),
33
+ WORK_CHAT_TOKEN: z.string().default(""),
46
34
 
47
35
  // QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
48
36
  // other members are logged and ignored — same trust model as the GitHub
@@ -112,8 +100,5 @@ export function loadConfig(): Config {
112
100
  if (!cfg.WORK_BINDINGS_FILE) {
113
101
  cfg.WORK_BINDINGS_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/bindings.json`;
114
102
  }
115
- if (!cfg.WORK_CHAT_HISTORY_FILE) {
116
- cfg.WORK_CHAT_HISTORY_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/chat-history.json`;
117
- }
118
103
  return cfg;
119
104
  }
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { loadConfig, parseGroupMap, parseList } from "./config";
2
2
  import { BridgeStore } from "./db";
3
3
  import { BindingStore } from "./bindings";
4
- import { ChatHistoryStore } from "./chat-store";
5
4
  import { createOneBotServer, type OneBotApi, type GroupMessageEvent } from "./onebot";
6
5
  import { createRouter } from "./router";
7
6
  import { createIngest } from "./ingest";
@@ -17,7 +16,6 @@ async function main() {
17
16
  const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
18
17
 
19
18
  const bindings = new BindingStore(parseGroupMap(cfg.GROUP_MAP), cfg.WORK_BINDINGS_FILE);
20
- const chatHistory = new ChatHistoryStore(cfg.WORK_CHAT_HISTORY_FILE);
21
19
 
22
20
  let api: OneBotApi | null = null;
23
21
  const send = async (groupId: number, text: string) => {
@@ -30,7 +28,6 @@ const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
30
28
  const router = createRouter({
31
29
  cfg,
32
30
  bindings,
33
- chatHistory,
34
31
  wakeList: new Set(parseList(cfg.QQ_WAKE_LIST)),
35
32
  ework,
36
33
  store,
package/src/router.ts CHANGED
@@ -3,8 +3,29 @@ 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 type { ChatHistoryStore } from "./chat-store";
7
- import { buildChatMessages, capContent, chatComplete, splitForQQ, trimStored, type ChatTurn } from "./chat";
6
+
7
+ export const QQ_CHAT_SYSTEM = [
8
+ "你是 QQ 群里的即时问答助手,背后是 ework 开发平台。",
9
+ "风格:简短直接,能用一两句话说清的就别铺开;技术问题给结论和关键理由,需要展开再展开。",
10
+ "群里成员通过 @你 提问。你看到的多轮对话里每条 user 消息前缀了提问者的昵称,注意区分不同人。",
11
+ "如果请求明显是需要长时间执行的开发任务(改代码、查仓库、提交 PR),不要假装去做——建议对方发「任务 <标题>」创建 issue,AI agent 会接单处理。",
12
+ "不知道就直说,不要编造。",
13
+ ].join("\n");
14
+
15
+ export function splitForQQ(text: string, maxLen = 1500): string[] {
16
+ if (text.length <= maxLen) return [text];
17
+ const parts: string[] = [];
18
+ let remaining = text;
19
+ while (remaining.length > maxLen) {
20
+ let cut = remaining.lastIndexOf("\n", maxLen);
21
+ if (cut < maxLen * 0.5) cut = remaining.lastIndexOf("。", maxLen);
22
+ if (cut < maxLen * 0.5) cut = maxLen;
23
+ parts.push(remaining.slice(0, cut + 1));
24
+ remaining = remaining.slice(cut + 1);
25
+ }
26
+ if (remaining) parts.push(remaining);
27
+ return parts;
28
+ }
8
29
 
9
30
  const HELP_TEXT = [
10
31
  "用法:",
@@ -13,14 +34,13 @@ const HELP_TEXT = [
13
34
  " 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
14
35
  " 解绑 —— 恢复为项目模式(接收整个项目的回复)",
15
36
  " 查询 —— 列出最近 issue",
16
- " @我 <问题> —— 即时问答(纯 API 直连 bili 压缩,历史落盘,重启不清)",
37
+ " @我 <问题> —— 即时问答(透明压缩长记忆,历史落盘)",
17
38
  " (绑定后:普通发言进绑定的 issue,AI 回复自动回群)",
18
39
  ].join("\n");
19
40
 
20
41
  export interface RouterDeps {
21
42
  cfg: Config;
22
43
  bindings: BindingStore;
23
- chatHistory: ChatHistoryStore;
24
44
  wakeList: Set<string>;
25
45
  ework: EworkClient;
26
46
  store: BridgeStore;
@@ -49,16 +69,27 @@ export function parseCommand(raw: string): ParsedCommand | null {
49
69
  }
50
70
 
51
71
  export function createRouter(deps: RouterDeps) {
52
- const { cfg, bindings, chatHistory, wakeList, ework, store } = deps;
72
+ const { cfg, bindings, wakeList, ework, store } = deps;
53
73
 
54
74
  async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
55
75
  try {
56
- const turn: ChatTurn = { role: "user", name: ev.nickname, content: capContent(question) };
57
- const stored = trimStored(chatHistory.get(ev.groupId), cfg.WORK_CHAT_MAX_HISTORY, cfg.WORK_CHAT_MAX_CONTEXT);
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
- for (const part of splitForQQ(answer)) {
76
+ const res = await fetch(`${cfg.WORK_CHAT_URL.replace(/\/+$/, "")}/v1/chat`, {
77
+ method: "POST",
78
+ headers: {
79
+ "Content-Type": "application/json",
80
+ ...(cfg.WORK_CHAT_TOKEN ? { Authorization: `Bearer ${cfg.WORK_CHAT_TOKEN}` } : {}),
81
+ },
82
+ body: JSON.stringify({
83
+ conversation: String(ev.groupId),
84
+ message: question,
85
+ user: ev.nickname,
86
+ system: QQ_CHAT_SYSTEM,
87
+ }),
88
+ });
89
+ if (!res.ok) throw new Error(`chat service ${res.status}: ${(await res.text()).slice(0, 120)}`);
90
+ const data = (await res.json()) as { reply?: string };
91
+ if (!data.reply) throw new Error("chat service returned no reply");
92
+ for (const part of splitForQQ(data.reply)) {
62
93
  await deps.reply(ev.groupId, part);
63
94
  }
64
95
  } catch (e) {
@@ -81,7 +112,7 @@ export function createRouter(deps: RouterDeps) {
81
112
  const cmd = parseCommand(ev.rawMessage);
82
113
  if (!cmd) {
83
114
  const text = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
84
- if (atBot && text && cfg.WORK_CHAT_API) {
115
+ if (atBot && text && cfg.WORK_CHAT_URL) {
85
116
  await answerChat(ev, text);
86
117
  return;
87
118
  }
@@ -1,7 +1,6 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { parseCommand } from "../src/router";
3
3
  import { BindingStore } from "../src/bindings";
4
- import { ChatHistoryStore } from "../src/chat-store";
5
4
  import { tmpdir } from "node:os";
6
5
  import { randomUUID } from "node:crypto";
7
6
  const pinFile = () => `${tmpdir()}/qqb-test-${randomUUID()}.json`;
@@ -90,7 +89,6 @@ describe("@bot unified routing", () => {
90
89
  const router = createRouter({
91
90
  cfg: { VERBOSE: false },
92
91
  bindings: bs,
93
- chatHistory: new ChatHistoryStore(histFile()),
94
92
  wakeList: new Set(["1"]),
95
93
  ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
96
94
  store: { seenPost: () => false },
@@ -183,7 +181,6 @@ describe("issue pinning", () => {
183
181
  const router = createRouter({
184
182
  cfg: { VERBOSE: false },
185
183
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
186
- chatHistory: new ChatHistoryStore(histFile()),
187
184
  wakeList: new Set(["1"]),
188
185
  ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
189
186
  store: { seenPost: () => false },
@@ -234,112 +231,37 @@ describe("issue pinning", () => {
234
231
  });
235
232
  });
236
233
 
237
- describe("pure-API chat", () => {
238
- const { estimateTokens, trimStored, buildChatMessages, CHAT_SYSTEM_PROMPT, CHAT_MSG_CHAR_CAP } = require("../src/chat");
239
- const turn = (content: string, role: "user" | "assistant" = "user") => ({ role, name: "u", content });
240
234
 
241
- test("estimateTokens grows with length", () => {
242
- expect(estimateTokens("ab")).toBeGreaterThan(0);
243
- expect(estimateTokens("abcd".repeat(100))).toBeGreaterThan(estimateTokens("abcd"));
244
- });
245
-
246
- test("trimStored is a no-op under both limits", () => {
247
- const hist = [turn("hi"), turn("yo"), turn("lo")];
248
- expect(trimStored(hist, 20, 50000)).toBe(hist);
249
- });
250
-
251
- test("trimStored evicts in bulk on count overflow and is sticky", () => {
252
- const mk = (n: number) => Array.from({ length: n }, (_, i) => turn("m" + i));
253
- const over = trimStored(mk(41), 20, 5000000);
254
- expect(over.length).toBe(Math.max(2, Math.floor(40 * 0.7)));
255
- expect(over[over.length - 1].content).toBe("m40");
256
- expect(trimStored(over, 20, 5000000)).toBe(over);
257
- });
258
-
259
- test("trimStored evicts in bulk on token overflow", () => {
260
- const hist = Array.from({ length: 10 }, (_, i) => turn("x".repeat(1000) + i));
261
- const per = estimateTokens("x".repeat(1000));
262
- const reserve = estimateTokens(CHAT_SYSTEM_PROMPT) + estimateTokens("x".repeat(CHAT_MSG_CHAR_CAP));
263
- const budget = reserve + per * 6;
264
- const kept = trimStored(hist, 100, budget);
265
- expect(kept.length).toBeLessThan(10);
266
- const keptTotal = kept.reduce((n: number, t: { content: string }) => n + estimateTokens(t.content), 0);
267
- const tokenFloor = Math.floor(per * 6 * 0.7);
268
- expect(keptTotal).toBeLessThanOrEqual(tokenFloor + per);
269
- expect(kept[kept.length - 1].content.endsWith("9")).toBe(true);
270
- });
271
-
272
- test("count cap evicts in bulk too (no sliding window)", () => {
273
- const mk = (n: number) => Array.from({ length: n }, (_, i) => turn("m" + i));
274
- expect(buildChatMessages(mk(40), turn("q")).length).toBe(42);
275
- const over = trimStored(mk(41), 20, 5000000);
276
- expect(buildChatMessages(over, turn("q")).length).toBe(Math.floor(40 * 0.7) + 2);
277
- });
278
-
279
- test("prompt grows append-only between evictions (prefix cache friendly)", () => {
280
- const maxHistory = 3;
281
- let hist: { role: "user" | "assistant"; name: string; content: string }[] = [];
282
- let prev: { role: string; content: string }[] | null = null;
283
- let evictions = 0;
284
- for (let i = 0; i < 12; i++) {
285
- const turnU = { role: "user" as const, name: "u", content: "q" + i };
286
- const trimmed = trimStored(hist, maxHistory, 5000000);
287
- if (trimmed !== hist) evictions++;
288
- hist = trimmed;
289
- const msgs = buildChatMessages(hist, turnU).map((m: { role: string; content: string }) => ({ role: m.role, content: m.content }));
290
- if (prev) {
291
- const prefixIntact = prev.every((pm, idx) => msgs[idx] && msgs[idx].role === pm.role && msgs[idx].content === pm.content);
292
- const grewAppendOnly = msgs.length >= prev.length;
293
- if (!grewAppendOnly) evictions++;
294
- expect(prefixIntact || !grewAppendOnly).toBe(true);
295
- }
296
- prev = msgs;
297
- hist = [...hist, turnU, { role: "assistant", name: "bot", content: "a" + i }];
298
- }
299
- expect(evictions).toBeGreaterThan(0);
300
- });
301
235
 
302
- test("buildChatMessages truncates oversized single message", () => {
303
- const msgs = buildChatMessages([], turn("x".repeat(CHAT_MSG_CHAR_CAP + 500)));
304
- expect(msgs[msgs.length - 1].content.length).toBeLessThanOrEqual(CHAT_MSG_CHAR_CAP + 10);
305
- expect(msgs[msgs.length - 1].content).toContain("已截断");
306
- });
307
-
308
- test("buildChatMessages keeps system prompt first and reserves question budget", () => {
309
- const hist = [turn("a".repeat(40000)), turn("b")];
310
- const msgs = buildChatMessages(trimStored(hist, 20, 50000), turn("q"));
311
- expect(msgs[0].role).toBe("system");
312
- expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
313
- const total = msgs.reduce((n: number, m: { content: string }) => n + estimateTokens(m.content), 0);
314
- expect(total).toBeLessThanOrEqual(50000 + estimateTokens("q") + estimateTokens(CHAT_SYSTEM_PROMPT));
315
- });
316
236
 
317
- test("router: @bot routes to chat when WORK_CHAT_API set", async () => {
237
+ describe("chat delegation to ework-chat", () => {
238
+ test("router: @bot delegates to ework-chat service", async () => {
318
239
  const origFetch = globalThis.fetch;
319
- const bodies: unknown[] = [];
320
- globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => {
321
- bodies.push(JSON.parse(String(init?.body)));
322
- return new Response(JSON.stringify({ choices: [{ message: { content: "秒回的答案" } }] }), { status: 200 });
240
+ const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
241
+ globalThis.fetch = (async (url: unknown, init?: { body?: string; headers?: Record<string, string> }) => {
242
+ calls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
243
+ return new Response(JSON.stringify({ conversation: "1", reply: "秒回的答案" }), { status: 200 });
323
244
  }) as typeof fetch;
324
245
  try {
325
246
  const { createRouter } = require("../src/router");
326
247
  const replies: string[] = [];
327
248
  const comments: unknown[] = [];
328
249
  const router = createRouter({
329
- 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 },
250
+ cfg: { VERBOSE: false, WORK_CHAT_URL: "http://127.0.0.1:8210", WORK_CHAT_TOKEN: "t0" },
330
251
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
331
- chatHistory: new ChatHistoryStore(histFile()),
332
252
  wakeList: new Set(["1"]),
333
253
  ework: { createIssue: async () => 9, addComment: async (...a: unknown[]) => { comments.push(a); } },
334
254
  store: { seenPost: () => false },
335
255
  reply: async (_g: number, x: string) => { replies.push(x); },
336
256
  });
337
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c1", rawMessage: "[CQ:at,qq=2661222094] 快问快答" });
257
+ await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "小狗", postId: "c1", rawMessage: "[CQ:at,qq=2661222094] 快问快答" });
338
258
  expect(replies).toEqual(["秒回的答案"]);
339
259
  expect(comments).toEqual([]);
340
- expect((bodies[0] as { messages: { content: string }[] }).messages[0].role).toBe("system");
341
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c2", rawMessage: "[CQ:at,qq=2661222094] 追问一句" });
342
- expect((bodies[1] as { messages: { content: string }[] }).messages.length).toBe(4);
260
+ expect(calls[0]?.url).toBe("http://127.0.0.1:8210/v1/chat");
261
+ expect(calls[0]?.body.conversation).toBe("1");
262
+ expect(calls[0]?.body.message).toBe("快问快答");
263
+ expect(calls[0]?.body.user).toBe("小狗");
264
+ expect(typeof calls[0]?.body.system).toBe("string");
343
265
  } finally {
344
266
  globalThis.fetch = origFetch;
345
267
  }
@@ -352,142 +274,15 @@ describe("pure-API chat", () => {
352
274
  const { createRouter } = require("../src/router");
353
275
  const replies: string[] = [];
354
276
  const router = createRouter({
355
- 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 },
277
+ cfg: { VERBOSE: false, WORK_CHAT_URL: "http://x", WORK_CHAT_TOKEN: "" },
356
278
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
357
- chatHistory: new ChatHistoryStore(histFile()),
358
279
  wakeList: new Set(["1"]),
359
280
  ework: { createIssue: async () => 9, addComment: async () => {} },
360
281
  store: { seenPost: () => false },
361
282
  reply: async (_g: number, x: string) => { replies.push(x); },
362
283
  });
363
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c3", rawMessage: "[CQ:at,qq=2661222094] 会失败吗" });
364
- expect(replies[0]).toContain("问答失败");
365
- } finally {
366
- globalThis.fetch = origFetch;
367
- }
368
- });
369
- });
370
-
371
- describe("no-think", () => {
372
- const { stripThink } = require("../src/chat");
373
-
374
- test("stripThink removes inline think blocks", () => {
375
- expect(stripThink("<think>internal</think>答案")).toBe("答案");
376
- expect(stripThink("<think>a</think>前<think>b</think>后")).toBe("前后");
377
- expect(stripThink("普通回复")).toBe("普通回复");
378
- });
379
-
380
- test("chat request carries enable_thinking:false by default", async () => {
381
- const origFetch = globalThis.fetch;
382
- let sent: Record<string, unknown> = {};
383
- globalThis.fetch = (async (_u: unknown, init?: { body?: string }) => {
384
- sent = JSON.parse(String(init?.body));
385
- return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 });
386
- }) as typeof fetch;
387
- try {
388
- const { chatComplete } = require("../src/chat");
389
- const out = await chatComplete("http://x/v1", "k", "m", [{ role: "user", content: "q" }], 1000);
390
- expect(out).toBe("ok");
391
- expect((sent.chat_template_kwargs as { enable_thinking?: boolean })?.enable_thinking).toBe(false);
392
- await chatComplete("http://x/v1", "k", "m", [{ role: "user", content: "q" }], 1000, false);
393
- expect(sent.chat_template_kwargs).toBeUndefined();
394
- } finally {
395
- globalThis.fetch = origFetch;
396
- }
397
- });
398
-
399
- test("thinking payload is stripped before replying", async () => {
400
- const origFetch = globalThis.fetch;
401
- globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: "<think>隐藏推理</think>可见答案" } }] }), { status: 200 })) as typeof fetch;
402
- try {
403
- const { createRouter } = require("../src/router");
404
- const replies: string[] = [];
405
- const router = createRouter({
406
- 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 },
407
- bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
408
- chatHistory: new ChatHistoryStore(histFile()),
409
- wakeList: new Set(["1"]),
410
- ework: { createIssue: async () => 9, addComment: async () => {} },
411
- store: { seenPost: () => false },
412
- reply: async (_g: number, x: string) => { replies.push(x); },
413
- });
414
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "nt1", rawMessage: "[CQ:at,qq=2661222094] 问" });
415
- expect(replies).toEqual(["可见答案"]);
416
- } finally {
417
- globalThis.fetch = origFetch;
418
- }
419
- });
420
- });
421
-
422
- describe("chat history persistence", () => {
423
- const { ChatHistoryStore } = require("../src/chat-store");
424
- const { trimStored } = require("../src/chat");
425
- const { writeFileSync } = require("node:fs");
426
-
427
- test("set then reload returns same turns", () => {
428
- const f = histFile();
429
- const a = new ChatHistoryStore(f);
430
- a.set(1, [{ role: "user", content: "早" }, { role: "assistant", content: "早呀" }]);
431
- a.set(2, [{ role: "user", content: "群二的问题" }]);
432
- const b = new ChatHistoryStore(f);
433
- expect(b.get(1)).toEqual([{ role: "user", content: "早" }, { role: "assistant", content: "早呀" }]);
434
- expect(b.get(2)).toEqual([{ role: "user", content: "群二的问题" }]);
435
- expect(b.get(3)).toEqual([]);
436
- });
437
-
438
- test("invalid file starts clean", () => {
439
- const f = histFile();
440
- writeFileSync(f, "{not json");
441
- const s = new ChatHistoryStore(f);
442
- expect(s.get(1)).toEqual([]);
443
- s.set(1, [{ role: "user", content: "x" }]);
444
- expect(new ChatHistoryStore(f).get(1)).toEqual([{ role: "user", content: "x" }]);
445
- });
446
-
447
- test("invalid turns filtered on load, groups isolated", () => {
448
- const f = histFile();
449
- writeFileSync(f, JSON.stringify({ "1": [{ role: "system", content: "bad" }, { role: "user", content: "ok" }], "2": "nope" }));
450
- const s = new ChatHistoryStore(f);
451
- expect(s.get(1)).toEqual([{ role: "user", content: "ok" }]);
452
- expect(s.get(2)).toEqual([]);
453
- });
454
-
455
- test("evicted turns stay evicted across reload", () => {
456
- const f = histFile();
457
- const turns = Array.from({ length: 50 }, (_, i) => ({ role: "user" as const, content: `msg${i}` }));
458
- const trimmed = trimStored(turns, 10, 500000);
459
- expect(trimmed.length).toBeLessThan(50);
460
- new ChatHistoryStore(f).set(7, trimmed);
461
- const re = new ChatHistoryStore(f);
462
- expect(re.get(7)).toEqual(trimmed);
463
- const again = trimStored(re.get(7), 10, 500000);
464
- expect(again.length).toBe(trimmed.length);
465
- });
466
-
467
- test("router chat survives simulated restart", async () => {
468
- const origFetch = globalThis.fetch;
469
- globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: "第一答" } }] }), { status: 200 })) as typeof fetch;
470
- try {
471
- const { createRouter } = require("../src/router");
472
- const f = histFile();
473
- const mk2 = () => {
474
- const replies: string[] = [];
475
- const router = createRouter({
476
- 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 },
477
- bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
478
- chatHistory: new ChatHistoryStore(f),
479
- wakeList: new Set(["1"]),
480
- ework: { createIssue: async () => 9, addComment: async () => {} },
481
- store: { seenPost: () => false },
482
- reply: async (_g: number, x: string) => { replies.push(x); },
483
- });
484
- return { router, replies };
485
- };
486
- const first = mk2();
487
- await first.router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "r1", rawMessage: "[CQ:at,qq=2661222094] 记住暗号是西瓜" });
488
- const second = mk2();
489
- await second.router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "r2", rawMessage: "[CQ:at,qq=2661222094] 我刚才说了什么" });
490
- expect(new ChatHistoryStore(f).get(1).map((t: { content: string }) => t.content)).toContain("记住暗号是西瓜");
284
+ await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c3", rawMessage: "[CQ:at,qq=2661222094] 问点啥" });
285
+ expect(replies[0]).toContain("问答失败");
491
286
  } finally {
492
287
  globalThis.fetch = origFetch;
493
288
  }
package/src/chat-store.ts DELETED
@@ -1,56 +0,0 @@
1
- import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import type { ChatTurn } from "./chat";
4
-
5
- // Persistent @bot chat history: group -> turns, survives restarts.
6
- // No trimming here — set() callers must pass already-trimmed arrays
7
- // (same trimStored policy as before), keeping the file bounded.
8
- type HistoryFile = Record<string, ChatTurn[]>;
9
-
10
- function validTurn(v: unknown): v is ChatTurn {
11
- if (typeof v !== "object" || v === null) return false;
12
- const t = v as { role?: unknown; content?: unknown };
13
- return (t.role === "user" || t.role === "assistant") && typeof t.content === "string";
14
- }
15
-
16
- export class ChatHistoryStore {
17
- private history = new Map<number, ChatTurn[]>();
18
-
19
- constructor(private file: string) {
20
- try {
21
- if (existsSync(file)) {
22
- const raw = JSON.parse(readFileSync(file, "utf8")) as HistoryFile;
23
- for (const [k, v] of Object.entries(raw)) {
24
- if (Number.isInteger(Number(k)) && Array.isArray(v)) {
25
- const turns = v.filter(validTurn);
26
- if (turns.length > 0) this.history.set(Number(k), turns);
27
- }
28
- }
29
- }
30
- } catch (err) {
31
- console.warn(`[qq-bridge] chat history file unreadable, starting clean: ${err instanceof Error ? err.message : err}`);
32
- }
33
- }
34
-
35
- private persist(): void {
36
- const out: HistoryFile = {};
37
- for (const [g, turns] of this.history) out[String(g)] = turns;
38
- try {
39
- mkdirSync(dirname(this.file), { recursive: true });
40
- const tmp = `${this.file}.tmp`;
41
- writeFileSync(tmp, JSON.stringify(out));
42
- renameSync(tmp, this.file);
43
- } catch (err) {
44
- console.error(`[qq-bridge] failed to persist chat history: ${err instanceof Error ? err.message : err}`);
45
- }
46
- }
47
-
48
- get(groupId: number): ChatTurn[] {
49
- return this.history.get(groupId) ?? [];
50
- }
51
-
52
- set(groupId: number, turns: ChatTurn[]): void {
53
- this.history.set(groupId, turns);
54
- this.persist();
55
- }
56
- }
package/src/chat.ts DELETED
@@ -1,129 +0,0 @@
1
- // Chat mode: instant Q&A directly against the OpenAI-compatible endpoint.
2
- // Issue mode (任务/#N/绑定) stays the path for long agent work — chat is for
3
- // the conversational 90% of group traffic that never needs a session.
4
-
5
- export interface ChatTurn {
6
- role: "user" | "assistant";
7
- name: string;
8
- content: string;
9
- }
10
-
11
- export const CHAT_SYSTEM_PROMPT = [
12
- "你是 QQ 群里的即时问答助手,背后是 ework 开发平台。",
13
- "风格:简短直接,能用一两句话说清的就别铺开;技术问题给结论和关键理由,需要展开再展开。",
14
- "群里成员通过 @你 提问。你看到的多轮对话里每条 user 消息前缀了提问者的昵称,注意区分不同人。",
15
- "如果请求明显是需要长时间执行的开发任务(改代码、查仓库、提交 PR),不要假装去做——建议对方发「任务 <标题>」创建 issue,AI agent 会接单处理。",
16
- "不知道就直说,不要编造。",
17
- ].join("\n");
18
-
19
- // qwen-family heuristic: CJK ≈ 1 token/char, latin ≈ 1/4 — blended constant.
20
- export function estimateTokens(text: string): number {
21
- return Math.ceil(text.length * 0.75);
22
- }
23
-
24
- // Single-message hard cap so one pasted log cannot eat the whole budget.
25
- export const CHAT_MSG_CHAR_CAP = 24000;
26
-
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 {
39
- return text.length > CHAT_MSG_CHAR_CAP ? text.slice(0, CHAT_MSG_CHAR_CAP) + "…(已截断)" : text;
40
- }
41
-
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++;
64
- }
65
- return history.slice(start);
66
- }
67
-
68
- export function buildChatMessages(
69
- history: ChatTurn[],
70
- question: ChatTurn,
71
- ): { role: string; name?: string; content: string }[] {
72
- return [
73
- { role: "system", content: CHAT_SYSTEM_PROMPT },
74
- ...history.map((t) => ({ role: t.role, name: t.name, content: capContent(t.content) })),
75
- { role: question.role, name: question.name, content: capContent(question.content) },
76
- ];
77
- }
78
-
79
- export function stripThink(text: string): string {
80
- return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
81
- }
82
-
83
- export async function chatComplete(
84
- apiBase: string,
85
- apiKey: string,
86
- model: string,
87
- messages: { role: string; name?: string; content: string }[],
88
- timeoutMs: number,
89
- noThink = true,
90
- ): Promise<string> {
91
- const ctrl = new AbortController();
92
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
93
- try {
94
- const res = await fetch(`${apiBase.replace(/\/+$/, "")}/chat/completions`, {
95
- method: "POST",
96
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
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
- ),
102
- signal: ctrl.signal,
103
- });
104
- if (!res.ok) {
105
- throw new Error(`LLM ${res.status}: ${(await res.text()).slice(0, 120)}`);
106
- }
107
- const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
108
- const text = stripThink(data.choices?.[0]?.message?.content ?? "");
109
- if (!text) throw new Error("LLM returned empty content");
110
- return text;
111
- } finally {
112
- clearTimeout(timer);
113
- }
114
- }
115
-
116
- export function splitForQQ(text: string, maxLen = 1500): string[] {
117
- if (text.length <= maxLen) return [text];
118
- const parts: string[] = [];
119
- let remaining = text;
120
- while (remaining.length > maxLen) {
121
- let cut = remaining.lastIndexOf("\n", maxLen);
122
- if (cut < maxLen * 0.5) cut = remaining.lastIndexOf("。", maxLen);
123
- if (cut < maxLen * 0.5) cut = maxLen;
124
- parts.push(remaining.slice(0, cut + 1));
125
- remaining = remaining.slice(cut + 1);
126
- }
127
- if (remaining) parts.push(remaining);
128
- return parts;
129
- }