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 +1 -1
- package/src/config.ts +6 -14
- package/src/router.ts +42 -11
- package/tests/bridge.test.ts +17 -140
- package/src/chat.ts +0 -129
package/package.json
CHANGED
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
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
" @我 <问题> ——
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
115
|
+
if (atBot && text && cfg.WORK_CHAT_URL) {
|
|
85
116
|
await answerChat(ev, text);
|
|
86
117
|
return;
|
|
87
118
|
}
|
package/tests/bridge.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
316
|
-
globalThis.fetch = (async (
|
|
317
|
-
|
|
318
|
-
return new Response(JSON.stringify({
|
|
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,
|
|
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: "
|
|
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(
|
|
336
|
-
|
|
337
|
-
expect(
|
|
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,
|
|
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: "
|
|
408
|
-
expect(replies).
|
|
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
|
-
}
|