@researai/deepscientist 1.5.0 → 1.5.1

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.
Files changed (163) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +19 -179
  3. package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
  4. package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
  5. package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
  6. package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
  7. package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
  8. package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
  9. package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
  10. package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
  11. package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
  12. package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
  13. package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
  14. package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
  15. package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
  16. package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
  17. package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
  18. package/bin/ds.js +233 -53
  19. package/docs/en/00_QUICK_START.md +134 -0
  20. package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
  21. package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
  22. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
  23. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
  24. package/docs/en/05_TUI_GUIDE.md +141 -0
  25. package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
  26. package/docs/en/07_MEMORY_AND_MCP.md +253 -0
  27. package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
  28. package/docs/en/09_DOCTOR.md +108 -0
  29. package/docs/en/90_ARCHITECTURE.md +245 -0
  30. package/docs/en/91_DEVELOPMENT.md +195 -0
  31. package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
  32. package/docs/zh/00_QUICK_START.md +134 -0
  33. package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
  34. package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
  35. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
  36. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
  37. package/docs/zh/05_TUI_GUIDE.md +128 -0
  38. package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
  39. package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
  40. package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
  41. package/docs/zh/09_DOCTOR.md +112 -0
  42. package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
  43. package/install.sh +32 -8
  44. package/package.json +4 -2
  45. package/pyproject.toml +1 -1
  46. package/src/deepscientist/artifact/guidance.py +9 -2
  47. package/src/deepscientist/artifact/service.py +482 -22
  48. package/src/deepscientist/bash_exec/monitor.py +27 -5
  49. package/src/deepscientist/bash_exec/runtime.py +639 -0
  50. package/src/deepscientist/bash_exec/service.py +99 -16
  51. package/src/deepscientist/bridges/base.py +3 -0
  52. package/src/deepscientist/bridges/connectors.py +292 -13
  53. package/src/deepscientist/channels/qq.py +19 -2
  54. package/src/deepscientist/channels/relay.py +1 -0
  55. package/src/deepscientist/cli.py +32 -25
  56. package/src/deepscientist/config/models.py +28 -2
  57. package/src/deepscientist/config/service.py +201 -6
  58. package/src/deepscientist/connector_runtime.py +2 -0
  59. package/src/deepscientist/daemon/api/handlers.py +50 -5
  60. package/src/deepscientist/daemon/api/router.py +1 -0
  61. package/src/deepscientist/daemon/app.py +442 -15
  62. package/src/deepscientist/doctor.py +444 -0
  63. package/src/deepscientist/home.py +1 -0
  64. package/src/deepscientist/latex_runtime.py +17 -4
  65. package/src/deepscientist/lingzhu_support.py +182 -0
  66. package/src/deepscientist/mcp/server.py +49 -2
  67. package/src/deepscientist/prompts/builder.py +181 -58
  68. package/src/deepscientist/quest/layout.py +1 -0
  69. package/src/deepscientist/quest/service.py +63 -2
  70. package/src/deepscientist/quest/stage_views.py +19 -1
  71. package/src/deepscientist/runtime_tools/__init__.py +16 -0
  72. package/src/deepscientist/runtime_tools/builtins.py +19 -0
  73. package/src/deepscientist/runtime_tools/models.py +29 -0
  74. package/src/deepscientist/runtime_tools/registry.py +40 -0
  75. package/src/deepscientist/runtime_tools/service.py +59 -0
  76. package/src/deepscientist/runtime_tools/tinytex.py +25 -0
  77. package/src/deepscientist/tinytex.py +276 -0
  78. package/src/prompts/connectors/lingzhu.md +12 -0
  79. package/src/prompts/connectors/qq.md +121 -0
  80. package/src/prompts/system.md +177 -33
  81. package/src/skills/analysis-campaign/SKILL.md +22 -6
  82. package/src/skills/baseline/SKILL.md +5 -4
  83. package/src/skills/decision/SKILL.md +4 -3
  84. package/src/skills/experiment/SKILL.md +5 -4
  85. package/src/skills/finalize/SKILL.md +5 -4
  86. package/src/skills/idea/SKILL.md +5 -4
  87. package/src/skills/intake-audit/SKILL.md +277 -0
  88. package/src/skills/intake-audit/references/state-audit-template.md +41 -0
  89. package/src/skills/rebuttal/SKILL.md +407 -0
  90. package/src/skills/rebuttal/references/action-plan-template.md +63 -0
  91. package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
  92. package/src/skills/rebuttal/references/response-letter-template.md +113 -0
  93. package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
  94. package/src/skills/review/SKILL.md +293 -0
  95. package/src/skills/review/references/experiment-todo-template.md +29 -0
  96. package/src/skills/review/references/review-report-template.md +83 -0
  97. package/src/skills/review/references/revision-log-template.md +40 -0
  98. package/src/skills/scout/SKILL.md +5 -4
  99. package/src/skills/write/SKILL.md +7 -3
  100. package/src/tui/dist/components/WelcomePanel.js +17 -43
  101. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
  102. package/src/tui/package.json +1 -1
  103. package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-w5lF2Ttt.js} +109 -575
  104. package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-DJOED79I.js} +1 -1
  105. package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-DaG61Y0M.js} +63 -8
  106. package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CV4LqUB_.js} +43 -609
  107. package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DylfAea4.js} +8 -8
  108. package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-F7saY0LM.js} +5 -5
  109. package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-COP0c7jf.js} +3 -3
  110. package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-CAS05pT9.js} +1 -1
  111. package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-Bco1CN_w.js} +5 -6
  112. package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-CvMlCD99.js} +12 -15
  113. package/src/ui/dist/assets/LabPlugin-BYankkE4.js +2676 -0
  114. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +2698 -0
  115. package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-LDSMR-t-.js} +16 -16
  116. package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-B7o80jgm.js} +4 -4
  117. package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-CM6ZOcpC.js} +3 -3
  118. package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-Dc61cXmK.js} +3 -3
  119. package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-DWowuQwx.js} +1 -1
  120. package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-BsJM1q_a.js} +3 -3
  121. package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-DB2eEEFQ.js} +10 -10
  122. package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-CraThSvt.js} +1 -1
  123. package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-CgocRTPq.js} +1 -1
  124. package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-B1JGhKtd.js} +4 -4
  125. package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-CclFC7FM.js} +9 -10
  126. package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-D3IKsMl7.js} +1 -1
  127. package/src/ui/dist/assets/{code-BnBeNxBc.js → code-BP37Xx0p.js} +1 -1
  128. package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-BAJSu-9r.js} +1 -1
  129. package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-DUGeCTuy.js} +1 -1
  130. package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CXc1Ojf7.js} +1 -1
  131. package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-2J21jt7M.js} +1 -1
  132. package/src/ui/dist/assets/{image-Boe6ffhu.js → image-CMMmgvcn.js} +1 -1
  133. package/src/ui/dist/assets/{index-BlplpvE1.js → index-BaVumsQT.js} +2 -2
  134. package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-CWgMgpow.js} +60 -2154
  135. package/src/ui/dist/assets/{index-DO43pFZP.js → index-DmwmJmbW.js} +6372 -8434
  136. package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-KGt-z-dD.css} +225 -2920
  137. package/src/ui/dist/assets/{index-2Zf65FZt.js → index-s7aHnNQ4.js} +1 -1
  138. package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-CQRfX0Am.js} +1 -1
  139. package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-B4TbdsrF.js} +1 -1
  140. package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-B8Rokodk.js} +1 -1
  141. package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-D_i96KH4.js} +2 -8
  142. package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-D12PnzCN.js} +1 -1
  143. package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-B6YrI4aJ.js} +1 -1
  144. package/src/ui/dist/assets/trash-Bc8jGp0V.js +32 -0
  145. package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-mXVCYSZ-.js} +12 -42
  146. package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-Bg6b9H9K.js} +1 -1
  147. package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Drh5GEnL.js} +1 -1
  148. package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-CJj9DZLn.js} +1 -1
  149. package/src/ui/dist/index.html +2 -2
  150. package/assets/fonts/Inter-Variable.ttf +0 -0
  151. package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
  152. package/assets/fonts/NunitoSans-Variable.ttf +0 -0
  153. package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
  154. package/assets/fonts/SourceSans3-Variable.ttf +0 -0
  155. package/assets/fonts/ds-fonts.css +0 -83
  156. package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
  157. package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
  158. package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
  159. package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
  160. package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
  161. package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
  162. package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
  163. package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
