@rexvane/piweb 0.3.17 → 0.3.19

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 (261) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -4
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +23 -9
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +12 -0
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +3 -3
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/config/route.js +1 -1
  32. package/.next/server/app/api/config/route.js.nft.json +1 -1
  33. package/.next/server/app/api/files/route.js +1 -1
  34. package/.next/server/app/api/files/route.js.nft.json +1 -1
  35. package/.next/server/app/api/fs/route.js +1 -0
  36. package/.next/server/app/api/fs/route.js.nft.json +1 -0
  37. package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/git/route.js +2 -2
  39. package/.next/server/app/api/git/route.js.nft.json +1 -1
  40. package/.next/server/app/api/growth/route.js +1 -1
  41. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  42. package/.next/server/app/api/health/route.js +1 -1
  43. package/.next/server/app/api/models/route.js +1 -1
  44. package/.next/server/app/api/models/route.js.nft.json +1 -1
  45. package/.next/server/app/api/pi-settings/route.js +1 -1
  46. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  47. package/.next/server/app/api/plugins/route.js +1 -1
  48. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  49. package/.next/server/app/api/prompts/route.js +1 -1
  50. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  51. package/.next/server/app/api/remote/route.js +8 -0
  52. package/.next/server/app/api/remote/route.js.nft.json +1 -0
  53. package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
  54. package/.next/server/app/api/security/route.js +1 -1
  55. package/.next/server/app/api/security/route.js.nft.json +1 -1
  56. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  57. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  58. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  59. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/import/route.js +2 -2
  61. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/route.js +1 -1
  63. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  64. package/.next/server/app/api/skills/route.js +1 -1
  65. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  66. package/.next/server/app/api/update/route.js +2 -2
  67. package/.next/server/app/api/update/route.js.nft.json +1 -1
  68. package/.next/server/app/api/version/route.js +1 -1
  69. package/.next/server/app/api/version/route.js.nft.json +1 -1
  70. package/.next/server/app/api/web-auth/route.js +3 -1
  71. package/.next/server/app/api/workspaces/route.js +1 -1
  72. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  73. package/.next/server/app/icon.svg/route.js +1 -1
  74. package/.next/server/app/index.html +1 -1
  75. package/.next/server/app/index.rsc +4 -4
  76. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  77. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  78. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  79. package/.next/server/app/login/page.js +1 -1
  80. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/login.html +1 -1
  82. package/.next/server/app/login.rsc +3 -3
  83. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  84. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  85. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  86. package/.next/server/app/page.js +1 -1
  87. package/.next/server/app/page.js.nft.json +1 -1
  88. package/.next/server/app/page_client-reference-manifest.js +1 -1
  89. package/.next/server/app-paths-manifest.json +6 -4
  90. package/.next/server/chunks/229.js +1 -1
  91. package/.next/server/chunks/243.js +1 -0
  92. package/.next/server/chunks/393.js +1 -0
  93. package/.next/server/chunks/496.js +1 -1
  94. package/.next/server/chunks/538.js +1 -0
  95. package/.next/server/chunks/686.js +1 -4
  96. package/.next/server/chunks/802.js +2 -0
  97. package/.next/server/chunks/807.js +1 -0
  98. package/.next/server/chunks/920.js +4 -0
  99. package/.next/server/chunks/924.js +6 -0
  100. package/.next/server/instrumentation.js +1 -0
  101. package/.next/server/instrumentation.js.nft.json +1 -0
  102. package/.next/server/middleware-build-manifest.js +1 -1
  103. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  104. package/.next/server/middleware.js +4 -3
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/server-reference-manifest.json +1 -1
  108. package/.next/static/JDGSR-TIbk9qZWcUNYIX3/_buildManifest.js +1 -0
  109. package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
  110. package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
  111. package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
  112. package/.next/static/chunks/4686-e3825f3b6a384aa1.js +1 -0
  113. package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
  114. package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
  115. package/.next/static/chunks/839.7403c6bdace4c56c.js +1 -0
  116. package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
  117. package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
  122. package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
  123. package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
  124. package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
  125. package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
  126. package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
  127. package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
  128. package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
  129. package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
  130. package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
  131. package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
  132. package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
  133. package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
  134. package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
  135. package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
  136. package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
  138. package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
  139. package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
  140. package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
  141. package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
  142. package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
  143. package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
  144. package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
  145. package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
  146. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
  149. package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +1 -0
  150. package/.next/static/css/7391c42e1ae617f9.css +1 -0
  151. package/.next/types/app/api/fs/route.ts +353 -0
  152. package/.next/types/app/api/remote/route.ts +353 -0
  153. package/.next/types/routes.d.ts +3 -1
  154. package/.next/types/validator.ts +18 -0
  155. package/README.md +56 -10
  156. package/next.config.ts +3 -2
  157. package/package.json +3 -3
  158. package/scripts/launcher.mjs +194 -13
  159. package/scripts/release.mjs +0 -2
  160. package/src/app/api/agent/[id]/events/route.ts +11 -6
  161. package/src/app/api/fs/route.ts +119 -0
  162. package/src/app/api/growth/route.ts +11 -6
  163. package/src/app/api/models/route.ts +21 -9
  164. package/src/app/api/remote/route.ts +67 -0
  165. package/src/app/api/sessions/[id]/export/route.ts +11 -1
  166. package/src/app/api/sessions/route.ts +3 -1
  167. package/src/app/api/web-auth/route.ts +45 -49
  168. package/src/app/api/workspaces/route.ts +16 -0
  169. package/src/app/globals.css +11 -3
  170. package/src/app/login/page.tsx +8 -60
  171. package/src/components/AppShell.tsx +202 -155
  172. package/src/components/ChatInput.tsx +61 -6
  173. package/src/components/ChatWindow.tsx +274 -132
  174. package/src/components/DirectoryPicker.tsx +189 -0
  175. package/src/components/DrawerPanel.tsx +164 -0
  176. package/src/components/FileViewer.tsx +2 -2
  177. package/src/components/GrowthTree.tsx +6 -5
  178. package/src/components/MarkdownBlock.tsx +134 -0
  179. package/src/components/OutlineRail.tsx +22 -1
  180. package/src/components/ProjectPanel.tsx +197 -14
  181. package/src/components/RemoteSection.tsx +232 -0
  182. package/src/components/SessionImportSection.tsx +60 -38
  183. package/src/components/SettingsPanel.tsx +104 -57
  184. package/src/components/icons.tsx +23 -0
  185. package/src/hooks/useGrowth.ts +47 -25
  186. package/src/hooks/usePiWeb.ts +100 -14
  187. package/src/i18n.tsx +115 -28
  188. package/src/instrumentation-node.ts +35 -0
  189. package/src/instrumentation.ts +18 -0
  190. package/src/lib/agent-config.ts +134 -0
  191. package/src/lib/agent-manager.ts +243 -61
  192. package/src/lib/auth-throttle.ts +102 -0
  193. package/src/lib/auth.ts +35 -27
  194. package/src/lib/files-service.ts +61 -1
  195. package/src/lib/font.ts +84 -0
  196. package/src/lib/git-service.ts +5 -3
  197. package/src/lib/growth-service.ts +78 -30
  198. package/src/lib/growth-tracker.ts +91 -9
  199. package/src/lib/models-service.ts +36 -11
  200. package/src/lib/pi-settings.ts +10 -12
  201. package/src/lib/pi.ts +145 -18
  202. package/src/lib/plugins-service.ts +67 -35
  203. package/src/lib/remote/assets.json +4 -0
  204. package/src/lib/remote/mobile-proxy.ts +149 -0
  205. package/src/lib/remote/supervisor.ts +482 -0
  206. package/src/lib/security-service.ts +6 -11
  207. package/src/lib/session-import/claude.ts +45 -21
  208. package/src/lib/session-reader.ts +106 -3
  209. package/src/lib/skills-service.ts +34 -12
  210. package/src/lib/text-sanitize.ts +12 -0
  211. package/src/lib/theme.ts +3 -0
  212. package/src/lib/types.ts +16 -2
  213. package/src/lib/update-service.ts +0 -1
  214. package/src/lib/web-auth-shared.ts +5 -1
  215. package/src/lib/web-auth.ts +139 -22
  216. package/src/lib/web-launch-token.ts +55 -0
  217. package/src/lib/web-secret.ts +182 -0
  218. package/src/lib/workspace-store.ts +91 -7
  219. package/src/proxy.ts +46 -12
  220. package/.next/server/chunks/532.js +0 -1
  221. package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
  222. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
  223. package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
  224. package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
  225. package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
  226. package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
  227. package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
  228. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
  229. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
  230. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
  231. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
  232. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
  233. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
  234. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
  235. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
  236. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
  237. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
  238. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
  239. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
  240. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
  241. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
  242. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
  243. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
  244. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
  245. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
  246. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
  247. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
  248. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
  249. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
  250. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
  251. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
  252. package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
  253. package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
  254. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
  255. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
  256. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
  257. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
  258. package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
  259. package/.next/static/css/850c2fd130ffa2fc.css +0 -1
  260. package/.next/static/zsynuO_9Kx6nrXgkTUJ04/_buildManifest.js +0 -1
  261. /package/.next/static/{zsynuO_9Kx6nrXgkTUJ04 → JDGSR-TIbk9qZWcUNYIX3}/_ssgManifest.js +0 -0
