@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
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
+ import { invalidateJsonFile, readJsonObjectFresh, readJsonObjectSafe } from "./agent-config";
7
8
  import { getAgentDir, getPackageManager, getResourceLoader, getSettingsManager, reloadAllLoaders, resourceLoaderReady } from "./pi";
8
9
  import { reloadSessionsForCwd } from "./agent-manager";
9
10
  import { flushSettingsOrThrow, withExternalSettingsLock, withSettingsWriteLock } from "./settings-write-lock";
@@ -34,6 +35,12 @@ function extensionPattern(extPath: string, baseDir: string): string {
34
35
  return rel && !rel.startsWith("..") ? rel : extPath.replace(/\\/g, "/");
35
36
  }
36
37
 
38
+ /** SDK 内置扩展的路径前缀(builtin:mcp / builtin:codemode / builtin:tool-search) */
39
+ const BUILTIN_PREFIX = "builtin:";
40
+
41
+ /** settings.extensions 里针对这个内置扩展的 +/-/! 覆盖项 */
42
+ const isBuiltinOverride = (pattern: string, extPath: string) => /^[+!-]/.test(pattern) && pattern.slice(1) === extPath;
43
+
37
44
  function isForceExcluded(extPath: string, patterns: string[], baseDir: string): boolean {
38
45
  const rel = path.relative(baseDir, extPath).replace(/\\/g, "/");
39
46
  const abs = extPath.replace(/\\/g, "/");
@@ -48,24 +55,12 @@ async function applyResourceChange(cwd: string, scope: "user" | "project"): Prom
48
55
  }
49
56
 
50
57
  async function readSettingsFile(filePath: string): Promise<Record<string, unknown>> {
51
- try {
52
- return await readSettingsFileStrict(filePath);
53
- } catch {
54
- return {};
55
- }
58
+ return readJsonObjectSafe(filePath);
56
59
  }
57
60
 
