ework-qq-bridge 0.4.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 +108 -0
- package/src/config.ts +11 -0
- package/src/router.ts +27 -2
- package/tests/bridge.test.ts +89 -1
package/package.json
CHANGED
package/src/chat.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
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
|
+
|
|
51
|
+
export function buildChatMessages(
|
|
52
|
+
history: ChatTurn[],
|
|
53
|
+
question: ChatTurn,
|
|
54
|
+
maxHistory: number,
|
|
55
|
+
maxContextTokens: number,
|
|
56
|
+
): { role: string; name?: string; content: string }[] {
|
|
57
|
+
const system = { role: "system", content: CHAT_SYSTEM_PROMPT };
|
|
58
|
+
const recent = history.slice(-maxHistory);
|
|
59
|
+
const kept = trimToContext(recent, maxContextTokens, estimateTokens(CHAT_SYSTEM_PROMPT) + estimateTokens(question.content));
|
|
60
|
+
return [
|
|
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) },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function chatComplete(
|
|
68
|
+
apiBase: string,
|
|
69
|
+
apiKey: string,
|
|
70
|
+
model: string,
|
|
71
|
+
messages: { role: string; name?: string; content: string }[],
|
|
72
|
+
timeoutMs: number,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const ctrl = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(`${apiBase.replace(/\/+$/, "")}/chat/completions`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
80
|
+
body: JSON.stringify({ model, messages, max_tokens: 1024, temperature: 0.4 }),
|
|
81
|
+
signal: ctrl.signal,
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`LLM ${res.status}: ${(await res.text()).slice(0, 120)}`);
|
|
85
|
+
}
|
|
86
|
+
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
87
|
+
const text = data.choices?.[0]?.message?.content?.trim();
|
|
88
|
+
if (!text) throw new Error("LLM returned empty content");
|
|
89
|
+
return text;
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function splitForQQ(text: string, maxLen = 1500): string[] {
|
|
96
|
+
if (text.length <= maxLen) return [text];
|
|
97
|
+
const parts: string[] = [];
|
|
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);
|
|
102
|
+
if (cut < maxLen * 0.5) cut = maxLen;
|
|
103
|
+
parts.push(remaining.slice(0, cut + 1));
|
|
104
|
+
remaining = remaining.slice(cut + 1);
|
|
105
|
+
}
|
|
106
|
+
if (remaining) parts.push(remaining);
|
|
107
|
+
return parts;
|
|
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.
|
package/src/router.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { EworkClient } from "./ework";
|
|
|
3
3
|
import type { GroupMessageEvent } from "./onebot";
|
|
4
4
|
import type { BridgeStore } from "./db";
|
|
5
5
|
import type { BindingStore } from "./bindings";
|
|
6
|
+
import { buildChatMessages, chatComplete, splitForQQ, type ChatTurn } from "./chat";
|
|
6
7
|
|
|
7
8
|
const HELP_TEXT = [
|
|
8
9
|
"用法:",
|
|
@@ -11,7 +12,8 @@ const HELP_TEXT = [
|
|
|
11
12
|
" 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
|
|
12
13
|
" 解绑 —— 恢复为项目模式(接收整个项目的回复)",
|
|
13
14
|
" 查询 —— 列出最近 issue",
|
|
14
|
-
"
|
|
15
|
+
" @我 <问题> —— 即时问答(纯 API,不留 issue,上下文满自动清理)",
|
|
16
|
+
" (绑定后:普通发言进绑定的 issue,AI 回复自动回群)",
|
|
15
17
|
].join("\n");
|
|
16
18
|
|
|
17
19
|
export interface RouterDeps {
|
|
@@ -46,6 +48,25 @@ export function parseCommand(raw: string): ParsedCommand | null {
|
|
|
46
48
|
|
|
47
49
|
export function createRouter(deps: RouterDeps) {
|
|
48
50
|
const { cfg, bindings, wakeList, ework, store } = deps;
|
|
51
|
+
const chatHistory = new Map<number, ChatTurn[]>();
|
|
52
|
+
|
|
53
|
+
async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
|
|
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 });
|
|
60
|
+
chatHistory.set(ev.groupId, history.slice(-cfg.WORK_CHAT_MAX_HISTORY * 2));
|
|
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}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
49
70
|
async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
|
|
50
71
|
if (store.seenPost(ev.postId)) return;
|
|
51
72
|
const binding = bindings.resolve(ev.groupId);
|
|
@@ -59,8 +80,12 @@ export function createRouter(deps: RouterDeps) {
|
|
|
59
80
|
const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task|新任务|#|绑定|解绑|帮助|help|查询)/.test(ev.rawMessage);
|
|
60
81
|
const cmd = parseCommand(ev.rawMessage);
|
|
61
82
|
if (!cmd) {
|
|
83
|
+
const text = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
|
|
84
|
+
if (atBot && text && cfg.WORK_CHAT_API) {
|
|
85
|
+
await answerChat(ev, text);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
62
88
|
if (binding.issue !== undefined) {
|
|
63
|
-
const text = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
|
|
64
89
|
if (text) {
|
|
65
90
|
await ework.addComment(binding.owner, binding.repo, binding.issue, `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})\n\n${text}`);
|
|
66
91
|
}
|
package/tests/bridge.test.ts
CHANGED
|
@@ -97,7 +97,7 @@ describe("@bot unified routing", () => {
|
|
|
97
97
|
};
|
|
98
98
|
const ev = (postId: string, raw: string) => ({ groupId: 1, userId: 1, nickname: "u", postId, rawMessage: raw });
|
|
99
99
|
|
|
100
|
-
test("pinned: @bot
|
|
100
|
+
test("pinned: @bot falls to bound issue when chat API unset", async () => {
|
|
101
101
|
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()));
|
|
102
102
|
await router.handleGroupMessage(ev("p1", "[CQ:at,qq=2661222094] 这个报错啥意思"));
|
|
103
103
|
expect(comments.length).toBe(1);
|
|
@@ -229,3 +229,91 @@ describe("issue pinning", () => {
|
|
|
229
229
|
expect(bs.resolve(1)?.issue).toBe(12);
|
|
230
230
|
});
|
|
231
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
|
+
});
|