qq-codex-bridge 0.1.0 → 0.1.2

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.
@@ -14,6 +14,7 @@ import { SqliteTranscriptStore } from "../../../packages/store/src/message-repo.
14
14
  import { SqliteSessionStore } from "../../../packages/store/src/session-repo.js";
15
15
  import { createSqliteDatabase } from "../../../packages/store/src/sqlite.js";
16
16
  import { loadConfigFromEnv } from "./config.js";
17
+ const INTERNAL_TURN_EVENT_PATH = "/internal/codex-turn-events";
17
18
  export function bootstrap() {
18
19
  const config = loadConfigFromEnv(process.env);
19
20
  const db = createSqliteDatabase(config.databasePath);
@@ -74,7 +75,16 @@ export function bootstrap() {
74
75
  replyToMessageId: message.messageId
75
76
  })));
76
77
  }
77
- : undefined
78
+ : undefined,
79
+ onTurnEvent: async (event) => {
80
+ await postTurnEvent(config.runtime.listenPort, {
81
+ ...event,
82
+ payload: {
83
+ ...event.payload,
84
+ replyToMessageId: message.messageId
85
+ }
86
+ });
87
+ }
78
88
  });
79
89
  return drafts.map((draft) => formatQqOutboundDraft(enrichQqOutboundDraft({
80
90
  ...draft,
@@ -98,3 +108,22 @@ export function bootstrap() {
98
108
  qqGatewaySessionStore
99
109
  };
100
110
  }
111
+ async function postTurnEvent(port, event) {
112
+ try {
113
+ await fetch(`http://127.0.0.1:${port}${INTERNAL_TURN_EVENT_PATH}`, {
114
+ method: "POST",
115
+ headers: {
116
+ "content-type": "application/json"
117
+ },
118
+ body: JSON.stringify(event)
119
+ });
120
+ }
121
+ catch (error) {
122
+ console.warn("[qq-codex-bridge] turn event callback failed", {
123
+ turnId: event.turnId,
124
+ sessionKey: event.sessionKey,
125
+ error: error instanceof Error ? error.message : String(error)
126
+ });
127
+ }
128
+ }
129
+ export { INTERNAL_TURN_EVENT_PATH };
@@ -1,11 +1,31 @@
1
1
  import { createServer } from "node:http";
2
2
  export function createQqWebhookServer(deps) {
3
+ return createJsonServer({
4
+ routePath: deps.webhookPath,
5
+ dispatchPayload: deps.ingress.dispatchPayload,
6
+ onDispatchError: deps.onDispatchError
7
+ });
8
+ }
9
+ export function createInternalTurnEventServer(deps) {
10
+ return createJsonServer({
11
+ routePath: deps.routePath,
12
+ dispatchPayload: deps.ingress.dispatchTurnEvent,
13
+ onDispatchError: deps.onDispatchError,
14
+ allowOnlyLocal: true
15
+ });
16
+ }
17
+ function createJsonServer(deps) {
3
18
  return createServer(async (request, response) => {
4
- if (request.url !== deps.webhookPath) {
19
+ if (request.url !== deps.routePath) {
5
20
  response.statusCode = 404;
6
21
  response.end("not found");
7
22
  return;
8
23
  }
24
+ if (deps.allowOnlyLocal && !isLocalRequest(request)) {
25
+ response.statusCode = 403;
26
+ response.end("forbidden");
27
+ return;
28
+ }
9
29
  if (request.method !== "POST") {
10
30
  response.statusCode = 405;
11
31
  response.end("method not allowed");
@@ -25,7 +45,7 @@ export function createQqWebhookServer(deps) {
25
45
  return;
26
46
  }
27
47
  Promise.resolve()
28
- .then(() => deps.ingress.dispatchPayload(payload))
48
+ .then(() => deps.dispatchPayload(payload))
29
49
  .catch((error) => {
30
50
  const normalized = error instanceof Error ? error : new Error(typeof error === "string" ? error : "dispatch failed");
31
51
  deps.onDispatchError?.(normalized, payload);
@@ -34,3 +54,10 @@ export function createQqWebhookServer(deps) {
34
54
  response.end("accepted");
35
55
  });
36
56
  }
57
+ function isLocalRequest(request) {
58
+ const address = request.socket.remoteAddress;
59
+ if (!address) {
60
+ return false;
61
+ }
62
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
63
+ }
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from "node:url";
2
- import { bootstrap } from "./bootstrap.js";
2
+ import { bootstrap, INTERNAL_TURN_EVENT_PATH } from "./bootstrap.js";
3
+ import { createInternalTurnEventServer } from "./http-server.js";
3
4
  import { ThreadCommandHandler } from "./thread-command-handler.js";
4
5
  export function createIngressMessageHandler(deps) {
5
6
  return async (message) => {
@@ -24,12 +25,33 @@ export function createIngressMessageHandler(deps) {
24
25
  }
25
26
  export async function runBridgeDaemon() {
26
27
  const app = bootstrap();
28
+ const internalTurnEventServer = createInternalTurnEventServer({
29
+ routePath: INTERNAL_TURN_EVENT_PATH,
30
+ ingress: {
31
+ dispatchTurnEvent: async (payload) => {
32
+ await app.orchestrator.handleTurnEvent(payload);
33
+ }
34
+ },
35
+ onDispatchError: (error, payload) => {
36
+ console.warn("[qq-codex-bridge] internal turn event dispatch failed", {
37
+ error: error.message,
38
+ payload
39
+ });
40
+ }
41
+ });
27
42
  const threadCommandHandler = new ThreadCommandHandler({
28
43
  sessionStore: app.sessionStore,
29
44
  transcriptStore: app.transcriptStore,
30
45
  desktopDriver: app.adapters.codexDesktop,
31
46
  qqEgress: app.adapters.qq.egress
32
47
  });
48
+ await new Promise((resolve, reject) => {
49
+ internalTurnEventServer.once("error", reject);
50
+ internalTurnEventServer.listen(app.config.runtime.listenPort, "127.0.0.1", () => {
51
+ internalTurnEventServer.off("error", reject);
52
+ resolve();
53
+ });
54
+ });
33
55
  await app.adapters.qq.ingress.onMessage(createIngressMessageHandler({
34
56
  threadCommandHandler,
35
57
  orchestrator: app.orchestrator
@@ -37,7 +59,8 @@ export async function runBridgeDaemon() {
37
59
  await app.adapters.qq.ingress.start();
38
60
  console.log("[qq-codex-bridge] ready", {
39
61
  transport: "qq-gateway-websocket",
40
- accountKey: "qqbot:default"
62
+ accountKey: "qqbot:default",
63
+ internalTurnEventPath: INTERNAL_TURN_EVENT_PATH
41
64
  });
42
65
  }
43
66
  function handleFatal(error) {
@@ -46,6 +46,34 @@ export class ThreadCommandHandler {
46
46
  await this.deliverControlReply(message, this.buildHelpText());
47
47
  return;
48
48
  }
49
+ if (text === "/h") {
50
+ await this.deliverControlReply(message, this.buildHelpText());
51
+ return;
52
+ }
53
+ if (text === "/model" || text === "/m") {
54
+ const state = await this.deps.desktopDriver.getControlState();
55
+ await this.deliverControlReply(message, this.formatModelReply(state));
56
+ return;
57
+ }
58
+ const switchModelMatch = text.match(/^(?:\/model\s+use|\/mu)\s+(.+)$/);
59
+ if (switchModelMatch) {
60
+ const targetModel = switchModelMatch[1].trim();
61
+ const state = await this.deps.desktopDriver.switchModel(targetModel);
62
+ await this.deliverControlReply(message, this.formatModelSwitchReply(targetModel, state));
63
+ return;
64
+ }
65
+ if (text === "/quota" || text === "/q") {
66
+ const quotaSummary = await this.deps.desktopDriver.getQuotaSummary();
67
+ await this.deliverControlReply(message, this.formatQuotaReply(quotaSummary));
68
+ return;
69
+ }
70
+ if (text === "/status" || text === "/st") {
71
+ const session = await this.deps.sessionStore.getSession(message.sessionKey);
72
+ const state = await this.deps.desktopDriver.getControlState();
73
+ const quotaSummary = await this.deps.desktopDriver.getQuotaSummary();
74
+ await this.deliverControlReply(message, this.formatStatusReply(session, state, quotaSummary));
75
+ return;
76
+ }
49
77
  const useMatch = text.match(/^(?:\/thread\s+use|\/tu)\s+(\d+)$/);
50
78
  if (useMatch) {
51
79
  const index = Number(useMatch[1]);
@@ -117,8 +145,17 @@ export class ThreadCommandHandler {
117
145
  text === "/thread current" ||
118
146
  text === "/tc" ||
119
147
  text === "/help" ||
148
+ text === "/h" ||
149
+ text === "/model" ||
150
+ text === "/m" ||
151
+ text === "/quota" ||
152
+ text === "/q" ||
153
+ text === "/status" ||
154
+ text === "/st" ||
120
155
  /^\/thread\s+use\s+\d+$/.test(text) ||
121
156
  /^\/tu\s+\d+$/.test(text) ||
157
+ /^\/model\s+use\s+.+$/.test(text) ||
158
+ /^\/mu\s+.+$/.test(text) ||
122
159
  /^\/thread\s+new\s+.+$/.test(text) ||
123
160
  /^\/tn\s+.+$/.test(text) ||
124
161
  /^\/thread\s+fork\s+.+$/.test(text) ||
@@ -136,7 +173,7 @@ export class ThreadCommandHandler {
136
173
  "| 序号 | 项目 | 线程标题 | 最近活动 |",
137
174
  "| --- | --- | --- | --- |",
138
175
  ...threads.map((thread) => {
139
- const index = thread.isCurrent ? `→ ${thread.index}` : `${thread.index}`;
176
+ const index = thread.isCurrent ? `👉🏻 ${thread.index}` : `${thread.index}`;
140
177
  const project = escapeCell(thread.projectName) || "-";
141
178
  const title = escapeCell(thread.title) || "-";
142
179
  const time = escapeCell(thread.relativeTime) || "-";
@@ -157,7 +194,7 @@ export class ThreadCommandHandler {
157
194
  }
158
195
  buildHelpText() {
159
196
  return [
160
- "线程管理命令:",
197
+ "快捷命令:",
161
198
  "",
162
199
  "| 用途 | 完整命令 | 简写 |",
163
200
  "| --- | --- | --- |",
@@ -166,8 +203,44 @@ export class ThreadCommandHandler {
166
203
  "| 切换到指定线程 | `/thread use <序号>` | `/tu <序号>` |",
167
204
  "| 新建线程 | `/thread new <标题>` | `/tn <标题>` |",
168
205
  "| 基于最近对话 fork 线程 | `/thread fork <标题>` | `/tf <标题>` |",
206
+ "| 查看当前模型 | `/model` | `/m` |",
207
+ "| 切换模型 | `/model use <名称>` | `/mu <名称>` |",
208
+ "| 查看额度信息 | `/quota` | `/q` |",
209
+ "| 查看当前运行状态 | `/status` | `/st` |",
210
+ "| 查看帮助 | `/help` | `/h` |",
169
211
  "",
170
- "建议先发 `/t` 看列表,再用 `/tu 2` 这种方式切换。"
212
+ "建议先发 `/t` 看列表,再用 `/tu 2` 这种方式切换。",
213
+ "模型和额度信息来自当前 Codex Desktop 界面,可见性取决于 UI 是否暴露对应信息。"
214
+ ].join("\n");
215
+ }
216
+ formatModelReply(state) {
217
+ return [
218
+ `当前模型:${state.model ?? "未识别"}`,
219
+ `推理强度:${state.reasoningEffort ?? "未识别"}`,
220
+ `工作区:${state.workspace ?? "未识别"}`,
221
+ `分支:${state.branch ?? "未识别"}`
222
+ ].join("\n");
223
+ }
224
+ formatModelSwitchReply(targetModel, state) {
225
+ return [
226
+ `已切换模型:${state.model ?? targetModel}`,
227
+ `推理强度:${state.reasoningEffort ?? "未识别"}`,
228
+ `工作区:${state.workspace ?? "未识别"}`
229
+ ].join("\n");
230
+ }
231
+ formatQuotaReply(quotaSummary) {
232
+ return `额度信息:${quotaSummary ?? "当前界面未显示明确额度,暂未识别到剩余配额。"}`;
233
+ }
234
+ formatStatusReply(session, state, quotaSummary) {
235
+ return [
236
+ "当前运行状态:",
237
+ `线程绑定:${session?.codexThreadRef ?? "未绑定"}`,
238
+ `模型:${state.model ?? "未识别"}`,
239
+ `推理强度:${state.reasoningEffort ?? "未识别"}`,
240
+ `工作区:${state.workspace ?? "未识别"}`,
241
+ `分支:${state.branch ?? "未识别"}`,
242
+ `权限:${state.permissionMode ?? "未识别"}`,
243
+ `额度:${quotaSummary ?? "当前界面未显示明确额度,暂未识别到剩余配额。"}`
171
244
  ].join("\n");
172
245
  }
173
246
  buildNewThreadSeedPrompt(title) {
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { DesktopDriverError } from "../../../domain/src/driver.js";
3
- import { MediaArtifactKind } from "../../../domain/src/message.js";
3
+ import { MediaArtifactKind, TurnEventType } from "../../../domain/src/message.js";
4
4
  import { isLikelyComposerSubmitButton } from "./composer-heuristics.js";
5
5
  import { parseAssistantReply } from "./reply-parser.js";
6
6
  const TARGET_REF_PREFIX = "cdp-target:";
@@ -33,6 +33,32 @@ export class CodexDesktopDriver {
33
33
  throw new DesktopDriverError("Codex desktop app is not exposing any inspectable page target", "app_not_ready");
34
34
  }
35
35
  }
36
+ async getControlState() {
37
+ const pageTarget = await this.resolvePageTarget();
38
+ const state = (await this.cdp.evaluateOnPage(this.buildReadControlStateScript(), pageTarget.id));
39
+ return (state ?? {
40
+ model: null,
41
+ reasoningEffort: null,
42
+ workspace: null,
43
+ branch: null,
44
+ permissionMode: null,
45
+ quotaSummary: null
46
+ });
47
+ }
48
+ async getQuotaSummary() {
49
+ const pageTarget = await this.resolvePageTarget();
50
+ const quotaSummary = (await this.cdp.evaluateOnPage(this.buildReadQuotaSummaryScript(), pageTarget.id));
51
+ return quotaSummary ?? null;
52
+ }
53
+ async switchModel(model) {
54
+ const pageTarget = await this.resolvePageTarget();
55
+ const result = (await this.cdp.evaluateOnPage(this.buildSwitchModelScript(model), pageTarget.id));
56
+ if (!result?.ok) {
57
+ throw new DesktopDriverError(`Codex desktop model switch failed: ${result?.reason ?? "unknown"}`, "control_not_found");
58
+ }
59
+ await this.sleep(300);
60
+ return this.getControlState();
61
+ }
36
62
  async openOrBindSession(sessionKey, binding) {
37
63
  const pageTarget = await this.resolvePageTarget();
38
64
  const pageId = pageTarget.id;
@@ -183,6 +209,23 @@ export class CodexDesktopDriver {
183
209
  let stablePolls = 0;
184
210
  let emittedReplyText = "";
185
211
  const emittedMediaReferences = new Set();
212
+ const turnId = randomUUID();
213
+ let turnSequence = 0;
214
+ const emitTurnEvent = async (eventType, payload, isFinal) => {
215
+ if (!options.onTurnEvent) {
216
+ return;
217
+ }
218
+ turnSequence += 1;
219
+ await options.onTurnEvent({
220
+ sessionKey: binding.sessionKey,
221
+ turnId,
222
+ sequence: turnSequence,
223
+ eventType,
224
+ createdAt: new Date().toISOString(),
225
+ isFinal,
226
+ payload
227
+ });
228
+ };
186
229
  for (let attempt = 0; attempt < this.maxReplyPollAttempts; attempt += 1) {
187
230
  const reply = await this.readLatestAssistantSnapshot(targetId);
188
231
  const hasReplyText = typeof reply.reply === "string" && reply.reply.trim() !== "";
@@ -203,23 +246,46 @@ export class CodexDesktopDriver {
203
246
  !reply.isStreaming &&
204
247
  stablePolls >= this.replyStablePolls) {
205
248
  this.pendingReplyBaselines.delete(binding.sessionKey);
249
+ const finalPayload = {
250
+ fullText: candidateReply.reply ?? "",
251
+ mediaReferences: candidateReply.mediaReferences
252
+ };
206
253
  if (options.onDraft) {
207
- const finalDeltaDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, candidateReply, emittedReplyText, emittedMediaReferences);
254
+ const finalDeltaDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, candidateReply, emittedReplyText, emittedMediaReferences, turnId);
208
255
  if (finalDeltaDraft) {
256
+ await emitTurnEvent(TurnEventType.Delta, {
257
+ text: finalDeltaDraft.text,
258
+ fullText: candidateReply.reply ?? "",
259
+ mediaReferences: candidateReply.mediaReferences
260
+ }, false);
209
261
  emittedReplyText = this.mergeObservedReply(emittedReplyText, candidateReply.reply ?? "");
210
262
  this.mergeObservedMediaReferences(emittedMediaReferences, candidateReply.mediaReferences);
211
263
  await options.onDraft(finalDeltaDraft);
212
264
  }
265
+ await emitTurnEvent(TurnEventType.Completed, {
266
+ ...finalPayload,
267
+ completionReason: "stable"
268
+ }, true);
213
269
  return [];
214
270
  }
215
- return [this.buildOutboundDraftFromSnapshot(binding.sessionKey, candidateReply)];
271
+ await emitTurnEvent(TurnEventType.Delta, finalPayload, false);
272
+ await emitTurnEvent(TurnEventType.Completed, {
273
+ ...finalPayload,
274
+ completionReason: "stable"
275
+ }, true);
276
+ return [this.buildOutboundDraftFromSnapshot(binding.sessionKey, candidateReply, turnId)];
216
277
  }
217
278
  if (options.onDraft &&
218
279
  candidateReply &&
219
280
  this.hasAssistantContent(candidateReply) &&
220
281
  stablePolls >= this.partialReplyStablePolls) {
221
- const deltaDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, candidateReply, emittedReplyText, emittedMediaReferences);
282
+ const deltaDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, candidateReply, emittedReplyText, emittedMediaReferences, turnId);
222
283
  if (deltaDraft) {
284
+ await emitTurnEvent(TurnEventType.Delta, {
285
+ text: deltaDraft.text,
286
+ fullText: candidateReply.reply ?? "",
287
+ mediaReferences: candidateReply.mediaReferences
288
+ }, false);
223
289
  emittedReplyText = this.mergeObservedReply(emittedReplyText, candidateReply.reply ?? "");
224
290
  this.mergeObservedMediaReferences(emittedMediaReferences, candidateReply.mediaReferences);
225
291
  await options.onDraft(deltaDraft);
@@ -237,19 +303,39 @@ export class CodexDesktopDriver {
237
303
  this.pendingReplyBaselines.delete(binding.sessionKey);
238
304
  if (latestNewReply && this.hasAssistantContent(latestNewReply)) {
239
305
  if (options.onDraft) {
240
- const timeoutDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, latestNewReply, emittedReplyText, emittedMediaReferences);
306
+ const timeoutDraft = this.buildIncrementalDraftFromSnapshot(binding.sessionKey, latestNewReply, emittedReplyText, emittedMediaReferences, turnId);
241
307
  if (timeoutDraft) {
308
+ await emitTurnEvent(TurnEventType.Delta, {
309
+ text: timeoutDraft.text,
310
+ fullText: latestNewReply.reply ?? "",
311
+ mediaReferences: latestNewReply.mediaReferences
312
+ }, false);
242
313
  await options.onDraft(timeoutDraft);
243
314
  }
315
+ await emitTurnEvent(TurnEventType.Completed, {
316
+ fullText: latestNewReply.reply ?? "",
317
+ mediaReferences: latestNewReply.mediaReferences,
318
+ completionReason: "timeout_flush"
319
+ }, true);
244
320
  return [];
245
321
  }
246
- return [this.buildOutboundDraftFromSnapshot(binding.sessionKey, latestNewReply)];
322
+ await emitTurnEvent(TurnEventType.Delta, {
323
+ fullText: latestNewReply.reply ?? "",
324
+ mediaReferences: latestNewReply.mediaReferences
325
+ }, false);
326
+ await emitTurnEvent(TurnEventType.Completed, {
327
+ fullText: latestNewReply.reply ?? "",
328
+ mediaReferences: latestNewReply.mediaReferences,
329
+ completionReason: "timeout_flush"
330
+ }, true);
331
+ return [this.buildOutboundDraftFromSnapshot(binding.sessionKey, latestNewReply, turnId)];
247
332
  }
248
333
  throw new DesktopDriverError("Codex desktop reply did not arrive before timeout", "reply_timeout");
249
334
  }
250
- buildOutboundDraftFromSnapshot(sessionKey, snapshot) {
335
+ buildOutboundDraftFromSnapshot(sessionKey, snapshot, turnId) {
251
336
  return {
252
337
  draftId: randomUUID(),
338
+ ...(turnId ? { turnId } : {}),
253
339
  sessionKey,
254
340
  text: snapshot.reply ?? "",
255
341
  ...(snapshot.mediaReferences.length > 0
@@ -260,7 +346,7 @@ export class CodexDesktopDriver {
260
346
  createdAt: new Date().toISOString()
261
347
  };
262
348
  }
263
- buildIncrementalDraftFromSnapshot(sessionKey, snapshot, emittedReplyText, emittedMediaReferences) {
349
+ buildIncrementalDraftFromSnapshot(sessionKey, snapshot, emittedReplyText, emittedMediaReferences, turnId) {
264
350
  const fullReply = snapshot.reply ?? "";
265
351
  const deltaText = this.extractReplyDelta(emittedReplyText, fullReply).trim();
266
352
  const incrementalMediaReferences = snapshot.mediaReferences.filter((reference) => !emittedMediaReferences.has(reference));
@@ -269,6 +355,7 @@ export class CodexDesktopDriver {
269
355
  }
270
356
  return {
271
357
  draftId: randomUUID(),
358
+ ...(turnId ? { turnId } : {}),
272
359
  sessionKey,
273
360
  text: deltaText,
274
361
  ...(incrementalMediaReferences.length > 0
@@ -874,6 +961,269 @@ export class CodexDesktopDriver {
874
961
  };
875
962
  })();`;
876
963
  }
964
+ buildReadControlStateScript() {
965
+ return `
966
+ (() => {
967
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
968
+ const buttons = Array.from(document.querySelectorAll('button,[role="button"],[aria-label]'))
969
+ .map((node) => {
970
+ const rect = node instanceof HTMLElement ? node.getBoundingClientRect() : null;
971
+ return {
972
+ text: normalize(node.textContent || ''),
973
+ aria: normalize(node.getAttribute('aria-label') || ''),
974
+ title: normalize(node.getAttribute('title') || ''),
975
+ className: String(node.className || ''),
976
+ x: rect ? rect.x : 0,
977
+ y: rect ? rect.y : 0,
978
+ width: rect ? rect.width : 0,
979
+ height: rect ? rect.height : 0
980
+ };
981
+ })
982
+ .filter((item) => (item.text || item.aria || item.title) && item.y >= window.innerHeight - 180 && item.x >= 320)
983
+ .sort((left, right) => (left.y - right.y) || (left.x - right.x));
984
+
985
+ const modelPattern = /(?:gpt|o1|o3|o4|4\\.1|4\\.5|5\\.4|mini|nano|sonnet|haiku|opus|gemini|claude|qwen|deepseek)/i;
986
+ const effortPattern = /^(?:低|中|高|minimal|low|medium|high)$/i;
987
+ const permissionPattern = /(?:访问权限|permission|sandbox)/i;
988
+ const quotaPattern = /(?:quota|usage|remaining|allowance|credit|credits|余额|额度|剩余|配额|使用量)/i;
989
+ const ignoredQuotaPattern = /(?:QQBOT_RUNTIME_CONTEXT|<qqmedia>|<!--|-->|会话类型|bridge|runtime\\/media|内部实现|相对路径)/i;
990
+
991
+ let model = null;
992
+ let reasoningEffort = null;
993
+ let workspace = null;
994
+ let branch = null;
995
+ let permissionMode = null;
996
+
997
+ for (const item of buttons) {
998
+ const text = item.text || item.aria || item.title;
999
+ if (!model && modelPattern.test(text)) {
1000
+ model = text;
1001
+ continue;
1002
+ }
1003
+ if (!reasoningEffort && effortPattern.test(text)) {
1004
+ reasoningEffort = text;
1005
+ continue;
1006
+ }
1007
+ if (!permissionMode && permissionPattern.test(text)) {
1008
+ permissionMode = text;
1009
+ continue;
1010
+ }
1011
+ if (!workspace && /^(?:本地|local|worktree)$/i.test(text)) {
1012
+ workspace = text;
1013
+ continue;
1014
+ }
1015
+ if (!branch && /[\\w.-]+\\/[\\w./-]+/.test(text)) {
1016
+ branch = text;
1017
+ }
1018
+ }
1019
+
1020
+ const bodyLines = (document.body ? document.body.innerText : '')
1021
+ .split('\\n')
1022
+ .map((line) => normalize(line))
1023
+ .filter(Boolean);
1024
+ const quotaLine =
1025
+ bodyLines.find((line) => quotaPattern.test(line) && !ignoredQuotaPattern.test(line)) || null;
1026
+
1027
+ return {
1028
+ model,
1029
+ reasoningEffort,
1030
+ workspace,
1031
+ branch,
1032
+ permissionMode,
1033
+ quotaSummary: quotaLine
1034
+ };
1035
+ })()
1036
+ `;
1037
+ }
1038
+ buildReadQuotaSummaryScript() {
1039
+ return `
1040
+ (() => {
1041
+ const normalize = (value) =>
1042
+ (value || '')
1043
+ .replace(/[\\u200B-\\u200D\\uFEFF]/g, '')
1044
+ .replace(/\\s+/g, ' ')
1045
+ .trim();
1046
+ const clickNode = (node) => {
1047
+ node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
1048
+ node.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
1049
+ node.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
1050
+ node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
1051
+ };
1052
+ const collectControls = () =>
1053
+ Array.from(document.querySelectorAll('button,[role="button"],[role="menuitem"],[role="option"],[aria-label]'));
1054
+ const getText = (node) =>
1055
+ normalize(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title') || '');
1056
+ const isQuotaHeader = (line) => line === '剩余额度' || /^remaining usage$/i.test(line);
1057
+ const parseQuotaEntries = (lines) => {
1058
+ const entries = [];
1059
+ for (let index = 0; index < lines.length; index += 1) {
1060
+ const line = lines[index];
1061
+ if (
1062
+ !line ||
1063
+ isQuotaHeader(line) ||
1064
+ /^\\d+%$/.test(line) ||
1065
+ /^(?:继续使用|本地项目|云端|升级至\\s*Pro|了解更多|移至工作树)$/i.test(line)
1066
+ ) {
1067
+ continue;
1068
+ }
1069
+
1070
+ const combinedMatch = line.match(/^(.+?(?:分钟|小时|天|周|月|minutes?|hours?|days?|weeks?|months?))\\s+(\\d+%)\\s+(.+)$/i);
1071
+ if (combinedMatch) {
1072
+ entries.push(\`\${combinedMatch[1]} \${combinedMatch[2]}(\${combinedMatch[3]} 重置)\`);
1073
+ continue;
1074
+ }
1075
+
1076
+ const timeframeMatch = line.match(/^(.+?(?:分钟|小时|天|周|月|minutes?|hours?|days?|weeks?|months?))$/i);
1077
+ if (timeframeMatch && index + 2 < lines.length) {
1078
+ const percentLine = lines[index + 1];
1079
+ const resetLine = lines[index + 2];
1080
+ if (/^\\d+%$/.test(percentLine) && resetLine) {
1081
+ entries.push(\`\${timeframeMatch[1]} \${percentLine}(\${resetLine} 重置)\`);
1082
+ index += 2;
1083
+ continue;
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ return entries;
1089
+ };
1090
+
1091
+ const findModeButton = () =>
1092
+ collectControls().find((node) => {
1093
+ if (!(node instanceof HTMLElement)) {
1094
+ return false;
1095
+ }
1096
+ const text = getText(node);
1097
+ const rect = node.getBoundingClientRect();
1098
+ return (
1099
+ rect.y >= window.innerHeight - 120 &&
1100
+ rect.height <= 40 &&
1101
+ rect.width <= 120 &&
1102
+ /^(?:本地|云端|local|cloud)(?:\\d+%)?$/i.test(text)
1103
+ );
1104
+ });
1105
+
1106
+ const modeButton = findModeButton();
1107
+ if (!modeButton) {
1108
+ return null;
1109
+ }
1110
+
1111
+ const readVisibleQuotaSummary = () => {
1112
+ const lines = (document.body ? document.body.innerText : '')
1113
+ .split('\\n')
1114
+ .map((line) => normalize(line))
1115
+ .filter(Boolean);
1116
+ const quotaIndex = lines.findIndex((line) => isQuotaHeader(line));
1117
+ if (quotaIndex < 0) {
1118
+ return null;
1119
+ }
1120
+
1121
+ const quotaBlock = lines.slice(quotaIndex, quotaIndex + 10);
1122
+ const entries = parseQuotaEntries(quotaBlock);
1123
+ return entries.length > 0 ? entries.join('\\n') : null;
1124
+ };
1125
+
1126
+ const ensureQuotaVisible = () =>
1127
+ new Promise((resolve) => {
1128
+ let attempts = 0;
1129
+ let openedMenu = false;
1130
+ let expandedQuota = false;
1131
+ const tick = () => {
1132
+ attempts += 1;
1133
+ const summary = readVisibleQuotaSummary();
1134
+ if (summary) {
1135
+ resolve(summary);
1136
+ return;
1137
+ }
1138
+
1139
+ if (!openedMenu) {
1140
+ clickNode(modeButton);
1141
+ openedMenu = true;
1142
+ }
1143
+
1144
+ const quotaToggle = collectControls().find((node) => /剩余额度|remaining usage/i.test(getText(node)));
1145
+ if (quotaToggle && !expandedQuota) {
1146
+ clickNode(quotaToggle);
1147
+ expandedQuota = true;
1148
+ }
1149
+
1150
+ if (attempts >= 8) {
1151
+ resolve(null);
1152
+ return;
1153
+ }
1154
+
1155
+ setTimeout(tick, 80);
1156
+ };
1157
+
1158
+ tick();
1159
+ });
1160
+
1161
+ return ensureQuotaVisible();
1162
+ })()
1163
+ `;
1164
+ }
1165
+ buildSwitchModelScript(targetModel) {
1166
+ return `
1167
+ (() => {
1168
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
1169
+ const target = ${JSON.stringify(targetModel)}.trim();
1170
+ const targetNormalized = normalize(target);
1171
+ const matchesModelText = (text) => /(?:gpt|o1|o3|o4|4\\.1|4\\.5|5\\.4|mini|nano|sonnet|haiku|opus|gemini|claude|qwen|deepseek)/i.test(text);
1172
+ const collectControls = () =>
1173
+ Array.from(document.querySelectorAll('button,[role="button"],[role="menuitem"],[role="option"],[aria-label]'));
1174
+ const findModelButton = () =>
1175
+ collectControls().find((node) => {
1176
+ if (!(node instanceof HTMLElement)) {
1177
+ return false;
1178
+ }
1179
+ const rect = node.getBoundingClientRect();
1180
+ const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
1181
+ return rect.y >= window.innerHeight - 180 && rect.x >= 320 && matchesModelText(text);
1182
+ });
1183
+ const clickNode = (node) => {
1184
+ node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
1185
+ node.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
1186
+ node.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
1187
+ node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
1188
+ };
1189
+ const modelButton = findModelButton();
1190
+ if (!modelButton) {
1191
+ return { ok: false, reason: 'model_button_not_found' };
1192
+ }
1193
+ clickNode(modelButton);
1194
+
1195
+ return new Promise((resolve) => {
1196
+ let attempts = 0;
1197
+ const tick = () => {
1198
+ attempts += 1;
1199
+ const option = collectControls().find((node) => {
1200
+ const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
1201
+ if (!text) {
1202
+ return false;
1203
+ }
1204
+ const normalized = normalize(text);
1205
+ return normalized === targetNormalized || normalized.includes(targetNormalized);
1206
+ });
1207
+
1208
+ if (option) {
1209
+ clickNode(option);
1210
+ resolve({ ok: true });
1211
+ return;
1212
+ }
1213
+
1214
+ if (attempts >= 20) {
1215
+ resolve({ ok: false, reason: 'model_option_not_found' });
1216
+ return;
1217
+ }
1218
+
1219
+ setTimeout(tick, 50);
1220
+ };
1221
+
1222
+ tick();
1223
+ });
1224
+ })()
1225
+ `;
1226
+ }
877
1227
  buildAssistantReplyProbeScript() {
878
1228
  return `(() => {
879
1229
  const assistantUnits = Array.from(
@@ -5,3 +5,9 @@ export var MediaArtifactKind;
5
5
  MediaArtifactKind["Video"] = "video";
6
6
  MediaArtifactKind["File"] = "file";
7
7
  })(MediaArtifactKind || (MediaArtifactKind = {}));
8
+ export var TurnEventType;
9
+ (function (TurnEventType) {
10
+ TurnEventType["Delta"] = "turn.delta";
11
+ TurnEventType["Status"] = "turn.status";
12
+ TurnEventType["Completed"] = "turn.completed";
13
+ })(TurnEventType || (TurnEventType = {}));
@@ -1,8 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { BridgeSessionStatus } from "../../domain/src/session.js";
3
+ import { TurnEventType } from "../../domain/src/message.js";
2
4
  import { DesktopDriverError } from "../../domain/src/driver.js";
5
+ import { formatQqOutboundDraft } from "./qq-outbound-format.js";
3
6
  export class BridgeOrchestrator {
4
7
  deps;
5
8
  recentInboundFingerprints = new Map();
9
+ turnStates = new Map();
6
10
  constructor(deps) {
7
11
  this.deps = deps;
8
12
  }
@@ -53,6 +57,7 @@ export class BridgeOrchestrator {
53
57
  await this.deps.transcriptStore.recordOutbound(draft);
54
58
  try {
55
59
  await this.deps.qqEgress.deliver(draft);
60
+ this.recordDeliveredDraft(draft);
56
61
  }
57
62
  catch (error) {
58
63
  const reason = error instanceof Error ? error.message : String(error);
@@ -89,6 +94,52 @@ export class BridgeOrchestrator {
89
94
  }
90
95
  });
91
96
  }
97
+ async handleTurnEvent(event) {
98
+ await this.deps.sessionStore.withSessionLock(event.sessionKey, async () => {
99
+ const state = this.getOrCreateTurnState(event);
100
+ if (event.sequence <= state.lastSequence) {
101
+ return;
102
+ }
103
+ state.lastSequence = event.sequence;
104
+ state.lastEventAt = event.createdAt;
105
+ if (typeof event.payload.fullText === "string") {
106
+ state.assembledText = event.payload.fullText;
107
+ }
108
+ else if (typeof event.payload.text === "string" && event.payload.text.length > 0) {
109
+ state.assembledText += event.payload.text;
110
+ }
111
+ if (event.eventType !== TurnEventType.Completed) {
112
+ return;
113
+ }
114
+ const pendingText = computePendingTurnText(state.sentText, state.assembledText);
115
+ if (!pendingText) {
116
+ state.completed = true;
117
+ state.finalFlushed = true;
118
+ return;
119
+ }
120
+ const draft = formatQqOutboundDraft({
121
+ draftId: randomUUID(),
122
+ turnId: event.turnId,
123
+ sessionKey: event.sessionKey,
124
+ text: pendingText,
125
+ createdAt: event.createdAt,
126
+ ...(event.payload.replyToMessageId
127
+ ? { replyToMessageId: event.payload.replyToMessageId }
128
+ : {})
129
+ });
130
+ if (!draft.text.trim()) {
131
+ state.sentText = state.assembledText;
132
+ state.completed = true;
133
+ state.finalFlushed = true;
134
+ return;
135
+ }
136
+ await this.deps.transcriptStore.recordOutbound(draft);
137
+ await this.deps.qqEgress.deliver(draft);
138
+ state.sentText = state.assembledText;
139
+ state.completed = true;
140
+ state.finalFlushed = true;
141
+ });
142
+ }
92
143
  isLikelyDuplicateInbound(message) {
93
144
  const record = this.recentInboundFingerprints.get(message.sessionKey);
94
145
  if (!record) {
@@ -119,6 +170,39 @@ export class BridgeOrchestrator {
119
170
  messageId: message.messageId
120
171
  });
121
172
  }
173
+ getOrCreateTurnState(event) {
174
+ const key = buildTurnStateKey(event.sessionKey, event.turnId);
175
+ const existing = this.turnStates.get(key);
176
+ if (existing) {
177
+ return existing;
178
+ }
179
+ const created = {
180
+ lastSequence: 0,
181
+ assembledText: "",
182
+ sentText: "",
183
+ lastEventAt: null,
184
+ completed: false,
185
+ finalFlushed: false
186
+ };
187
+ this.turnStates.set(key, created);
188
+ return created;
189
+ }
190
+ recordDeliveredDraft(draft) {
191
+ if (!draft.turnId || !draft.text) {
192
+ return;
193
+ }
194
+ const key = buildTurnStateKey(draft.sessionKey, draft.turnId);
195
+ const state = this.turnStates.get(key) ?? {
196
+ lastSequence: 0,
197
+ assembledText: "",
198
+ sentText: "",
199
+ lastEventAt: null,
200
+ completed: false,
201
+ finalFlushed: false
202
+ };
203
+ state.sentText += draft.text;
204
+ this.turnStates.set(key, state);
205
+ }
122
206
  }
123
207
  function isRecoverableTurnError(error) {
124
208
  return error instanceof DesktopDriverError && error.reason === "reply_timeout";
@@ -141,3 +225,37 @@ function buildInboundFingerprint(message) {
141
225
  mediaFingerprint
142
226
  ].join("||");
143
227
  }
228
+ function buildTurnStateKey(sessionKey, turnId) {
229
+ return `${sessionKey}::${turnId}`;
230
+ }
231
+ function computePendingTurnText(sentText, fullText) {
232
+ if (!fullText) {
233
+ return "";
234
+ }
235
+ if (!sentText) {
236
+ return fullText;
237
+ }
238
+ if (fullText.startsWith(sentText)) {
239
+ return fullText.slice(sentText.length);
240
+ }
241
+ if (stripWhitespace(fullText) === stripWhitespace(sentText)) {
242
+ return "";
243
+ }
244
+ const overlap = findSuffixPrefixOverlap(sentText, fullText);
245
+ if (overlap > 0) {
246
+ return fullText.slice(overlap);
247
+ }
248
+ return fullText;
249
+ }
250
+ function findSuffixPrefixOverlap(previous, next) {
251
+ const maxLength = Math.min(previous.length, next.length);
252
+ for (let length = maxLength; length > 0; length -= 1) {
253
+ if (previous.slice(-length) === next.slice(0, length)) {
254
+ return length;
255
+ }
256
+ }
257
+ return 0;
258
+ }
259
+ function stripWhitespace(value) {
260
+ return value.replace(/\s+/g, "");
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-codex-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A bridge between QQ Official Bot and Codex Desktop, with media relay, STT, thread management, and incremental reply delivery.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/983033995/qq-codex-bridge",