61
+ /** 只用在写路径(预检、改写、落盘核对):SDK 刚 flush 过的文件不能拿缓存里的旧版来判断 */
58
62
  async function readSettingsFileStrict(filePath: string): Promise<Record<string, unknown>> {
59
- let text: string;
60
- try {
61
- text = await fs.readFile(filePath, "utf8");
62
- } catch (error) {
63
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
64
- throw error;
65
- }
66
- const parsed: unknown = JSON.parse(text.replace(/^\uFEFF/, ""));
67
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${filePath} must contain a JSON object`);
68
- return parsed as Record<string, unknown>;
63
+ return readJsonObjectFresh(filePath);
69
64
  }
70
65
 
71
66
  async function prepareSettingsWrite(cwd: string, file: string): Promise<ReturnType<typeof getSettingsManager>> {
@@ -93,6 +88,7 @@ async function writeSettingsFile(filePath: string, data: Record<string, unknown>
93
88
  await fs.rename(temporary, filePath);
94
89
  } finally {
95
90
  await fs.rm(temporary, { force: true }).catch(() => undefined);
91
+ invalidateJsonFile(filePath);
96
92
  }
97
93
  }
98
94
 
@@ -185,7 +181,7 @@ export async function listLoadedExtensions(cwd: string): Promise<ExtensionView[]
185
181
  for (const e of result?.extensions ?? []) {
186
182
  const p = String(e.path ?? "");
187
183
  if (!p) continue;
188
- const name = String(e.name ?? path.basename(p).replace(/\.(ts|js)$/, ""));
184
+ const name = p.startsWith(BUILTIN_PREFIX) ? p.slice(BUILTIN_PREFIX.length) : String(e.name ?? path.basename(p).replace(/\.(ts|js)$/, ""));
189
185
  seenPaths.add(p);
190
186
  out.push({ name, path: p, disabled: false });
191
187
  }
@@ -200,6 +196,13 @@ export async function listLoadedExtensions(cwd: string): Promise<ExtensionView[]
200
196
  ...projectPatterns.filter((p) => p.startsWith("-")).map((p) => ({ pattern: p.slice(1), base: projectBase })),
201
197
  ];
202
198
  for (const { pattern, base } of excluded) {
199
+ // 被停用的内置扩展:路径就是 `builtin:<name>`,不能按目录解析
200
+ if (pattern.startsWith(BUILTIN_PREFIX)) {
201
+ if (seenPaths.has(pattern)) continue;
202
+ seenPaths.add(pattern);
203
+ out.push({ name: pattern.slice(BUILTIN_PREFIX.length), path: pattern, disabled: true });
204
+ continue;
205
+ }
203
206
  const abs = path.isAbsolute(pattern) ? pattern : path.resolve(base, pattern);
204
207
  if (seenPaths.has(abs)) continue;
205
208
  seenPaths.add(abs);
@@ -215,33 +218,62 @@ export async function togglePackage(
215
218
  cwd: string
216
219
  ): Promise<void> {
217
220
  const settingsPath = scope === "project" ? path.join(cwd, ".pi", "settings.json") : path.join(getAgentDir(), "settings.json");
218
- await withSettingsWriteLock(settingsPath, () => withExternalSettingsLock(settingsPath, async () => {
219
- const settings = await readSettingsFileStrict(settingsPath);
220
- const sourceOf = (p: unknown) => (typeof p === "string" ? p : (p as { source?: string } | null)?.source);
221
- const packages: unknown[] = Array.isArray(settings.packages) ? settings.packages : [];
222
- // 停用的包原样存到 disabledPackages(PiWeb 私有键,pi 不读)
223
- const disabledPackages: unknown[] = Array.isArray(settings.disabledPackages) ? settings.disabledPackages : [];
224
- if (disable) {
225
- const moved = packages.filter((p) => sourceOf(p) === source);
226
- settings.packages = packages.filter((p) => sourceOf(p) !== source);
227
- settings.disabledPackages = [...disabledPackages.filter((p) => sourceOf(p) !== source), ...(moved.length ? moved : [source])];
228
- } else {
229
- const restored = disabledPackages.filter((p) => sourceOf(p) === source);
230
- settings.disabledPackages = disabledPackages.filter((p) => sourceOf(p) !== source);
231
- settings.packages = packages.some((p) => sourceOf(p) === source) ? packages : [...packages, ...(restored.length ? restored : [source])];
232
- }
233
- await writeSettingsFile(settingsPath, settings);
234
- }));
221
+ await withSettingsWriteLock(settingsPath, async () => {
222
+ // 直接改文件之前先让 SettingsManager 把还没落盘的改动写完:不然它稍后落盘时
223
+ // 会拿内存里的旧 packages 盖掉这次的修改(写完后 applyResourceChange 会让所有实例重新读盘)
224
+ await prepareSettingsWrite(cwd, settingsPath);
225
+ await withExternalSettingsLock(settingsPath, () => movePackage(settingsPath, source, disable));
226
+ });
235
227
  await applyResourceChange(cwd, scope);
236
228
  }
237
229
 
230
+ async function movePackage(settingsPath: string, source: string, disable: boolean): Promise<void> {
231
+ const settings = await readSettingsFileStrict(settingsPath);
232
+ const sourceOf = (p: unknown) => (typeof p === "string" ? p : (p as { source?: string } | null)?.source);
233
+ const packages: unknown[] = Array.isArray(settings.packages) ? settings.packages : [];
234
+ // 停用的包原样存到 disabledPackages(PiWeb 私有键,pi 不读)
235
+ const disabledPackages: unknown[] = Array.isArray(settings.disabledPackages) ? settings.disabledPackages : [];
236
+ if (disable) {
237
+ const moved = packages.filter((p) => sourceOf(p) === source);
238
+ settings.packages = packages.filter((p) => sourceOf(p) !== source);
239
+ settings.disabledPackages = [...disabledPackages.filter((p) => sourceOf(p) !== source), ...(moved.length ? moved : [source])];
240
+ } else {
241
+ const restored = disabledPackages.filter((p) => sourceOf(p) === source);
242
+ settings.disabledPackages = disabledPackages.filter((p) => sourceOf(p) !== source);
243
+ settings.packages = packages.some((p) => sourceOf(p) === source) ? packages : [...packages, ...(restored.length ? restored : [source])];
244
+ }
245
+ await writeSettingsFile(settingsPath, settings);
246
+ }
247
+
248
+ /**
249
+ * 内置扩展的开关,与 `pi config` 一致:默认写全局 settings 的 `-builtin:<name>`。
250
+ * 项目 settings 已经有这一项的 +/-/! 覆盖时改项目那条(项目覆盖优先于全局),并写成显式的 +/-,才盖得过全局。
251
+ */
252
+ async function toggleBuiltinExtension(extPath: string, disable: boolean, cwd: string): Promise<void> {
253
+ const isProject = (getSettingsManager(cwd).getProjectSettings().extensions ?? []).some((p) => isBuiltinOverride(p, extPath));
254
+ const settingsPath = isProject ? path.join(cwd, ".pi", "settings.json") : path.join(getAgentDir(), "settings.json");
255
+ await withSettingsWriteLock(settingsPath, async () => {
256
+ const sm = await prepareSettingsWrite(cwd, settingsPath);
257
+ const current = (isProject ? sm.getProjectSettings().extensions : sm.getGlobalSettings().extensions) ?? [];
258
+ const rest = current.filter((p) => !isBuiltinOverride(p, extPath));
259
+ const next = isProject ? [...rest, `${disable ? "-" : "+"}${extPath}`] : disable ? [...rest, `-${extPath}`] : rest;
260
+ if (isProject) sm.setProjectExtensionPaths(next);
261
+ else sm.setExtensionPaths(next);
262
+ await flushSettingsOrThrow(sm);
263
+ await verifySettingsField(settingsPath, "extensions", next);
264
+ });
265
+ await applyResourceChange(cwd, isProject ? "project" : "user");
266
+ }
267
+
238
268
  export async function toggleExtension(
239
269
  extPath: string,
240
270
  disable: boolean,
241
271
  cwd: string
242
272
  ): Promise<void> {
243
- const norm = extPath.replace(/\\/g, "/").toLowerCase();
244
- const isProject = norm.startsWith(`${path.resolve(cwd).replace(/\\/g, "/").toLowerCase()}/`);
273
+ if (extPath.startsWith(BUILTIN_PREFIX)) return toggleBuiltinExtension(extPath, disable, cwd);
274
+ // 只有 Windows 的路径不分大小写:Linux/mac 上 /Work 与 /work 是两个目录
275
+ const fold = (p: string) => (process.platform === "win32" ? p.replace(/\\/g, "/").toLowerCase() : p.replace(/\\/g, "/"));
276
+ const isProject = fold(extPath).startsWith(`${fold(path.resolve(cwd))}/`);
245
277
  const baseDir = isProject ? path.join(cwd, ".pi") : getAgentDir();
246
278
  const settingsPath = isProject ? path.join(cwd, ".pi", "settings.json") : path.join(getAgentDir(), "settings.json");
247
279
  await withSettingsWriteLock(settingsPath, async () => {
@@ -0,0 +1,4 @@
1
+ {
2
+ "css": "/*\n * 手机端适配。不改 PiWeb,只在经代理的远程页面上生效\n * (本机直连 127.0.0.1:30141 的桌面页面完全不加载它)。\n *\n * 取值参照 dsh-remote 为 DSH 定的那套移动适配(同作者),并针对 PiWeb 的实测问题:\n * 390px 下输入框 304px、markdown 表格 442px 超出视口、底部统计行被截断。\n */\n@media (max-width: 840px) {\n\t/* 1) 输入框自适应高度失控。\n\t 实测:视口 390 → 304px,500 → 43px,700+ → 22px;而自然内容高始终 22px。\n\t 原因在 ChatInput 的 autoSize():它把\"被 flex 拉伸后\"的 scrollHeight 写进了\n\t inline height,写回后又把自己撑住,形成自锁。inline 样式不是 important,\n\t 所以这里用 max-height !important 把它夹住。 */\n\ttextarea {\n\t\tmax-height: 132px !important;\n\t\toverflow-y: auto !important;\n\t}\n\n\t/* 2) Markdown 宽表格超出视口被裁掉(实测 442 \u003e 390,body 又是 overflow-x:hidden,\n\t 等于右侧内容既看不到也滑不到)。让表格自己横向滚动。 */\n\t.md table {\n\t\tdisplay: block !important;\n\t\tmax-width: 100% !important;\n\t\toverflow-x: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\n\t/* 3) 全局溢出护栏(dsh-remote 同款):任何一块都不许把文档撑宽 */\n\thtml,\n\tbody {\n\t\tmax-width: 100vw !important;\n\t\toverflow-x: hidden !important;\n\t}\n\tpre {\n\t\tmax-width: 100% !important;\n\t\toverflow-x: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\timg,\n\tsvg,\n\tvideo,\n\tcanvas {\n\t\tmax-width: 100% !important;\n\t}\n\n\t/* 4) 底部统计行原本 nowrap + ellipsis,手机上「| 输入 2…」之后永远看不到。\n\t 改成可横向滑动,滚动条隐藏,信息不再丢。 */\n\tdiv.text-center \u003e div.overflow-hidden.text-ellipsis.whitespace-nowrap {\n\t\toverflow-x: auto !important;\n\t\toverflow-y: hidden !important;\n\t\ttext-overflow: clip !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t\tscrollbar-width: none;\n\t}\n\tdiv.text-center \u003e div.overflow-hidden.text-ellipsis.whitespace-nowrap::-webkit-scrollbar {\n\t\tdisplay: none;\n\t}\n\n\t/* 5) 给手机的手势条留位置(没有 insets 的设备 env() 为 0,等于没写) */\n\t.pi-main {\n\t\tpadding-bottom: env(safe-area-inset-bottom) !important;\n\t}\n\n\t/* 6) 左上角的抽屉按钮。原来是个悬浮的圆角方块(12px/12px、34px),盖在内容上、\n\t 和右边那排图标也不在一条水平线上。按 dsh-remote 对 DSH 左侧的做法收成贴角圆形,\n\t 尺寸和位置改成与右侧图标完全一致(实测右侧图标是 top 12 / 30×30,中心 y=27),\n\t 这样整行对齐,按钮底边 42 也在头部分隔线(y=43)之上,不会压住那条线。 */\n\t.pi-mobile-menu {\n\t\twidth: 30px !important;\n\t\theight: 30px !important;\n\t\tborder-radius: 999px !important;\n\t\tleft: calc(env(safe-area-inset-left) + 12px) !important;\n\t\ttop: calc(env(safe-area-inset-top) + 12px) !important;\n\t}\n\t.pi-main \u003e .hairline-b {\n\t\tpadding-left: 52px !important;\n\t}\n\n\t/* 7) 右侧大纲导轨是绝对定位的(right-2 + 约 34px 宽的竖条),正文和用户气泡\n\t 会从它下面穿过去。不动条宽——那是层级信息——而是把导轨贴边收窄,\n\t 再给滚动区留出它实际占的宽度。 */\n\t.outline-rail {\n\t\tright: 2px !important;\n\t}\n\t.outline-rail \u003e div {\n\t\tpadding-left: 6px !important;\n\t\tpadding-right: 2px !important;\n\t}\n\t.pi-main div.min-h-0.flex-1.overflow-y-auto {\n\t\tpadding-right: 30px !important;\n\t}\n\n\t/* 8) 设置弹窗:高度用 100vh(移动端的大视口),内容列又没有 min-height:0\n\t 也没有 overflow-y,于是 1986px 的内容被 overflow:hidden 裁在 828px 里,\n\t 手机上完全滑不动。实测 client=828 / scroll=1986。 */\n\t.settings-dialog {\n\t\theight: calc(100vh - 16px) !important;\n\t}\n\t.settings-dialog \u003e div:not(.settings-nav) {\n\t\tmin-height: 0 !important;\n\t\toverflow-y: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\n\t/* 9) 模型 / 思考等级浮层锚在胶囊自己的 relative 容器上,right-0 是**胶囊**右缘,\n\t 312px 往左展开就顶出屏幕左边(实测 left 为负)。把胶囊容器解除定位,\n\t 浮层改用整张输入卡(上一层 relative)做定位父级,right-0 落在卡片右缘就装得下。\n\t :has() 在移动端 Safari 15.4+ / Chrome 105+ 都可用。 */\n\tdiv.relative:has(\u003e button.chip[aria-haspopup=\"menu\"]) {\n\t\tposition: static !important;\n\t}\n\n\t/* 10) 设置→模型 的提供方卡片:那一行是 nowrap,中间的名字列被右侧一串按钮挤成\n\t 0 宽(实测 w=0),于是「凭据: auth.json」竖着拆行、「编辑」拆成「编/辑」、\n\t 右侧的按钮还被裁掉。手机上让这行能换行,并给名字列一个真实的最小宽度。 */\n\t.settings-dialog div.flex.items-center.gap-3:has(\u003e div.min-w-0.flex-1) {\n\t\tflex-wrap: wrap !important;\n\t\trow-gap: 8px !important;\n\t}\n\t.settings-dialog div.flex.items-center.gap-3:has(\u003e div.min-w-0.flex-1) \u003e div.min-w-0.flex-1 {\n\t\tflex: 1 1 140px !important;\n\t\tmin-width: 140px !important;\n\t}\n\n\t/* 11) 设置里 Row 那一套([文字列][控件列] 两栏)。控件列是 flex-none,\n\t 有的行右侧很宽(「开关 + 两个数字输入框」≈272px、「繁忙时 Enter」的下拉≈233px),\n\t 于是 390px 下文字列只剩 70px 左右,中文按每行一两字竖排,行高被撑到\n\t 122px / 218px(实测 70×122、69×218)。\n\t 试过的几种写法与实测结论:\n\t - 只给文字列 flex 增长权重:完全没用(那行没有空闲空间)\n\t - 只缩小 gap:只能缓解(70×122 → 86×98)\n\t - 让所有 Row 都上下排(flex:1 1 100%):能修好,但把本来正常的行也撑高\n\t (界面语言 53→85、字体步进 95→105),是密度回退\n\t - 正解:文字列 flex-basis 设 0、只留 min-width 当\"不许被压到多窄\"的下限。\n\t 换行判断用的是 flex-basis(max-content 会很大,所以上一版全换行了),\n\t 设成 0 之后判断就只看 min-width:\n\t 控件窄的行(界面语言 77px、字体步进 116px)→ 仍并排,保持紧凑\n\t 控件宽的行(272px / 233px)→ 控件换到下一行,文字拿满 326px\n\t 实测:界面语言 53 不变、界面字体 95→71、繁忙时Enter 239→135、\n\t 自动压缩 143→111、自动重试 119→111,全部不再竖排。 */\n\t.settings-dialog div.hairline-b.gap-6 {\n\t\tflex-wrap: wrap !important;\n\t\tgap: 8px !important;\n\t}\n\t.settings-dialog div.hairline-b.gap-6 \u003e div.min-w-0 {\n\t\tflex: 1 1 0 !important;\n\t\tmin-width: 45% !important;\n\t}\n\t.settings-dialog div.hairline-b.gap-6 \u003e div.flex-none \u003e div.flex.items-center {\n\t\tflex-wrap: wrap !important;\n\t\tjustify-content: flex-end !important;\n\t\trow-gap: 6px !important;\n\t}\n\n\t/* 12) 外观主题卡片。卡片是 flex: 1 1 150px 的两列网格,但左右各留 32px 内边距,\n\t 159px 的卡片只剩 95px 给标题,于是「石灰与煤炭」「亚麻与苔绿」掉到第二行,\n\t 卡片高度变成 110px 而其余是 88px,网格参差(实测 6 张卡:4 张 88 / 2 张 110)。\n\t 把左右内边距收到 10px,6 张卡全部回到 88px,标题一行放得下。 */\n\t.settings-dialog button[aria-pressed] {\n\t\tpadding-left: 10px !important;\n\t\tpadding-right: 10px !important;\n\t}\n\n\t/* 13) 可点目标偏小(实测:字体步进器 26×26、reset 48×24、开关 36×20)。\n\t 只扩**热区**,绝不改可见尺寸:开关的滑块位置在 PiWeb 里是写死的\n\t (left: 18px / 2px),把开关拉宽会让滑块错位、变成被拉长的怪药丸\n\t —— 我第一版写的 min-width:44px 就是这个后果,用户报了\"启动按钮好丑\"。\n\t 另外这里只做**纵向**扩张(左右不扩):字体步进器 −/+/ 之间只有 8px,\n\t 横向扩到 44px 会让两个按钮的热区重叠约 10px,点 − 反而命中 +。 */\n\t.settings-dialog .icon-btn,\n\t.settings-dialog button.h-6,\n\t.settings-dialog button.h-5,\n\t.settings-dialog [role=\"switch\"] {\n\t\tposition: relative !important;\n\t}\n\t.settings-dialog .icon-btn::after,\n\t.settings-dialog button.h-6::after,\n\t.settings-dialog button.h-5::after,\n\t.settings-dialog [role=\"switch\"]::after {\n\t\tcontent: \"\" !important;\n\t\tposition: absolute !important;\n\t\tleft: 0 !important;\n\t\tright: 0 !important;\n\t\ttop: 50% !important;\n\t\theight: max(100%, 44px) !important;\n\t\ttransform: translateY(-50%) !important;\n\t\tborder-radius: 999px !important;\n\t}\n}\n\n/* 手机上的浮层统一兜底:不许比屏幕宽,不许比屏幕高,超出就自己滚。 */\n@media (max-width: 840px) {\n\t.popover {\n\t\tmax-width: calc(100vw - 16px) !important;\n\t\tmax-height: 420px !important;\n\t\toverflow-y: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n}\n\n/* 11) 模型名太长时跑马灯。取值与做法照搬 dsh-remote 对 DSH 的:\n\t 名字窗口固定 28vw 并允许被压缩,装得下就完全不动;装不下时由注入的\n\t mobile-fit.js 打上 data-piweb-marquee,再由这里的两份伪元素首尾相接匀速左移。\n\t 一份走完另一份正好接上(travel 已含间隔),所以循环处看不出接缝。 */\n@media (max-width: 840px) {\n\tbutton.chip[aria-haspopup=\"menu\"] \u003e span.truncate {\n\t\tmin-width: 0 !important;\n\t\tmax-width: 28vw !important;\n\t}\n\t[data-piweb-marquee] {\n\t\tposition: relative !important;\n\t\tdisplay: block !important;\n\t\twidth: 28vw !important;\n\t\tflex: none !important;\n\t\ttext-overflow: clip !important;\n\t\twhite-space: nowrap !important;\n\t\toverflow: hidden !important;\n\t\ttext-indent: 0 !important;\n\t\tcontain: layout paint !important;\n\t\tcolor: transparent !important;\n\t\tline-height: 20px !important;\n\t}\n\t[data-piweb-marquee]::before,\n\t[data-piweb-marquee]::after {\n\t\tcontent: attr(data-piweb-text);\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tcolor: var(--piweb-marquee-color, var(--dsw-label-primary));\n\t\tfont-size: 13px;\n\t\tline-height: 20px;\n\t\twhite-space: nowrap;\n\t\twill-change: transform;\n\t\ttransform: translate3d(0, 0, 0);\n\t\tanimation: piweb-mfit-marquee var(--piweb-marquee-duration, 8s) linear infinite both;\n\t}\n\t[data-piweb-marquee]::after {\n\t\tleft: var(--piweb-marquee-text, 0px);\n\t}\n\t@keyframes piweb-mfit-marquee {\n\t\tfrom { transform: translate3d(0, 0, 0); }\n\t\tto { transform: translate3d(calc(-1 * var(--piweb-marquee-text, 0px)), 0, 0); }\n\t}\n}\n\n/* dvh 是\"实际可见高度\",比 100vh(移动 Chrome 的大视口)可靠;\n 不支持时上面那两条固定值兜底。 */\n@supports (height: 100dvh) {\n\t@media (max-width: 840px) {\n\t\ttextarea {\n\t\t\tmax-height: min(132px, 26dvh) !important;\n\t\t}\n\t\t.popover {\n\t\t\tmax-height: min(60dvh, 420px) !important;\n\t\t}\n\t\t.settings-dialog {\n\t\t\theight: calc(100dvh - 16px) !important;\n\t\t}\n\t}\n}\n",
3
+ "js": "/*\n * 手机端脚本(配合 mobile-fit.css)。\n *\n * 做两件事:\n * 1) 模型名太长时改成「跑马灯」。做法照搬 dsh-remote 对 DSH 的处理——\n * 名字窗口固定 28vw,装得下就完全不动;装不下时才把两份文本首尾相接匀速左移,\n * 一份走完另一份正好接上,循环没有空档。CSS 负责动画,这里只负责\"量\"和\"打标记\"。\n * 为什么必须用 JS 量:纯 CSS 判断不了\"是否真的被截断\",只能用省略号一裁了之,\n * 名字的尾巴就永远看不到了。\n * 2) 设置里「文字 + 开关」那种行,手机上点文字也能切开关。\n * 手机断点下这行是上下排(见 mobile-fit.css 第 11 条),文字占满一行、开关缩在下面,\n * 而 PiWeb 只让那个 36×20 的小开关可点 —— 用户点最显眼的标题文字毫无反应,\n * 看起来就是\"这个开关坏了\"。实测:点文字 0 次请求,点开关才发请求。\n */\n(function () {\n\t\"use strict\";\n\n\tvar ATTR = \"data-piweb-marquee\";\n\tvar TEXT_ATTR = \"data-piweb-text\";\n\tvar TEXT_VAR = \"--piweb-marquee-text\";\n\tvar DURATION_VAR = \"--piweb-marquee-duration\";\n\tvar COLOR_VAR = \"--piweb-marquee-color\";\n\tvar /** 两份文本之间留的间隔,也是 travel 里多出来的那一段 */\n\t\tGAP = 40;\n\tvar /** 每秒移动多少像素,太长就慢一点 */\n\t\tSPEED = 22;\n\tvar MIN_CYCLE = 6;\n\tvar ROW_BOUND_ATTR = \"data-piweb-row-bound\";\n\n\t/** 模型胶囊里的名字(ModelSelector 里那个 span.truncate) */\n\tfunction label() {\n\t\treturn document.querySelector(\u0027button.chip[aria-haspopup=\"menu\"] \u003e span.truncate\u0027);\n\t}\n\n\tfunction apply() {\n\t\tvar el = label();\n\t\tif (!el) return;\n\t\tvar text = el.textContent || \"\";\n\t\tif (!text) return;\n\n\t\t// 同一个名字已经标过就别再动它:重设属性会把 CSS 动画从头开始,看起来是\"一直重启\"\n\t\tif (el.hasAttribute(ATTR) \u0026\u0026 el.getAttribute(TEXT_ATTR) === text) return;\n\n\t\t// 量必须在\"静止态\"下做:先摘掉标记,否则量到的是被 28vw 框死之后的宽度\n\t\tif (el.hasAttribute(ATTR)) el.removeAttribute(ATTR);\n\t\tvar natural = el.scrollWidth;\n\t\tvar windowWidth = el.clientWidth;\n\t\tif (!windowWidth || natural \u003c= windowWidth + 1) return; // 装得下,什么都不做\n\n\t\tel.style.setProperty(COLOR_VAR, getComputedStyle(el).color);\n\t\tel.style.setProperty(TEXT_VAR, natural + GAP + \"px\");\n\t\tel.style.setProperty(DURATION_VAR, Math.max(MIN_CYCLE, (natural + GAP) / SPEED).toFixed(1) + \"s\");\n\t\tel.setAttribute(TEXT_ATTR, text);\n\t\tel.setAttribute(ATTR, \"\");\n\t}\n\n\t/**\n\t * 设置里「文字 + 开关」的行:把文字列变成开关的\"大号按钮\"。\n\t *\n\t * 只绑文字列(Row 里那个 min-w-0 的直系子元素),不碰控件列:\n\t * 控件列里有数字输入框,整行可点会把\"点输入框\"也变成切开关。\n\t * React 重渲染会换掉节点,所以每次轮询都重查;用属性做幂等标记,重复绑不会叠加。\n\t */\n\tfunction bindSwitchRows() {\n\t\tvar switches = document.querySelectorAll(\u0027.settings-dialog [role=\"switch\"]\u0027);\n\t\tfor (var i = 0; i \u003c switches.length; i++) {\n\t\t\tvar sw = switches[i];\n\t\t\tvar row = sw.closest(\".hairline-b\");\n\t\t\tif (!row || row.getAttribute(ROW_BOUND_ATTR) === \"1\") continue;\n\t\t\t// Row 的文字列:hairline-b 的直系子元素里带 min-w-0 的那个\n\t\t\tvar label = null;\n\t\t\tfor (var c = 0; c \u003c row.children.length; c += 1) {\n\t\t\t\tif (row.children[c].classList.contains(\"min-w-0\")) { label = row.children[c]; break; }\n\t\t\t}\n\t\t\tif (!label) continue;\n\t\t\trow.setAttribute(ROW_BOUND_ATTR, \"1\");\n\t\t\t(function (button, labelEl) {\n\t\t\t\tlabelEl.style.cursor = \"pointer\";\n\t\t\t\tlabelEl.addEventListener(\"click\", function (event) {\n\t\t\t\t\t// 文字里可能有链接/按钮,别抢它们\n\t\t\t\t\tif (event.target.closest(\"a, button, input, select, textarea\")) return;\n\t\t\t\t\tif (button.disabled) return;\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tbutton.click();\n\t\t\t\t});\n\t\t\t})(sw, label);\n\t\t}\n\t}\n\n\t// 模型名会随会话/选择变化,输入卡也会整体重挂载;轮询最省事,且因为有上面的\n\t// 提前返回,稳态下每次只花一个 querySelector。\n\tfunction start() {\n\t\tapply();\n\t\tbindSwitchRows();\n\t\tsetInterval(function () { apply(); bindSwitchRows(); }, 800);\n\t\twindow.addEventListener(\"resize\", apply, { passive: true });\n\t\twindow.addEventListener(\"orientationchange\", apply, { passive: true });\n\t}\n\n\tif (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n\telse start();\n})();"
4
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 手机排版注入代理 —— 「远程连接」内置插件的数据面。
3
+ *
4
+ * 它只做一件事:往 PiWeb 的 HTML 里插一段样式与脚本,其它流量原样转发。
5
+ * 注入只对非回环 Host 生效,所以本机直连的桌面页面拿到的仍是 PiWeb 原样。
6
+ *
7
+ * 流式是硬要求,不能图省事全缓冲:
8
+ * - /api/agent/<id>/events 是 SSE,缓冲住就等于聊天不流式了;
9
+ * - 图片提示词上传可以到 272MB,缓冲住就是内存爆炸。
10
+ * 所以只有 content-type 是 text/html 的响应才读进内存做插入。
11
+ *
12
+ * 样式与脚本以 JSON 内联在 assets.json 里(不是运行时读盘):这样 Next 的打包与
13
+ * 文件追踪都不需要额外配置,发布包里也不会漏掉这两个文件。
14
+ */
15
+ import http from "node:http";
16
+ import { pipeline } from "node:stream";
17
+ import zlib from "node:zlib";
18
+ import { isLoopbackHostname } from "../auth";
19
+ import assets from "./assets.json";
20
+
21
+ export const INJECT_ID = "piweb-mobile-fit";
22
+ export const MOBILE_FIT_CSS: string = assets.css;
23
+ export const MOBILE_FIT_JS: string = assets.js;
24
+
25
+ /** 去掉 Host 头里的端口;裸 IPv6 不能被冒号切坏 */
26
+ function hostWithoutPort(hostHeader: string | undefined): string {
27
+ let host = String(hostHeader ?? "").trim().toLowerCase();
28
+ if (host.startsWith("[")) {
29
+ const end = host.indexOf("]");
30
+ return end >= 0 ? host.slice(1, end) : host.slice(1);
31
+ }
32
+ // 只有一个冒号才是端口分隔符;::1 这种裸 IPv6 保持原样
33
+ return (host.match(/:/g) ?? []).length === 1 ? host.slice(0, host.indexOf(":")) : host;
34
+ }
35
+
36
+ /** 请求是不是来自本机回环(决定要不要注入样式) */
37
+ export function isLoopbackHost(hostHeader: string | undefined): boolean {
38
+ return isLoopbackHostname(hostWithoutPort(hostHeader));
39
+ }
40
+
41
+ export function decodeBody(buffer: Buffer, encoding: string | undefined): Buffer {
42
+ const value = String(encoding ?? "").toLowerCase();
43
+ if (!value || value === "identity") return buffer;
44
+ if (value === "gzip" || value === "x-gzip") return zlib.gunzipSync(buffer);
45
+ if (value === "deflate") return zlib.inflateSync(buffer);
46
+ if (value === "br") return zlib.brotliDecompressSync(buffer);
47
+ throw new Error(`不支持的 content-encoding:${encoding}`);
48
+ }
49
+
50
+ /** 在 </head> 前插入样式与脚本;没有 head 就插在 <body ...> 之后;再没有就前置。 */
51
+ export function injectAssets(html: string, { css = null, js = null }: { css?: string | null; js?: string | null } = {}): string {
52
+ if (!css && !js) return html;
53
+ if (html.includes(`id="${INJECT_ID}"`)) return html;
54
+ const parts: string[] = [];
55
+ if (css) parts.push(`<style id="${INJECT_ID}">\n${css}\n</style>`);
56
+ if (js) parts.push(`<script id="${INJECT_ID}-js">\n${js}\n</script>`);
57
+ const tag = parts.join("\n");
58
+ const head = html.indexOf("</head>");
59
+ if (head >= 0) return `${html.slice(0, head)}${tag}\n${html.slice(head)}`;
60
+ const body = html.indexOf("<body");
61
+ if (body >= 0) {
62
+ const end = html.indexOf(">", body);
63
+ if (end >= 0) return `${html.slice(0, end + 1)}${tag}${html.slice(end + 1)}`;
64
+ }
65
+ return tag + html;
66
+ }
67
+
68
+ /** @deprecated 保留旧名字,测试与调用方还在用 */
69
+ export function injectStyle(html: string, css: string): string {
70
+ return injectAssets(html, { css });
71
+ }
72
+
73
+ export interface MobileProxyOptions {
74
+ targetPort: number;
75
+ targetHost?: string;
76
+ css?: string | null;
77
+ js?: string | null;
78
+ log?: (message: string) => void;
79
+ }
80
+
81
+ export function createMobileProxy({ targetPort, targetHost = "127.0.0.1", css = null, js = null, log = () => {} }: MobileProxyOptions): http.Server {
82
+ return http.createServer((req, res) => {
83
+ // 保留原 Host / Origin 原样转发:PiWeb 的 isSafeHost 与 isSafeOrigin 都要看它们,
84
+ // 改写成回环反而会让写操作被判成跨源。
85
+ const headers: http.OutgoingHttpHeaders = { ...req.headers };
86
+ const styled = Boolean(css || js) && !isLoopbackHost(headers.host as string | undefined);
87
+ if (styled && String(headers.accept ?? "").includes("text/html")) {
88
+ // 想插入就必须拿到未压缩的 HTML;静态资源不受影响,别拖慢传输
89
+ headers["accept-encoding"] = "identity";
90
+ }
91
+
92
+ const upstream = http.request({ host: targetHost, port: targetPort, method: req.method, path: req.url ?? "/", headers }, (up) => {
93
+ const type = String(up.headers["content-type"] ?? "");
94
+ if (!styled || !type.includes("text/html")) {
95
+ res.writeHead(up.statusCode ?? 502, up.headers);
96
+ // pipeline 而不是 pipe:任一端提前断开都把另一端一起拆掉。
97
+ // 手机切后台 / 断网时,到 PiWeb 的 SSE 连接必须跟着断,否则订阅者只增不减。
98
+ pipeline(up, res, () => undefined);
99
+ return;
100
+ }
101
+ const chunks: Buffer[] = [];
102
+ up.on("error", (error) => {
103
+ // 上游中途断开:已经没有完整的 HTML 可插,直接断掉,别让客户端一直挂着
104
+ log(`[远程连接] 上游中途断开:${error.message}`);
105
+ res.destroy();
106
+ });
107
+ up.on("data", (chunk: Buffer) => chunks.push(chunk));
108
+ up.on("end", () => {
109
+ const raw = Buffer.concat(chunks);
110
+ let out: Buffer;
111
+ try {
112
+ out = Buffer.from(injectAssets(decodeBody(raw, up.headers["content-encoding"]).toString("utf8"), { css, js }), "utf8");
113
+ } catch (error) {
114
+ log(`[远程连接] 注入失败,原样返回:${error instanceof Error ? error.message : String(error)}`);
115
+ const plain = { ...up.headers };
116
+ delete plain["content-encoding"];
117
+ delete plain["content-length"];
118
+ res.writeHead(up.statusCode ?? 200, plain);
119
+ res.end(raw);
120
+ return;
121
+ }
122
+ const outHeaders = { ...up.headers };
123
+ delete outHeaders["content-encoding"];
124
+ // 上游多半是 chunked;既然我们给了确定的长度,两个头不能并存(否则客户端报
125
+ // HPE_INVALID_CONTENT_LENGTH)。
126
+ delete outHeaders["transfer-encoding"];
127
+ delete outHeaders.etag; // 内容变了,原来的 ETag 不再成立
128
+ outHeaders["content-length"] = String(out.byteLength);
129
+ outHeaders["cache-control"] = "no-store";
130
+ res.writeHead(up.statusCode ?? 200, outHeaders);
131
+ res.end(out);
132
+ });
133
+ });
134
+ let clientGone = false;
135
+ upstream.on("error", (error) => {
136
+ if (clientGone) return; // 是我们自己因为客户端走了才拆的,不是 PiWeb 连不上
137
+ log(`[远程连接] 代理连不上 PiWeb:${error.message}`);
138
+ if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
139
+ res.end(`[远程连接] 连不上 PiWeb(${error.message})`);
140
+ });
141
+ // 客户端在上游回应前就走了(或缓冲 HTML 时走了):上游请求也撤掉,别继续占着 PiWeb
142
+ res.on("close", () => {
143
+ if (res.writableFinished) return;
144
+ clientGone = true;
145
+ upstream.destroy();
146
+ });
147
+ req.pipe(upstream);
148
+ });
149
+ }