@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
@@ -2,6 +2,7 @@ import net from "node:net";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { randomBytes } from "node:crypto";
5
6
  import { spawn } from "node:child_process";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { assertInstallation, selectProductionBuild } from "./build-output.mjs";
@@ -11,7 +12,7 @@ export { isMainModule } from "./entrypoint.mjs";
11
12
  const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
13
 
13
14
  /** @typedef {Record<string, string | undefined>} Environment */
14
- /** @typedef {{port: number, hostname: string, noOpen: boolean, isDev: boolean, help: boolean}} ParsedOptions */
15
+ /** @typedef {{port: number, hostname: string, publicUrl: string | null, noOpen: boolean, isDev: boolean, help: boolean}} ParsedOptions */
15
16
  /** @typedef {import('node:events').EventEmitter & {kill(signal?: NodeJS.Signals | number): boolean}} LauncherChild */
16
17
  /**
17
18
  * @typedef {Object} LauncherOptions
@@ -20,6 +21,7 @@ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
20
21
  * @property {string} [root]
21
22
  * @property {{log?: (message: string) => void, probePort?: typeof probePort,
22
23
  * isPiWebRunning?: typeof isPiWebRunning, openBrowser?: (url: string) => Promise<void>,
24
+ * warmFirstPaint?: typeof warmFirstPaint, readLaunchToken?: typeof readLaunchToken,
23
25
  * spawn?: (command: string, args: string[], options: import('node:child_process').SpawnOptions) => LauncherChild}} [dependencies]
24
26
  */
25
27
 
@@ -40,10 +42,78 @@ function normalizeHostname(value) {
40
42
  return host;
41
43
  }
42
44
 
