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.
- package/dist/apps/bridge-daemon/src/bootstrap.js +30 -1
- package/dist/apps/bridge-daemon/src/http-server.js +29 -2
- package/dist/apps/bridge-daemon/src/main.js +25 -2
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +76 -3
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +358 -8
- package/dist/packages/domain/src/message.js +6 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +118 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|