ework-qq-bridge 0.5.2 → 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.2",
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,20 +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"),
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(""),
42
34
 
43
35
  // QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
44
36
  // other members are logged and ignored — same trust model as the GitHub
package/src/router.ts CHANGED
@@ -3,7 +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 { 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
+ }
7
29
 
8
30
  const HELP_TEXT = [
9
31
  "用法:",
@@ -12,7 +34,7 @@ const HELP_TEXT = [
12
34
  " 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
13
35
  " 解绑 —— 恢复为项目模式(接收整个项目的回复)",
14
36
  " 查询 —— 列出最近 issue",
15
- " @我 <问题> —— 即时问答(纯 API,不留 issue,上下文满自动清理)",
37
+ " @我 <问题> —— 即时问答(透明压缩长记忆,历史落盘)",
16
38
  " (绑定后:普通发言进绑定的 issue,AI 回复自动回群)",
17
39
  ].join("\n");
18
40
 
@@ -48,17 +70,26 @@ export function parseCommand(raw: string): ParsedCommand | null {
48
70
 
49
71
  export function createRouter(deps: RouterDeps) {
50
72
  const { cfg, bindings, wakeList, ework, store } = deps;
51
- const chatHistory = new Map<number, ChatTurn[]>();
52
73
 
53
74
  async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
54
75
  try {
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
- 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
  }
@@ -4,6 +4,7 @@ import { BindingStore } from "../src/bindings";
4
4
  import { tmpdir } from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
6
  const pinFile = () => `${tmpdir()}/qqb-test-${randomUUID()}.json`;
7
+ const histFile = () => `${tmpdir()}/qqb-hist-${randomUUID()}.json`;
7
8
  import { parseGroupMap, parseList } from "../src/config";
8
9
  import { buildScrubber } from "../src/scrub";
9
10
  import { verifySignature } from "../src/ingest";
@@ -230,111 +231,37 @@ describe("issue pinning", () => {
230
231
  });
231
232
  });
232
233
 
233
- describe("pure-API chat", () => {
234
- const { estimateTokens, trimStored, buildChatMessages, CHAT_SYSTEM_PROMPT, CHAT_MSG_CHAR_CAP } = require("../src/chat");
235
- const turn = (content: string, role: "user" | "assistant" = "user") => ({ role, name: "u", content });
236
234
 
237
- test("estimateTokens grows with length", () => {
238
- expect(estimateTokens("ab")).toBeGreaterThan(0);
239
- expect(estimateTokens("abcd".repeat(100))).toBeGreaterThan(estimateTokens("abcd"));
240
- });
241
-
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);
245
- });
246
-
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);
296
- });
297
-
298
- test("buildChatMessages truncates oversized single message", () => {
299
- const msgs = buildChatMessages([], turn("x".repeat(CHAT_MSG_CHAR_CAP + 500)));
300
- expect(msgs[msgs.length - 1].content.length).toBeLessThanOrEqual(CHAT_MSG_CHAR_CAP + 10);
301
- expect(msgs[msgs.length - 1].content).toContain("已截断");
302
- });
303
235
 
304
- test("buildChatMessages keeps system prompt first and reserves question budget", () => {
305
- const hist = [turn("a".repeat(40000)), turn("b")];
306
- const msgs = buildChatMessages(trimStored(hist, 20, 50000), turn("q"));
307
- expect(msgs[0].role).toBe("system");
308
- expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
309
- const total = msgs.reduce((n: number, m: { content: string }) => n + estimateTokens(m.content), 0);
310
- expect(total).toBeLessThanOrEqual(50000 + estimateTokens("q") + estimateTokens(CHAT_SYSTEM_PROMPT));
311
- });
312
236
 
313
- 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 () => {
314
239
  const origFetch = globalThis.fetch;
315
- const bodies: unknown[] = [];
316
- globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => {
317
- bodies.push(JSON.parse(String(init?.body)));
318
- 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 });
319
244
  }) as typeof fetch;
320
245
  try {
321
246
  const { createRouter } = require("../src/router");
322
247
  const replies: string[] = [];
323
248
  const comments: unknown[] = [];
324
249
  const router = createRouter({
325
- 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" },
326
251
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
327
252
  wakeList: new Set(["1"]),
328
253
  ework: { createIssue: async () => 9, addComment: async (...a: unknown[]) => { comments.push(a); } },
329
254
  store: { seenPost: () => false },
330
255
  reply: async (_g: number, x: string) => { replies.push(x); },
331
256
  });
332
- 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] 快问快答" });
333
258
  expect(replies).toEqual(["秒回的答案"]);
334
259
  expect(comments).toEqual([]);
335
- expect((bodies[0] as { messages: { content: string }[] }).messages[0].role).toBe("system");
336
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c2", rawMessage: "[CQ:at,qq=2661222094] 追问一句" });
337
- 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");
338
265
  } finally {
339
266
  globalThis.fetch = origFetch;
340
267
  }
@@ -347,65 +274,15 @@ describe("pure-API chat", () => {
347
274
  const { createRouter } = require("../src/router");
348
275
  const replies: string[] = [];
349
276
  const router = createRouter({
350
- 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 },
351
- bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
352
- wakeList: new Set(["1"]),
353
- ework: { createIssue: async () => 9, addComment: async () => {} },
354
- store: { seenPost: () => false },
355
- reply: async (_g: number, x: string) => { replies.push(x); },
356
- });
357
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c3", rawMessage: "[CQ:at,qq=2661222094] 会失败吗" });
358
- expect(replies[0]).toContain("问答失败");
359
- } finally {
360
- globalThis.fetch = origFetch;
361
- }
362
- });
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 },
277
+ cfg: { VERBOSE: false, WORK_CHAT_URL: "http://x", WORK_CHAT_TOKEN: "" },
401
278
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
402
279
  wakeList: new Set(["1"]),
403
280
  ework: { createIssue: async () => 9, addComment: async () => {} },
404
281
  store: { seenPost: () => false },
405
282
  reply: async (_g: number, x: string) => { replies.push(x); },
406
283
  });
407
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "nt1", rawMessage: "[CQ:at,qq=2661222094] " });
408
- expect(replies).toEqual(["可见答案"]);
284
+ await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c3", rawMessage: "[CQ:at,qq=2661222094] 问点啥" });
285
+ expect(replies[0]).toContain("❌ 问答失败");
409
286
  } finally {
410
287
  globalThis.fetch = origFetch;
411
288
  }
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
- }