@rexvane/piweb 0.3.24 → 0.3.26

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 (285) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +11 -11
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +5 -5
  5. package/.next/react-loadable-manifest.json +13 -20
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +6 -8
  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 +2 -2
  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/agent/resources/route.js +1 -0
  32. package/.next/server/app/api/agent/resources/route.js.nft.json +1 -0
  33. package/.next/server/app/api/agent/resources/route_client-reference-manifest.js +1 -0
  34. package/.next/server/app/api/config/route.js +1 -1
  35. package/.next/server/app/api/config/route.js.nft.json +1 -1
  36. package/.next/server/app/api/files/route.js +1 -1
  37. package/.next/server/app/api/files/route.js.nft.json +1 -1
  38. package/.next/server/app/api/fs/route.js +1 -1
  39. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  40. package/.next/server/app/api/git/route.js +4 -4
  41. package/.next/server/app/api/git/route.js.nft.json +1 -1
  42. package/.next/server/app/api/github/route.js +4 -4
  43. package/.next/server/app/api/github/route.js.nft.json +1 -1
  44. package/.next/server/app/api/growth/route.js +1 -1
  45. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  46. package/.next/server/app/api/health/route.js +1 -1
  47. package/.next/server/app/api/models/defaults/route.js +1 -1
  48. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
  49. package/.next/server/app/api/models/route.js +1 -1
  50. package/.next/server/app/api/models/route.js.nft.json +1 -1
  51. package/.next/server/app/api/pi-settings/route.js +1 -1
  52. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  53. package/.next/server/app/api/plugins/route.js +1 -1
  54. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  55. package/.next/server/app/api/prompts/route.js +1 -1
  56. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  57. package/.next/server/app/api/remote/route.js +4 -3
  58. package/.next/server/app/api/security/route.js +1 -1
  59. package/.next/server/app/api/security/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  61. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  63. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  64. package/.next/server/app/api/sessions/import/route.js +2 -2
  65. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  66. package/.next/server/app/api/sessions/route.js +1 -1
  67. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  68. package/.next/server/app/api/skills/route.js +2 -2
  69. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  70. package/.next/server/app/api/update/route.js +2 -2
  71. package/.next/server/app/api/update/route.js.nft.json +1 -1
  72. package/.next/server/app/api/version/route.js +1 -1
  73. package/.next/server/app/api/version/route.js.nft.json +1 -1
  74. package/.next/server/app/api/web-auth/route.js +2 -2
  75. package/.next/server/app/api/workspaces/route.js +1 -1
  76. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  77. package/.next/server/app/icon.svg/route.js +1 -1
  78. package/.next/server/app/index.html +1 -1
  79. package/.next/server/app/index.rsc +6 -7
  80. package/.next/server/app/index.segments/__PAGE__.segment.rsc +6 -7
  81. package/.next/server/app/index.segments/_full.segment.rsc +6 -7
  82. package/.next/server/app/index.segments/_tree.segment.rsc +2 -3
  83. package/.next/server/app/login/page.js +1 -1
  84. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/login.html +1 -1
  86. package/.next/server/app/login.rsc +3 -3
  87. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  88. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  89. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  90. package/.next/server/app/page.js +1 -1
  91. package/.next/server/app/page.js.nft.json +1 -1
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app-paths-manifest.json +11 -11
  94. package/.next/server/chunks/3924.js +3 -3
  95. package/.next/server/chunks/538.js +1 -1
  96. package/.next/server/chunks/620.js +1 -0
  97. package/.next/server/chunks/6229.js +1 -1
  98. package/.next/server/chunks/7496.js +1 -1
  99. package/.next/server/chunks/7662.js +2 -2
  100. package/.next/server/chunks/8686.js +1 -1
  101. package/.next/server/functions-config-manifest.json +1 -2
  102. package/.next/server/instrumentation.js.nft.json +1 -1
  103. package/.next/server/middleware-build-manifest.js +1 -1
  104. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/preview-props.json +3 -3
  108. package/.next/server/server-reference-manifest.json +1 -1
  109. package/.next/static/QJCtqgDKzGS2BdKABd9ch/_buildManifest.js +1 -0
  110. package/.next/static/chunks/2577.86c8538fbf05a8b2.js +1 -0
  111. package/.next/static/chunks/2716.fa561519675a3ff1.js +1 -0
  112. package/.next/static/chunks/2906.8a0e09bb47605cf1.js +1 -0
  113. package/.next/static/chunks/4673.ea36e55005c0f82f.js +1 -0
  114. package/.next/static/chunks/4686-4dc565817a160351.js +1 -0
  115. package/.next/static/chunks/4778.0d2c5d75b791d352.js +1 -0
  116. package/.next/static/chunks/{618.949981da99be7182.js → 618.9a22656b863f242b.js} +2 -2
  117. package/.next/static/chunks/app/_global-error/page-f2d93969c0bcf406.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-f2d93969c0bcf406.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-f2d93969c0bcf406.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-f2d93969c0bcf406.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-f2d93969c0bcf406.js +1 -0
  122. package/.next/static/chunks/app/api/agent/resources/route-f2d93969c0bcf406.js +1 -0
  123. package/.next/static/chunks/app/api/config/route-f2d93969c0bcf406.js +1 -0
  124. package/.next/static/chunks/app/api/files/route-f2d93969c0bcf406.js +1 -0
  125. package/.next/static/chunks/app/api/fs/route-f2d93969c0bcf406.js +1 -0
  126. package/.next/static/chunks/app/api/git/route-f2d93969c0bcf406.js +1 -0
  127. package/.next/static/chunks/app/api/github/route-f2d93969c0bcf406.js +1 -0
  128. package/.next/static/chunks/app/api/growth/route-f2d93969c0bcf406.js +1 -0
  129. package/.next/static/chunks/app/api/health/route-f2d93969c0bcf406.js +1 -0
  130. package/.next/static/chunks/app/api/models/defaults/route-f2d93969c0bcf406.js +1 -0
  131. package/.next/static/chunks/app/api/models/route-f2d93969c0bcf406.js +1 -0
  132. package/.next/static/chunks/app/api/pi-settings/route-f2d93969c0bcf406.js +1 -0
  133. package/.next/static/chunks/app/api/plugins/route-f2d93969c0bcf406.js +1 -0
  134. package/.next/static/chunks/app/api/prompts/route-f2d93969c0bcf406.js +1 -0
  135. package/.next/static/chunks/app/api/remote/route-f2d93969c0bcf406.js +1 -0
  136. package/.next/static/chunks/app/api/security/route-f2d93969c0bcf406.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/[id]/export/route-f2d93969c0bcf406.js +1 -0
  138. package/.next/static/chunks/app/api/sessions/[id]/route-f2d93969c0bcf406.js +1 -0
  139. package/.next/static/chunks/app/api/sessions/import/route-f2d93969c0bcf406.js +1 -0
  140. package/.next/static/chunks/app/api/sessions/route-f2d93969c0bcf406.js +1 -0
  141. package/.next/static/chunks/app/api/skills/route-f2d93969c0bcf406.js +1 -0
  142. package/.next/static/chunks/app/api/update/route-f2d93969c0bcf406.js +1 -0
  143. package/.next/static/chunks/app/api/version/route-f2d93969c0bcf406.js +1 -0
  144. package/.next/static/chunks/app/api/web-auth/route-f2d93969c0bcf406.js +1 -0
  145. package/.next/static/chunks/app/api/workspaces/route-f2d93969c0bcf406.js +1 -0
  146. package/.next/static/chunks/app/page-71f2d3b3ed864b3d.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f2d93969c0bcf406.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f2d93969c0bcf406.js +1 -0
  149. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f2d93969c0bcf406.js +1 -0
  150. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f2d93969c0bcf406.js +1 -0
  151. package/.next/static/chunks/webpack-ffc09b5fcc8aaa6d.js +1 -0
  152. package/.next/static/css/014eb1f21414e615.css +1 -0
  153. package/.next/static/css/56b129a13637fcfd.css +1 -0
  154. package/.next/static/css/7e3caf797e4cb19a.css +1 -0
  155. package/.next/types/app/api/agent/{[id]/side-chat → resources}/route.ts +3 -3
  156. package/.next/types/routes.d.ts +2 -2
  157. package/.next/types/validator.ts +9 -9
  158. package/README.md +1 -1
  159. package/next.config.ts +1 -1
  160. package/package.json +9 -9
  161. package/scripts/apply-global.mjs +87 -0
  162. package/scripts/launcher.mjs +21 -0
  163. package/scripts/testing/prepare-sdk-tools.mjs +19 -0
  164. package/src/app/api/agent/new/route.ts +2 -1
  165. package/src/app/api/agent/resources/route.ts +18 -0
  166. package/src/app/api/models/route.ts +22 -1
  167. package/src/app/api/plugins/route.ts +3 -2
  168. package/src/app/api/prompts/route.ts +3 -2
  169. package/src/app/api/remote/route.ts +22 -4
  170. package/src/app/api/sessions/[id]/export/route.ts +3 -1
  171. package/src/app/api/skills/route.ts +3 -2
  172. package/src/app/globals.css +1002 -1765
  173. package/src/components/AppShell.tsx +128 -93
  174. package/src/components/ChatInput.tsx +177 -98
  175. package/src/components/ChatWindow.tsx +189 -56
  176. package/src/components/ContextMeter.tsx +33 -9
  177. package/src/components/CustomSelect.module.css +142 -0
  178. package/src/components/CustomSelect.tsx +157 -0
  179. package/src/components/DrawerPanel.tsx +1 -1
  180. package/src/components/ExtensionUI.tsx +9 -3
  181. package/src/components/FileViewer.tsx +24 -21
  182. package/src/components/GitPanel.tsx +35 -13
  183. package/src/components/GrowthTree.tsx +6 -2
  184. package/src/components/ImageViewer.tsx +72 -0
  185. package/src/components/MarkdownBlock.tsx +67 -96
  186. package/src/components/ModelSelector.tsx +36 -39
  187. package/src/components/PiMark.tsx +76 -41
  188. package/src/components/PreviewImage.tsx +33 -0
  189. package/src/components/ProjectPanel.tsx +8 -5
  190. package/src/components/PromptPanel.tsx +85 -20
  191. package/src/components/ProviderSetupModal.module.css +12 -11
  192. package/src/components/ProviderSetupModal.tsx +107 -36
  193. package/src/components/RemoteSection.tsx +53 -2
  194. package/src/components/SessionImportSection.tsx +30 -15
  195. package/src/components/SessionSidebar.tsx +72 -62
  196. package/src/components/SettingsPanel.tsx +49 -151
  197. package/src/components/TrajectoryAnalyzer.module.css +26 -11
  198. package/src/components/TrajectoryView.tsx +11 -3
  199. package/src/components/chat/markdown-actions.tsx +23 -0
  200. package/src/components/composer/ComposerFiles.tsx +224 -0
  201. package/src/components/composer/ComposerImages.tsx +59 -127
  202. package/src/hooks/usePiWeb.ts +47 -2
  203. package/src/i18n.tsx +62 -80
  204. package/src/lib/agent-manager.ts +365 -33
  205. package/src/lib/chat/file-path.ts +54 -0
  206. package/src/lib/chat/skill-expansion.ts +51 -0
  207. package/src/lib/extension-ui.ts +25 -1
  208. package/src/lib/font.ts +37 -39
  209. package/src/lib/git-service.ts +4 -2
  210. package/src/lib/growth-turns.ts +3 -1
  211. package/src/lib/model-draft.ts +69 -2
  212. package/src/lib/models-service.ts +53 -3
  213. package/src/lib/pi.ts +2 -2
  214. package/src/lib/remote/assets.json +1 -1
  215. package/src/lib/remote/supervisor.ts +48 -5
  216. package/src/lib/security-service.ts +2 -1
  217. package/src/lib/session-reader.ts +25 -13
  218. package/src/lib/session-retention.ts +11 -8
  219. package/src/lib/skills-service.ts +2 -1
  220. package/src/lib/slash-command-token.ts +14 -0
  221. package/src/lib/trajectory.ts +111 -15
  222. package/src/lib/types.ts +28 -3
  223. package/src/lib/update-service.ts +29 -9
  224. package/src/lib/workspace-store.ts +36 -1
  225. package/.next/server/app/api/agent/[id]/side-chat/route.js +0 -5
  226. package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +0 -1
  227. package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +0 -1
  228. package/.next/server/chunks/3421.js +0 -1
  229. package/.next/static/chunks/2716.44fdf1b21454b961.js +0 -1
  230. package/.next/static/chunks/4673.e261707dce391be7.js +0 -1
  231. package/.next/static/chunks/4686-0cd87513f9ea3538.js +0 -1
  232. package/.next/static/chunks/4778.eddd3a5813f85824.js +0 -1
  233. package/.next/static/chunks/7411.5f79671208209b13.js +0 -1
  234. package/.next/static/chunks/8552.25c39ef4c995eba0.js +0 -1
  235. package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +0 -1
  236. package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +0 -1
  237. package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +0 -1
  238. package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +0 -1
  239. package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +0 -1
  240. package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +0 -1
  241. package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +0 -1
  242. package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +0 -1
  243. package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +0 -1
  244. package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +0 -1
  245. package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +0 -1
  246. package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +0 -1
  247. package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +0 -1
  248. package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +0 -1
  249. package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +0 -1
  250. package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +0 -1
  251. package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +0 -1
  252. package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +0 -1
  253. package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +0 -1
  254. package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +0 -1
  255. package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +0 -1
  256. package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +0 -1
  257. package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +0 -1
  258. package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +0 -1
  259. package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +0 -1
  260. package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +0 -1
  261. package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +0 -1
  262. package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +0 -1
  263. package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +0 -1
  264. package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +0 -1
  265. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +0 -1
  266. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +0 -1
  267. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +0 -1
  268. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +0 -1
  269. package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +0 -1
  270. package/.next/static/css/08815507594e8b8d.css +0 -1
  271. package/.next/static/css/9fc559c2c4ad1d0f.css +0 -1
  272. package/.next/static/css/c6e2227c36ec2fd6.css +0 -1
  273. package/.next/static/css/f15a853af8b5127f.css +0 -1
  274. package/.next/static/lvKCJmjFj8LuqSojRVLRF/_buildManifest.js +0 -1
  275. package/src/app/api/agent/[id]/side-chat/route.ts +0 -77
  276. package/src/components/side-chat/SideChatPanel.module.css +0 -131
  277. package/src/components/side-chat/SideChatPanel.tsx +0 -189
  278. package/src/components/side-chat/icons.tsx +0 -8
  279. package/src/components/side-chat/request-history.ts +0 -29
  280. package/src/components/side-chat/store.ts +0 -248
  281. package/src/lib/chat/stream-settings.ts +0 -192
  282. package/src/lib/side-chat/context.ts +0 -123
  283. package/src/lib/side-chat/service.ts +0 -191
  284. package/src/lib/side-chat/types.ts +0 -61
  285. /package/.next/static/{lvKCJmjFj8LuqSojRVLRF → QJCtqgDKzGS2BdKABd9ch}/_ssgManifest.js +0 -0