@@ -0,0 +1,482 @@
1
+ /**
2
+ * 「远程连接」内置插件的控制面。它管两个监听:
3
+ *
4
+ * 1. 局域网入口 —— 手机排版注入代理,绑 0.0.0.0。默认就该有,手机连同一个 WiFi 就能用。
5
+ * 2. 公网入口 —— cloudflared 快速隧道,指向局域网入口。由设置页的按钮开关。
6
+ *
7
+ * 引擎(隧道实现)刻意做成可替换的:`startPublic` 只依赖「起一个进程 + 从输出里抓到
8
+ * https 地址」这两件事,换 ngrok / frp 时改这一个函数即可。
9
+ *
10
+ * 状态挂 globalThis:Next 的热重载会重新求值模块,不能把监听弄丢或重复起。
11
+ */
12
+ import { spawn, type ChildProcess } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import http from "node:http";
15
+ import https from "node:https";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { authEnabled } from "../web-auth";
19
+ import { agentDir } from "../web-secret";
20
+ import { createMobileProxy, MOBILE_FIT_CSS, MOBILE_FIT_JS } from "./mobile-proxy";
21
+
22
+ export const PLUGIN_ID = "builtin-remote";
23
+ export const DEFAULT_LAN_PORT = 30142;
24
+ /** cloudflared 快速隧道给的地址长这样 */
25
+ const PUBLIC_URL_PATTERN = /https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com/;
26
+ const TUNNEL_TIMEOUT_MS = 45_000;
27
+ /** 拿到地址后等它就绪的上限:快速隧道的域名要十几秒才在全球 DNS 生效 */
28
+ const TUNNEL_READY_TIMEOUT_MS = 60_000;
29
+ const TUNNEL_READY_POLL_MS = 2_500;
30
+
31
+ /**
32
+ * 从 cloudflared 的输出里抓公网地址,**只认第一个**。
33
+ *
34
+ * 为什么必须只认第一个:隧道域名是绑在隧道上的,一次运行期内不该变。
35
+ * cloudflared 在重连等情况下的输出里可能再出现一个地址,若跟着换,
36
+ * 用户手里那条链接会当场失效,旧域名还会留在 Host 白名单里没人摘。
37
+ */
38
+ export function createUrlCatcher(pattern: RegExp = PUBLIC_URL_PATTERN) {
39
+ let captured: string | null = null;
40
+ let buffer = "";
41
+ return {
42
+ /** 返回新捕获到的地址;已经捕获过、或这次还没匹配到,都返回 null */
43
+ feed(chunk: string): string | null {
44
+ if (captured) return null;
45
+ // 累积再匹配:子进程的 chunk 边界不保证落在行边界上,
46
+ // 逐块匹配会把一个被切断的地址整条漏掉。
47
+ buffer += chunk;
48
+ const match = pattern.exec(buffer);
49
+ if (!match) {
50
+ // 只留尾巴,够覆盖一个被切断的地址即可,避免缓冲无限增长
51
+ if (buffer.length > 4096) buffer = buffer.slice(-4096);
52
+ return null;
53
+ }
54
+ captured = match[0];
55
+ buffer = "";
56
+ return captured;
57
+ },
58
+ get url(): string | null {
59
+ return captured;
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * 探一次隧道是否真的可用。
66
+ *
67
+ * 只看 DNS 不够:域名解析出来之后边缘还可能要几秒才把请求转到这条隧道上。
68
+ * 所以这里发一次 HEAD,把「能连上并拿到 HTTP 响应」当作就绪标准;
69
+ * 4xx 也算就绪(说明请求已经打到我们的上游了),只有连不上/DNS 失败才算没就绪。
70
+ */
71
+ function probeTunnel(url: string): Promise<boolean> {
72
+ return new Promise((resolve) => {
73
+ const target = new URL(url);
74
+ const request = https.request({ host: target.hostname, path: "/api/health", method: "HEAD", timeout: 8_000 }, (response) => {
75
+ response.resume();
76
+ resolve(true);
77
+ });
78
+ request.on("timeout", () => { request.destroy(); resolve(false); });
79
+ request.on("error", () => resolve(false));
80
+ request.end();
81
+ });
82
+ }
83
+
84
+ /** 等隧道真的能连上;等不到也返回(地址已经拿到,用户至少能看到它) */
85
+ async function waitForTunnelReady(url: string, keepGoing: () => boolean): Promise<boolean> {
86
+ const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
87
+ for (;;) {
88
+ if (!keepGoing()) return false;
89
+ if (await probeTunnel(url)) return true;
90
+ if (Date.now() >= deadline) return false;
91
+ await new Promise((resolve) => setTimeout(resolve, TUNNEL_READY_POLL_MS));
92
+ }
93
+ }
94
+
95
+ export type TunnelEngine = "cloudflared";
96
+
97
+ export interface LanState {
98
+ running: boolean;
99
+ port: number | null;
100
+ urls: string[];
101
+ error: string | null;
102
+ }
103
+
104
+ export interface PublicState {
105
+ running: boolean;
106
+ url: string | null;
107
+ /** 地址已可访问。快速隧道拿到地址后还要十几秒域名才在全球 DNS 生效 */
108
+ ready: boolean;
109
+ engine: TunnelEngine | null;
110
+ /** 引擎二进制是否可用;不可用时 UI 应给出安装提示而不是「启动失败」 */
111
+ engineAvailable: boolean;
112
+ error: string | null;
113
+ }
114
+
115
+ /** 不允许开入口的原因:没开认证 / 开发模式(Next 开发工具端点不能暴露出去) */
116
+ export type RemoteBlocked = "auth" | "dev";
117
+
118
+ export interface RemoteStatus {
119
+ plugin: typeof PLUGIN_ID;
120
+ /** PiWeb 自己的端口(局域网入口的转发目标) */
121
+ targetPort: number;
122
+ /** 非空时两个入口都不许开,UI 应置灰按钮并说明原因 */
123
+ blocked: RemoteBlocked | null;
124
+ lan: LanState;
125
+ public: PublicState;
126
+ }
127
+
128
+ interface SupervisorState {
129
+ lan: { server: http.Server; port: number } | null;
130
+ tunnel: { child: ChildProcess; url: string | null; ready: boolean } | null;
131
+ /** 正在启动中的那一次:并发调用复用它,不能起两个监听 / 两个 cloudflared */
132
+ lanStarting?: Promise<LanState> | null;
133
+ publicStarting?: Promise<PublicState> | null;
134
+ lanError: string | null;
135
+ publicError: string | null;
136
+ enginePath: string | null;
137
+ engineChecked: boolean;
138
+ }
139
+
140
+ /**
141
+ * 入口能不能开。两个入口都会把 PiWeb 交给本机以外的设备,所以:
142
+ * - 没开认证绝不能开(直接 `next start`、或显式 PI_WEB_AUTH=0 时,开出去就是一个免密 shell);
143
+ * - 开发模式不能开(认证保护不了 Next 开发工具端点,启动器也拒绝非本机开发模式)。
144
+ */
145
+ export function remoteBlocked(env: Record<string, string | undefined> = process.env): RemoteBlocked | null {
146
+ if (!authEnabled(env)) return "auth";
147
+ if (env.NODE_ENV === "development") return "dev";
148
+ return null;
149
+ }
150
+
151
+ const STATE_KEY = "piweb:remote-supervisor";
152
+
153
+ /**
154
+ * 把隧道域名并进 Host 白名单。
155
+ *
156
+ * 必须是动态的:快速隧道的域名每次启动都变,没法在启动前写进环境变量。
157
+ * isSafeHost 每次请求现读 process.env,所以同进程内改了立刻生效,不用重启。
158
+ */
159
+ export function allowTunnelHost(url: string): void {
160
+ let hostname: string;
161
+ try {
162
+ hostname = new URL(url).hostname.toLowerCase();
163
+ } catch {
164
+ return;
165
+ }
166
+ const current = (process.env.PI_WEB_TRUSTED_HOSTS ?? "")
167
+ .split(",")
168
+ .map((part) => part.trim().toLowerCase())
169
+ .filter(Boolean);
170
+ if (!current.includes(hostname)) current.push(hostname);
171
+ process.env.PI_WEB_TRUSTED_HOSTS = current.join(",");
172
+ }
173
+
174
+ /** 隧道停了就把域名从白名单摘掉:不要给一个已经没人管的域名留后门 */
175
+ export function disallowTunnelHost(url: string | null): void {
176
+ if (!url) return;
177
+ let hostname: string;
178
+ try {
179
+ hostname = new URL(url).hostname.toLowerCase();
180
+ } catch {
181
+ return;
182
+ }
183
+ const remaining = (process.env.PI_WEB_TRUSTED_HOSTS ?? "")
184
+ .split(",")
185
+ .map((part) => part.trim().toLowerCase())
186
+ .filter((part) => part && part !== hostname);
187
+ process.env.PI_WEB_TRUSTED_HOSTS = remaining.join(",");
188
+ }
189
+
190
+ function state(): SupervisorState {
191
+ const store = globalThis as Record<PropertyKey, unknown>;
192
+ const key = Symbol.for(STATE_KEY);
193
+ const existing = store[key] as SupervisorState | undefined;
194
+ if (existing) return existing;
195
+ const created: SupervisorState = { lan: null, tunnel: null, lanError: null, publicError: null, enginePath: null, engineChecked: false };
196
+ store[key] = created;
197
+ return created;
198
+ }
199
+
200
+ /** PiWeb 自己的监听端口:启动器显式给的优先 */
201
+ export function piwebPort(env: Record<string, string | undefined> = process.env): number {
202
+ for (const raw of [env.PIWEB_PORT, env.PORT]) {
203
+ const value = Number(raw);
204
+ if (Number.isInteger(value) && value >= 1 && value <= 65535) return value;
205
+ }
206
+ return 30141;
207
+ }
208
+
209
+ function isOff(value: string | undefined): boolean {
210
+ const flag = (value ?? "").trim().toLowerCase();
211
+ return flag === "0" || flag === "false" || flag === "off" || flag === "no";
212
+ }
213
+
214
+ /**
215
+ * 局域网入口是否随服务启动。默认开 —— 「不开启也默认局域网可用」就是这个开关。
216
+ * 设 PIWEB_REMOTE_LAN=0 关掉。
217
+ */
218
+ export function remoteLanEnabled(env: Record<string, string | undefined> = process.env): boolean {
219
+ return !isOff(env.PIWEB_REMOTE_LAN);
220
+ }
221
+
222
+ export function remoteLanPort(env: Record<string, string | undefined> = process.env): number {
223
+ const value = Number(env.PIWEB_REMOTE_LAN_PORT);
224
+ return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : DEFAULT_LAN_PORT;
225
+ }
226
+
227
+ /** 所有非回环 IPv4 —— 手机该用哪个地址,UI 直接列出来 */
228
+ export function lanAddresses(): string[] {
229
+ const out: string[] = [];
230
+ for (const entries of Object.values(os.networkInterfaces())) {
231
+ for (const entry of entries ?? []) {
232
+ if (entry.family === "IPv4" && !entry.internal && !entry.address.startsWith("169.254.")) out.push(entry.address);
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+
238
+ export function remoteStatus(): RemoteStatus {
239
+ const current = state();
240
+ const lanPort = current.lan?.port ?? null;
241
+ return {
242
+ plugin: PLUGIN_ID,
243
+ targetPort: piwebPort(),
244
+ blocked: remoteBlocked(),
245
+ lan: {
246
+ running: Boolean(current.lan),
247
+ port: lanPort,
248
+ urls: lanPort ? lanAddresses().map((address) => `http://${address}:${lanPort}`) : [],
249
+ error: current.lanError,
250
+ },
251
+ public: {
252
+ running: Boolean(current.tunnel),
253
+ url: current.tunnel?.url ?? null,
254
+ ready: Boolean(current.tunnel?.ready),
255
+ engine: current.tunnel ? "cloudflared" : null,
256
+ engineAvailable: current.engineChecked ? current.enginePath !== null : true,
257
+ error: current.publicError,
258
+ },
259
+ };
260
+ }
261
+
262
+ /** PiWeb 自己管的引擎位置(与 web-secret 同一批状态文件放在 agent 目录下) */
263
+ export function managedEnginePath(): string {
264
+ return path.join(agentDir(), "web-bin", process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
265
+ }
266
+
267
+ /**
268
+ * 引擎查找顺序:
269
+ * 1. PIWEB_CLOUDFLARED(显式指定绝对路径)
270
+ * 2. PiWeb 自己管的位置(「一键安装」就放这儿)
271
+ * 3. PATH 上的 cloudflared
272
+ * 都没找到时返回 PATH 上的名字,让 spawn 报 ENOENT —— startPublic 会翻译成安装提示。
273
+ */
274
+ function resolveTunnelEngine(): string {
275
+ const explicit = process.env.PIWEB_CLOUDFLARED?.trim();
276
+ if (explicit) return explicit;
277
+ const managed = managedEnginePath();
278
+ try {
279
+ if (fs.existsSync(managed)) return managed;
280
+ } catch {
281
+ /* 读不了就当没有 */
282
+ }
283
+ return "cloudflared";
284
+ }
285
+
286
+ /** 探测隧道引擎是否可用(结果缓存) */
287
+ export function tunnelEngine(): string | null {
288
+ const current = state();
289
+ if (current.engineChecked) return current.enginePath;
290
+ current.engineChecked = true;
291
+ current.enginePath = resolveTunnelEngine();
292
+ return current.enginePath;
293
+ }
294
+
295
+ export function markEngineMissing(): void {
296
+ const current = state();
297
+ current.engineChecked = true;
298
+ current.enginePath = null;
299
+ }
300
+
301
+ /** 起局域网入口;已经在跑(或正在起)就复用。targetPort 默认转发回 PiWeb 自己,测试可改成别的上游。 */
302
+ export function startLan(port = remoteLanPort(), host = "0.0.0.0", targetPort = piwebPort()): Promise<LanState> {
303
+ const current = state();
304
+ if (current.lan) return Promise.resolve(remoteStatus().lan);
305
+ if (current.lanStarting) return current.lanStarting;
306
+ const starting = listenLan(port, host, targetPort).finally(() => {
307
+ if (current.lanStarting === starting) current.lanStarting = null;
308
+ });
309
+ current.lanStarting = starting;
310
+ return starting;
311
+ }
312
+
313
+ async function listenLan(port: number, host: string, targetPort: number): Promise<LanState> {
314
+ const current = state();
315
+ current.lanError = null;
316
+ const server = createMobileProxy({
317
+ targetPort,
318
+ css: MOBILE_FIT_CSS,
319
+ js: MOBILE_FIT_JS,
320
+ log: (message) => console.warn(message),
321
+ });
322
+ try {
323
+ await new Promise<void>((resolve, reject) => {
324
+ server.once("error", reject);
325
+ server.listen(port, host, resolve);
326
+ });
327
+ } catch (error) {
328
+ current.lanError = error instanceof Error ? error.message : String(error);
329
+ server.close();
330
+ return remoteStatus().lan;
331
+ }
332
+ current.lan = { server, port };
333
+ return remoteStatus().lan;
334
+ }
335
+
336
+ export async function stopLan(): Promise<void> {
337
+ const current = state();
338
+ const lan = current.lan;
339
+ current.lan = null;
340
+ if (!lan) return;
341
+ lan.server.closeAllConnections?.();
342
+ await new Promise<void>((resolve) => lan.server.close(() => resolve()));
343
+ }
344
+
345
+ /**
346
+ * 起公网隧道;已在跑(或正在起)就复用。
347
+ * 「正在起」必须算进来:起局域网入口、等 cloudflared 吐地址都要 await,连点两下若各自 spawn,
348
+ * 先起的那个会变成没人管的孤儿进程,它的域名还留在 Host 白名单里。
349
+ */
350
+ export function startPublic(): Promise<PublicState> {
351
+ const current = state();
352
+ if (current.tunnel) return Promise.resolve(remoteStatus().public);
353
+ if (current.publicStarting) return current.publicStarting;
354
+ const starting = spawnTunnel().finally(() => {
355
+ if (current.publicStarting === starting) current.publicStarting = null;
356
+ });
357
+ current.publicStarting = starting;
358
+ return starting;
359
+ }
360
+
361
+ /** 把一条隧道彻底收掉:摘白名单、清状态、杀进程(只动传进来的这一条) */
362
+ function dropTunnel(child: ChildProcess): void {
363
+ const current = state();
364
+ const active = current.tunnel;
365
+ if (active?.child === child) {
366
+ disallowTunnelHost(active.url);
367
+ current.tunnel = null;
368
+ }
369
+ if (child.exitCode === null && !child.killed) child.kill();
370
+ }
371
+
372
+ async function spawnTunnel(): Promise<PublicState> {
373
+ const current = state();
374
+ current.publicError = null;
375
+
376
+ // 公网入口转发到局域网入口,所以局域网入口必须在跑
377
+ if (!current.lan) await startLan();
378
+ const lan = remoteStatus().lan;
379
+ if (!lan.running || lan.port === null) {
380
+ current.publicError = lan.error ?? "局域网入口没能启动,公网入口无从转发";
381
+ return remoteStatus().public;
382
+ }
383
+
384
+ const binary = tunnelEngine();
385
+ if (!binary) {
386
+ current.publicError = "找不到 cloudflared";
387
+ return remoteStatus().public;
388
+ }
389
+
390
+ const child = spawn(binary, ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${lan.port}`], {
391
+ stdio: ["ignore", "pipe", "pipe"],
392
+ windowsHide: true,
393
+ });
394
+ current.tunnel = { child, url: null, ready: false };
395
+
396
+ await new Promise<void>((resolve) => {
397
+ let settled = false;
398
+ const finish = (error?: string) => {
399
+ if (settled) return;
400
+ settled = true;
401
+ clearTimeout(timer);
402
+ if (error) current.publicError = error;
403
+ resolve();
404
+ };
405
+ const timer = setTimeout(() => {
406
+ // 不收掉的话进程还活着、状态一直是「运行中」却永远没有地址
407
+ dropTunnel(child);
408
+ finish("启动隧道超时:cloudflared 没有在 45 秒内给出地址");
409
+ }, TUNNEL_TIMEOUT_MS);
410
+ const catcher = createUrlCatcher();
411
+ const scan = (chunk: Buffer) => {
412
+ const url = catcher.feed(chunk.toString("utf8"));
413
+ if (!url || !current.tunnel) return;
414
+ current.tunnel.url = url;
415
+ // 域名必须先放行再算启动成功,否则第一次访问必然被 Host 校验挡成 403
416
+ allowTunnelHost(url);
417
+ // 抓到就够了,摘掉监听:既避免后续输出改动已生效的地址,也不白占着流
418
+ child.stdout?.off("data", scan);
419
+ child.stderr?.off("data", scan);
420
+ finish();
421
+ };
422
+ child.stdout?.on("data", scan);
423
+ child.stderr?.on("data", scan);
424
+ child.once("error", (error: NodeJS.ErrnoException) => {
425
+ // spawn 失败时不一定还有 exit 事件:这里不清状态,界面会永远显示「运行中」
426
+ dropTunnel(child);
427
+ if (error.code === "ENOENT") {
428
+ markEngineMissing();
429
+ finish("找不到 cloudflared:请先安装它(https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)");
430
+ return;
431
+ }
432
+ finish(error.message);
433
+ });
434
+ child.once("exit", (code) => {
435
+ const active = current.tunnel;
436
+ if (active?.child === child) {
437
+ disallowTunnelHost(active.url);
438
+ current.tunnel = null;
439
+ finish(`cloudflared 退出了(code ${code ?? "?"})`);
440
+ } else {
441
+ finish();
442
+ }
443
+ });
444
+ });
445
+
446
+ // 地址拿到了,但快速隧道的域名还要十几秒才在全球 DNS 生效。
447
+ // 不等它就直接报「就绪」,用户一复制地址打开就是打不开 —— 那才是真的像坏了。
448
+ const tunnel = current.tunnel;
449
+ if (tunnel?.url) {
450
+ const ready = await waitForTunnelReady(tunnel.url, () => current.tunnel === tunnel);
451
+ if (current.tunnel === tunnel) tunnel.ready = ready;
452
+ }
453
+ return remoteStatus().public;
454
+ }
455
+
456
+ export async function stopPublic(): Promise<void> {
457
+ const current = state();
458
+ const tunnel = current.tunnel;
459
+ current.tunnel = null;
460
+ if (!tunnel) return;
461
+ // 先摘域名再杀进程:别给一个已经没人管的域名留后门
462
+ disallowTunnelHost(tunnel.url);
463
+ await new Promise<void>((resolve) => {
464
+ tunnel.child.once("exit", () => resolve());
465
+ tunnel.child.kill();
466
+ // 进程已经死了或杀不掉时不要卡住调用方
467
+ setTimeout(resolve, 3_000).unref();
468
+ });
469
+ }
470
+
471
+ export async function stopAll(): Promise<void> {
472
+ await stopPublic();
473
+ await stopLan();
474
+ }
475
+
476
+ /** 仅供测试 */
477
+ export function resetRemoteSupervisor(): void {
478
+ const store = globalThis as Record<PropertyKey, unknown>;
479
+ store[Symbol.for(STATE_KEY)] = {
480
+ lan: null, tunnel: null, lanError: null, publicError: null, enginePath: null, engineChecked: false,
481
+ } satisfies SupervisorState;
482
+ }
@@ -4,8 +4,8 @@
4
4
  * - 项目信任(settings.json 的 defaultProjectTrust:ask/always/never)
5
5
  * pi 没有 OS 级沙箱与审批系统——不做假开关。
6
6
  */
7
- import fs from "node:fs/promises";
8
7
  import path from "node:path";
8
+ import { invalidateJsonFile, readJsonObject } from "./agent-config";
9
9
  import { getAgentDir, getSettingsManager, reloadAllLoaders, reloadSettingsManagers, syncProjectTrust } from "./pi";
10
10
  import { reloadSessionsForCwd } from "./agent-manager";
11
11
  import { flushSettingsOrThrow, withSettingsWriteLock } from "./settings-write-lock";
@@ -13,16 +13,9 @@ import { flushSettingsOrThrow, withSettingsWriteLock } from "./settings-write-lo
13
13
  export type ProjectTrust = "ask" | "always" | "never";
14
14
 
15
15
  export async function getSecurity(): Promise<{ defaultProjectTrust: ProjectTrust }> {
16
- let raw: string;
17
- try {
18
- raw = await fs.readFile(path.join(getAgentDir(), "settings.json"), "utf8");
19
- } catch (error) {
20
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { defaultProjectTrust: "ask" };
21
- throw error;
22
- }
23
- const parsed: unknown = JSON.parse(raw.replace(/^\uFEFF/, ""));
24
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("settings.json must contain a JSON object");
25
- const trust = (parsed as { defaultProjectTrust?: unknown }).defaultProjectTrust;
16
+ // 统一缓存:settings.json 在信任判定 / 会话创建 / 快照里被读很多次
17
+ const parsed = await readJsonObject(path.join(getAgentDir(), "settings.json"));
18
+ const trust = parsed.defaultProjectTrust;
26
19
  return { defaultProjectTrust: trust === "always" || trust === "never" ? trust : "ask" };
27
20
  }
28
21
 
@@ -37,6 +30,8 @@ export async function setProjectTrust(value: ProjectTrust): Promise<void> {
37
30
  // SDK 自己持有跨进程锁,只落修改字段;flush 不抛写错,必须显式 drainErrors。
38
31
  manager.setDefaultProjectTrust(value);
39
32
  await flushSettingsOrThrow(manager);
33
+ // 刚写过的文件必须重新读盘再验:(mtime, ctime, size, ino) 校验戳有可能与旧值相同
34
+ invalidateJsonFile(path.join(getAgentDir(), "settings.json"));
40
35
  if ((await getSecurity()).defaultProjectTrust !== value) {
41
36
  throw new Error("defaultProjectTrust was not persisted");
42
37
  }
@@ -91,6 +91,17 @@ function assistantBlocks(content: unknown): ImportedBlock[] {
91
91
  }));
92
92
  }
93
93
 
94
+ /**
95
+ * Claude 的 stop_reason → pi 的 stopReason(与 pi-ai 的 anthropic 映射一致)。
96
+ * 拆开的记录每行都带整条回复的 stop_reason(思考那行也写着 tool_use),所以以合并后的内容为准:
97
+ * 有工具调用就是 toolUse。原样写进去的 tool_use / end_turn pi 不认,界面会把中途的说明当成最终回答。
98
+ */
99
+ function stopReasonOf(content: ImportedBlock[], raw: unknown): { stopReason: string; errorMessage?: string } {
100
+ if (raw === "refusal" || raw === "sensitive") return { stopReason: "error", errorMessage: `Claude stopped with: ${raw}` };
101
+ if (content.some((block) => block.type === "toolCall")) return { stopReason: "toolUse" };
102
+ return { stopReason: raw === "max_tokens" ? "length" : "stop" };
103
+ }
104
+
94
105
  /** user 行可能同时含文本与工具结果:文本进用户消息,工具结果单列 */
95
106
  function splitUserContent(content: unknown): { text: ImportedBlock[]; results: Array<{ toolCallId: string; content: ImportedBlock[]; isError: boolean }> } {
96
107
  const text: Array<ImportedBlock | null> = [];
@@ -163,6 +174,14 @@ export const claudeSource: ImportSourceModule = {
163
174
  let projectPath = summary.projectPath;
164
175
  let model = summary.model;
165
176
  let provider: string | undefined;
177
+ /**
178
+ * Claude Code 把一次回复按内容块拆成多行(思考、正文、每个工具调用各一行),共用 message.id,
179
+ * 每行还带着整条回复的 usage。必须合并回一条:拆开后并行调用变成 assistant[A] → assistant[B],
180
+ * pi-ai 会给 A 补一条 "No result provided",后面真正的 A 结果就成了孤儿,接着聊直接 400;usage 也会重复计 N 次。
181
+ * 流式时工具边跑边出结果,同一 id 的行之间会夹着 tool_result,所以按 id 跨位置合并。
182
+ */
183
+ const assistantById = new Map<string, Extract<ImportedMessage, { role: "assistant" }>>();
184
+ const rawStopById = new Map<string, unknown>();
166
185
 
167
186
  for (const record of eachJsonLine(text)) {
168
187
  if (record.isSidechain === true) {
@@ -178,28 +197,31 @@ export const claudeSource: ImportSourceModule = {
178
197
 
179
198
  if (record.type === "assistant") {
180
199
  const content = assistantBlocks(message.content);
181
- if (!content.length) continue;
200
+ const id = typeof message.id === "string" && message.id ? message.id : undefined;
201
+ const existing = id ? assistantById.get(id) : undefined;
202
+ if (!content.length && !existing) continue;
182
203
  const usage = message.usage as Record<string, unknown> | undefined;
183
- entries.push({
184
- type: "message",
185
- message: {
186
- role: "assistant",
187
- content,
188
- timestamp,
189
- ...(typeof message.model === "string" ? { model: message.model } : {}),
190
- ...(typeof message.stop_reason === "string" && message.stop_reason ? { stopReason: message.stop_reason } : {}),
191
- ...(usage
192
- ? {
193
- usage: {
194
- input: Number(usage.input_tokens) || 0,
195
- output: Number(usage.output_tokens) || 0,
196
- cacheRead: Number(usage.cache_read_input_tokens) || 0,
197
- cacheWrite: Number(usage.cache_creation_input_tokens) || 0,
198
- },
199
- }
200
- : {}),
201
- },
202
- });
204
+ const target = existing ?? { role: "assistant" as const, content: [], timestamp };
205
+ target.content.push(...content);
206
+ if (typeof message.model === "string") target.model = message.model;
207
+ // 同一回复的每行 usage 相同(后面的行只会更完整):取最后一行,只记一次
208
+ if (usage) {
209
+ target.usage = {
210
+ input: Number(usage.input_tokens) || 0,
211
+ output: Number(usage.output_tokens) || 0,
212
+ cacheRead: Number(usage.cache_read_input_tokens) || 0,
213
+ cacheWrite: Number(usage.cache_creation_input_tokens) || 0,
214
+ };
215
+ }
216
+ if (id && message.stop_reason != null) rawStopById.set(id, message.stop_reason);
217
+ const { stopReason, errorMessage } = stopReasonOf(target.content, id ? rawStopById.get(id) : message.stop_reason);
218
+ target.stopReason = stopReason;
219
+ if (errorMessage) target.errorMessage = errorMessage;
220
+ else delete target.errorMessage;
221
+ if (!existing) {
222
+ entries.push({ type: "message", message: target });
223
+ if (id) assistantById.set(id, target);
224
+ }
203
225
  continue;
204
226
  }
205
227
 
@@ -209,6 +231,8 @@ export const claudeSource: ImportSourceModule = {
209
231
  const keepText = textBlocks.filter((block) => block.type !== "text");
210
232
  if (plain && !isSynthetic(plain.trim()) && !/^Caveat:/i.test(plain.trim())) {
211
233
  entries.push({ type: "message", message: { role: "user", content: [...keepText, { type: "text", text: plain }], timestamp } as ImportedMessage });
234
+ // 真人发了新消息,之前的回复已经结束:不要再把后面的行合并到它前面去
235
+ assistantById.clear();
212
236
  } else if (plain) {
213
237
  synthetic += 1;
214
238
  }