@@ -0,0 +1,1133 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { LingzhuConfig, LingzhuContext, LingzhuRequest, LingzhuSSEData } from "./types.js";
3
+ import crypto from "node:crypto";
4
+ import dns from "node:dns/promises";
5
+ import http from "node:http";
6
+ import https from "node:https";
7
+ import { createWriteStream, promises as fs } from "node:fs";
8
+ import net from "node:net";
9
+ import path from "node:path";
10
+ import tls from "node:tls";
11
+ import { fileURLToPath } from "node:url";
12
+ import {
13
+ createFollowUpResponse,
14
+ detectIntentFromText,
15
+ extractFollowUpFromText,
16
+ formatLingzhuSSE,
17
+ lingzhuToOpenAI,
18
+ parseToolCallFromAccumulated,
19
+ ToolCallAccumulator,
20
+ } from "./transform.js";
21
+ import { buildRequestLogName, summarizeForDebug, writeDebugLog } from "./debug-log.js";
22
+ import { cleanupImageCacheIfNeeded, ensureImageCacheDir } from "./image-cache.js";
23
+ import { lingzhuEventBus } from "./events.js";
24
+
25
+ interface LingzhuRuntimeState {
26
+ config: LingzhuConfig;
27
+ authAk: string;
28
+ gatewayPort: number;
29
+ chatCompletionsEnabled?: boolean;
30
+ }
31
+
32
+ interface ValidatedRemoteImageUrl {
33
+ url: URL;
34
+ address: string;
35
+ family: number;
36
+ }
37
+
38
+ const REMOTE_IMAGE_PROTOCOLS = new Set(["http:", "https:"]);
39
+ const REMOTE_IMAGE_TIMEOUT_MS = 15000;
40
+
41
+ function resolveMaxImageBytes(config: LingzhuConfig): number {
42
+ if (typeof config.maxImageBytes === "number" && Number.isFinite(config.maxImageBytes)) {
43
+ return Math.max(256 * 1024, Math.min(20 * 1024 * 1024, Math.trunc(config.maxImageBytes)));
44
+ }
45
+
46
+ return 5 * 1024 * 1024;
47
+ }
48
+
49
+ function normalizeContext(metadata: LingzhuRequest["metadata"]): LingzhuContext | undefined {
50
+ if (!metadata || typeof metadata !== "object") {
51
+ return undefined;
52
+ }
53
+
54
+ if ("context" in metadata && metadata.context && typeof metadata.context === "object") {
55
+ return metadata.context as LingzhuContext;
56
+ }
57
+
58
+ return metadata as LingzhuContext;
59
+ }
60
+
61
+ function extractFallbackUserText(messages: LingzhuRequest["message"]): string {
62
+ return messages
63
+ .map((message) => message.text || message.content || "")
64
+ .filter(Boolean)
65
+ .join(" ")
66
+ .trim();
67
+ }
68
+
69
+ function prefersChinese(body: LingzhuRequest, context?: LingzhuContext): boolean {
70
+ const language = String(context?.lang || "").trim().toLowerCase();
71
+ if (language.startsWith("zh")) {
72
+ return true;
73
+ }
74
+ if (language.startsWith("en")) {
75
+ return false;
76
+ }
77
+ const fallbackText = extractFallbackUserText(body.message);
78
+ return /[\u3400-\u9fff]/.test(fallbackText);
79
+ }
80
+
81
+ function autoReceiptAckText(chinese: boolean): string {
82
+ return chinese
83
+ ? "已收到,我正在处理您的请求。"
84
+ : "Received. I’m processing your request now.";
85
+ }
86
+
87
+ function visibleProgressHeartbeatText(chinese: boolean): string {
88
+ return chinese
89
+ ? "仍在处理中,请稍候,我会继续返回结果。"
90
+ : "Still working on it. Please wait; I’ll keep streaming updates.";
91
+ }
92
+
93
+ function buildSessionKey(config: LingzhuConfig, body: LingzhuRequest): string {
94
+ const namespace = config.sessionNamespace || "lingzhu";
95
+ const targetAgentId = config.agentId || body.agent_id || "main";
96
+ const userId = body.user_id || body.agent_id || "anonymous";
97
+
98
+ switch (config.sessionMode) {
99
+ case "shared_agent":
100
+ return `agent:${targetAgentId}:${namespace}_shared`;
101
+ case "per_message":
102
+ return `agent:${targetAgentId}:${namespace}_${body.message_id}`;
103
+ case "per_user":
104
+ default:
105
+ return `agent:${targetAgentId}:${namespace}_${userId}`;
106
+ }
107
+ }
108
+
109
+ function verifyAuth(
110
+ authHeader: string | string[] | undefined,
111
+ expectedAk: string
112
+ ): boolean {
113
+ if (!expectedAk) {
114
+ return true;
115
+ }
116
+
117
+ const header = Array.isArray(authHeader) ? authHeader[0] : authHeader;
118
+ if (!header) {
119
+ return false;
120
+ }
121
+
122
+ const match = header.match(/^Bearer\s+(.+)$/i);
123
+ if (!match) {
124
+ return false;
125
+ }
126
+
127
+ return match[1].trim() === expectedAk;
128
+ }
129
+
130
+ async function readJsonBody(req: IncomingMessage, maxBytes = 1024 * 1024): Promise<unknown> {
131
+ const chunks: Buffer[] = [];
132
+ let totalBytes = 0;
133
+
134
+ return new Promise((resolve, reject) => {
135
+ req.on("data", (chunk: Buffer) => {
136
+ totalBytes += chunk.length;
137
+ if (totalBytes > maxBytes) {
138
+ reject(new Error(`Request body too large (>${maxBytes} bytes)`));
139
+ req.destroy();
140
+ return;
141
+ }
142
+ chunks.push(chunk);
143
+ });
144
+
145
+ req.on("end", () => {
146
+ try {
147
+ const body = Buffer.concat(chunks).toString("utf-8");
148
+ resolve(body ? JSON.parse(body) : {});
149
+ } catch (error) {
150
+ reject(error);
151
+ }
152
+ });
153
+
154
+ req.on("error", reject);
155
+ });
156
+ }
157
+
158
+ function readHeaderValue(value: string | string[] | undefined): string {
159
+ if (Array.isArray(value)) {
160
+ return value[0] ?? "";
161
+ }
162
+
163
+ return value ?? "";
164
+ }
165
+
166
+ async function downloadImageToFile(imageUrl: string, maxBytes: number): Promise<string | null> {
167
+ try {
168
+ const validatedUrl = await validateRemoteImageUrl(imageUrl);
169
+ if (!validatedUrl) {
170
+ return null;
171
+ }
172
+
173
+ const response = await requestValidatedRemoteImage(validatedUrl);
174
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
175
+ response.resume();
176
+ return null;
177
+ }
178
+
179
+ const contentLength = Number(readHeaderValue(response.headers["content-length"]) || "0");
180
+ if (contentLength > maxBytes) {
181
+ response.resume();
182
+ return null;
183
+ }
184
+
185
+ const contentType = readHeaderValue(response.headers["content-type"]).toLowerCase();
186
+ if (contentType && !contentType.startsWith("image/")) {
187
+ response.resume();
188
+ return null;
189
+ }
190
+
191
+ const ext = contentType.includes("png")
192
+ ? ".png"
193
+ : contentType.includes("jpeg") || contentType.includes("jpg")
194
+ ? ".jpg"
195
+ : contentType.includes("gif")
196
+ ? ".gif"
197
+ : contentType.includes("webp")
198
+ ? ".webp"
199
+ : ".img";
200
+
201
+ const cacheDir = await ensureImageCacheDir();
202
+ const hash = crypto.createHash("md5").update(imageUrl).digest("hex").slice(0, 12);
203
+ const fileName = `img_${Date.now()}_${hash}${ext}`;
204
+ const filePath = path.join(cacheDir, fileName);
205
+ const fileStream = createWriteStream(filePath, { flags: "wx" });
206
+ let totalBytes = 0;
207
+ let completed = false;
208
+
209
+ try {
210
+ await new Promise<void>((resolve, reject) => {
211
+ let settled = false;
212
+
213
+ const fail = (error: Error) => {
214
+ if (settled) {
215
+ return;
216
+ }
217
+ settled = true;
218
+ response.destroy();
219
+ fileStream.destroy();
220
+ reject(error);
221
+ };
222
+
223
+ response.on("data", (chunk: Buffer) => {
224
+ totalBytes += chunk.length;
225
+ if (totalBytes > maxBytes) {
226
+ fail(new Error("image exceeds size limit"));
227
+ return;
228
+ }
229
+
230
+ if (!fileStream.write(chunk)) {
231
+ response.pause();
232
+ fileStream.once("drain", () => response.resume());
233
+ }
234
+ });
235
+
236
+ response.on("end", () => {
237
+ if (settled) {
238
+ return;
239
+ }
240
+ fileStream.end(() => {
241
+ settled = true;
242
+ resolve();
243
+ });
244
+ });
245
+
246
+ response.on("error", (error) => fail(error instanceof Error ? error : new Error(String(error))));
247
+ fileStream.on("error", (error) => fail(error instanceof Error ? error : new Error(String(error))));
248
+ });
249
+ completed = true;
250
+ } finally {
251
+ if (!completed) {
252
+ fileStream.destroy();
253
+ await fs.unlink(filePath).catch(() => undefined);
254
+ }
255
+ }
256
+
257
+ return `file://${filePath}`;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ async function saveDataUrlToFile(dataUrl: string, maxBytes: number): Promise<string | null> {
264
+ const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
265
+ if (!match) {
266
+ return null;
267
+ }
268
+
269
+ const mimeType = match[1].toLowerCase();
270
+ const payload = match[2].replace(/\s+/g, "");
271
+ if (estimateBase64DecodedBytes(payload) > maxBytes) {
272
+ return null;
273
+ }
274
+
275
+ const buffer = Buffer.from(payload, "base64");
276
+ if (buffer.length > maxBytes) {
277
+ return null;
278
+ }
279
+
280
+ const ext = mimeType.includes("png")
281
+ ? ".png"
282
+ : mimeType.includes("jpeg") || mimeType.includes("jpg")
283
+ ? ".jpg"
284
+ : mimeType.includes("gif")
285
+ ? ".gif"
286
+ : mimeType.includes("webp")
287
+ ? ".webp"
288
+ : ".img";
289
+
290
+ const cacheDir = await ensureImageCacheDir();
291
+ const hash = crypto.createHash("md5").update(payload).digest("hex").slice(0, 12);
292
+ const fileName = `img_${Date.now()}_${hash}${ext}`;
293
+ const filePath = path.join(cacheDir, fileName);
294
+ await fs.writeFile(filePath, buffer);
295
+ return `file://${filePath}`;
296
+ }
297
+
298
+ function estimateBase64DecodedBytes(payload: string): number {
299
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
300
+ return Math.floor((payload.length * 3) / 4) - padding;
301
+ }
302
+
303
+ function isPrivateIpv4(address: string): boolean {
304
+ const parts = address.split(".").map((part) => Number(part));
305
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
306
+ return true;
307
+ }
308
+
309
+ const [a, b] = parts;
310
+ return a === 0
311
+ || a === 10
312
+ || a === 127
313
+ || (a === 169 && b === 254)
314
+ || (a === 172 && b >= 16 && b <= 31)
315
+ || (a === 192 && b === 168);
316
+ }
317
+
318
+ function isPrivateIpv6(address: string): boolean {
319
+ const normalized = address.toLowerCase();
320
+ return normalized === "::1"
321
+ || normalized === "::"
322
+ || normalized.startsWith("fc")
323
+ || normalized.startsWith("fd")
324
+ || normalized.startsWith("fe8")
325
+ || normalized.startsWith("fe9")
326
+ || normalized.startsWith("fea")
327
+ || normalized.startsWith("feb");
328
+ }
329
+
330
+ function isPrivateAddress(address: string): boolean {
331
+ const ipVersion = net.isIP(address);
332
+ if (ipVersion === 4) {
333
+ return isPrivateIpv4(address);
334
+ }
335
+ if (ipVersion === 6) {
336
+ return isPrivateIpv6(address);
337
+ }
338
+ return false;
339
+ }
340
+
341
+ async function validateRemoteImageUrl(imageUrl: string): Promise<ValidatedRemoteImageUrl | null> {
342
+ let parsedUrl: URL;
343
+
344
+ try {
345
+ parsedUrl = new URL(imageUrl);
346
+ } catch {
347
+ return null;
348
+ }
349
+
350
+ if (!REMOTE_IMAGE_PROTOCOLS.has(parsedUrl.protocol)) {
351
+ return null;
352
+ }
353
+
354
+ if (parsedUrl.username || parsedUrl.password) {
355
+ return null;
356
+ }
357
+
358
+ const hostname = parsedUrl.hostname.toLowerCase();
359
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || isPrivateAddress(hostname)) {
360
+ return null;
361
+ }
362
+
363
+ try {
364
+ const resolved = await dns.lookup(parsedUrl.hostname, { all: true, verbatim: true });
365
+ const safeEntry = resolved.find((entry) => !isPrivateAddress(entry.address));
366
+ if (!safeEntry || resolved.some((entry) => isPrivateAddress(entry.address))) {
367
+ return null;
368
+ }
369
+
370
+ return {
371
+ url: parsedUrl,
372
+ address: safeEntry.address,
373
+ family: safeEntry.family,
374
+ };
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ async function requestValidatedRemoteImage(target: ValidatedRemoteImageUrl): Promise<IncomingMessage> {
381
+ const client = target.url.protocol === "https:" ? https : http;
382
+ const defaultPort = target.url.protocol === "https:" ? 443 : 80;
383
+ const port = target.url.port ? Number(target.url.port) : defaultPort;
384
+ const hostHeader = target.url.port ? `${target.url.hostname}:${target.url.port}` : target.url.hostname;
385
+
386
+ return new Promise<IncomingMessage>((resolve, reject) => {
387
+ const request = client.request(
388
+ {
389
+ protocol: target.url.protocol,
390
+ host: target.address,
391
+ port,
392
+ method: "GET",
393
+ path: `${target.url.pathname}${target.url.search}`,
394
+ headers: {
395
+ Host: hostHeader,
396
+ "User-Agent": "openclaw-lingzhu/1.0",
397
+ },
398
+ family: target.family,
399
+ servername: target.url.protocol === "https:" ? target.url.hostname : undefined,
400
+ lookup: (_hostname, _options, callback) => {
401
+ callback(null, target.address, target.family);
402
+ },
403
+ checkServerIdentity:
404
+ target.url.protocol === "https:"
405
+ ? (_hostname, cert) => tls.checkServerIdentity(target.url.hostname, cert)
406
+ : undefined,
407
+ },
408
+ (response) => {
409
+ const statusCode = response.statusCode ?? 0;
410
+ if (statusCode >= 300 && statusCode < 400) {
411
+ response.resume();
412
+ reject(new Error("redirect not allowed"));
413
+ return;
414
+ }
415
+ resolve(response);
416
+ }
417
+ );
418
+
419
+ request.setTimeout(REMOTE_IMAGE_TIMEOUT_MS, () => {
420
+ request.destroy(new Error("remote image timeout"));
421
+ });
422
+ request.on("error", reject);
423
+ request.end();
424
+ });
425
+ }
426
+
427
+ function isPathWithinDirectory(filePath: string, parentDir: string): boolean {
428
+ const relative = path.relative(parentDir, path.resolve(filePath));
429
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
430
+ }
431
+
432
+ async function resolveTrustedFileUrl(fileUrl: string): Promise<string | null> {
433
+ try {
434
+ const cacheDir = await ensureImageCacheDir();
435
+ const localPath = fileURLToPath(fileUrl);
436
+ return isPathWithinDirectory(localPath, cacheDir) ? localPath : null;
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+
442
+ async function preprocessOpenAIMessages(
443
+ messages: Array<{
444
+ role: "system" | "user" | "assistant";
445
+ content: string | Array<{ type: string; image_url?: { url: string }; text?: string }>;
446
+ }>,
447
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
448
+ maxImageBytes: number
449
+ ): Promise<Array<{ role: "system" | "user" | "assistant"; content: string }>> {
450
+ const result: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
451
+
452
+ for (const msg of messages) {
453
+ if (typeof msg.content === "string") {
454
+ result.push({ role: msg.role, content: msg.content });
455
+ continue;
456
+ }
457
+
458
+ if (!Array.isArray(msg.content)) {
459
+ result.push({ role: msg.role, content: String(msg.content) });
460
+ continue;
461
+ }
462
+
463
+ const textParts: string[] = [];
464
+ const imagePaths: string[] = [];
465
+
466
+ for (const part of msg.content) {
467
+ if (part.type === "text" && part.text) {
468
+ textParts.push(part.text);
469
+ } else if (part.type === "image_url" && part.image_url?.url) {
470
+ const imagePartUrl = part.image_url.url;
471
+
472
+ if (imagePartUrl.startsWith("file://")) {
473
+ const localPath = await resolveTrustedFileUrl(imagePartUrl);
474
+ if (localPath) {
475
+ imagePaths.push(localPath);
476
+ } else {
477
+ logger.warn("[Lingzhu] 已拒绝非缓存目录 file URL");
478
+ }
479
+ } else if (imagePartUrl.startsWith("data:")) {
480
+ const fileUrl = await saveDataUrlToFile(imagePartUrl, maxImageBytes);
481
+ if (fileUrl) {
482
+ imagePaths.push(fileUrl.replace("file://", ""));
483
+ logger.info("[Lingzhu] data URL 图片已保存到本地缓存");
484
+ } else {
485
+ logger.warn("[Lingzhu] data URL 图片处理失败或超出大小限制");
486
+ }
487
+ } else {
488
+ logger.info(`[Lingzhu] 正在下载图片到本地: ${imagePartUrl.substring(0, 80)}...`);
489
+ const fileUrl = await downloadImageToFile(imagePartUrl, maxImageBytes);
490
+ if (fileUrl) {
491
+ imagePaths.push(fileUrl.replace("file://", ""));
492
+ logger.info(`[Lingzhu] 图片已保存到: ${fileUrl}`);
493
+ } else {
494
+ logger.warn(`[Lingzhu] 图片下载失败或地址被拒绝: ${imagePartUrl}`);
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ let finalContent = textParts.join("\n");
501
+
502
+ if (imagePaths.length > 0) {
503
+ const imageRefs = imagePaths.map((imagePath) => `[图片: ${imagePath}]`).join("\n");
504
+ if (finalContent) {
505
+ finalContent = `${finalContent}\n\n${imageRefs}`;
506
+ } else {
507
+ finalContent = `读取这个图片\n\n${imageRefs},请根据执行的对话内容进行回答`;
508
+ logger.info("[Lingzhu] 为纯图片消息添加了占位文本");
509
+ }
510
+ }
511
+
512
+ if (finalContent) {
513
+ result.push({ role: msg.role, content: finalContent });
514
+ }
515
+ }
516
+
517
+ return result;
518
+ }
519
+
520
+ export function createHttpHandler(api: any, getRuntimeState: () => LingzhuRuntimeState) {
521
+ return async function handler(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
522
+ const url = new URL(req.url ?? "/", "http://localhost");
523
+
524
+ if (url.pathname === "/metis/agent/api/health" && req.method === "GET") {
525
+ const state = getRuntimeState();
526
+ res.statusCode = 200;
527
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
528
+ res.end(
529
+ JSON.stringify({
530
+ ok: true,
531
+ endpoint: "/metis/agent/api/sse",
532
+ enabled: state.config.enabled !== false,
533
+ agentId: state.config.agentId || "main",
534
+ supportedCommands:
535
+ state.config.enableExperimentalNativeActions === true
536
+ ? [
537
+ "take_photo",
538
+ "take_navigation",
539
+ "control_calendar",
540
+ "notify_agent_off",
541
+ "send_notification",
542
+ "send_toast",
543
+ "speak_tts",
544
+ "start_video_record",
545
+ "stop_video_record",
546
+ "open_custom_view",
547
+ ]
548
+ : ["take_photo", "take_navigation", "control_calendar", "notify_agent_off"],
549
+ followUpEnabled: state.config.enableFollowUp !== false,
550
+ sessionMode: state.config.sessionMode || "per_user",
551
+ debugLogging: state.config.debugLogging === true,
552
+ experimentalNativeActions: state.config.enableExperimentalNativeActions === true,
553
+ chatCompletionsEnabled: state.chatCompletionsEnabled === true,
554
+ })
555
+ );
556
+ return true;
557
+ }
558
+
559
+ if (url.pathname !== "/metis/agent/api/sse") {
560
+ return false;
561
+ }
562
+
563
+ if (req.method !== "POST") {
564
+ res.statusCode = 405;
565
+ res.end("Method Not Allowed");
566
+ return true;
567
+ }
568
+
569
+ const logger = api.logger;
570
+ const state = getRuntimeState();
571
+ const config = state.config;
572
+ const upstreamController = new AbortController();
573
+ let keepaliveInterval: NodeJS.Timeout | undefined;
574
+
575
+ const stopKeepalive = () => {
576
+ if (keepaliveInterval) {
577
+ clearInterval(keepaliveInterval);
578
+ keepaliveInterval = undefined;
579
+ }
580
+ };
581
+
582
+ const safeWrite = (payload: string): boolean => {
583
+ if (res.writableEnded || res.destroyed) {
584
+ return false;
585
+ }
586
+
587
+ try {
588
+ res.write(payload);
589
+ return true;
590
+ } catch {
591
+ return false;
592
+ }
593
+ };
594
+
595
+ let lastVisibleEventAt = Date.now();
596
+ const safeWriteLingzhuMessage = (payload: LingzhuSSEData): boolean => {
597
+ const ok = safeWrite(formatLingzhuSSE("message", payload));
598
+ if (ok) {
599
+ lastVisibleEventAt = Date.now();
600
+ }
601
+ return ok;
602
+ };
603
+
604
+ const abortUpstream = (reason: string) => {
605
+ stopKeepalive();
606
+ if (!upstreamController.signal.aborted) {
607
+ upstreamController.abort(reason);
608
+ }
609
+ };
610
+
611
+ req.on("aborted", () => abortUpstream("Client disconnected"));
612
+ res.on("close", () => {
613
+ if (!res.writableEnded) {
614
+ abortUpstream("Client disconnected");
615
+ }
616
+ });
617
+
618
+ if (config.enabled === false) {
619
+ res.statusCode = 503;
620
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
621
+ res.end(JSON.stringify({ error: "Lingzhu plugin is disabled" }));
622
+ return true;
623
+ }
624
+
625
+ const authHeader = req.headers.authorization;
626
+ if (!verifyAuth(authHeader, state.authAk || "")) {
627
+ logger.warn("[Lingzhu] Unauthorized request");
628
+ res.statusCode = 401;
629
+ res.setHeader("Content-Type", "application/json");
630
+ res.end(JSON.stringify({ error: "Unauthorized" }));
631
+ return true;
632
+ }
633
+
634
+ let requestMessageId = "unknown";
635
+ let requestAgentId = "unknown";
636
+ let nativeToolListener: ((eventData: any) => void) | undefined;
637
+ let nativeToolInvoked = false;
638
+
639
+ try {
640
+ const body = (await readJsonBody(req)) as LingzhuRequest | undefined;
641
+ if (!body || !body.message_id || !body.agent_id || !Array.isArray(body.message)) {
642
+ res.statusCode = 400;
643
+ res.setHeader("Content-Type", "application/json");
644
+ res.end(JSON.stringify({ error: "Missing required fields: message_id, agent_id, message" }));
645
+ return true;
646
+ }
647
+
648
+ requestMessageId = body.message_id;
649
+ requestAgentId = body.agent_id;
650
+ const includePayload = config.debugLogPayloads === true;
651
+
652
+ writeDebugLog(
653
+ config,
654
+ buildRequestLogName(body.message_id, "request.in"),
655
+ {
656
+ headers: req.headers,
657
+ body: summarizeForDebug(body, includePayload),
658
+ }
659
+ );
660
+
661
+ logger.info(
662
+ `[Lingzhu] Request: message_id=${body.message_id}, agent_id=${body.agent_id}, messages=${body.message.length}`
663
+ );
664
+
665
+ res.setHeader("Content-Type", "text/event-stream");
666
+ res.setHeader("Cache-Control", "no-cache");
667
+ res.setHeader("Connection", "keep-alive");
668
+ res.setHeader("X-Accel-Buffering", "no");
669
+ if (typeof res.flushHeaders === "function") {
670
+ res.flushHeaders();
671
+ }
672
+
673
+ const requestContext = normalizeContext(body.metadata);
674
+ const chinese = prefersChinese(body, requestContext);
675
+ const visibleProgressHeartbeatSec =
676
+ typeof config.visibleProgressHeartbeatSec === "number"
677
+ ? Math.max(5, Math.min(120, Math.trunc(config.visibleProgressHeartbeatSec)))
678
+ : 10;
679
+ safeWrite(": keepalive\n\n");
680
+ if (config.autoReceiptAck !== false) {
681
+ const receiptAckData: LingzhuSSEData = {
682
+ role: "agent",
683
+ type: "answer",
684
+ answer_stream: autoReceiptAckText(chinese),
685
+ message_id: body.message_id,
686
+ agent_id: body.agent_id,
687
+ is_finish: false,
688
+ };
689
+ writeDebugLog(
690
+ config,
691
+ buildRequestLogName(body.message_id, "response.auto_receipt_ack"),
692
+ summarizeForDebug(receiptAckData, includePayload)
693
+ );
694
+ safeWriteLingzhuMessage(receiptAckData);
695
+ }
696
+ keepaliveInterval = setInterval(() => {
697
+ if (!safeWrite(": keepalive\n\n")) {
698
+ stopKeepalive();
699
+ return;
700
+ }
701
+ if (
702
+ config.visibleProgressHeartbeat !== false
703
+ && Date.now() - lastVisibleEventAt >= visibleProgressHeartbeatSec * 1000
704
+ ) {
705
+ const progressData: LingzhuSSEData = {
706
+ role: "agent",
707
+ type: "answer",
708
+ answer_stream: visibleProgressHeartbeatText(chinese),
709
+ message_id: body.message_id,
710
+ agent_id: body.agent_id,
711
+ is_finish: false,
712
+ };
713
+ writeDebugLog(
714
+ config,
715
+ buildRequestLogName(body.message_id, "response.visible_progress_heartbeat"),
716
+ summarizeForDebug(progressData, includePayload)
717
+ );
718
+ safeWriteLingzhuMessage(progressData);
719
+ }
720
+ }, 7000);
721
+
722
+ const includeMetadata = config.includeMetadata !== false;
723
+ const maxImageBytes = resolveMaxImageBytes(config);
724
+ void cleanupImageCacheIfNeeded().catch((error) => {
725
+ logger.warn(`[Lingzhu] 图片缓存清理失败: ${error instanceof Error ? error.message : String(error)}`);
726
+ });
727
+
728
+ const context = includeMetadata ? requestContext : undefined;
729
+ let openaiMessages = lingzhuToOpenAI(body.message, context, {
730
+ systemPrompt: config.systemPrompt,
731
+ defaultNavigationMode: config.defaultNavigationMode,
732
+ enableExperimentalNativeActions: config.enableExperimentalNativeActions,
733
+ });
734
+
735
+ openaiMessages = await preprocessOpenAIMessages(openaiMessages as any, logger, maxImageBytes);
736
+ const hasUserMsg = openaiMessages.some((message) => message.role === "user");
737
+ if (!hasUserMsg) {
738
+ const fallbackText = extractFallbackUserText(body.message) || "你好";
739
+ openaiMessages.push({ role: "user", content: fallbackText });
740
+ logger.warn(`[Lingzhu] No user message after transform, fallback=${fallbackText}`);
741
+ }
742
+
743
+ logger.info(
744
+ `[Lingzhu] includeMetadata=${includeMetadata}, openaiMessages=${openaiMessages.length}, maxImageBytes=${maxImageBytes}`
745
+ );
746
+
747
+ const sessionKey = buildSessionKey(config, body);
748
+ const targetAgentId = config.agentId || body.agent_id || "main";
749
+ const gatewayPort = api.config?.gateway?.port ?? state.gatewayPort ?? 18789;
750
+ const gatewayToken = api.config?.gateway?.auth?.token;
751
+
752
+ nativeToolListener = (eventData: any) => {
753
+ logger.info(`[Lingzhu:NativeEvent] Received native_invoke event: ${JSON.stringify(eventData)}`);
754
+ logger.info(`[Lingzhu:NativeEvent] Current sessionKey=${sessionKey}, targetAgentId=${targetAgentId}`);
755
+
756
+ if (eventData.sessionKey && eventData.sessionKey !== sessionKey) {
757
+ logger.warn(`[Lingzhu:NativeEvent] Filtered out! Event sessionKey (${eventData.sessionKey}) != current (${sessionKey})`);
758
+ return;
759
+ }
760
+ if (!eventData.sessionKey && eventData.agentId && eventData.agentId !== targetAgentId) {
761
+ logger.warn(`[Lingzhu:NativeEvent] Filtered out by agentId! Event agentId (${eventData.agentId}) != target (${targetAgentId})`);
762
+ return;
763
+ }
764
+
765
+ logger.info(`[Lingzhu:NativeEvent] Match successful! Firing SSE tool data...`);
766
+ nativeToolInvoked = true;
767
+
768
+ const toolData: LingzhuSSEData = {
769
+ role: "agent",
770
+ type: "tool_call",
771
+ message_id: body.message_id,
772
+ agent_id: body.agent_id,
773
+ is_finish: false, // 重要:不要过早发送 is_finish: true,避免 Lingzhu 代理粗暴断流
774
+ tool_call: eventData.tool_call,
775
+ };
776
+
777
+ writeDebugLog(
778
+ config,
779
+ buildRequestLogName(body.message_id, "response.native_tool_call"),
780
+ summarizeForDebug(toolData, includePayload)
781
+ );
782
+
783
+ const sseFormatted = formatLingzhuSSE("message", toolData);
784
+ logger.info(`[Lingzhu:DEBUG] Sending Native SSE >> ${sseFormatted.replace(/\n/g, '\\n')}`);
785
+ if (safeWrite(sseFormatted)) {
786
+ lastVisibleEventAt = Date.now();
787
+ }
788
+ };
789
+ lingzhuEventBus.on("native_invoke", nativeToolListener);
790
+
791
+ const openclawUrl = `http://127.0.0.1:${gatewayPort}/v1/chat/completions`;
792
+ const openclawBody = {
793
+ model: `openclaw:${targetAgentId}`,
794
+ stream: true,
795
+ messages: openaiMessages,
796
+ user: sessionKey,
797
+ client: "lingzhu",
798
+ platform: "lingzhu",
799
+ // tools: lingzhuTools,
800
+ };
801
+
802
+ const headers: Record<string, string> = {
803
+ "Content-Type": "application/json",
804
+ "x-openclaw-agent-id": targetAgentId,
805
+ "x-openclaw-session-key": sessionKey,
806
+ "x-openclaw-message-channel": "lingzhu",
807
+ // "x-openclaw-client": "lingzhu",
808
+ // "x-openclaw-platform": "lingzhu",
809
+ };
810
+ if (gatewayToken) {
811
+ headers.Authorization = `Bearer ${gatewayToken}`;
812
+ }
813
+
814
+ writeDebugLog(
815
+ config,
816
+ buildRequestLogName(body.message_id, "openclaw.request"),
817
+ {
818
+ url: openclawUrl,
819
+ headers: summarizeForDebug(headers, includePayload),
820
+ body: summarizeForDebug(openclawBody, includePayload),
821
+ }
822
+ );
823
+
824
+ const timeoutMs =
825
+ typeof config.requestTimeoutMs === "number"
826
+ ? Math.max(5000, Math.min(300000, Math.trunc(config.requestTimeoutMs)))
827
+ : 60000;
828
+
829
+ logger.info(
830
+ `[Lingzhu] Calling OpenClaw: ${openclawUrl}, agentId=${targetAgentId}, sessionKey=${sessionKey}, timeout=${timeoutMs}ms`
831
+ );
832
+
833
+ const timeoutHandle = setTimeout(() => {
834
+ abortUpstream(`OpenClaw request timeout after ${timeoutMs}ms`);
835
+ }, timeoutMs);
836
+
837
+ let openclawResponse: Response;
838
+ try {
839
+ openclawResponse = await fetch(openclawUrl, {
840
+ method: "POST",
841
+ headers,
842
+ body: JSON.stringify(openclawBody),
843
+ signal: upstreamController.signal,
844
+ });
845
+ } catch (error) {
846
+ if (upstreamController.signal.aborted) {
847
+ throw new Error(String(upstreamController.signal.reason || `OpenClaw request timeout after ${timeoutMs}ms`));
848
+ }
849
+ throw error;
850
+ } finally {
851
+ clearTimeout(timeoutHandle);
852
+ }
853
+
854
+ if (!openclawResponse.ok) {
855
+ const errorText = await openclawResponse.text();
856
+ throw new Error(`OpenClaw API error: ${openclawResponse.status} - ${errorText}`);
857
+ }
858
+
859
+ let fullResponse = "";
860
+ const toolAccumulator = new ToolCallAccumulator();
861
+ const streamedToolCalls: LingzhuSSEData[] = [];
862
+ let streamedAnswer = false;
863
+ const reader = openclawResponse.body?.getReader();
864
+ if (!reader) {
865
+ throw new Error("No response body");
866
+ }
867
+
868
+ const decoder = new TextDecoder();
869
+ let buffer = "";
870
+
871
+ try {
872
+ while (true) {
873
+ const { done, value } = await reader.read();
874
+ if (done) {
875
+ break;
876
+ }
877
+
878
+ buffer += decoder.decode(value, { stream: true });
879
+ const lines = buffer.split("\n");
880
+ buffer = lines.pop() || "";
881
+
882
+ for (const line of lines) {
883
+ const trimmed = line.trim();
884
+ if (!trimmed.startsWith("data: ")) {
885
+ continue;
886
+ }
887
+
888
+ const data = trimmed.slice(6);
889
+ if (data === "[DONE]") {
890
+ continue;
891
+ }
892
+
893
+ try {
894
+ const chunk = JSON.parse(data);
895
+ const delta = chunk.choices?.[0]?.delta;
896
+ const finishReason = chunk.choices?.[0]?.finish_reason;
897
+
898
+ if (delta?.tool_calls) {
899
+ toolAccumulator.accumulate(delta.tool_calls);
900
+ }
901
+
902
+ writeDebugLog(
903
+ config,
904
+ buildRequestLogName(body.message_id, "openclaw.chunk"),
905
+ summarizeForDebug(chunk, includePayload)
906
+ );
907
+
908
+ if (delta?.content) {
909
+ fullResponse += delta.content;
910
+ streamedAnswer = true;
911
+ const answerChunkData: LingzhuSSEData = {
912
+ role: "agent",
913
+ type: "answer",
914
+ answer_stream: delta.content,
915
+ message_id: body.message_id,
916
+ agent_id: body.agent_id,
917
+ is_finish: false,
918
+ };
919
+ writeDebugLog(
920
+ config,
921
+ buildRequestLogName(body.message_id, "response.answer_chunk"),
922
+ summarizeForDebug(answerChunkData, includePayload)
923
+ );
924
+ safeWriteLingzhuMessage(answerChunkData);
925
+ }
926
+
927
+ if (finishReason === "tool_calls" || (finishReason && toolAccumulator.hasTools())) {
928
+ for (const tool of toolAccumulator.getCompleted()) {
929
+ const lingzhuToolCall = parseToolCallFromAccumulated(tool.name, tool.arguments, {
930
+ defaultNavigationMode: config.defaultNavigationMode,
931
+ enableExperimentalNativeActions: config.enableExperimentalNativeActions,
932
+ });
933
+
934
+ if (lingzhuToolCall) {
935
+ const toolData: LingzhuSSEData = {
936
+ role: "agent",
937
+ type: "tool_call",
938
+ message_id: body.message_id,
939
+ agent_id: body.agent_id,
940
+ is_finish: false, // 改为 false,配合结尾的 is_finish
941
+ tool_call: lingzhuToolCall,
942
+ };
943
+ writeDebugLog(
944
+ config,
945
+ buildRequestLogName(body.message_id, "response.tool_call"),
946
+ summarizeForDebug(toolData, includePayload)
947
+ );
948
+ streamedToolCalls.push(toolData);
949
+ }
950
+ }
951
+ }
952
+ } catch {
953
+ // Ignore chunk parse failures.
954
+ }
955
+ }
956
+ }
957
+ } finally {
958
+ stopKeepalive();
959
+ }
960
+
961
+ const hasToolCall = streamedToolCalls.length > 0 || nativeToolInvoked;
962
+
963
+ if (!nativeToolInvoked && streamedToolCalls.length > 0) {
964
+ for (const toolData of streamedToolCalls) {
965
+ const sseFormatted = formatLingzhuSSE("message", toolData);
966
+ logger.info(`[Lingzhu:DEBUG] Sending Streamed Tool SSE >> ${sseFormatted.replace(/\n/g, '\\n')}`);
967
+ if (safeWrite(sseFormatted)) {
968
+ lastVisibleEventAt = Date.now();
969
+ }
970
+ }
971
+ }
972
+
973
+ if (!hasToolCall && fullResponse) {
974
+ const detectedIntent = detectIntentFromText(fullResponse, {
975
+ defaultNavigationMode: config.defaultNavigationMode,
976
+ enableExperimentalNativeActions: config.enableExperimentalNativeActions,
977
+ });
978
+ if (detectedIntent) {
979
+ logger.info(`[Lingzhu] 从文本检测到意图: ${JSON.stringify(detectedIntent)}`);
980
+ const toolData: LingzhuSSEData = {
981
+ role: "agent",
982
+ type: "tool_call",
983
+ message_id: body.message_id,
984
+ agent_id: body.agent_id,
985
+ is_finish: false,
986
+ tool_call: detectedIntent,
987
+ };
988
+ const sseOutput = formatLingzhuSSE("message", toolData);
989
+ logger.info(`[Lingzhu:DEBUG] Sending Legacy Intent SSE >> ${sseOutput.replace(/\n/g, "\\n")}`);
990
+ writeDebugLog(
991
+ config,
992
+ buildRequestLogName(body.message_id, "response.intent_fallback"),
993
+ summarizeForDebug(toolData, includePayload)
994
+ );
995
+ if (safeWrite(sseOutput)) {
996
+ lastVisibleEventAt = Date.now();
997
+ }
998
+ }
999
+ } else if (!hasToolCall && streamedAnswer) {
1000
+ const finalAnswerData: LingzhuSSEData = {
1001
+ role: "agent",
1002
+ type: "answer",
1003
+ answer_stream: "",
1004
+ message_id: body.message_id,
1005
+ agent_id: body.agent_id,
1006
+ is_finish: true,
1007
+ };
1008
+ writeDebugLog(
1009
+ config,
1010
+ buildRequestLogName(body.message_id, "response.answer_done"),
1011
+ summarizeForDebug(finalAnswerData, includePayload)
1012
+ );
1013
+ safeWriteLingzhuMessage(finalAnswerData);
1014
+
1015
+ if (config.enableFollowUp !== false) {
1016
+ const followUps = extractFollowUpFromText(
1017
+ fullResponse,
1018
+ typeof config.followUpMaxCount === "number" ? config.followUpMaxCount : 3
1019
+ );
1020
+
1021
+ if (followUps && followUps.length > 0) {
1022
+ const followUpData = createFollowUpResponse(followUps, body.message_id, body.agent_id);
1023
+ writeDebugLog(
1024
+ config,
1025
+ buildRequestLogName(body.message_id, "response.follow_up"),
1026
+ summarizeForDebug(followUpData, includePayload)
1027
+ );
1028
+ safeWriteLingzhuMessage(followUpData);
1029
+ }
1030
+ }
1031
+ } else if (!hasToolCall && fullResponse) {
1032
+ const finalAnswerData: LingzhuSSEData = {
1033
+ role: "agent",
1034
+ type: "answer",
1035
+ answer_stream: fullResponse,
1036
+ message_id: body.message_id,
1037
+ agent_id: body.agent_id,
1038
+ is_finish: true,
1039
+ };
1040
+ writeDebugLog(
1041
+ config,
1042
+ buildRequestLogName(body.message_id, "response.final_answer"),
1043
+ summarizeForDebug(finalAnswerData, includePayload)
1044
+ );
1045
+ safeWriteLingzhuMessage(finalAnswerData);
1046
+
1047
+ if (config.enableFollowUp !== false) {
1048
+ const followUps = extractFollowUpFromText(
1049
+ fullResponse,
1050
+ typeof config.followUpMaxCount === "number" ? config.followUpMaxCount : 3
1051
+ );
1052
+
1053
+ if (followUps && followUps.length > 0) {
1054
+ const followUpData = createFollowUpResponse(followUps, body.message_id, body.agent_id);
1055
+ writeDebugLog(
1056
+ config,
1057
+ buildRequestLogName(body.message_id, "response.follow_up"),
1058
+ summarizeForDebug(followUpData, includePayload)
1059
+ );
1060
+ safeWriteLingzhuMessage(followUpData);
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ writeDebugLog(
1066
+ config,
1067
+ buildRequestLogName(body.message_id, "response.done"),
1068
+ {
1069
+ hasToolCall,
1070
+ fullResponse: summarizeForDebug(fullResponse, includePayload),
1071
+ }
1072
+ );
1073
+
1074
+ // 如果整个请求链路中触发了任意 tool_call (原生 or 文本识别),
1075
+ // 补发一个空的 is_finish: true 作为最终流束结标记,适配灵珠的接收器逻辑
1076
+ if (hasToolCall || nativeToolInvoked) {
1077
+ const finalFinishData: LingzhuSSEData = {
1078
+ role: "agent",
1079
+ type: "answer",
1080
+ answer_stream: "",
1081
+ message_id: body.message_id,
1082
+ agent_id: body.agent_id,
1083
+ is_finish: true,
1084
+ };
1085
+ safeWriteLingzhuMessage(finalFinishData);
1086
+ }
1087
+
1088
+ if (!res.writableEnded) {
1089
+ res.end();
1090
+ }
1091
+ logger.info(`[Lingzhu] Completed: message_id=${body.message_id}`);
1092
+ } catch (error) {
1093
+ stopKeepalive();
1094
+ const errorMsg = error instanceof Error ? error.message : String(error);
1095
+
1096
+ if (errorMsg.includes("Client disconnected") || errorMsg.includes("Native tool fulfilled")) {
1097
+ logger.info(`[Lingzhu] Request fulfilled or client disconnected normally: ${errorMsg}`);
1098
+ } else {
1099
+ logger.error(`[Lingzhu] Error: ${errorMsg}`);
1100
+ }
1101
+
1102
+ writeDebugLog(
1103
+ config,
1104
+ buildRequestLogName(requestMessageId, "error"),
1105
+ {
1106
+ message_id: requestMessageId,
1107
+ agent_id: requestAgentId,
1108
+ error: errorMsg,
1109
+ },
1110
+ true
1111
+ );
1112
+
1113
+ if (!upstreamController.signal.aborted && !res.writableEnded) {
1114
+ const errorData: LingzhuSSEData = {
1115
+ role: "agent",
1116
+ type: "answer",
1117
+ answer_stream: `[错误] ${errorMsg}`,
1118
+ message_id: requestMessageId,
1119
+ agent_id: requestAgentId,
1120
+ is_finish: true,
1121
+ };
1122
+ safeWriteLingzhuMessage(errorData);
1123
+ res.end();
1124
+ }
1125
+ } finally {
1126
+ if (nativeToolListener) {
1127
+ lingzhuEventBus.off("native_invoke", nativeToolListener);
1128
+ }
1129
+ }
1130
+
1131
+ return true;
1132
+ };
1133
+ }