@@ -6,7 +6,7 @@
6
6
  * 技能 / 提示模板,含扩展与 npm 包提供的那些);热会话额外给出"组装后的系统提示词"
7
7
  * 与每个工具的定义,它们不是文件、没有路径,直接在本面板内展开查看。
8
8
  */
9
- import { useCallback, useEffect, useState } from "react";
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
10
  import { useI18n } from "@/i18n";
11
11
  import { fmtBytes } from "@/lib/format";
12
12
  import { IconRefreshOutline14, IconCloseOutline14 } from "@/components/icons";
@@ -69,8 +69,11 @@ export function PromptPanel({
69
69
  const [error, setError] = useState<string | null>(null);
70
70
  const [busy, setBusy] = useState(false);
71
71
  const [opening, setOpening] = useState<string | null>(null);
72
+ /** 只认最后一次请求:快速切会话时,先发的请求后回来会把上一个会话的提示词盖到当前会话上 */
73
+ const latestRequest = useRef(0);
72
74
 
73
75
  const load = useCallback(async () => {
76
+ const request = ++latestRequest.current;
74
77
  setBusy(true);
75
78
  setError(null);
76
79
  try {
@@ -78,15 +81,21 @@ export function PromptPanel({
78
81
  if (sessionId) query.set("session", sessionId);
79
82
  const response = await fetch(`/api/prompts?${query.toString()}`);
80
83
  const body = await response.json();
84
+ if (request !== latestRequest.current) return;
81
85
  if (!response.ok || !body.success) throw new Error(body.error || `request failed (${response.status})`);
82
86
  setData(body.data as PromptSourcesData);
83
87
  } catch (err) {
84
- setError(err instanceof Error ? err.message : String(err));
88
+ if (request === latestRequest.current) setError(err instanceof Error ? err.message : String(err));
85
89
  } finally {
86
- setBusy(false);
90
+ if (request === latestRequest.current) setBusy(false);
87
91
  }
88
92
  }, [cwd, sessionId]);
89
93
 
94
+ // 换了工作区/会话:先清掉上一个的内容,别在加载期间把旧会话的提示词当成当前的
95
+ useEffect(() => {
96
+ setData(null);
97
+ }, [cwd, sessionId]);
98
+
90
99
  useEffect(() => {
91
100
  void load();
92
101
  }, [load, refreshKey]);
@@ -165,19 +174,33 @@ export function PromptPanel({
165
174
  {groups.map((group) => (
166
175
  <section key={group.kind} className="mb-3">
167
176
  <div className="mb-1 px-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{kindLabel(group.kind)}</div>
168
- <div className="flex flex-col gap-1">
177
+ <div className="flex flex-col gap-1.5">
169
178
  {group.items.map((source) => (
170
179
  <button
171
180
  key={`${source.kind}:${source.path ?? source.name}`}
172
181
  type="button"
173
- className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left"
174
- style={{ background: opening === source.path ? "var(--dsw-active)" : "transparent", border: "0.5px solid var(--dsw-border-l2)" }}
182
+ className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-all"
183
+ style={{
184
+ background: opening === source.path ? "var(--dsw-active)" : "var(--dsw-bg-elevated)",
185
+ border: "1px solid var(--dsw-border-l2)",
186
+ transition: "all 0.2s var(--isx-ease)",
187
+ }}
175
188
  title={source.path ?? source.name}
189
+ onMouseEnter={(e) => {
190
+ e.currentTarget.style.borderColor = "var(--dsw-border-l3)";
191
+ e.currentTarget.style.background = "var(--dsw-hover)";
192
+ e.currentTarget.style.transform = "translateY(-1px)";
193
+ }}
194
+ onMouseLeave={(e) => {
195
+ e.currentTarget.style.borderColor = "var(--dsw-border-l2)";
196
+ e.currentTarget.style.background = opening === source.path ? "var(--dsw-active)" : "var(--dsw-bg-elevated)";
197
+ e.currentTarget.style.transform = "none";
198
+ }}
176
199
  onClick={() => void openFile(source)}
177
200
  >
178
- <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-primary)" }}>{source.name}</span>
179
- <span className="flex-none" style={{ fontSize: 10.5, color: "var(--dsw-label-tertiary)" }}>{formatBytes(source.bytes)}</span>
180
- <span className="flex-none rounded-md px-1.5 py-0.5" style={{ fontSize: 10.5, border: "0.5px solid var(--dsw-border-l3)", color: "var(--dsw-label-tertiary)" }}>
201
+ <span className="min-w-0 flex-1 truncate font-medium" style={{ color: "var(--dsw-label-primary)" }}>{source.name}</span>
202
+ <span className="flex-none font-mono" style={{ fontSize: 10.5, color: "var(--dsw-label-tertiary)" }}>{formatBytes(source.bytes)}</span>
203
+ <span className="flex-none rounded-full px-2 py-0.5" style={{ fontSize: 10.5, border: "1px solid var(--dsw-border-l2)", color: "var(--dsw-label-tertiary)", background: "var(--dsw-bg-base)" }}>
181
204
  {originLabel(source.origin)}
182
205
  </span>
183
206
  </button>
@@ -189,14 +212,28 @@ export function PromptPanel({
189
212
  {hasTools && (
190
213
  <section className="mb-3">
191
214
  <div className="mb-1 px-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{t.promptKindTool}</div>
192
- <div className="flex flex-col gap-1">
215
+ <div className="flex flex-col gap-1.5">
193
216
  {data!.tools!.map((tool) => (
194
217
  <button
195
218
  key={`tool:${tool.name}`}
196
219
  type="button"
197
- className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left"
198
- style={{ border: "0.5px solid var(--dsw-border-l2)" }}
220
+ className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-all"
221
+ style={{
222
+ border: "1px solid var(--dsw-border-l2)",
223
+ background: "var(--dsw-bg-elevated)",
224
+ transition: "all 0.2s var(--isx-ease)",
225
+ }}
199
226
  title={tool.name}
227
+ onMouseEnter={(e) => {
228
+ e.currentTarget.style.borderColor = "var(--dsw-border-l3)";
229
+ e.currentTarget.style.background = "var(--dsw-hover)";
230
+ e.currentTarget.style.transform = "translateY(-1px)";
231
+ }}
232
+ onMouseLeave={(e) => {
233
+ e.currentTarget.style.borderColor = "var(--dsw-border-l2)";
234
+ e.currentTarget.style.background = "var(--dsw-bg-elevated)";
235
+ e.currentTarget.style.transform = "none";
236
+ }}
200
237
  onClick={() =>
201
238
  onOpenContent?.(
202
239
  `tool-${tool.name}.md`,
@@ -212,8 +249,8 @@ export function PromptPanel({
212
249
  )
213
250
  }
214
251
  >
215
- <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-primary)" }}>{tool.name}</span>
216
- <span className="flex-none rounded-md px-1.5 py-0.5" style={{ fontSize: 10.5, border: "0.5px solid var(--dsw-border-l3)", color: "var(--dsw-label-tertiary)" }}>
252
+ <span className="min-w-0 flex-1 truncate font-mono text-[12px] font-semibold" style={{ color: "var(--dsw-label-primary)" }}>{tool.name}</span>
253
+ <span className="flex-none rounded-full px-2 py-0.5" style={{ fontSize: 10.5, border: "1px solid var(--dsw-border-l2)", color: "var(--dsw-label-tertiary)", background: "var(--dsw-bg-base)" }}>
217
254
  {tool.source === "builtin" ? t.promptToolBuiltin : t.promptToolExtension}
218
255
  </span>
219
256
  </button>
@@ -227,12 +264,26 @@ export function PromptPanel({
227
264
  <div className="mb-1 px-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{t.promptKindAssembled}</div>
228
265
  <button
229
266
  type="button"
230
- className="w-full rounded-lg px-2 py-1.5 text-left"
231
- style={{ border: "0.5px solid var(--dsw-border-l2)" }}
267
+ className="w-full rounded-xl px-2.5 py-2 text-left transition-all"
268
+ style={{
269
+ border: "1px solid var(--dsw-border-l2)",
270
+ background: "var(--dsw-bg-elevated)",
271
+ transition: "all 0.2s var(--isx-ease)",
272
+ }}
232
273
  title={t.promptAssembledKey}
274
+ onMouseEnter={(e) => {
275
+ e.currentTarget.style.borderColor = "var(--dsw-border-l3)";
276
+ e.currentTarget.style.background = "var(--dsw-hover)";
277
+ e.currentTarget.style.transform = "translateY(-1px)";
278
+ }}
279
+ onMouseLeave={(e) => {
280
+ e.currentTarget.style.borderColor = "var(--dsw-border-l2)";
281
+ e.currentTarget.style.background = "var(--dsw-bg-elevated)";
282
+ e.currentTarget.style.transform = "none";
283
+ }}
233
284
  onClick={() => onOpenContent?.("system-prompt.md", data.assembledSystemPrompt ?? "")}
234
285
  >
235
- <span className="truncate" style={{ color: "var(--dsw-label-primary)" }}>{t.promptAssembledKey}</span>
286
+ <span className="truncate font-medium" style={{ color: "var(--dsw-label-primary)" }}>{t.promptAssembledKey}</span>
236
287
  </button>
237
288
  </section>
238
289
  )}
@@ -242,12 +293,26 @@ export function PromptPanel({
242
293
  <div className="mb-1 px-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{t.promptKindCompacted}</div>
243
294
  <button
244
295
  type="button"
245
- className="w-full rounded-lg px-2 py-1.5 text-left"
246
- style={{ border: "0.5px solid var(--dsw-border-l2)" }}
296
+ className="w-full rounded-xl px-2.5 py-2 text-left transition-all"
297
+ style={{
298
+ border: "1px solid var(--dsw-border-l2)",
299
+ background: "var(--dsw-bg-elevated)",
300
+ transition: "all 0.2s var(--isx-ease)",
301
+ }}
247
302
  title={t.promptCompactedKey}
303
+ onMouseEnter={(e) => {
304
+ e.currentTarget.style.borderColor = "var(--dsw-border-l3)";
305
+ e.currentTarget.style.background = "var(--dsw-hover)";
306
+ e.currentTarget.style.transform = "translateY(-1px)";
307
+ }}
308
+ onMouseLeave={(e) => {
309
+ e.currentTarget.style.borderColor = "var(--dsw-border-l2)";
310
+ e.currentTarget.style.background = "var(--dsw-bg-elevated)";
311
+ e.currentTarget.style.transform = "none";
312
+ }}
248
313
  onClick={() => onOpenContent?.("compacted-summary.md", data.compactedSummary ?? "")}
249
314
  >
250
- <span className="truncate" style={{ color: "var(--dsw-label-primary)" }}>{t.promptCompactedKey}</span>
315
+ <span className="truncate font-medium" style={{ color: "var(--dsw-label-primary)" }}>{t.promptCompactedKey}</span>
251
316
  </button>
252
317
  </section>
253
318
  )}
@@ -278,17 +278,6 @@
278
278
  gap: 12px;
279
279
  }
280
280
 
281
- .allowPrivate {
282
- display: flex;
283
- align-items: center;
284
- gap: 4px;
285
- font-size: 11.5px;
286
- color: var(--dsw-label-caption);
287
- cursor: pointer;
288
- user-select: none;
289
- white-space: nowrap;
290
- }
291
-
292
281
  .modelEmpty {
293
282
  margin-top: 13px;
294
283
  padding: 17px;
@@ -307,6 +296,18 @@
307
296
  margin-top: 13px;
308
297
  }
309
298
 
299
+ /* 自定义请求头:名字 | 值 | 删除 */
300
+ .headerRow {
301
+ display: grid;
302
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 34px;
303
+ gap: 8px;
304
+ align-items: center;
305
+ }
306
+
307
+ .headerRow + .headerRow {
308
+ margin-top: 8px;
309
+ }
310
+
310
311
  .modelRow {
311
312
  display: grid;
312
313
  /* 折叠箭头 | 模型 ID | 名称 | 删除 */
@@ -10,10 +10,11 @@ import {
10
10
  IconTrashOutline16,
11
11
  } from "@/components/icons";
12
12
  import { ProviderBrand } from "@/components/ProviderBrand";
13
+ import { CustomSelect } from "@/components/CustomSelect";
13
14
  import { useI18n } from "@/i18n";
14
15
  import { looksPrivateEndpoint } from "@/lib/model-endpoint";
15
16
  import { CUSTOM_PROVIDER_ID_PATTERN, customApiOptions } from "@/lib/provider-display";
16
- import { formatCapacity, modelDraftFromCatalog, parseCapacity, serializeModelDraft, serializeProviderDraft, validateModelDrafts, type ModelDraft } from "@/lib/model-draft";
17
+ import { formatCapacity, modelDraftFromCatalog, parseCapacity, serializeModelDraft, serializeProviderDraft, validateHeaderEntries, validateModelDrafts, type ModelDraft } from "@/lib/model-draft";
17
18
  import type { ProviderView } from "@/lib/models-service";
18
19
  import styles from "./ProviderSetupModal.module.css";
19
20
 
@@ -66,6 +67,10 @@ export function ProviderSetupModal({
66
67
  const allApis = customApiOptions(providers.flatMap((provider) => provider.apis));
67
68
  const [api, setApi] = useState(allApis.includes("openai-completions") ? "openai-completions" : (allApis[0] ?? "openai-completions"));
68
69
  const [models, setModels] = useState<ModelDraft[]>([]);
70
+ // 自定义请求头(models.json 的 headers):只对自定义 provider 开放。
71
+ // null = 尚未载入(不表态,保存时不碰原文件里的 headers);对象 = 用户编辑中。
72
+ // 载入时把源里的头读进表单,保存时整体替换。
73
+ const [headers, setHeaders] = useState<Array<{ name: string; value: string }> | null>(null);
69
74
  // 自定义 provider 的自动识别只在地址 / 密钥输入完(失焦)或换协议时触发:
70
75
  // 还没有任何模型行,且 URL(+ 密钥,本机网关可省)都填了,这次失焦才算数
71
76
  const autoReady = models.length === 0 && Boolean(baseUrl.trim()) && Boolean(apiKey.trim() || looksPrivateEndpoint(baseUrl));
@@ -131,6 +136,8 @@ export function ProviderSetupModal({
131
136
  result = await onSaveBuiltin({ providerId: selected.id, apiKey: apiKey.trim(), config });
132
137
  }
133
138
  } else {
139
+ // 表单里非空的头才写下去;一条都没有就不带这个键,免得留个空对象在文件里
140
+ const headerEntries = (headers ?? []).map(({ name, value }) => [name.trim(), value.trim()] as [string, string]).filter(([name, value]) => name && value);
134
141
  result = await onSaveCustom({
135
142
  providerId: normalizedId,
136
143
  config: {
@@ -138,6 +145,7 @@ export function ProviderSetupModal({
138
145
  baseUrl: baseUrl.trim(),
139
146
  api,
140
147
  ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
148
+ ...(headerEntries.length ? { headers: Object.fromEntries(headerEntries) } : {}),
141
149
  models: models.map((model) => serializeModelDraft(model)),
142
150
  },
143
151
  });
@@ -155,11 +163,23 @@ export function ProviderSetupModal({
155
163
  /** 供 ModelCatalog 的候选对话框调用:用表单当前值(含未保存的 key)探测远端目录 */
156
164
  const discoverAvailableModels = async (allowPrivate: boolean): Promise<ModelDraft[]> => {
157
165
  if (!baseUrl.trim()) throw new Error(t.apiAddress);
166
+ // 自定义头要跟着发现请求一起发:网关要求自定义头时,不带就永远拿不到模型目录
167
+ const headerEntries = (headers ?? []).map(({ name, value }) => [name.trim(), value.trim()] as [string, string]).filter(([name, value]) => name && value);
168
+ const headerError = validateHeaderEntries(headerEntries);
169
+ if (headerError) throw new Error(headerError);
158
170
  const response = await fetch("/api/models", {
159
171
  method: "POST",
160
172
  headers: { "Content-Type": "application/json" },
161
173
  // builtin 模式带 providerId:表单 key 为空时后端回退到已存密钥 / OAuth 令牌
162
- body: JSON.stringify({ action: "discoverModels", baseUrl: baseUrl.trim(), api: mode === "builtin" ? selected?.apis[0] : api, apiKey: apiKey.trim(), providerId: mode === "builtin" ? selected?.id : undefined, allowPrivate }),
174
+ body: JSON.stringify({
175
+ action: "discoverModels",
176
+ baseUrl: baseUrl.trim(),
177
+ api: mode === "builtin" ? selected?.apis[0] : api,
178
+ apiKey: apiKey.trim(),
179
+ providerId: mode === "builtin" ? selected?.id : undefined,
180
+ allowPrivate,
181
+ ...(headerEntries.length && mode !== "builtin" ? { headers: Object.fromEntries(headerEntries) } : {}),
182
+ }),
163
183
  });
164
184
  const result = await response.json();
165
185
  if (!response.ok || !result.success) throw new Error(result.error || t.toastError);
@@ -238,10 +258,15 @@ export function ProviderSetupModal({
238
258
  {selected?.authTypes.includes("oauth") && onOAuthLogin ? (
239
259
  <div className={styles.field}>
240
260
  <label className={styles.label}>{t.authMethod}</label>
241
- <select className={styles.select} value={authMethod} onChange={(event) => setAuthMethod(event.target.value as "api_key" | "oauth")}>
242
- <option value="api_key">{t.authMethodApiKey}</option>
243
- <option value="oauth">{t.oauthLogin}</option>
244
- </select>
261
+ <CustomSelect
262
+ value={authMethod}
263
+ onChange={(val) => setAuthMethod(val as "api_key" | "oauth")}
264
+ options={[
265
+ { value: "api_key", label: t.authMethodApiKey },
266
+ { value: "oauth", label: t.oauthLogin },
267
+ ]}
268
+ aria-label={t.authMethod}
269
+ />
245
270
  </div>
246
271
  ) : null}
247
272
 
@@ -287,22 +312,58 @@ export function ProviderSetupModal({
287
312
  </div>
288
313
  <div className={styles.field}>
289
314
  <label className={styles.label}>{t.apiProtocol}</label>
290
- <select className={styles.select} value={api} onChange={(event) => { setApi(event.target.value); requestAutoDiscover(); }}>
291
- {allApis.map((value) => <option key={value} value={value}>{value}</option>)}
292
- </select>
315
+ <CustomSelect
316
+ value={api}
317
+ onChange={(val) => { setApi(val); requestAutoDiscover(); }}
318
+ options={allApis.map((value) => ({ value, label: value }))}
319
+ aria-label={t.apiProtocol}
320
+ />
293
321
  </div>
294
322
  <div className={styles.field}>
295
323
  <label className={styles.label}>{t.apiKey}</label>
296
324
  <input className={styles.input} type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} onBlur={requestAutoDiscover} placeholder={t.apiKeyOptional} />
297
325
  <div className={styles.hint}>{t.customApiKeyHint}</div>
298
326
  </div>
327
+ {/* 自定义请求头:网关常要求额外认证头,探测模型目录与发消息都会带上 */}
328
+ <div className={styles.field}>
329
+ <label className={styles.label}>{t.customHeaders}</label>
330
+ {(headers ?? []).map((entry, index) => (
331
+ <div key={index} className={styles.headerRow}>
332
+ <input
333
+ className={styles.input}
334
+ value={entry.name}
335
+ placeholder={t.customHeaderName}
336
+ onChange={(event) => setHeaders((current) => (current ?? []).map((item, i) => (i === index ? { ...item, name: event.target.value } : item)))}
337
+ />
338
+ <input
339
+ className={styles.input}
340
+ value={entry.value}
341
+ placeholder={t.customHeaderValue}
342
+ onChange={(event) => setHeaders((current) => (current ?? []).map((item, i) => (i === index ? { ...item, value: event.target.value } : item)))}
343
+ />
344
+ <button
345
+ type="button"
346
+ className={styles.removeModel}
347
+ title={t.delete}
348
+ aria-label={`${t.delete} ${entry.name || index + 1}`}
349
+ onClick={() => setHeaders((current) => (current ?? []).filter((_, i) => i !== index))}
350
+ >
351
+ <IconTrashOutline16 size={15} />
352
+ </button>
353
+ </div>
354
+ ))}
355
+ <button type="button" className={styles.addModel} onClick={() => setHeaders((current) => [...(current ?? []), { name: "", value: "" }])}>
356
+ <IconPlusOutline16 size={14} /> {t.customHeaderAdd}
357
+ </button>
358
+ <div className={styles.hint}>{t.customHeadersHint}</div>
359
+ </div>
299
360
  <ModelCatalog
300
361
  models={models}
301
362
  setModels={setModels}
302
363
  updateModel={updateModel}
303
364
  discover={discoverAvailableModels}
304
365
  discoverDisabled={!baseUrl.trim()}
305
- autoDiscover={autoReady ? { signature: `${baseUrl.trim()}|${api}|${apiKey.trim()}`, privateHost: looksPrivateEndpoint(baseUrl), trigger: autoTrigger } : undefined}
366
+ autoDiscover={autoReady ? { signature: `${baseUrl.trim()}|${api}|${apiKey.trim()}`, trigger: autoTrigger } : undefined}
306
367
  />
307
368
  </>
308
369
  )}
@@ -345,21 +406,19 @@ export function ModelCatalog({
345
406
  inheritedCount?: number;
346
407
  /** 内置 provider 的目录模型 ID:候选里已在目录中的不能勾选(本来就能选),同 ID 的行只覆盖填写的字段 */
347
408
  catalogIds?: ReadonlySet<string>;
348
- /** 用表单当前值探测远端目录(allowPrivate:用户勾选了允许访问本机/私网地址);抛错时错误显示在目录内 */
409
+ /** 用表单当前值探测远端目录;本机 / 私网地址一律放行(服务端仍会自己再判一次);抛错时错误显示在目录内 */
349
410
  discover?: (allowPrivate: boolean) => Promise<ModelDraft[]>;
350
411
  discoverDisabled?: boolean;
351
412
  /**
352
413
  * 自动识别:trigger 变了(地址 / 密钥失焦、换协议)就按当前签名(URL + 协议 + 密钥)拉一次模型目录。
353
414
  * 只在「还没有任何模型行」时给出;同一签名只跑一次,清空模型行后不会自己又加回来。
354
415
  */
355
- autoDiscover?: { signature: string; privateHost: boolean; trigger: number };
416
+ autoDiscover?: { signature: string; trigger: number };
356
417
  }) {
357
418
  const { t } = useI18n();
358
419
  const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
359
420
  const [discovering, setDiscovering] = useState(false);
360
421
  const [discoverError, setDiscoverError] = useState("");
361
- // 本地网关(Ollama 等)在私网/回环地址上,探测需要用户显式授权
362
- const [allowPrivate, setAllowPrivate] = useState(false);
363
422
  // 候选对话框:null = 关闭
364
423
  const [candidates, setCandidates] = useState<ModelDraft[] | null>(null);
365
424
  const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
@@ -434,8 +493,8 @@ export function ModelCatalog({
434
493
  setAutoStatus(status);
435
494
  return true;
436
495
  };
437
- // 本机/私网地址自动允许(服务端仍会自己再判一次)
438
- discover(allowPrivate || auto.privateHost).then(
496
+ // 本机/私网地址一并放行:探测模型目录本来就是本机使用,服务端仍会自己再判一次
497
+ discover(true).then(
439
498
  (found) => {
440
499
  if (!settle(found.length ? "done" : "empty")) return;
441
500
  setAutoFound(found.length);
@@ -460,7 +519,7 @@ export function ModelCatalog({
460
519
  setDiscovering(true);
461
520
  setDiscoverError("");
462
521
  try {
463
- const found = await discover(allowPrivate);
522
+ const found = await discover(true);
464
523
  if (!found.length) {
465
524
  setDiscoverError(t.discoverEmpty);
466
525
  return;
@@ -527,14 +586,6 @@ export function ModelCatalog({
527
586
  </div>
528
587
  {discover ? (
529
588
  <div className={styles.discoverControls}>
530
- <label className={styles.allowPrivate} title={t.allowPrivateHint}>
531
- <input
532
- type="checkbox"
533
- checked={allowPrivate}
534
- onChange={(event) => setAllowPrivate(event.target.checked)}
535
- />
536
- {t.allowPrivateDiscovery}
537
- </label>
538
589
  <button className={styles.discoverButton} disabled={discoverDisabled || discovering} onClick={() => void startDiscover()}>
539
590
  {discovering ? t.discoveringModels : t.discoverModels}
540
591
  </button>
@@ -592,22 +643,42 @@ export function ModelCatalog({
592
643
  placeholder="16K"
593
644
  />
594
645
  </label>
595
- <label className={styles.capacityField} title={t.visionHint}>
646
+ <div className={styles.capacityField} title={t.visionHint}>
596
647
  <span>{t.visionLabel}</span>
597
- <select
598
- className={styles.input}
648
+ <CustomSelect
649
+ size="sm"
650
+ style={{ width: 104 }}
599
651
  value={model.vision === undefined ? "unset" : model.vision ? "image" : "text"}
600
- onChange={(event) => {
601
- const choice = event.target.value;
652
+ onChange={(choice) => {
602
653
  // 未声明 = 不写 input:pi 按纯文本处理,体检卡片会列出来提醒
603
654
  updateModel(index, { vision: choice === "unset" ? undefined : choice === "image" });
604
655
  }}
605
- >
606
- <option value="unset">{t.visionUnset}</option>
607
- <option value="image">{t.visionOn}</option>
608
- <option value="text">{t.visionOff}</option>
609
- </select>
610
- </label>
656
+ options={[
657
+ { value: "unset", label: t.visionUnset },
658
+ { value: "image", label: t.visionOn },
659
+ { value: "text", label: t.visionOff },
660
+ ]}
661
+ aria-label={t.visionLabel}
662
+ />
663
+ </div>
664
+ <div className={styles.capacityField} title={t.reasoningHint}>
665
+ <span>{t.reasoningLabel}</span>
666
+ <CustomSelect
667
+ size="sm"
668
+ style={{ width: 104 }}
669
+ value={model.reasoning === undefined ? "unset" : model.reasoning ? "on" : "off"}
670
+ onChange={(choice) => {
671
+ // 未声明 = 不写 reasoning:pi 按 false 处理,思考档位会消失,体检卡片会提醒
672
+ updateModel(index, { reasoning: choice === "unset" ? undefined : choice === "on" });
673
+ }}
674
+ options={[
675
+ { value: "unset", label: t.reasoningUnset },
676
+ { value: "on", label: t.reasoningOn },
677
+ { value: "off", label: t.reasoningOff },
678
+ ]}
679
+ aria-label={t.reasoningLabel}
680
+ />
681
+ </div>
611
682
  <span className={styles.capacityHint}>{t.capacityHint}</span>
612
683
  </div>
613
684
  ) : null}
@@ -21,6 +21,8 @@ interface LanState {
21
21
  running: boolean;
22
22
  port: number | null;
23
23
  urls: string[];
24
+ /** 是否在注入手机排版;插件关闭时为 false。旧服务端的响应里可能没有这个字段 */
25
+ styled?: boolean;
24
26
  error: string | null;
25
27
  }
26
28
 
@@ -35,6 +37,9 @@ interface PublicState {
35
37
 
36
38
  interface RemoteStatus {
37
39
  plugin: string;
40
+ /** 内置插件总开关;关闭后局域网入口仍在(纯转发),但没有手机排版与公网入口。
41
+ * 旧服务端的响应里可能没有这个字段,界面按「已启用」处理 */
42
+ pluginEnabled?: boolean;
38
43
  targetPort: number;
39
44
  /** 非空时服务端拒绝开任何入口(没开认证 / 开发模式) */
40
45
  blocked: "auth" | "dev" | null;
@@ -181,12 +186,35 @@ export function RemoteSection() {
181
186
  const lan = status?.lan;
182
187
  const pub = status?.public;
183
188
  const blocked = status?.blocked ?? null;
189
+ // 开关未取到状态前按「已启用」显示,避免首帧闪一下「已关闭」
190
+ const pluginOn = status?.pluginEnabled ?? true;
184
191
  const lanTone: "on" | "off" = lan?.running ? "on" : "off";
185
192
  const pubTone: "on" | "pending" | "off" = pub?.running ? (pub.ready ? "on" : "pending") : "off";
186
193
 
194
+ /** 切换内置插件总开关;局域网入口照旧(关掉后退化成纯转发),公网入口会一并停掉 */
195
+ const togglePlugin = async () => {
196
+ if (busy) return;
197
+ setBusy(true);
198
+ setError("");
199
+ try {
200
+ const response = await fetch("/api/remote", {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ action: "set-plugin", enabled: !pluginOn }),
204
+ });
205
+ const result = await response.json();
206
+ if (result?.success) setStatus(result.data as RemoteStatus);
207
+ else setError(result?.error ?? t.remoteFailed);
208
+ } catch {
209
+ setError(t.remoteFailed);
210
+ } finally {
211
+ if (alive.current) setBusy(false);
212
+ }
213
+ };
214
+
187
215
  return (
188
216
  <div className="flex flex-col gap-6">
189
- {/* 插件抬头 */}
217
+ {/* 插件抬头 + 总开关 */}
190
218
  <div className="flex items-center gap-2.5">
191
219
  <div style={{ fontSize: 15, fontWeight: 500 }}>{t.setRemote}</div>
192
220
  <span
@@ -195,7 +223,24 @@ export function RemoteSection() {
195
223
  >
196
224
  {t.remoteBuiltin}
197
225
  </span>
226
+ <div className="flex-1" />
227
+ <span style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{pluginOn ? t.remotePluginOn : t.remotePluginOff}</span>
228
+ <button
229
+ type="button"
230
+ className="relative h-5 w-9 flex-none rounded-full transition-colors"
231
+ style={{ background: pluginOn ? "var(--dsw-accent)" : "var(--dsw-border-l3)" }}
232
+ role="switch"
233
+ aria-checked={pluginOn}
234
+ aria-label={t.remotePluginToggle}
235
+ title={t.remotePluginHint}
236
+ // 被拦住(没开认证 / 开发模式)时入口一律开不出来,开关拨了也没有意义
237
+ disabled={busy || !status || blocked !== null}
238
+ onClick={() => void togglePlugin()}
239
+ >
240
+ <span className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all" style={{ left: pluginOn ? 18 : 2 }} />
241
+ </button>
198
242
  </div>
243
+ <div style={{ fontSize: 12, color: "var(--dsw-label-secondary)", lineHeight: 1.6, marginTop: -12 }}>{t.remotePluginHint}</div>
199
244
 
200
245
  {/* 局域网入口 */}
201
246
  <Row title={t.remoteLanTitle} hint={t.remoteLanHint}>
@@ -205,6 +250,11 @@ export function RemoteSection() {
205
250
  <span style={{ color: lan?.running ? "var(--dsw-label-primary)" : "var(--dsw-label-caption)" }}>
206
251
  {lan?.running ? t.remoteRunning : t.remoteStopped}
207
252
  </span>
253
+ {/* 只在服务端明确回了 styled:false 时才说「纯转发」:
254
+ 字段缺失(旧服务端、缓存的响应)不能当成关闭,否则会误报 */}
255
+ {lan?.running && lan.styled === false ? (
256
+ <span style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>· {t.remoteLanPlain}</span>
257
+ ) : null}
208
258
  </div>
209
259
  {(lan?.urls ?? []).map((url) => (
210
260
  <EntryLink key={url} url={url} token={status?.loginToken} />
@@ -229,11 +279,12 @@ export function RemoteSection() {
229
279
  {t.remoteStop}
230
280
  </button>
231
281
  ) : (
232
- <button className="btn-primary-white" style={{ height: 30, fontSize: 13 }} disabled={busy || blocked !== null} onClick={() => void act("start")}>
282
+ <button className="btn-primary-white" style={{ height: 30, fontSize: 13 }} disabled={busy || blocked !== null || !pluginOn} onClick={() => void act("start")}>
233
283
  {busy ? t.remoteWorking : t.remoteStart}
234
284
  </button>
235
285
  )}
236
286
  </div>
287
+ {!pluginOn ? <div style={{ fontSize: 11.5, color: "var(--dsw-label-caption)", textAlign: "right" }}>{t.remotePublicNeedsPlugin}</div> : null}
237
288
  </div>
238
289
  </Row>
239
290