ework-qq-bridge 0.3.0 → 0.5.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/chat.ts +48 -12
- package/src/config.ts +11 -8
- package/src/router.ts +26 -27
- package/tests/bridge.test.ts +131 -63
package/package.json
CHANGED
package/src/chat.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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
|
-
// conversational 90% of group traffic that never needs a session.
|
|
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
4
|
|
|
5
5
|
export interface ChatTurn {
|
|
6
6
|
role: "user" | "assistant";
|
|
@@ -16,16 +16,51 @@ export const CHAT_SYSTEM_PROMPT = [
|
|
|
16
16
|
"不知道就直说,不要编造。",
|
|
17
17
|
].join("\n");
|
|
18
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
|
+
function capContent(text: string): string {
|
|
28
|
+
return text.length > CHAT_MSG_CHAR_CAP ? text.slice(0, CHAT_MSG_CHAR_CAP) + "…(已截断)" : text;
|
|
29
|
+
}
|
|
30
|
+
|
|
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);
|
|
47
|
+
}
|
|
48
|
+
return kept;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export function buildChatMessages(
|
|
20
52
|
history: ChatTurn[],
|
|
21
53
|
question: ChatTurn,
|
|
22
54
|
maxHistory: number,
|
|
55
|
+
maxContextTokens: number,
|
|
23
56
|
): { role: string; name?: string; content: string }[] {
|
|
24
|
-
const
|
|
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));
|
|
25
60
|
return [
|
|
26
|
-
|
|
27
|
-
...kept.map((t) => ({ role: t.role, name: t.name, content: t.content })),
|
|
28
|
-
{ role: question.role, name: question.name, content: question.content },
|
|
61
|
+
system,
|
|
62
|
+
...kept.map((t) => ({ role: t.role, name: t.name, content: capContent(t.content) })),
|
|
63
|
+
{ role: question.role, name: question.name, content: capContent(question.content) },
|
|
29
64
|
];
|
|
30
65
|
}
|
|
31
66
|
|
|
@@ -60,13 +95,14 @@ export async function chatComplete(
|
|
|
60
95
|
export function splitForQQ(text: string, maxLen = 1500): string[] {
|
|
61
96
|
if (text.length <= maxLen) return [text];
|
|
62
97
|
const parts: string[] = [];
|
|
63
|
-
let
|
|
64
|
-
while (
|
|
65
|
-
let cut =
|
|
98
|
+
let remaining = text;
|
|
99
|
+
while (remaining.length > maxLen) {
|
|
100
|
+
let cut = remaining.lastIndexOf("\n", maxLen);
|
|
101
|
+
if (cut < maxLen * 0.5) cut = remaining.lastIndexOf("。", maxLen);
|
|
66
102
|
if (cut < maxLen * 0.5) cut = maxLen;
|
|
67
|
-
parts.push(
|
|
68
|
-
|
|
103
|
+
parts.push(remaining.slice(0, cut + 1));
|
|
104
|
+
remaining = remaining.slice(cut + 1);
|
|
69
105
|
}
|
|
70
|
-
if (
|
|
106
|
+
if (remaining) parts.push(remaining);
|
|
71
107
|
return parts;
|
|
72
108
|
}
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,17 @@ 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
|
+
|
|
28
39
|
// QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
|
|
29
40
|
// other members are logged and ignored — same trust model as the GitHub
|
|
30
41
|
// side (WORK_WAKE_LOGINS): strangers never wake the AI.
|
|
@@ -41,14 +52,6 @@ const Schema = z.object({
|
|
|
41
52
|
|
|
42
53
|
DB_PATH: z.string().default(""),
|
|
43
54
|
|
|
44
|
-
// Chat mode (instant Q&A). When WORK_CHAT_API is set, @bot messages without
|
|
45
|
-
// a recognized command are answered directly by the LLM instead of usage
|
|
46
|
-
// help. Empty = chat mode disabled.
|
|
47
|
-
WORK_CHAT_API: z.string().default(""),
|
|
48
|
-
WORK_CHAT_API_KEY: z.string().default("sk-vllm"),
|
|
49
|
-
WORK_CHAT_MODEL: z.string().default("qwen3.8-27b"),
|
|
50
|
-
WORK_CHAT_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
|
|
51
|
-
WORK_CHAT_MAX_HISTORY: z.coerce.number().int().positive().default(20),
|
|
52
55
|
|
|
53
56
|
VERBOSE: z.coerce.boolean().default(false),
|
|
54
57
|
|
package/src/router.ts
CHANGED
|
@@ -12,7 +12,8 @@ const HELP_TEXT = [
|
|
|
12
12
|
" 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
|
|
13
13
|
" 解绑 —— 恢复为项目模式(接收整个项目的回复)",
|
|
14
14
|
" 查询 —— 列出最近 issue",
|
|
15
|
-
" @我
|
|
15
|
+
" @我 <问题> —— 即时问答(纯 API,不留 issue,上下文满自动清理)",
|
|
16
|
+
" (绑定后:普通发言进绑定的 issue,AI 回复自动回群)",
|
|
16
17
|
].join("\n");
|
|
17
18
|
|
|
18
19
|
export interface RouterDeps {
|
|
@@ -50,21 +51,22 @@ export function createRouter(deps: RouterDeps) {
|
|
|
50
51
|
const chatHistory = new Map<number, ChatTurn[]>();
|
|
51
52
|
|
|
52
53
|
async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
58
|
+
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 });
|
|
59
60
|
chatHistory.set(ev.groupId, history.slice(-cfg.WORK_CHAT_MAX_HISTORY * 2));
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
for (const part of splitForQQ(answer)) {
|
|
62
|
+
await deps.reply(ev.groupId, part);
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
66
|
+
console.error(`[qq-bridge] chat failed: ${msg}`);
|
|
67
|
+
await deps.reply(ev.groupId, `❌ 问答失败:${msg}`);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
|
-
|
|
68
70
|
async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
|
|
69
71
|
if (store.seenPost(ev.postId)) return;
|
|
70
72
|
const binding = bindings.resolve(ev.groupId);
|
|
@@ -77,26 +79,23 @@ export function createRouter(deps: RouterDeps) {
|
|
|
77
79
|
|
|
78
80
|
const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task|新任务|#|绑定|解绑|帮助|help|查询)/.test(ev.rawMessage);
|
|
79
81
|
const cmd = parseCommand(ev.rawMessage);
|
|
80
|
-
if (!cmd && !atBot && binding.issue === undefined) {
|
|
81
|
-
if (cfg.VERBOSE) console.log(`[qq-bridge] unrecognized message from ${ev.userId}: ${ev.rawMessage.slice(0, 80)}`);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
82
|
if (!cmd) {
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
await
|
|
83
|
+
const text = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
|
|
84
|
+
if (atBot && text && cfg.WORK_CHAT_API) {
|
|
85
|
+
await answerChat(ev, text);
|
|
88
86
|
return;
|
|
89
87
|
}
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
await
|
|
93
|
-
} catch (err) {
|
|
94
|
-
console.error(`[qq-bridge] chat failed: ${err instanceof Error ? err.message : err}`);
|
|
95
|
-
await deps.reply(ev.groupId, "🤖 回答失败了,稍后再试一次。");
|
|
88
|
+
if (binding.issue !== undefined) {
|
|
89
|
+
if (text) {
|
|
90
|
+
await ework.addComment(binding.owner, binding.repo, binding.issue, `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})\n\n${text}`);
|
|
96
91
|
}
|
|
97
92
|
return;
|
|
98
93
|
}
|
|
99
|
-
|
|
94
|
+
if (atBot) {
|
|
95
|
+
await deps.reply(ev.groupId, "本群还没绑定 issue。发「绑定 #<编号>」绑定已有任务,或「任务 <标题>」新建并自动绑定。");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (cfg.VERBOSE) console.log(`[qq-bridge] ignore non-command message from ${ev.userId}`);
|
|
100
99
|
return;
|
|
101
100
|
}
|
|
102
101
|
if (cmd.kind === "help") {
|
package/tests/bridge.test.ts
CHANGED
|
@@ -80,72 +80,52 @@ test("helps when @bot without verb", () => {
|
|
|
80
80
|
expect(parseCommand("[CQ:at,qq=2661222094] 测试2")).toBeNull();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
describe("
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
test("buildChatMessages: system + capped history + new turn", () => {
|
|
87
|
-
const hist = Array.from({ length: 30 }, (_, i) => ({ role: "user" as const, name: `u${i}`, content: `m${i}` }));
|
|
88
|
-
const msgs = buildChatMessages(hist, { role: "user", name: "dog", content: "q" }, 20);
|
|
89
|
-
expect(msgs[0].role).toBe("system");
|
|
90
|
-
expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
|
|
91
|
-
expect(msgs).toHaveLength(22);
|
|
92
|
-
expect(msgs[1].name).toBe("u10");
|
|
93
|
-
expect(msgs[msgs.length - 1]).toEqual({ role: "user", name: "dog", content: "q" });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("splitForQQ keeps <=1500 chunks and prefers newline cuts", () => {
|
|
97
|
-
const short = splitForQQ("hello");
|
|
98
|
-
expect(short).toEqual(["hello"]);
|
|
99
|
-
const long = "line\n".repeat(600);
|
|
100
|
-
const parts = splitForQQ(long);
|
|
101
|
-
expect(parts.length).toBeGreaterThan(1);
|
|
102
|
-
for (const p of parts) expect(p.length).toBeLessThanOrEqual(1500);
|
|
103
|
-
expect(parts.join("\n")).toBe(long);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("router: @bot + question hits chat when configured, help when not", async () => {
|
|
107
|
-
const { createRouter } = require("../src/router");
|
|
108
|
-
const replies: string[] = [];
|
|
109
|
-
const chatCalls: string[] = [];
|
|
110
|
-
const baseDeps = (chatApi: string) => ({
|
|
111
|
-
cfg: {
|
|
112
|
-
VERBOSE: false,
|
|
113
|
-
WORK_CHAT_API: chatApi,
|
|
114
|
-
WORK_CHAT_API_KEY: "k",
|
|
115
|
-
WORK_CHAT_MODEL: "m",
|
|
116
|
-
WORK_CHAT_TIMEOUT_MS: 1000,
|
|
117
|
-
WORK_CHAT_MAX_HISTORY: 20,
|
|
118
|
-
},
|
|
119
|
-
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
|
|
120
|
-
wakeList: new Set(["1403558951"]),
|
|
121
|
-
ework: { createIssue: async () => 1, addComment: async () => {} },
|
|
122
|
-
store: { seenPost: () => false },
|
|
123
|
-
reply: async (_g: number, text: string) => { replies.push(text); },
|
|
124
|
-
});
|
|
125
|
-
// chat disabled → usage help
|
|
126
|
-
let router = createRouter(baseDeps(""));
|
|
127
|
-
await router.handleGroupMessage({ groupId: 1, userId: 1403558951, nickname: "dog", postId: "p1", rawMessage: "[CQ:at,qq=2661222094] 你好呀" });
|
|
128
|
-
expect(replies[0]).toContain("没看懂指令");
|
|
129
|
-
// chat enabled → mocked LLM answer (patch chatComplete via env endpoint failure is overkill; test buildChatMessages path instead)
|
|
130
|
-
replies.length = 0;
|
|
131
|
-
router = createRouter(baseDeps("http://invalid.test/v1"));
|
|
132
|
-
await router.handleGroupMessage({ groupId: 1, userId: 1403558951, nickname: "dog", postId: "p2", rawMessage: "[CQ:at,qq=2661222094] 你好呀" });
|
|
133
|
-
expect(replies[0]).toContain("回答失败");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("router: @bot with CQ-only payload (no text) still gets help", async () => {
|
|
83
|
+
describe("@bot unified routing", () => {
|
|
84
|
+
const mk = (bs: BindingStore) => {
|
|
137
85
|
const { createRouter } = require("../src/router");
|
|
138
86
|
const replies: string[] = [];
|
|
87
|
+
const comments: Array<[string, string, number, string]> = [];
|
|
139
88
|
const router = createRouter({
|
|
140
|
-
cfg: { VERBOSE: false
|
|
141
|
-
bindings:
|
|
89
|
+
cfg: { VERBOSE: false },
|
|
90
|
+
bindings: bs,
|
|
142
91
|
wakeList: new Set(["1"]),
|
|
143
|
-
ework: { createIssue: async () =>
|
|
92
|
+
ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
|
|
144
93
|
store: { seenPost: () => false },
|
|
145
|
-
reply: async (_g: number,
|
|
94
|
+
reply: async (_g: number, x: string) => { replies.push(x); },
|
|
146
95
|
});
|
|
147
|
-
|
|
148
|
-
|
|
96
|
+
return { router, replies, comments };
|
|
97
|
+
};
|
|
98
|
+
const ev = (postId: string, raw: string) => ({ groupId: 1, userId: 1, nickname: "u", postId, rawMessage: raw });
|
|
99
|
+
|
|
100
|
+
test("pinned: @bot falls to bound issue when chat API unset", async () => {
|
|
101
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()));
|
|
102
|
+
await router.handleGroupMessage(ev("p1", "[CQ:at,qq=2661222094] 这个报错啥意思"));
|
|
103
|
+
expect(comments.length).toBe(1);
|
|
104
|
+
expect(comments[0][2]).toBe(7);
|
|
105
|
+
expect(comments[0][3]).toContain("这个报错啥意思");
|
|
106
|
+
expect(comments[0][3]).not.toContain("CQ:at");
|
|
107
|
+
expect(replies).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("pinned: @bot CQ-only payload silently dropped", async () => {
|
|
111
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()));
|
|
112
|
+
await router.handleGroupMessage(ev("p2", "[CQ:at,qq=2661222094]"));
|
|
113
|
+
expect(comments).toEqual([]);
|
|
114
|
+
expect(replies).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("unpinned: @bot question gets bind guidance", async () => {
|
|
118
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()));
|
|
119
|
+
await router.handleGroupMessage(ev("p3", "[CQ:at,qq=2661222094] 你好呀"));
|
|
120
|
+
expect(comments).toEqual([]);
|
|
121
|
+
expect(replies[0]).toContain("绑定");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("unpinned: plain chatter stays silent", async () => {
|
|
125
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()));
|
|
126
|
+
await router.handleGroupMessage(ev("p4", "今天天气不错"));
|
|
127
|
+
expect(comments).toEqual([]);
|
|
128
|
+
expect(replies).toEqual([]);
|
|
149
129
|
});
|
|
150
130
|
});
|
|
151
131
|
|
|
@@ -198,7 +178,7 @@ describe("issue pinning", () => {
|
|
|
198
178
|
const replies: string[] = [];
|
|
199
179
|
const comments: Array<[string, string, number, string]> = [];
|
|
200
180
|
const router = createRouter({
|
|
201
|
-
cfg: { VERBOSE: false
|
|
181
|
+
cfg: { VERBOSE: false },
|
|
202
182
|
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
|
|
203
183
|
wakeList: new Set(["1"]),
|
|
204
184
|
ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
|
|
@@ -218,7 +198,7 @@ describe("issue pinning", () => {
|
|
|
218
198
|
const comments: Array<number, any> = [];
|
|
219
199
|
const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile());
|
|
220
200
|
const router = createRouter({
|
|
221
|
-
cfg: { VERBOSE: false
|
|
201
|
+
cfg: { VERBOSE: false },
|
|
222
202
|
bindings: bs,
|
|
223
203
|
wakeList: new Set(["1"]),
|
|
224
204
|
ework: { createIssue: async () => 9, addComment: async (_o: any, _r: any, n: number) => { comments.push(n); } },
|
|
@@ -236,7 +216,7 @@ describe("issue pinning", () => {
|
|
|
236
216
|
const replies: string[] = [];
|
|
237
217
|
const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 3 }], pinFile());
|
|
238
218
|
const router = createRouter({
|
|
239
|
-
cfg: { VERBOSE: false
|
|
219
|
+
cfg: { VERBOSE: false },
|
|
240
220
|
bindings: bs,
|
|
241
221
|
wakeList: new Set(["1"]),
|
|
242
222
|
ework: { createIssue: async () => 12, addComment: async () => {} },
|
|
@@ -249,3 +229,91 @@ describe("issue pinning", () => {
|
|
|
249
229
|
expect(bs.resolve(1)?.issue).toBe(12);
|
|
250
230
|
});
|
|
251
231
|
});
|
|
232
|
+
|
|
233
|
+
describe("pure-API chat", () => {
|
|
234
|
+
const { estimateTokens, trimToContext, 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
|
+
|
|
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("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"]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("trimToContext keeps everything under budget", () => {
|
|
250
|
+
const hist = [turn("hi"), turn("yo")];
|
|
251
|
+
expect(trimToContext(hist, 100000, 0).length).toBe(2);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("buildChatMessages truncates oversized single message", () => {
|
|
255
|
+
const msgs = buildChatMessages([], turn("x".repeat(CHAT_MSG_CHAR_CAP + 500)), 20, 50000);
|
|
256
|
+
expect(msgs[msgs.length - 1].content.length).toBeLessThanOrEqual(CHAT_MSG_CHAR_CAP + 10);
|
|
257
|
+
expect(msgs[msgs.length - 1].content).toContain("已截断");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("buildChatMessages keeps system prompt first and reserves question budget", () => {
|
|
261
|
+
const hist = [turn("a".repeat(40000)), turn("b")];
|
|
262
|
+
const msgs = buildChatMessages(hist, turn("q"), 20, 50000);
|
|
263
|
+
expect(msgs[0].role).toBe("system");
|
|
264
|
+
expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
|
|
265
|
+
const total = msgs.reduce((n: number, m: { content: string }) => n + estimateTokens(m.content), 0);
|
|
266
|
+
expect(total).toBeLessThanOrEqual(50000 + estimateTokens("q") + estimateTokens(CHAT_SYSTEM_PROMPT));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("router: @bot routes to chat when WORK_CHAT_API set", async () => {
|
|
270
|
+
const origFetch = globalThis.fetch;
|
|
271
|
+
const bodies: unknown[] = [];
|
|
272
|
+
globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => {
|
|
273
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
274
|
+
return new Response(JSON.stringify({ choices: [{ message: { content: "秒回的答案" } }] }), { status: 200 });
|
|
275
|
+
}) as typeof fetch;
|
|
276
|
+
try {
|
|
277
|
+
const { createRouter } = require("../src/router");
|
|
278
|
+
const replies: string[] = [];
|
|
279
|
+
const comments: unknown[] = [];
|
|
280
|
+
const router = createRouter({
|
|
281
|
+
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 },
|
|
282
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
|
|
283
|
+
wakeList: new Set(["1"]),
|
|
284
|
+
ework: { createIssue: async () => 9, addComment: async (...a: unknown[]) => { comments.push(a); } },
|
|
285
|
+
store: { seenPost: () => false },
|
|
286
|
+
reply: async (_g: number, x: string) => { replies.push(x); },
|
|
287
|
+
});
|
|
288
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c1", rawMessage: "[CQ:at,qq=2661222094] 快问快答" });
|
|
289
|
+
expect(replies).toEqual(["秒回的答案"]);
|
|
290
|
+
expect(comments).toEqual([]);
|
|
291
|
+
expect((bodies[0] as { messages: { content: string }[] }).messages[0].role).toBe("system");
|
|
292
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c2", rawMessage: "[CQ:at,qq=2661222094] 追问一句" });
|
|
293
|
+
expect((bodies[1] as { messages: { content: string }[] }).messages.length).toBe(4);
|
|
294
|
+
} finally {
|
|
295
|
+
globalThis.fetch = origFetch;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("router: chat failure surfaces error reply", async () => {
|
|
300
|
+
const origFetch = globalThis.fetch;
|
|
301
|
+
globalThis.fetch = (async () => new Response("boom", { status: 500 })) as typeof fetch;
|
|
302
|
+
try {
|
|
303
|
+
const { createRouter } = require("../src/router");
|
|
304
|
+
const replies: string[] = [];
|
|
305
|
+
const router = createRouter({
|
|
306
|
+
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 },
|
|
307
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
|
|
308
|
+
wakeList: new Set(["1"]),
|
|
309
|
+
ework: { createIssue: async () => 9, addComment: async () => {} },
|
|
310
|
+
store: { seenPost: () => false },
|
|
311
|
+
reply: async (_g: number, x: string) => { replies.push(x); },
|
|
312
|
+
});
|
|
313
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "c3", rawMessage: "[CQ:at,qq=2661222094] 会失败吗" });
|
|
314
|
+
expect(replies[0]).toContain("问答失败");
|
|
315
|
+
} finally {
|
|
316
|
+
globalThis.fetch = origFetch;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
});
|