45
+ /**
46
+ * 对外地址(反代/隧道给用户看的那个 URL)。只用于拼登录链接和补 Host 白名单。
47
+ * 去掉查询串与结尾斜杠,拼接时统一补。
48
+ * @param {string | null | undefined} value
49
+ * @returns {string | null}
50
+ */
51
+ export function normalizePublicUrl(value) {
52
+ if (value === null || value === undefined) return null;
53
+ const trimmed = String(value).trim();
54
+ if (!trimmed) return null;
55
+ let url;
56
+ try {
57
+ url = new URL(trimmed);
58
+ } catch {
59
+ throw new Error(`无效的对外地址:${value}`);
60
+ }
61
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`对外地址必须是 http/https:${value}`);
62
+ if (url.username || url.password) throw new Error(`对外地址不能带凭据:${value}`);
63
+ url.search = "";
64
+ url.hash = "";
65
+ return url.href.replace(/\/+$/, "");
66
+ }
67
+
68
+ /** 把令牌拼成可直接打开的登录链接 */
69
+ export function tokenLoginUrl(base, token) {
70
+ return `${String(base).replace(/\/+$/, "")}/?token=${encodeURIComponent(token)}`;
71
+ }
72
+
73
+ /**
74
+ * 读某个端口上正在运行的实例留下的启动令牌(服务端 publishLaunchToken 写的,
75
+ * 位置是 `<agentDir>/web-launch/<端口>.token`,agentDir 规则与 src/lib/web-secret.ts 一致)。
76
+ * 没有、读不了、内容不像令牌都返回 null —— 调用方退回打开裸地址。
77
+ * @param {number} port
78
+ * @param {Environment} [env]
79
+ * @returns {string | null}
80
+ */
81
+ export function readLaunchToken(port, env = process.env) {
82
+ const raw = env.PI_CODING_AGENT_DIR;
83
+ const home = os.homedir();
84
+ const dir = !raw ? path.join(home, ".pi", "agent")
85
+ : raw === "~" ? home
86
+ : raw.startsWith("~/") || raw.startsWith("~\\") ? path.join(home, raw.slice(2))
87
+ : raw;
88
+ try {
89
+ const token = fs.readFileSync(path.join(dir, "web-launch", `${port}.token`), "utf8").trim();
90
+ return /^[A-Za-z0-9_-]{16,}$/.test(token) ? token : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function mergeHostnames(current, hostname) {
97
+ const list = (current ?? "").split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
98
+ if (hostname && !list.includes(hostname)) list.push(hostname);
99
+ return list.join(",");
100
+ }
101
+
102
+ function isTruthyFlag(value) {
103
+ const flag = (value ?? "").trim().toLowerCase();
104
+ return flag === "1" || flag === "true" || flag === "on" || flag === "yes";
105
+ }
106
+
107
+ function isOffFlag(value) {
108
+ const flag = (value ?? "").trim().toLowerCase();
109
+ return flag === "0" || flag === "false" || flag === "off" || flag === "no";
110
+ }
111
+
43
112
  /** @param {string[]} [args] @param {Environment} [env] @returns {ParsedOptions} */
44
113
  export function parseLauncherArgs(args = [], env = process.env) {
45
114
  let port = Number(env.PORT || 30141);
46
115
  let hostname = env.PI_WEB_HOSTNAME || "127.0.0.1";
116
+ let publicUrl = env.PI_WEB_PUBLIC_URL ?? null;
47
117
  let noOpen = env.PI_WEB_NO_OPEN === "1";
48
118
  let isDev = env.PI_WEB_DEV === "1";
49
119
  let help = false;
@@ -53,13 +123,15 @@ export function parseLauncherArgs(args = [], env = process.env) {
53
123
  else if (arg.startsWith("--port=")) port = Number(arg.slice(7));
54
124
  else if (arg === "-H" || arg === "--hostname") hostname = args[++i];
55
125
  else if (arg.startsWith("--hostname=")) hostname = arg.slice(11);
126
+ else if (arg === "--public-url") publicUrl = args[++i];
127
+ else if (arg.startsWith("--public-url=")) publicUrl = arg.slice(13);
56
128
  else if (arg === "--no-open") noOpen = true;
57
129
  else if (arg === "--dev") isDev = true;
58
130
  else if (arg === "-h" || arg === "--help") help = true;
59
131
  else throw new Error(`未知参数:${arg}`);
60
132
  }
61
133
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`无效端口:${port}`);
62
- return { port, hostname: normalizeHostname(hostname), noOpen, isDev, help };
134
+ return { port, hostname: normalizeHostname(hostname), publicUrl: normalizePublicUrl(publicUrl), noOpen, isDev, help };
63
135
  }
64
136
 
65
137
  /**
@@ -69,23 +141,40 @@ export function parseLauncherArgs(args = [], env = process.env) {
69
141
  * @returns {{root: string, command: 'dev' | 'start', env: Environment}}
70
142
  */
71
143
  export function createLaunchPlan(root, options, env = process.env) {
144
+ // 拒绝启动而不是静默忽略:设了 PI_WEB_PASSWORD 的部署本来是有认证的,
145
+ // 悄悄放过就会让它在没有任何登录要求的情况下继续对外服务。
146
+ if (env.PI_WEB_PASSWORD) {
147
+ throw new Error("PI_WEB_PASSWORD 已被移除:密码登录不再存在,认证现在只用启动令牌(PI_WEB_AUTH=1,启动器默认已开)。请删掉这个环境变量再重试。");
148
+ }
72
149
  const loopback = isLoopback(options.hostname);
73
150
  if (!loopback && options.isDev) {
74
- throw new Error("拒绝非本机开发模式:PI_WEB_PASSWORD 不能保护 Next 开发工具端点。请绑定 127.0.0.1,或先运行 npm run build:release 后用生产模式启动。");
151
+ throw new Error("拒绝非本机开发模式:启动令牌认证保护不了 Next 开发工具端点。请绑定 127.0.0.1,或先运行 npm run build:release 后用生产模式启动。");
75
152
  }
76
- if (!loopback && !env.PI_WEB_PASSWORD) throw new Error("拒绝无密码的非本机监听。请设置 PI_WEB_PASSWORD,或绑定 127.0.0.1。");
153
+ const publicUrl = options.publicUrl ?? null;
77
154
  const installation = assertInstallation(root);
78
155
  const build = options.isDev ? null : selectProductionBuild(installation, env.PIWEB_BUILD_DIR);
79
156
  if (!build && !loopback) {
80
157
  throw new Error("找不到可用生产构建,拒绝对外自动回退到开发模式。请先运行 npm run build:release,再以相同启动参数重试。");
81
158
  }
82
159
  const command = options.isDev || !build ? "dev" : "start";
160
+ // 局域网入口(「远程连接」内置插件)默认开着,等于这台机器同网段可达;
161
+ // 非本机监听、配了对外地址同样算暴露面。三种情况都必须有认证。
162
+ // 开发模式下局域网入口根本不会起(instrumentation 会跳过),不算暴露面。
163
+ const lanEntry = command !== "dev" && !isOffFlag(env.PIWEB_REMOTE_LAN);
164
+ const exposed = !loopback || Boolean(publicUrl) || lanEntry;
83
165
  const childEnv = {
84
166
  ...env,
85
167
  PI_WEB_ROOT: installation,
86
168
  PYTHONUTF8: env.PYTHONUTF8 ?? "1",
87
169
  PYTHONIOENCODING: env.PYTHONIOENCODING ?? "utf-8",
88
170
  };
171
+ // 每次启动换一个启动令牌;调用方已经给出就沿用(便于测试与外部编排)。
172
+ if (!childEnv.PI_WEB_LAUNCH_TOKEN) childEnv.PI_WEB_LAUNCH_TOKEN = randomBytes(32).toString("base64url");
173
+ // 有暴露面 + 用户没表态 → 自动打开启动令牌认证,而不是拒绝启动。
174
+ // 只在用户没表态时才替它打开:显式 PI_WEB_AUTH=0 会被尊重(此时局域网入口也会自行跳过)。
175
+ if (exposed && !String(childEnv.PI_WEB_AUTH ?? "").trim()) childEnv.PI_WEB_AUTH = "1";
176
+ // 反代会把外部主机名原样转给后端,不进白名单会被判 untrusted host。
177
+ if (publicUrl) childEnv.PI_WEB_TRUSTED_HOSTS = mergeHostnames(childEnv.PI_WEB_TRUSTED_HOSTS, new URL(publicUrl).hostname.toLowerCase());
89
178
  if (command === "start") childEnv.PIWEB_BUILD_DIR = build.buildDir;
90
179
  else {
91
180
  delete childEnv.PIWEB_BUILD_DIR;
@@ -142,6 +231,70 @@ export async function isPiWebRunning(base, { fetchImpl = fetch, timeoutMs = 1500
142
231
  }
143
232
  }
144
233
 
234
+ /** 预热要打的四个首屏路由(会话列表 / 工作区 / 模型目录 / pi 设置) */
235
+ export const FIRST_PAINT_ROUTES = ["/api/sessions", "/api/workspaces", "/api/models", "/api/pi-settings"];
236
+
237
+ /** @param {number} ms @param {AbortSignal} [signal] */
238
+ function delay(ms, signal) {
239
+ return new Promise((resolve) => {
240
+ const timer = setTimeout(resolve, ms);
241
+ timer.unref?.();
242
+ signal?.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
243
+ });
244
+ }
245
+
246
+ /**
247
+ * 首屏预热:生产模式下,Next 服务器对**第一个动态 API 请求**要一次性付出 2.4~3.3 秒的
248
+ * 模块加载成本(本机实测:`/api/version` 首次 2559~3566ms、第二次 4~10ms;页面路由与
249
+ * `/api/health` 不吃这笔账)。浏览器首屏正好撞在这笔成本上,于是"页面出来了、列表还在转圈"。
250
+ *
251
+ * 这里在子进程刚起来时就后台打一次最便宜的路由吃掉它,再预热首屏真正要用的四个路由。
252
+ * 只等端口起来 + 身份确认,绝不阻塞启动;失败一律静默(预热属于优化,不是启动的前置条件)。
253
+ *
254
+ * @param {string} base
255
+ * @param {{fetchImpl?: typeof fetch, token?: string, timeoutMs?: number, waitMs?: number,
256
+ * pollMs?: number, check?: typeof isPiWebRunning, signal?: AbortSignal}} [options]
257
+ * @returns {Promise<boolean>} 是否完成了管线预热
258
+ */
259
+ export async function warmFirstPaint(base, { fetchImpl = fetch, token, timeoutMs = 20_000, waitMs = 60_000, pollMs = 100, check = isPiWebRunning, signal } = {}) {
260
+ const timeoutFor = () => (signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs));
261
+ // 身份确认不带凭据(与 isPiWebRunning 的既有约定一致):端口可能被别的进程抢走。
262
+ const deadline = Date.now() + waitMs;
263
+ for (;;) {
264
+ if (signal?.aborted) return false;
265
+ if (await check(base, { fetchImpl, timeoutMs: Math.min(1500, timeoutMs), signal })) break;
266
+ if (Date.now() >= deadline) return false;
267
+ await delay(pollMs, signal);
268
+ }
269
+ // 令牌只认根路径的 GET:先换到 Cookie,后面几条路由才有资格预热。
270
+ let cookie;
271
+ if (token) {
272
+ try {
273
+ const response = await fetchImpl(new URL(tokenLoginUrl("/", token), base), { credentials: "omit", redirect: "manual", signal: timeoutFor() });
274
+ const match = /^([^;]+)/.exec(response.headers.get("set-cookie") ?? "");
275
+ if (match) cookie = match[1];
276
+ await response.arrayBuffer().catch(() => undefined);
277
+ } catch {
278
+ /* 换不到就退化成只预热公开路由 */
279
+ }
280
+ }
281
+ const headers = {};
282
+ if (cookie) headers.cookie = cookie;
283
+ const headerInit = Object.keys(headers).length > 0 ? headers : undefined;
284
+ const get = async (route) => {
285
+ try {
286
+ const response = await fetchImpl(new URL(route, base), { credentials: "omit", redirect: "error", headers: headerInit, signal: timeoutFor() });
287
+ // 只需让服务端把这条路由跑完;响应体要么丢弃,要么读干净以释放连接。
288
+ await response.arrayBuffer().catch(() => undefined);
289
+ } catch {
290
+ /* 预热失败不影响启动 */
291
+ }
292
+ };
293
+ await get("/api/version");
294
+ for (const route of FIRST_PAINT_ROUTES) void get(route);
295
+ return true;
296
+ }
297
+
145
298
  /** @param {number} port @param {string} hostname @returns {Promise<'busy' | 'free' | NodeJS.ErrnoException>} */
146
299
  export function probePort(port, hostname) {
147
300
  return new Promise((resolve) => {
@@ -169,9 +322,10 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
169
322
  const options = parseLauncherArgs(args, env);
170
323
  const log = dependencies.log ?? console.log;
171
324
  if (options.help) {
172
- log("Usage: pi web [-p, --port <port>] [-H, --hostname <host>] [--dev] [--no-open]");
325
+ log("Usage: pi web [-p, --port <port>] [-H, --hostname <host>] [--public-url <url>] [--dev] [--no-open]");
173
326
  log("默认绑定 127.0.0.1:30141;端口被占自动 +1。开发模式仅允许本机监听。");
174
- log("对外监听必须使用生产构建与 PI_WEB_PASSWORD。构建/恢复发布:npm run build:release。");
327
+ log("非本机监听、或配了 --public-url,都会自动启用启动令牌认证(PI_WEB_AUTH=1)。");
328
+ log("对外监听必须使用生产构建。构建/恢复发布:npm run build:release。");
175
329
  return 0;
176
330
  }
177
331
  // Validate before probing/reusing: an existing server must not hide an unsafe request.
@@ -183,7 +337,7 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
183
337
  // 常见根因:包目录属主不是当前用户(sudo 安装残留),构建产物写不进去
184
338
  let owner = "";
185
339
  try {
186
- const stat = await fs.stat(root);
340
+ const stat = fs.statSync(root);
187
341
  if (typeof stat.uid === "number" && stat.uid !== process.getuid?.()) {
188
342
  owner = `\n此安装目录属主不是当前用户(uid ${stat.uid})——之前可能用 sudo 安装过。修复:sudo chown -R $(whoami) "${root}",然后重试 piweb。`;
189
343
  }
@@ -204,7 +358,12 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
204
358
  const url = localUrl(options.hostname, port);
205
359
  if (await checkHealth(url)) {
206
360
  log(`[piweb] 检测到已有 PiWeb:${url}(未发送凭据)`);
207
- if (!options.noOpen) await browse(url);
361
+ if (!options.noOpen) {
362
+ // 那个实例开了认证的话,裸地址只会落到登录页,而这里手里没有它的令牌;
363
+ // 它启动时留下过令牌就直接开登录链接(浏览器里已有 Cookie 时令牌也无害)
364
+ const token = (dependencies.readLaunchToken ?? readLaunchToken)(port, env);
365
+ await browse(token ? tokenLoginUrl(url, token) : url);
366
+ }
208
367
  return 0;
209
368
  }
210
369
  if (attempts >= 50 || port >= 65535) throw new Error(`连续 ${attempts + 1} 个端口都被占用,请用 -p 指定端口`);
@@ -212,31 +371,53 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
212
371
  port += 1;
213
372
  }
214
373
  const url = localUrl(options.hostname, port);
374
+ // 内置插件要转发回 PiWeb 自己的端口;端口可能被占而 +1,所以在这里给最终值。
375
+ plan.env.PIWEB_PORT = String(port);
215
376
  log(`[piweb] 启动 ${url} (${plan.command} 模式)`);
216
- if (env.PI_WEB_PASSWORD) log(plan.command === "dev" ? "[piweb] 开发模式仅限本机;密码不保护 Next 开发工具端点。" : "[piweb] 已启用密码认证(浏览器登录页,API 兼容 Basic 用户名 pi)");
377
+ const authOn = isTruthyFlag(plan.env.PI_WEB_AUTH);
378
+ if (authOn) log("[piweb] 已启用启动令牌认证");
217
379
  if (!isLoopback(options.hostname)) {
218
380
  for (const addresses of Object.values(os.networkInterfaces())) {
219
381
  for (const address of addresses ?? []) {
220
382
  if (address.family === "IPv4" && !address.internal && !address.address.startsWith("169.254.")) {
221
- log(`[piweb] 局域网/手机访问地址(需同一网络;已启用密码认证):http://${address.address}:${port}`);
383
+ log(`[piweb] 局域网/手机访问地址(需同一网络):http://${address.address}:${port}`);
222
384
  }
223
385
  }
224
386
  }
225
387
  }
388
+ // 令牌即凭据:只往终端打印,不写日志文件,也不要贴到聊天或截图里。
389
+ const loginBase = options.publicUrl ?? url;
390
+ const loginUrl = tokenLoginUrl(loginBase, plan.env.PI_WEB_LAUNCH_TOKEN);
391
+ if (authOn) {
392
+ if (options.publicUrl) log(`[piweb] 对外地址:${options.publicUrl}`);
393
+ log(`[piweb] 登录链接(令牌即凭据,别外传;它会进浏览器历史):${loginUrl}`);
394
+ }
226
395
  const next = path.join(plan.root, "node_modules", "next", "dist", "bin", "next");
227
396
  const child = start(process.execPath, [next, plan.command, ...(plan.command === "dev" ? ["--webpack"] : []), "-p", String(port), "-H", options.hostname], {
228
397
  cwd: plan.root, stdio: "inherit", env: plan.env,
229
398
  });
230
399
  const controller = new AbortController();
231
400
  let timer;
401
+ // 首屏预热:开发模式每次请求都要现场编译,预热只会白占 CPU,因此只在生产模式下做。
402
+ let warmPromise = null;
403
+ if (plan.command !== "dev" && env.PIWEB_SKIP_WARMUP !== "1") {
404
+ const warm = dependencies.warmFirstPaint ?? warmFirstPaint;
405
+ warmPromise = Promise.resolve(warm(url, { token: authOn ? plan.env.PI_WEB_LAUNCH_TOKEN : undefined, signal: controller.signal })).then(() => true, () => false);
406
+ }
232
407
  const ready = async (attempt = 0) => {
233
- if (controller.signal.aborted || attempt > 120) return;
408
+ if (controller.signal.aborted || attempt > 240) return;
234
409
  if (await checkHealth(url, { signal: controller.signal })) {
235
410
  if (controller.signal.aborted) return;
411
+ if (warmPromise) {
412
+ log("[piweb] 预热首屏…");
413
+ await warmPromise;
414
+ if (controller.signal.aborted) return;
415
+ }
236
416
  log(`[piweb] 就绪:${url}`);
237
- if (!options.noOpen) await browse(url);
417
+ // 开了认证就直接开登录链接:否则用户打开的是登录页,而他手里还没有会话 Cookie。
418
+ if (!options.noOpen) await browse(authOn ? loginUrl : url);
238
419
  } else if (!controller.signal.aborted) {
239
- timer = setTimeout(() => void ready(attempt + 1), 500);
420
+ timer = setTimeout(() => void ready(attempt + 1), 250);
240
421
  timer.unref();
241
422
  }
242
423
  };
@@ -141,8 +141,6 @@ export async function prepareRelease({ root = ROOT, env = process.env, run = run
141
141
  ...env, PI_WEB_ROOT: installation, PIWEB_BUILD_DIR: buildDir,
142
142
  PYTHONUTF8: env.PYTHONUTF8 ?? "1", PYTHONIOENCODING: env.PYTHONIOENCODING ?? "utf-8",
143
143
  };
144
- // Do not pass the live web password to Git/npm/package build scripts.
145
- delete childEnv.PI_WEB_PASSWORD;
146
144
  delete childEnv.PI_WEB_DEV;
147
145
  delete childEnv.NODE_ENV;
148
146
  const context = { root: installation, buildDir, env: childEnv, run, signal };
@@ -1,4 +1,4 @@
1
- import { buildSnapshot, getManaged, subscribe, unsubscribe } from "@/lib/agent-manager";
1
+ import { buildSnapshot, getManaged, parseLastEventId, sseEventId, subscribe, unsubscribe } from "@/lib/agent-manager";
2
2
  import { BoundaryError, resolveSessionPath } from "@/lib/path-security";
3
3
 
4
4
  export const dynamic = "force-dynamic";
@@ -14,10 +14,15 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
14
14
  } catch (error) {
15
15
  return new Response(error instanceof BoundaryError ? error.message : "invalid session", { status: 400 });
16
16
  }
17
- const m = getManaged(sessionPath);
18
- const rawLastEventId = req.headers.get("last-event-id");
19
- const parsedLastEventId = rawLastEventId === null ? undefined : Number.parseInt(rawLastEventId, 10);
20
- const lastEventId = Number.isSafeInteger(parsedLastEventId) && (parsedLastEventId ?? -1) >= 0 ? parsedLastEventId : undefined;
17
+ let m: ReturnType<typeof getManaged>;
18
+ try {
19
+ m = getManaged(sessionPath);
20
+ } catch (error) {
21
+ // 待落盘的会话在服务重启后就不存在了:给明确的 404,而不是未捕获异常的 500
22
+ return new Response(error instanceof BoundaryError ? error.message : "session unavailable", { status: error instanceof BoundaryError ? 404 : 500 });
23
+ }
24
+ // id 带着这份会话的 epoch:服务重启或会话被回收重开后,旧的 Last-Event-ID 不会被误续到新一轮事件上
25
+ const lastEventId = parseLastEventId(m, req.headers.get("last-event-id"));
21
26
 
22
27
  const encoder = new TextEncoder();
23
28
  let closed = false;
@@ -41,7 +46,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
41
46
  }
42
47
  };
43
48
  subscriber = (seq: number, json: string) => {
44
- write(`id: ${seq}\ndata: ${json}\n\n`);
49
+ write(`id: ${sseEventId(m, seq)}\ndata: ${json}\n\n`);
45
50
  if (json.includes('"state":"disposed"')) close();
46
51
  };
47
52
  const send = subscriber;
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { NextResponse } from "next/server";
5
+ import { BoundaryError } from "@/lib/path-security";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ /**
10
+ * 只读的目录枚举,供应用内的工作区选择器用(手机等远程设备)。
11
+ *
12
+ * 只列目录、不读内容:选择器的产出是一个路径,随后仍由 resolveWorkspacePath 校验
13
+ * (必须绝对路径、必须存在、必须是目录)才写进工作区注册表。
14
+ */
15
+
16
+ /** 单个目录最多返回多少条,避免在手机上拉一个几万项的目录 */
17
+ const MAX_ENTRIES = 2000;
18
+
19
+ interface FsEntry {
20
+ name: string;
21
+ path: string;
22
+ }
23
+
24
+ async function isDirectory(target: string): Promise<boolean> {
25
+ try {
26
+ return (await fs.stat(target)).isDirectory();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** 设备的起点:Windows 逐个探测盘符,macOS 列 /Volumes,其余从主目录与根开始 */
33
+ async function listRoots(): Promise<FsEntry[]> {
34
+ const roots: FsEntry[] = [];
35
+ if (process.platform === "win32") {
36
+ // 26 个盘符一起探:断开的网络盘、没插卡的读卡器一个就能卡上几秒,逐个探会把等待叠加起来
37
+ const drives = Array.from({ length: 26 }, (_, i) => `${String.fromCharCode(65 + i)}:\\`);
38
+ const present = await Promise.all(drives.map(isDirectory));
39
+ return drives.filter((_, i) => present[i]).map((drive) => ({ name: drive, path: drive }));
40
+ }
41
+ if (process.platform === "darwin") {
42
+ const volumes = await fs.readdir("/Volumes", { withFileTypes: true }).catch(() => []);
43
+ for (const entry of volumes) {
44
+ if (entry.isDirectory() || entry.isSymbolicLink()) roots.push({ name: entry.name, path: path.join("/Volumes", entry.name) });
45
+ }
46
+ }
47
+ const home = os.homedir();
48
+ if (await isDirectory(home)) roots.push({ name: home, path: home });
49
+ if (await isDirectory("/")) roots.push({ name: "/", path: "/" });
50
+ return roots;
51
+ }
52
+
53
+ function directoryError(error: unknown): BoundaryError {
54
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
55
+ if (code === "ENOENT") return new BoundaryError("目录不存在");
56
+ if (code === "EACCES" || code === "EPERM") return new BoundaryError("没有权限读取此目录");
57
+ if (code === "ENOTDIR") return new BoundaryError("这不是一个目录");
58
+ return new BoundaryError(error instanceof Error ? error.message : String(error));
59
+ }
60
+
61
+ async function listDirectory(target: unknown) {
62
+ if (target !== undefined && (typeof target !== "string" || target.length === 0 || target.length > 4096)) {
63
+ throw new BoundaryError("invalid path");
64
+ }
65
+ const requested = typeof target === "string" ? target : os.homedir();
66
+ if (!path.isAbsolute(requested)) throw new BoundaryError("path must be absolute");
67
+ // realpath 同时做两件事:解析链接,以及确认目录真的存在
68
+ const resolved = await fs.realpath(requested).catch((error: unknown) => {
69
+ throw directoryError(error);
70
+ });
71
+ let stat;
72
+ try {
73
+ stat = await fs.stat(resolved);
74
+ } catch (error) {
75
+ throw directoryError(error);
76
+ }
77
+ if (!stat.isDirectory()) throw new BoundaryError("这不是一个目录");
78
+
79
+ const dirents = await fs.readdir(resolved, { withFileTypes: true }).catch((error: unknown) => {
80
+ throw directoryError(error);
81
+ });
82
+ const entries: FsEntry[] = [];
83
+ for (const entry of dirents) {
84
+ // 符号链接也列出来(macOS 的 /Volumes 全是链接);能否进入由下一次 list 决定
85
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
86
+ entries.push({ name: entry.name, path: path.join(resolved, entry.name) });
87
+ }
88
+ entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
89
+ const parent = path.dirname(resolved);
90
+ return {
91
+ path: resolved,
92
+ parent: parent === resolved ? null : parent,
93
+ entries: entries.slice(0, MAX_ENTRIES),
94
+ truncated: entries.length > MAX_ENTRIES,
95
+ };
96
+ }
97
+
98
+ export async function POST(req: Request) {
99
+ let body: unknown;
100
+ try {
101
+ body = await req.json();
102
+ } catch {
103
+ return NextResponse.json({ success: false, error: "invalid JSON body" }, { status: 400 });
104
+ }
105
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
106
+ return NextResponse.json({ success: false, error: "expected an object body" }, { status: 400 });
107
+ }
108
+ const { action, path: target } = body as { action?: unknown; path?: unknown };
109
+ try {
110
+ if (action === "roots") return NextResponse.json({ success: true, data: { roots: await listRoots() } });
111
+ if (action === "list") return NextResponse.json({ success: true, data: await listDirectory(target) });
112
+ return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
113
+ } catch (error) {
114
+ return NextResponse.json(
115
+ { success: false, error: error instanceof Error ? error.message : String(error) },
116
+ { status: error instanceof BoundaryError ? 400 : 500 },
117
+ );
118
+ }
119
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { getManaged, growthRecord } from "@/lib/agent-manager";
2
+ import { getManaged, growthConsent, growthRecord } from "@/lib/agent-manager";
3
3
  import { GrowthError, changesBetween, fileContent, filePatch, isAvailable, listTree, readRounds } from "@/lib/growth-service";
4
4
  import { BoundaryError, resolveSessionPath, resolveWorkspacePath } from "@/lib/path-security";
5
5
 
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
7
7
 
8
8
  function status(err: unknown): number {
9
9
  if (err instanceof BoundaryError) return 400;
10
- if (err instanceof GrowthError) return err.code === "not-found" ? 404 : err.code === "unavailable" ? 503 : err.code === "too-large" ? 413 : 500;
10
+ if (err instanceof GrowthError) return err.code === "not-found" ? 404 : err.code === "unavailable" ? 503 : err.code === "too-large" ? 413 : err.code === "needs-consent" ? 409 : 500;
11
11
  return 500;
12
12
  }
13
13
 
@@ -36,13 +36,18 @@ export async function GET(req: Request) {
36
36
  }
37
37
  }
38
38
 
39
- /** POST { action: "record", session: <会话 id> }:立即记录,把当前改动提交为「你的修改」(并广播给会话订阅者) */
39
+ /**
40
+ * POST { action: "record", session: <会话 id> }:立即记录,把当前改动提交为「你的修改」(并广播给会话订阅者)
41
+ * POST { action: "consent", session, allow: boolean }:回答「要不要在工作区建 git 仓库」;同意时立即拍基线
42
+ */
40
43
  export async function POST(req: Request) {
41
44
  try {
42
- const body = (await req.json()) as { action?: string; session?: unknown };
43
- if (body.action !== "record") return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
45
+ const body = (await req.json()) as { action?: string; session?: unknown; allow?: unknown };
46
+ if (body.action !== "record" && body.action !== "consent") return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
47
+ if (body.action === "consent" && typeof body.allow !== "boolean") return NextResponse.json({ success: false, error: "allow must be a boolean" }, { status: 400 });
44
48
  const sessionPath = await resolveSessionPath(String(body.session ?? ""), { allowPending: true });
45
- const round = await growthRecord(getManaged(sessionPath));
49
+ const m = getManaged(sessionPath);
50
+ const round = body.action === "consent" ? await growthConsent(m, body.allow as boolean) : await growthRecord(m);
46
51
  return NextResponse.json({ success: true, data: { round } });
47
52
  } catch (err: any) {
48
53
  return NextResponse.json({ success: false, error: String(err?.message ?? err) }, { status: err instanceof SyntaxError ? 400 : status(err) });
@@ -9,6 +9,7 @@ import {
9
9
  listModels,
10
10
  listModelsForPiWeb,
11
11
  loginState,
12
+ modelListStamp,
12
13
  providerUsage,
13
14
  readCustomProviders,
14
15
  removeApiKey,
@@ -19,17 +20,28 @@ import {
19
20
 
20
21
  export const dynamic = "force-dynamic";
21
22
 
22
- /** 1300+ 个模型的目录有 240KB~420KB;Next 的压缩层实测没有压这条响应,这里自己 gzip(浏览器透明解压) */
23
- function jsonMaybeGzip(req: Request, payload: unknown): Response {
23
+ /**
24
+ * 完整目录 482KB、精简目录 271KB,未压缩发送太浪费;这里自己 gzip。
25
+ * gzip 是主线程同步操作,设置面板开关一次会连着请求好几次,所以按目录版本戳复用压好的字节:
26
+ * 目录没变(5 秒 TTL 内、且没人改配置)就不再重新压一遍。
27
+ */
28
+ const gzipCache = new Map<string, { at: number; body: Uint8Array }>();
29
+ const GZIP_CACHE_TTL_MS = 5_000;
30
+
31
+ function jsonMaybeGzip(req: Request, payload: unknown, stamp: string): Response {
24
32
  const body = JSON.stringify(payload);
25
33
  const accept = req.headers.get("accept-encoding") ?? "";
26
- if (!/\bgzip\b/.test(accept) || body.length < 2048) {
27
- return new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8" } });
34
+ const headers = { "Content-Type": "application/json; charset=utf-8", Vary: "Accept-Encoding", "Cache-Control": "no-store" };
35
+ if (!/\bgzip\b/.test(accept) || body.length < 2048) return new Response(body, { headers });
36
+ const key = `${stamp}:${body.length}`;
37
+ const cached = gzipCache.get(key);
38
+ if (cached && Date.now() - cached.at < GZIP_CACHE_TTL_MS) {
39
+ return new Response(cached.body.slice(), { headers: { ...headers, "Content-Encoding": "gzip" } });
28
40
  }
29
- const gz = gzipSync(Buffer.from(body, "utf8"));
30
- return new Response(new Uint8Array(gz), {
31
- headers: { "Content-Type": "application/json; charset=utf-8", "Content-Encoding": "gzip", Vary: "Accept-Encoding", "Cache-Control": "no-store" },
32
- });
41
+ const gz = new Uint8Array(gzipSync(Buffer.from(body, "utf8")));
42
+ if (gzipCache.size > 8) gzipCache.clear();
43
+ gzipCache.set(key, { at: Date.now(), body: gz });
44
+ return new Response(gz.slice(), { headers: { ...headers, "Content-Encoding": "gzip" } });
33
45
  }
34
46
 
35
47
  export async function GET(req: Request) {
@@ -46,7 +58,7 @@ export async function GET(req: Request) {
46
58
  const providers = full
47
59
  ? data.providers
48
60
  : data.providers.map((p) => ({ id: p.id, name: p.name, authReady: p.authReady, builtIn: p.builtIn, modelCount: p.modelCount }));
49
- return jsonMaybeGzip(req, { success: true, data: { providers, models, customProviders } });
61
+ return jsonMaybeGzip(req, { success: true, data: { providers, models, customProviders } }, `${modelListStamp()}:${full ? "full" : "piweb"}`);
50
62
  } catch (err: any) {
51
63
  return NextResponse.json({ success: false, error: String(err?.message ?? err) }, { status: 500 });
52
64
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 「远程连接」内置插件的控制接口。
3
+ *
4
+ * GET 当前状态(局域网入口 + 公网入口)
5
+ * POST { action: "start" | "stop" | "start-lan" | "stop-lan" }
6
+ * DELETE 全停
7
+ *
8
+ * 「谁能调这个接口」由 proxy.ts 把关;但「现在能不能开入口」必须在这里再看一次:
9
+ * 认证没开时 proxy 一律放行,这时开出去的局域网 / 公网入口就是一个免密 shell。
10
+ */
11
+ import { NextResponse } from "next/server";
12
+ import { remoteBlocked, remoteStatus, startLan, startPublic, stopAll, stopPublic } from "@/lib/remote/supervisor";
13
+
14
+ export const dynamic = "force-dynamic";
15
+
16
+ const NO_STORE = { "Cache-Control": "no-store" };
17
+
18
+ function respond() {
19
+ return NextResponse.json({ success: true, data: remoteStatus() }, { headers: NO_STORE });
20
+ }
21
+
22
+ function bad(error: string, status = 400) {
23
+ return NextResponse.json({ success: false, error }, { status, headers: NO_STORE });
24
+ }
25
+
26
+ export async function GET() {
27
+ return respond();
28
+ }
29
+
30
+ export async function POST(request: Request) {
31
+ let body: unknown;
32
+ try {
33
+ body = await request.json();
34
+ } catch {
35
+ return bad("invalid JSON body");
36
+ }
37
+ const action = typeof body === "object" && body !== null && !Array.isArray(body)
38
+ ? (body as { action?: unknown }).action
39
+ : undefined;
40
+ if (action === "start" || action === "start-lan") {
41
+ const blocked = remoteBlocked();
42
+ if (blocked === "auth") return bad("remote access requires authentication (PI_WEB_AUTH=1)", 409);
43
+ if (blocked === "dev") return bad("remote access is disabled in development mode", 409);
44
+ }
45
+ switch (action) {
46
+ case "start":
47
+ await startLan();
48
+ await startPublic();
49
+ return respond();
50
+ case "stop":
51
+ await stopPublic();
52
+ return respond();
53
+ case "start-lan":
54
+ await startLan();
55
+ return respond();
56
+ case "stop-lan":
57
+ await stopAll();
58
+ return respond();
59
+ default:
60
+ return bad("unknown action");
61
+ }
62
+ }
63
+
64
+ export async function DELETE() {
65
+ await stopAll();
66
+ return respond();
67
+ }