@rexvane/piweb 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (535) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/assets/showcase.png +0 -0
  4. package/bin/pi.js +50 -0
  5. package/bin/piweb.js +12 -0
  6. package/next.config.ts +21 -0
  7. package/package.json +88 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/file-icons/3d.svg +1 -0
  10. package/public/file-icons/LICENSE +8 -0
  11. package/public/file-icons/adobe-illustrator.svg +1 -0
  12. package/public/file-icons/adobe-photoshop.svg +1 -0
  13. package/public/file-icons/agent.svg +1 -0
  14. package/public/file-icons/asciidoc.svg +1 -0
  15. package/public/file-icons/assembly.svg +1 -0
  16. package/public/file-icons/astro.svg +1 -0
  17. package/public/file-icons/audio.svg +1 -0
  18. package/public/file-icons/authors.svg +1 -0
  19. package/public/file-icons/babel.svg +1 -0
  20. package/public/file-icons/bibliography.svg +1 -0
  21. package/public/file-icons/bicep.svg +1 -0
  22. package/public/file-icons/blender.svg +1 -0
  23. package/public/file-icons/bun.svg +1 -0
  24. package/public/file-icons/c.svg +1 -0
  25. package/public/file-icons/certificate.svg +1 -0
  26. package/public/file-icons/changelog.svg +1 -0
  27. package/public/file-icons/clojure.svg +1 -0
  28. package/public/file-icons/cmake.svg +1 -0
  29. package/public/file-icons/codeowners.svg +1 -0
  30. package/public/file-icons/commitlint.svg +1 -0
  31. package/public/file-icons/conduct.svg +1 -0
  32. package/public/file-icons/console.svg +1 -0
  33. package/public/file-icons/contributing.svg +1 -0
  34. package/public/file-icons/cpp.svg +1 -0
  35. package/public/file-icons/csharp.svg +1 -0
  36. package/public/file-icons/css.svg +1 -0
  37. package/public/file-icons/cursor.svg +1 -0
  38. package/public/file-icons/dart.svg +1 -0
  39. package/public/file-icons/database.svg +1 -0
  40. package/public/file-icons/deno.svg +1 -0
  41. package/public/file-icons/diff.svg +1 -0
  42. package/public/file-icons/docker.svg +1 -0
  43. package/public/file-icons/document.svg +1 -0
  44. package/public/file-icons/editorconfig.svg +1 -0
  45. package/public/file-icons/ejs.svg +1 -0
  46. package/public/file-icons/elixir.svg +1 -0
  47. package/public/file-icons/elm.svg +1 -0
  48. package/public/file-icons/email.svg +1 -0
  49. package/public/file-icons/erlang.svg +1 -0
  50. package/public/file-icons/eslint.svg +1 -0
  51. package/public/file-icons/favicon.svg +1 -0
  52. package/public/file-icons/file.svg +1 -0
  53. package/public/file-icons/folder-admin-open.svg +1 -0
  54. package/public/file-icons/folder-admin.svg +1 -0
  55. package/public/file-icons/folder-android-open.svg +1 -0
  56. package/public/file-icons/folder-android.svg +1 -0
  57. package/public/file-icons/folder-api-open.svg +1 -0
  58. package/public/file-icons/folder-api.svg +1 -0
  59. package/public/file-icons/folder-app-open.svg +1 -0
  60. package/public/file-icons/folder-app.svg +1 -0
  61. package/public/file-icons/folder-archive-open.svg +1 -0
  62. package/public/file-icons/folder-archive.svg +1 -0
  63. package/public/file-icons/folder-atom-open.svg +1 -0
  64. package/public/file-icons/folder-atom.svg +1 -0
  65. package/public/file-icons/folder-audio-open.svg +1 -0
  66. package/public/file-icons/folder-audio.svg +1 -0
  67. package/public/file-icons/folder-aws-open.svg +1 -0
  68. package/public/file-icons/folder-aws.svg +1 -0
  69. package/public/file-icons/folder-backup-open.svg +1 -0
  70. package/public/file-icons/folder-backup.svg +1 -0
  71. package/public/file-icons/folder-benchmark-open.svg +1 -0
  72. package/public/file-icons/folder-benchmark.svg +1 -0
  73. package/public/file-icons/folder-cart-open.svg +1 -0
  74. package/public/file-icons/folder-cart.svg +1 -0
  75. package/public/file-icons/folder-ci-open.svg +1 -0
  76. package/public/file-icons/folder-ci.svg +1 -0
  77. package/public/file-icons/folder-circleci-open.svg +1 -0
  78. package/public/file-icons/folder-circleci.svg +1 -0
  79. package/public/file-icons/folder-class-open.svg +1 -0
  80. package/public/file-icons/folder-class.svg +1 -0
  81. package/public/file-icons/folder-client-open.svg +1 -0
  82. package/public/file-icons/folder-client.svg +1 -0
  83. package/public/file-icons/folder-command-open.svg +1 -0
  84. package/public/file-icons/folder-command.svg +1 -0
  85. package/public/file-icons/folder-components-open.svg +1 -0
  86. package/public/file-icons/folder-components.svg +1 -0
  87. package/public/file-icons/folder-config-open.svg +1 -0
  88. package/public/file-icons/folder-config.svg +1 -0
  89. package/public/file-icons/folder-console-open.svg +1 -0
  90. package/public/file-icons/folder-console.svg +1 -0
  91. package/public/file-icons/folder-content-open.svg +1 -0
  92. package/public/file-icons/folder-content.svg +1 -0
  93. package/public/file-icons/folder-context-open.svg +1 -0
  94. package/public/file-icons/folder-context.svg +1 -0
  95. package/public/file-icons/folder-contract-open.svg +1 -0
  96. package/public/file-icons/folder-contract.svg +1 -0
  97. package/public/file-icons/folder-controller-open.svg +1 -0
  98. package/public/file-icons/folder-controller.svg +1 -0
  99. package/public/file-icons/folder-core-open.svg +1 -0
  100. package/public/file-icons/folder-core.svg +1 -0
  101. package/public/file-icons/folder-coverage-open.svg +1 -0
  102. package/public/file-icons/folder-coverage.svg +1 -0
  103. package/public/file-icons/folder-css-open.svg +1 -0
  104. package/public/file-icons/folder-css.svg +1 -0
  105. package/public/file-icons/folder-database-open.svg +1 -0
  106. package/public/file-icons/folder-database.svg +1 -0
  107. package/public/file-icons/folder-debug-open.svg +1 -0
  108. package/public/file-icons/folder-debug.svg +1 -0
  109. package/public/file-icons/folder-deprecated-open.svg +1 -0
  110. package/public/file-icons/folder-deprecated.svg +1 -0
  111. package/public/file-icons/folder-desktop-open.svg +1 -0
  112. package/public/file-icons/folder-desktop.svg +1 -0
  113. package/public/file-icons/folder-dist-open.svg +1 -0
  114. package/public/file-icons/folder-dist.svg +1 -0
  115. package/public/file-icons/folder-docker-open.svg +1 -0
  116. package/public/file-icons/folder-docker.svg +1 -0
  117. package/public/file-icons/folder-docs-open.svg +1 -0
  118. package/public/file-icons/folder-docs.svg +1 -0
  119. package/public/file-icons/folder-element-open.svg +1 -0
  120. package/public/file-icons/folder-element.svg +1 -0
  121. package/public/file-icons/folder-environment-open.svg +1 -0
  122. package/public/file-icons/folder-environment.svg +1 -0
  123. package/public/file-icons/folder-event-open.svg +1 -0
  124. package/public/file-icons/folder-event.svg +1 -0
  125. package/public/file-icons/folder-examples-open.svg +1 -0
  126. package/public/file-icons/folder-examples.svg +1 -0
  127. package/public/file-icons/folder-features-open.svg +1 -0
  128. package/public/file-icons/folder-features.svg +1 -0
  129. package/public/file-icons/folder-font-open.svg +1 -0
  130. package/public/file-icons/folder-font.svg +1 -0
  131. package/public/file-icons/folder-functions-open.svg +1 -0
  132. package/public/file-icons/folder-functions.svg +1 -0
  133. package/public/file-icons/folder-generator-open.svg +1 -0
  134. package/public/file-icons/folder-generator.svg +1 -0
  135. package/public/file-icons/folder-git-open.svg +1 -0
  136. package/public/file-icons/folder-git.svg +1 -0
  137. package/public/file-icons/folder-github-open.svg +1 -0
  138. package/public/file-icons/folder-github.svg +1 -0
  139. package/public/file-icons/folder-gitlab-open.svg +1 -0
  140. package/public/file-icons/folder-gitlab.svg +1 -0
  141. package/public/file-icons/folder-graphql-open.svg +1 -0
  142. package/public/file-icons/folder-graphql.svg +1 -0
  143. package/public/file-icons/folder-helper-open.svg +1 -0
  144. package/public/file-icons/folder-helper.svg +1 -0
  145. package/public/file-icons/folder-hook-open.svg +1 -0
  146. package/public/file-icons/folder-hook.svg +1 -0
  147. package/public/file-icons/folder-husky-open.svg +1 -0
  148. package/public/file-icons/folder-husky.svg +1 -0
  149. package/public/file-icons/folder-i18n-open.svg +1 -0
  150. package/public/file-icons/folder-i18n.svg +1 -0
  151. package/public/file-icons/folder-images-open.svg +1 -0
  152. package/public/file-icons/folder-images.svg +1 -0
  153. package/public/file-icons/folder-include-open.svg +1 -0
  154. package/public/file-icons/folder-include.svg +1 -0
  155. package/public/file-icons/folder-intellij-open.svg +1 -0
  156. package/public/file-icons/folder-intellij.svg +1 -0
  157. package/public/file-icons/folder-interface-open.svg +1 -0
  158. package/public/file-icons/folder-interface.svg +1 -0
  159. package/public/file-icons/folder-ios-open.svg +1 -0
  160. package/public/file-icons/folder-ios.svg +1 -0
  161. package/public/file-icons/folder-java-open.svg +1 -0
  162. package/public/file-icons/folder-java.svg +1 -0
  163. package/public/file-icons/folder-job-open.svg +1 -0
  164. package/public/file-icons/folder-job.svg +1 -0
  165. package/public/file-icons/folder-jupyter-open.svg +1 -0
  166. package/public/file-icons/folder-jupyter.svg +1 -0
  167. package/public/file-icons/folder-keys-open.svg +1 -0
  168. package/public/file-icons/folder-keys.svg +1 -0
  169. package/public/file-icons/folder-kubernetes-open.svg +1 -0
  170. package/public/file-icons/folder-kubernetes.svg +1 -0
  171. package/public/file-icons/folder-layout-open.svg +1 -0
  172. package/public/file-icons/folder-layout.svg +1 -0
  173. package/public/file-icons/folder-less-open.svg +1 -0
  174. package/public/file-icons/folder-less.svg +1 -0
  175. package/public/file-icons/folder-lib-open.svg +1 -0
  176. package/public/file-icons/folder-lib.svg +1 -0
  177. package/public/file-icons/folder-log-open.svg +1 -0
  178. package/public/file-icons/folder-log.svg +1 -0
  179. package/public/file-icons/folder-messages-open.svg +1 -0
  180. package/public/file-icons/folder-messages.svg +1 -0
  181. package/public/file-icons/folder-meta-open.svg +1 -0
  182. package/public/file-icons/folder-meta.svg +1 -0
  183. package/public/file-icons/folder-middleware-open.svg +1 -0
  184. package/public/file-icons/folder-middleware.svg +1 -0
  185. package/public/file-icons/folder-migrations-open.svg +1 -0
  186. package/public/file-icons/folder-migrations.svg +1 -0
  187. package/public/file-icons/folder-mobile-open.svg +1 -0
  188. package/public/file-icons/folder-mobile.svg +1 -0
  189. package/public/file-icons/folder-mock-open.svg +1 -0
  190. package/public/file-icons/folder-mock.svg +1 -0
  191. package/public/file-icons/folder-molecule-open.svg +1 -0
  192. package/public/file-icons/folder-molecule.svg +1 -0
  193. package/public/file-icons/folder-next-open.svg +1 -0
  194. package/public/file-icons/folder-next.svg +1 -0
  195. package/public/file-icons/folder-node-open.svg +1 -0
  196. package/public/file-icons/folder-node.svg +1 -0
  197. package/public/file-icons/folder-nuxt-open.svg +1 -0
  198. package/public/file-icons/folder-nuxt.svg +1 -0
  199. package/public/file-icons/folder-open.svg +1 -0
  200. package/public/file-icons/folder-organism-open.svg +1 -0
  201. package/public/file-icons/folder-organism.svg +1 -0
  202. package/public/file-icons/folder-packages-open.svg +1 -0
  203. package/public/file-icons/folder-packages.svg +1 -0
  204. package/public/file-icons/folder-plugin-open.svg +1 -0
  205. package/public/file-icons/folder-plugin.svg +1 -0
  206. package/public/file-icons/folder-prisma-open.svg +1 -0
  207. package/public/file-icons/folder-prisma.svg +1 -0
  208. package/public/file-icons/folder-prompts-open.svg +1 -0
  209. package/public/file-icons/folder-prompts.svg +1 -0
  210. package/public/file-icons/folder-proto-open.svg +1 -0
  211. package/public/file-icons/folder-proto.svg +1 -0
  212. package/public/file-icons/folder-public-open.svg +1 -0
  213. package/public/file-icons/folder-public.svg +1 -0
  214. package/public/file-icons/folder-python-open.svg +1 -0
  215. package/public/file-icons/folder-python.svg +1 -0
  216. package/public/file-icons/folder-queue-open.svg +1 -0
  217. package/public/file-icons/folder-queue.svg +1 -0
  218. package/public/file-icons/folder-resource-open.svg +1 -0
  219. package/public/file-icons/folder-resource.svg +1 -0
  220. package/public/file-icons/folder-review-open.svg +1 -0
  221. package/public/file-icons/folder-review.svg +1 -0
  222. package/public/file-icons/folder-robot-open.svg +1 -0
  223. package/public/file-icons/folder-robot.svg +1 -0
  224. package/public/file-icons/folder-routes-open.svg +1 -0
  225. package/public/file-icons/folder-routes.svg +1 -0
  226. package/public/file-icons/folder-rust-open.svg +1 -0
  227. package/public/file-icons/folder-rust.svg +1 -0
  228. package/public/file-icons/folder-sass-open.svg +1 -0
  229. package/public/file-icons/folder-sass.svg +1 -0
  230. package/public/file-icons/folder-scripts-open.svg +1 -0
  231. package/public/file-icons/folder-scripts.svg +1 -0
  232. package/public/file-icons/folder-secure-open.svg +1 -0
  233. package/public/file-icons/folder-secure.svg +1 -0
  234. package/public/file-icons/folder-seeders-open.svg +1 -0
  235. package/public/file-icons/folder-seeders.svg +1 -0
  236. package/public/file-icons/folder-server-open.svg +1 -0
  237. package/public/file-icons/folder-server.svg +1 -0
  238. package/public/file-icons/folder-shared-open.svg +1 -0
  239. package/public/file-icons/folder-shared.svg +1 -0
  240. package/public/file-icons/folder-skills-open.svg +1 -0
  241. package/public/file-icons/folder-skills.svg +1 -0
  242. package/public/file-icons/folder-src-open.svg +1 -0
  243. package/public/file-icons/folder-src.svg +1 -0
  244. package/public/file-icons/folder-store-open.svg +1 -0
  245. package/public/file-icons/folder-store.svg +1 -0
  246. package/public/file-icons/folder-storybook-open.svg +1 -0
  247. package/public/file-icons/folder-storybook.svg +1 -0
  248. package/public/file-icons/folder-supabase-open.svg +1 -0
  249. package/public/file-icons/folder-supabase.svg +1 -0
  250. package/public/file-icons/folder-svelte-open.svg +1 -0
  251. package/public/file-icons/folder-svelte.svg +1 -0
  252. package/public/file-icons/folder-target-open.svg +1 -0
  253. package/public/file-icons/folder-target.svg +1 -0
  254. package/public/file-icons/folder-tasks-open.svg +1 -0
  255. package/public/file-icons/folder-tasks.svg +1 -0
  256. package/public/file-icons/folder-temp-open.svg +1 -0
  257. package/public/file-icons/folder-temp.svg +1 -0
  258. package/public/file-icons/folder-template-open.svg +1 -0
  259. package/public/file-icons/folder-template.svg +1 -0
  260. package/public/file-icons/folder-terraform-open.svg +1 -0
  261. package/public/file-icons/folder-terraform.svg +1 -0
  262. package/public/file-icons/folder-test-open.svg +1 -0
  263. package/public/file-icons/folder-test.svg +1 -0
  264. package/public/file-icons/folder-theme-open.svg +1 -0
  265. package/public/file-icons/folder-theme.svg +1 -0
  266. package/public/file-icons/folder-tools-open.svg +1 -0
  267. package/public/file-icons/folder-tools.svg +1 -0
  268. package/public/file-icons/folder-turborepo-open.svg +1 -0
  269. package/public/file-icons/folder-turborepo.svg +1 -0
  270. package/public/file-icons/folder-typescript-open.svg +1 -0
  271. package/public/file-icons/folder-typescript.svg +1 -0
  272. package/public/file-icons/folder-ui-open.svg +1 -0
  273. package/public/file-icons/folder-ui.svg +1 -0
  274. package/public/file-icons/folder-utils-open.svg +1 -0
  275. package/public/file-icons/folder-utils.svg +1 -0
  276. package/public/file-icons/folder-video-open.svg +1 -0
  277. package/public/file-icons/folder-video.svg +1 -0
  278. package/public/file-icons/folder-views-open.svg +1 -0
  279. package/public/file-icons/folder-views.svg +1 -0
  280. package/public/file-icons/folder-vscode-open.svg +1 -0
  281. package/public/file-icons/folder-vscode.svg +1 -0
  282. package/public/file-icons/folder.svg +1 -0
  283. package/public/file-icons/font.svg +1 -0
  284. package/public/file-icons/fsharp.svg +1 -0
  285. package/public/file-icons/gemfile.svg +1 -0
  286. package/public/file-icons/git.svg +1 -0
  287. package/public/file-icons/go-mod.svg +1 -0
  288. package/public/file-icons/go.svg +1 -0
  289. package/public/file-icons/gradle.svg +1 -0
  290. package/public/file-icons/graphql.svg +1 -0
  291. package/public/file-icons/groovy.svg +1 -0
  292. package/public/file-icons/h.svg +1 -0
  293. package/public/file-icons/handlebars.svg +1 -0
  294. package/public/file-icons/haskell.svg +1 -0
  295. package/public/file-icons/hcl.svg +1 -0
  296. package/public/file-icons/heroku.svg +1 -0
  297. package/public/file-icons/hpp.svg +1 -0
  298. package/public/file-icons/html.svg +1 -0
  299. package/public/file-icons/husky.svg +1 -0
  300. package/public/file-icons/i18n.svg +1 -0
  301. package/public/file-icons/image.svg +1 -0
  302. package/public/file-icons/java.svg +1 -0
  303. package/public/file-icons/javascript.svg +1 -0
  304. package/public/file-icons/jenkins.svg +1 -0
  305. package/public/file-icons/jest.svg +1 -0
  306. package/public/file-icons/jsconfig.svg +1 -0
  307. package/public/file-icons/json.svg +1 -0
  308. package/public/file-icons/julia.svg +1 -0
  309. package/public/file-icons/jupyter.svg +1 -0
  310. package/public/file-icons/just.svg +1 -0
  311. package/public/file-icons/key.svg +1 -0
  312. package/public/file-icons/kotlin.svg +1 -0
  313. package/public/file-icons/lerna.svg +1 -0
  314. package/public/file-icons/less.svg +1 -0
  315. package/public/file-icons/license.svg +1 -0
  316. package/public/file-icons/lintstaged.svg +1 -0
  317. package/public/file-icons/liquid.svg +1 -0
  318. package/public/file-icons/lock.svg +1 -0
  319. package/public/file-icons/log.svg +1 -0
  320. package/public/file-icons/lua.svg +1 -0
  321. package/public/file-icons/makefile.svg +1 -0
  322. package/public/file-icons/markdown.svg +1 -0
  323. package/public/file-icons/maven.svg +1 -0
  324. package/public/file-icons/mdx.svg +1 -0
  325. package/public/file-icons/netlify.svg +1 -0
  326. package/public/file-icons/next.svg +1 -0
  327. package/public/file-icons/nim.svg +1 -0
  328. package/public/file-icons/nix.svg +1 -0
  329. package/public/file-icons/nodejs.svg +1 -0
  330. package/public/file-icons/nodemon.svg +1 -0
  331. package/public/file-icons/npm.svg +1 -0
  332. package/public/file-icons/nx.svg +1 -0
  333. package/public/file-icons/objective-c.svg +1 -0
  334. package/public/file-icons/objective-cpp.svg +1 -0
  335. package/public/file-icons/ocaml.svg +1 -0
  336. package/public/file-icons/pdf.svg +1 -0
  337. package/public/file-icons/php.svg +1 -0
  338. package/public/file-icons/pm2-ecosystem.svg +1 -0
  339. package/public/file-icons/pnpm.svg +1 -0
  340. package/public/file-icons/poetry.svg +1 -0
  341. package/public/file-icons/postcss.svg +1 -0
  342. package/public/file-icons/powerpoint.svg +1 -0
  343. package/public/file-icons/powershell.svg +1 -0
  344. package/public/file-icons/prettier.svg +1 -0
  345. package/public/file-icons/prisma.svg +1 -0
  346. package/public/file-icons/prolog.svg +1 -0
  347. package/public/file-icons/proto.svg +1 -0
  348. package/public/file-icons/pug.svg +1 -0
  349. package/public/file-icons/python-misc.svg +1 -0
  350. package/public/file-icons/python.svg +1 -0
  351. package/public/file-icons/r.svg +1 -0
  352. package/public/file-icons/razor.svg +1 -0
  353. package/public/file-icons/react.svg +1 -0
  354. package/public/file-icons/react_ts.svg +1 -0
  355. package/public/file-icons/readme.svg +1 -0
  356. package/public/file-icons/renovate.svg +1 -0
  357. package/public/file-icons/robots.svg +1 -0
  358. package/public/file-icons/rollup.svg +1 -0
  359. package/public/file-icons/ruby.svg +1 -0
  360. package/public/file-icons/rust.svg +1 -0
  361. package/public/file-icons/sass.svg +1 -0
  362. package/public/file-icons/scala.svg +1 -0
  363. package/public/file-icons/semantic-release.svg +1 -0
  364. package/public/file-icons/settings.svg +1 -0
  365. package/public/file-icons/shader.svg +1 -0
  366. package/public/file-icons/sml.svg +1 -0
  367. package/public/file-icons/solidity.svg +1 -0
  368. package/public/file-icons/stylus.svg +1 -0
  369. package/public/file-icons/svelte.svg +1 -0
  370. package/public/file-icons/svg.svg +1 -0
  371. package/public/file-icons/swift.svg +1 -0
  372. package/public/file-icons/table.svg +1 -0
  373. package/public/file-icons/tailwindcss.svg +1 -0
  374. package/public/file-icons/taskfile.svg +1 -0
  375. package/public/file-icons/terraform.svg +1 -0
  376. package/public/file-icons/test-js.svg +1 -0
  377. package/public/file-icons/test-jsx.svg +1 -0
  378. package/public/file-icons/test-ts.svg +1 -0
  379. package/public/file-icons/tex.svg +1 -0
  380. package/public/file-icons/toml.svg +1 -0
  381. package/public/file-icons/travis.svg +1 -0
  382. package/public/file-icons/tsconfig.svg +1 -0
  383. package/public/file-icons/tune.svg +1 -0
  384. package/public/file-icons/turborepo.svg +1 -0
  385. package/public/file-icons/typescript-def.svg +1 -0
  386. package/public/file-icons/typescript.svg +1 -0
  387. package/public/file-icons/unity.svg +1 -0
  388. package/public/file-icons/uv.svg +1 -0
  389. package/public/file-icons/vala.svg +1 -0
  390. package/public/file-icons/vercel.svg +1 -0
  391. package/public/file-icons/video.svg +1 -0
  392. package/public/file-icons/vim.svg +1 -0
  393. package/public/file-icons/vite.svg +1 -0
  394. package/public/file-icons/vitest.svg +1 -0
  395. package/public/file-icons/vlang.svg +1 -0
  396. package/public/file-icons/vue.svg +1 -0
  397. package/public/file-icons/webassembly.svg +1 -0
  398. package/public/file-icons/webpack.svg +1 -0
  399. package/public/file-icons/word.svg +1 -0
  400. package/public/file-icons/xml.svg +1 -0
  401. package/public/file-icons/yaml.svg +1 -0
  402. package/public/file-icons/yarn.svg +1 -0
  403. package/public/file-icons/zig.svg +1 -0
  404. package/public/file-icons/zip.svg +1 -0
  405. package/public/provider-icons/antgroup-color.svg +1 -0
  406. package/public/provider-icons/anthropic.svg +1 -0
  407. package/public/provider-icons/azure-color.svg +1 -0
  408. package/public/provider-icons/baseten.svg +1 -0
  409. package/public/provider-icons/bedrock-color.svg +1 -0
  410. package/public/provider-icons/cerebras-color.svg +1 -0
  411. package/public/provider-icons/cloudflare-color.svg +1 -0
  412. package/public/provider-icons/deepseek-color.svg +1 -0
  413. package/public/provider-icons/fireworks-color.svg +1 -0
  414. package/public/provider-icons/githubcopilot.svg +1 -0
  415. package/public/provider-icons/google-color.svg +1 -0
  416. package/public/provider-icons/googlecloud-color.svg +1 -0
  417. package/public/provider-icons/groq.svg +1 -0
  418. package/public/provider-icons/huggingface-color.svg +1 -0
  419. package/public/provider-icons/kimi-color.svg +1 -0
  420. package/public/provider-icons/minimax-color.svg +1 -0
  421. package/public/provider-icons/mistral-color.svg +1 -0
  422. package/public/provider-icons/moonshot.svg +1 -0
  423. package/public/provider-icons/nvidia-color.svg +1 -0
  424. package/public/provider-icons/openai.svg +1 -0
  425. package/public/provider-icons/opencode.svg +1 -0
  426. package/public/provider-icons/openrouter-color.svg +1 -0
  427. package/public/provider-icons/qwen-color.svg +1 -0
  428. package/public/provider-icons/together-color.svg +1 -0
  429. package/public/provider-icons/vercel.svg +1 -0
  430. package/public/provider-icons/xai.svg +1 -0
  431. package/public/provider-icons/xiaomimimo.svg +1 -0
  432. package/public/provider-icons/zai.svg +1 -0
  433. package/scripts/build-output.mjs +131 -0
  434. package/scripts/dev.mjs +18 -0
  435. package/scripts/entrypoint.mjs +8 -0
  436. package/scripts/install-build.mjs +134 -0
  437. package/scripts/launcher.mjs +249 -0
  438. package/scripts/pick-folder.ps1 +110 -0
  439. package/scripts/postinstall.mjs +12 -0
  440. package/scripts/process-runner.mjs +60 -0
  441. package/scripts/release.mjs +172 -0
  442. package/scripts/sync-file-icons.mjs +118 -0
  443. package/scripts/sync-provider-icons.mjs +41 -0
  444. package/src/app/api/agent/[id]/events/route.ts +81 -0
  445. package/src/app/api/agent/[id]/route.ts +24 -0
  446. package/src/app/api/agent/[id]/tree/route.ts +49 -0
  447. package/src/app/api/agent/new/route.ts +19 -0
  448. package/src/app/api/config/route.ts +22 -0
  449. package/src/app/api/files/route.ts +63 -0
  450. package/src/app/api/git/route.ts +32 -0
  451. package/src/app/api/growth/route.ts +50 -0
  452. package/src/app/api/health/route.ts +7 -0
  453. package/src/app/api/models/route.ts +129 -0
  454. package/src/app/api/pi-settings/route.ts +22 -0
  455. package/src/app/api/plugins/route.ts +79 -0
  456. package/src/app/api/security/route.ts +41 -0
  457. package/src/app/api/sessions/[id]/export/route.ts +25 -0
  458. package/src/app/api/sessions/[id]/route.ts +37 -0
  459. package/src/app/api/sessions/route.ts +23 -0
  460. package/src/app/api/skills/route.ts +39 -0
  461. package/src/app/api/update/route.ts +33 -0
  462. package/src/app/api/version/route.ts +8 -0
  463. package/src/app/api/web-auth/route.ts +80 -0
  464. package/src/app/api/workspaces/route.ts +78 -0
  465. package/src/app/globals.css +2039 -0
  466. package/src/app/icon.svg +21 -0
  467. package/src/app/layout.tsx +29 -0
  468. package/src/app/login/page.tsx +111 -0
  469. package/src/app/page.tsx +10 -0
  470. package/src/components/AppShell.tsx +1171 -0
  471. package/src/components/ChatInput.tsx +729 -0
  472. package/src/components/ChatWindow.tsx +1320 -0
  473. package/src/components/ContextMeter.tsx +131 -0
  474. package/src/components/DiffView.tsx +139 -0
  475. package/src/components/ExtensionUI.tsx +181 -0
  476. package/src/components/FileViewer.tsx +555 -0
  477. package/src/components/FullDiff.tsx +220 -0
  478. package/src/components/GitPanel.tsx +357 -0
  479. package/src/components/GrowthTree.tsx +317 -0
  480. package/src/components/ModelSelector.tsx +286 -0
  481. package/src/components/OutlineRail.tsx +166 -0
  482. package/src/components/PiMark.tsx +79 -0
  483. package/src/components/ProjectPanel.tsx +309 -0
  484. package/src/components/ProviderBrand.tsx +28 -0
  485. package/src/components/ProviderSetupModal.module.css +560 -0
  486. package/src/components/ProviderSetupModal.tsx +559 -0
  487. package/src/components/SessionSidebar.tsx +1066 -0
  488. package/src/components/SettingsPanel.tsx +2216 -0
  489. package/src/components/TrajectoryAnalyzer.module.css +225 -0
  490. package/src/components/TrajectoryView.tsx +289 -0
  491. package/src/components/icons.tsx +443 -0
  492. package/src/hooks/useFileViewer.ts +58 -0
  493. package/src/hooks/useGrowth.ts +635 -0
  494. package/src/hooks/usePiWeb.ts +900 -0
  495. package/src/i18n.tsx +1043 -0
  496. package/src/lib/agent-manager.ts +1348 -0
  497. package/src/lib/app-root.ts +4 -0
  498. package/src/lib/auth.ts +51 -0
  499. package/src/lib/command-validation.ts +116 -0
  500. package/src/lib/enter-behavior.ts +40 -0
  501. package/src/lib/extension-ui.ts +134 -0
  502. package/src/lib/file-icons.json +557 -0
  503. package/src/lib/file-icons.ts +39 -0
  504. package/src/lib/files-service.ts +280 -0
  505. package/src/lib/git-service.ts +255 -0
  506. package/src/lib/growth-rounds.ts +84 -0
  507. package/src/lib/growth-service.ts +586 -0
  508. package/src/lib/growth-tracker.ts +328 -0
  509. package/src/lib/growth-tree.ts +245 -0
  510. package/src/lib/growth-turns.ts +23 -0
  511. package/src/lib/highlight.ts +59 -0
  512. package/src/lib/model-draft.ts +103 -0
  513. package/src/lib/models-service.ts +852 -0
  514. package/src/lib/path-security.ts +101 -0
  515. package/src/lib/pi-settings.ts +115 -0
  516. package/src/lib/pi.ts +367 -0
  517. package/src/lib/plugins-service.ts +312 -0
  518. package/src/lib/process-format.ts +261 -0
  519. package/src/lib/provider-display.ts +57 -0
  520. package/src/lib/security-service.ts +52 -0
  521. package/src/lib/semver.ts +39 -0
  522. package/src/lib/session-reader.ts +204 -0
  523. package/src/lib/settings-write-lock.ts +45 -0
  524. package/src/lib/skills-service.ts +97 -0
  525. package/src/lib/text-sanitize.ts +20 -0
  526. package/src/lib/theme.ts +137 -0
  527. package/src/lib/trajectory.ts +294 -0
  528. package/src/lib/types.ts +300 -0
  529. package/src/lib/update-service.ts +129 -0
  530. package/src/lib/version.ts +6 -0
  531. package/src/lib/web-auth-shared.ts +35 -0
  532. package/src/lib/web-auth.ts +53 -0
  533. package/src/lib/workspace-store.ts +261 -0
  534. package/src/proxy.ts +53 -0
  535. package/tsconfig.json +49 -0
@@ -0,0 +1,2216 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 设置弹窗(对齐 dsh SettingsRoot):居中模态、左导航 + 右侧行式内容。
5
+ * 五节:General / Models / 安全 / 技能 / 插件。
6
+ */
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { type EnterBehavior, getEnterBehavior, setEnterBehavior as saveEnterBehavior } from "@/lib/enter-behavior";
9
+ import {
10
+ IconAgentPresetOutline16,
11
+ IconChevronDown14,
12
+ IconCheckOutline14,
13
+ IconCloseOutline14,
14
+ IconCopyOutline16,
15
+ IconDownloadOutline16,
16
+ IconModelOutline16,
17
+ IconPluginOutline16,
18
+ IconPlusOutline16,
19
+ IconRefreshOutline14,
20
+ IconSearchOutline16,
21
+ IconSettingsOutline16,
22
+ IconShieldOutline16,
23
+ IconSkillOutline16,
24
+ IconTrashOutline16,
25
+ } from "@/components/icons";
26
+ import { ProviderBrand } from "@/components/ProviderBrand";
27
+ import {
28
+ ModelCatalog,
29
+ ProviderSetupModal,
30
+ type BuiltinProviderSetup,
31
+ type CustomProviderSetup,
32
+ } from "@/components/ProviderSetupModal";
33
+ import { useI18n } from "@/i18n";
34
+ import { applyPebrelTheme, loadPebrelTheme, loadThemeMode, type PebrelTheme, type ThemeMode } from "@/lib/theme";
35
+ import { modelDraftFromConfig, serializeProviderDraft, validateModelDrafts, type ModelDraft } from "@/lib/model-draft";
36
+ import { customApiOptions } from "@/lib/provider-display";
37
+ import type { ProviderUsage, ProviderView } from "@/lib/models-service";
38
+ import type { ToolPreset } from "@/lib/types";
39
+
40
+ type Section = "general" | "models" | "tools" | "skills" | "plugins";
41
+
42
+ export function SettingsPanel({
43
+ open,
44
+ onClose,
45
+ cwd,
46
+ toolPreset,
47
+ onToolPresetChange,
48
+ tools,
49
+ onSetTools,
50
+ onOpenFileContent,
51
+ }: {
52
+ open: boolean;
53
+ onClose: () => void;
54
+ cwd: string;
55
+ toolPreset: ToolPreset;
56
+ onToolPresetChange: (preset: ToolPreset) => void;
57
+ /** 当前会话的工具状态(快照数据;无会话时为 null) */
58
+ tools?: { active: string[]; all: { name: string; description?: string }[] } | null;
59
+ /** 逐个启停当前会话的工具(写回 setActiveTools) */
60
+ onSetTools?: (names: string[]) => void;
61
+ /** 在大窗口查看器中打开内容已就绪的文件(技能文档等) */
62
+ onOpenFileContent?: (path: string, content: string) => void;
63
+ }) {
64
+ const { t, lang, setLang } = useI18n();
65
+ const [section, setSection] = useState<Section>("general");
66
+
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ const h = (e: KeyboardEvent) => {
70
+ if (e.key === "Escape" && !document.querySelector('[data-provider-modal="true"]')) onClose();
71
+ };
72
+ document.addEventListener("keydown", h);
73
+ return () => document.removeEventListener("keydown", h);
74
+ }, [open, onClose]);
75
+
76
+ if (!open) return null;
77
+
78
+ const navItems: { id: Section; label: string; icon: React.ReactNode }[] = [
79
+ { id: "general", label: t.setGeneral, icon: <IconSettingsOutline16 size={15} /> },
80
+ { id: "models", label: t.setModels, icon: <IconModelOutline16 size={15} /> },
81
+ { id: "tools", label: t.toolsSection, icon: <IconAgentPresetOutline16 size={15} /> },
82
+ { id: "plugins", label: t.setPlugins, icon: <IconPluginOutline16 size={15} /> },
83
+ { id: "skills", label: t.setSkills, icon: <IconSkillOutline16 size={15} /> },
84
+ ];
85
+
86
+ return (
87
+ <div
88
+ className="modal-mask fixed inset-0 z-[100] flex items-center justify-center"
89
+ onMouseDown={(e) => {
90
+ if (e.target === e.currentTarget) onClose();
91
+ }}
92
+ >
93
+ <div
94
+ className="settings-dialog glass-modal flex overflow-hidden"
95
+ style={{
96
+ width: 800,
97
+ maxWidth: "calc(100vw - 48px)",
98
+ height: "min(800px, calc(100vh - 48px))",
99
+ borderRadius: 32,
100
+ boxShadow: "var(--dsw-elevation-prominent)",
101
+ }}
102
+ >
103
+ {/* 左导航(dsh trigger:h42 r12,active 填充) */}
104
+ <div className="settings-nav flex w-52 flex-none flex-col gap-0.5 py-4 pl-4 pr-3">
105
+ <div className="settings-title px-2 pb-3" style={{ fontSize: 16, fontWeight: 500 }}>
106
+ {t.settings}
107
+ </div>
108
+ {navItems.map((n) => (
109
+ <button
110
+ key={n.id}
111
+ className="flex h-[42px] items-center gap-2 rounded-xl px-3 text-left transition-colors"
112
+ style={{
113
+ paddingLeft: 8,
114
+ paddingRight: 10,
115
+ fontSize: 14,
116
+ background: section === n.id ? "var(--dsw-active)" : "transparent",
117
+ color: section === n.id ? "var(--dsw-label-primary)" : "var(--dsw-label-secondary)",
118
+ }}
119
+ onMouseEnter={(e) => {
120
+ if (section !== n.id) e.currentTarget.style.background = "var(--dsw-hover)";
121
+ }}
122
+ onMouseLeave={(e) => {
123
+ if (section !== n.id) e.currentTarget.style.background = "transparent";
124
+ }}
125
+ onClick={() => setSection(n.id)}
126
+ >
127
+ {section === n.id ? <span style={{ color: "var(--dsw-accent)" }}>{n.icon}</span> : n.icon}
128
+ {n.label}
129
+ </button>
130
+ ))}
131
+ </div>
132
+
133
+ {/* 右侧内容 */}
134
+ <div className="flex min-w-0 flex-1 flex-col">
135
+ <div className="flex items-center gap-2 px-6 py-3.5">
136
+ <div className="flex-1" />
137
+ <button className="btn-outline" style={{ height: 32, fontSize: 13 }} onClick={() => window.open("/api/config", "_blank")}>
138
+ <IconDownloadOutline16 size={14} /> {t.openConfigFile}
139
+ </button>
140
+ <button className="icon-btn" onClick={onClose}>
141
+ <IconCloseOutline14 size={15} />
142
+ </button>
143
+ </div>
144
+ <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
145
+ {section === "general" && <GeneralSection lang={lang} setLang={setLang} />}
146
+ {section === "models" && <ModelsSection />}
147
+ {section === "skills" && <SkillsSection cwd={cwd} onOpenFileContent={onOpenFileContent} />}
148
+ {section === "tools" && <ToolsSection toolPreset={toolPreset} onToolPresetChange={onToolPresetChange} tools={tools} onSetTools={onSetTools} />}
149
+ {section === "plugins" && <PluginsSection cwd={cwd} />}
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ );
155
+ }
156
+
157
+ // ---------- General ----------
158
+
159
+ function GeneralSection({
160
+ lang,
161
+ setLang,
162
+ }: {
163
+ lang: "zh" | "en";
164
+ setLang: (l: "zh" | "en") => void;
165
+ }) {
166
+ const { t } = useI18n();
167
+ const [theme, setTheme] = useState<PebrelTheme>("dsh");
168
+ const [themeMode, setThemeMode] = useState<ThemeMode>("system");
169
+ const [fontSize, setFontSize] = useState(14);
170
+ const [chatFontSize, setChatFontSize] = useState(14);
171
+ const [enterBehavior, setEnterBehavior] = useState<EnterBehavior>("steer");
172
+ const [trust, setTrust] = useState<"ask" | "always" | "never">("ask");
173
+ const [trustLoaded, setTrustLoaded] = useState(false);
174
+ const [piSettings, setPiSettings] = useState<{ compaction: { enabled: boolean; reserveTokens: number; keepRecentTokens: number }; retry: { enabled: boolean; maxRetries: number; baseDelayMs: number } } | null>(null);
175
+ const [versions, setVersions] = useState<{ piWeb: string; piEngine: string } | null>(null);
176
+ const [webAuth, setWebAuth] = useState<{ enabled: boolean; authenticated: boolean } | null>(null);
177
+ const [settingsLoading, setSettingsLoading] = useState(true);
178
+ const [settingsSaving, setSettingsSaving] = useState(false);
179
+ const [settingsError, setSettingsError] = useState<"load" | "save" | null>(null);
180
+ const [numberDrafts, setNumberDrafts] = useState({ reserveTokens: "", keepRecentTokens: "", maxRetries: "", baseDelayMs: "" });
181
+
182
+ const loadRemoteSettings = async () => {
183
+ setSettingsLoading(true);
184
+ setSettingsError(null);
185
+ setTrustLoaded(false);
186
+ const request = async (url: string) => {
187
+ const response = await fetch(url);
188
+ const json = await response.json();
189
+ if (!response.ok || !json.success) throw new Error(json.error || `request failed (${response.status})`);
190
+ return json.data;
191
+ };
192
+ const [sec, ps, health, auth] = await Promise.allSettled([
193
+ request("/api/security"),
194
+ request("/api/pi-settings"),
195
+ request("/api/version"),
196
+ request("/api/web-auth"),
197
+ ]);
198
+ if (sec.status === "fulfilled") {
199
+ setTrust(sec.value.defaultProjectTrust);
200
+ setTrustLoaded(true);
201
+ }
202
+ if (ps.status === "fulfilled") {
203
+ setPiSettings(ps.value);
204
+ setNumberDrafts({
205
+ reserveTokens: String(ps.value.compaction.reserveTokens),
206
+ keepRecentTokens: String(ps.value.compaction.keepRecentTokens),
207
+ maxRetries: String(ps.value.retry.maxRetries),
208
+ baseDelayMs: String(ps.value.retry.baseDelayMs),
209
+ });
210
+ } else setPiSettings(null);
211
+ if (health.status === "fulfilled" && health.value?.piWeb) setVersions({ piWeb: health.value.piWeb, piEngine: health.value.piEngine });
212
+ else setVersions(null);
213
+ if (auth.status === "fulfilled" && typeof auth.value?.enabled === "boolean") setWebAuth({ enabled: auth.value.enabled, authenticated: auth.value.authenticated === true });
214
+ else setWebAuth(null);
215
+ if ([sec, ps, health].some((item) => item.status === "rejected")) setSettingsError("load");
216
+ setSettingsLoading(false);
217
+ };
218
+
219
+ useEffect(() => {
220
+ setTheme(loadPebrelTheme());
221
+ setThemeMode(loadThemeMode());
222
+ const fs = parseInt(localStorage.getItem("piweb.fontSize") ?? "14", 10);
223
+ if (!Number.isNaN(fs)) setFontSize(fs);
224
+ const chatFs = parseInt(localStorage.getItem("piweb.chatFontSize") ?? "14", 10);
225
+ if (!Number.isNaN(chatFs)) setChatFontSize(chatFs);
226
+ setEnterBehavior(getEnterBehavior());
227
+ void loadRemoteSettings();
228
+ }, []);
229
+
230
+ const pickTheme = (p: PebrelTheme) => {
231
+ setTheme(p);
232
+ applyPebrelTheme(p, themeMode);
233
+ };
234
+
235
+ /** dsh 亮暗三选一(light / dark / system),与配色主题独立 */
236
+ const pickThemeMode = (m: ThemeMode) => {
237
+ setThemeMode(m);
238
+ applyPebrelTheme(theme, m);
239
+ };
240
+
241
+ /** 界面字号:body/导航/标签等(与 theme.ts applyFontSize 同一套变量,改完即生效并持久化) */
242
+ const pickFont = (n: number) => {
243
+ const v = Math.max(12, Math.min(18, n));
244
+ setFontSize(v);
245
+ localStorage.setItem("piweb.fontSize", String(v));
246
+ document.documentElement.style.setProperty("--dsh-content-font-size", `${v}px`);
247
+ document.documentElement.style.setProperty("--dsh-content-font-delta", `${v - 14}px`);
248
+ };
249
+
250
+ /** 对话字号:消息正文/用户气泡/输入框 */
251
+ const pickChatFont = (n: number) => {
252
+ const v = Math.max(12, Math.min(20, n));
253
+ setChatFontSize(v);
254
+ localStorage.setItem("piweb.chatFontSize", String(v));
255
+ document.documentElement.style.setProperty("--piweb-chat-font-size", `${v}px`);
256
+ };
257
+
258
+ const pickEnter = (v: EnterBehavior) => {
259
+ setEnterBehavior(v);
260
+ saveEnterBehavior(v);
261
+ };
262
+
263
+ const patchPiSettings = async (patch: Record<string, unknown>) => {
264
+ if (!piSettings || settingsSaving) return false;
265
+ setSettingsSaving(true);
266
+ setSettingsError(null);
267
+ try {
268
+ const r = await fetch("/api/pi-settings", {
269
+ method: "PUT",
270
+ headers: { "Content-Type": "application/json" },
271
+ body: JSON.stringify(patch),
272
+ });
273
+ const j = await r.json();
274
+ if (!r.ok || !j.success) throw new Error(j.error || "save failed");
275
+ setPiSettings(j.data);
276
+ return true;
277
+ } catch {
278
+ setSettingsError("save");
279
+ return false;
280
+ } finally {
281
+ setSettingsSaving(false);
282
+ }
283
+ };
284
+
285
+ const commitNumber = async (section: "compaction" | "retry", field: "reserveTokens" | "keepRecentTokens" | "maxRetries" | "baseDelayMs") => {
286
+ if (!piSettings) return;
287
+ const raw = numberDrafts[field].trim();
288
+ const value = Number(raw);
289
+ if (!raw || !Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) {
290
+ setSettingsError("save");
291
+ return;
292
+ }
293
+ if (value === (piSettings[section] as unknown as Record<string, number>)[field]) return;
294
+ await patchPiSettings({ [section]: { [field]: value } });
295
+ };
296
+ const numberInput = (section: "compaction" | "retry", field: "reserveTokens" | "keepRecentTokens" | "maxRetries" | "baseDelayMs", title: string) => (
297
+ <input
298
+ type="number"
299
+ min={0}
300
+ value={numberDrafts[field]}
301
+ disabled={!piSettings || settingsLoading || settingsSaving}
302
+ onChange={(event) => setNumberDrafts((previous) => ({ ...previous, [field]: event.target.value }))}
303
+ onBlur={() => void commitNumber(section, field)}
304
+ onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }}
305
+ className="rounded-lg px-2 py-1"
306
+ title={title}
307
+ style={{ width: 90, textAlign: "right", background: "var(--dsw-hover)" }}
308
+ />
309
+ );
310
+
311
+ const pickTrust = async (v: "ask" | "always" | "never") => {
312
+ if (!trustLoaded || settingsSaving) return;
313
+ setSettingsSaving(true);
314
+ setSettingsError(null);
315
+ try {
316
+ const response = await fetch("/api/security", {
317
+ method: "PUT",
318
+ headers: { "Content-Type": "application/json" },
319
+ body: JSON.stringify({ defaultProjectTrust: v }),
320
+ });
321
+ const result = await response.json();
322
+ if (!response.ok || !result.success) throw new Error(result.error || "save failed");
323
+ setTrust(v);
324
+ } catch {
325
+ setSettingsError("save");
326
+ } finally {
327
+ setSettingsSaving(false);
328
+ }
329
+ };
330
+
331
+ const themes: { id: PebrelTheme; label: string; light: string; dark: string; accent: string }[] = [
332
+ { id: "dsh", label: t.themeDsh, light: "#F9FAFB", dark: "#151517", accent: "#4176E6" },
333
+ { id: "silver-steel", label: t.themeSilverSteel, light: "#F3F4F6", dark: "#1A1C24", accent: "#94A3B8" },
334
+ { id: "limestone-coal", label: t.themeLimestoneCoal, light: "#F0EFEB", dark: "#171717", accent: "#CEB27E" },
335
+ { id: "linen-moss", label: t.themeLinenMoss, light: "#F2F2EC", dark: "#1E211E", accent: "#A3B3A3" },
336
+ { id: "nord-paper", label: t.themeNordPaper, light: "#FCFBF9", dark: "#2E3440", accent: "#88C0D0" },
337
+ { id: "pebrel", label: t.themePebrel, light: "#F3F4F6", dark: "#0F111A", accent: "#52A8FF" },
338
+ ];
339
+
340
+ return (
341
+ <div className="flex flex-col gap-6">
342
+ {(settingsLoading || settingsError) && (
343
+ <div role={settingsError ? "alert" : "status"} className="flex items-center gap-3 rounded-lg px-3 py-2" style={{ background: "var(--dsw-hover)", color: settingsError ? "var(--dsw-danger)" : "var(--dsw-label-secondary)" }}>
344
+ <span className="flex-1">{settingsError === "load" ? t.settingsLoadFailed : settingsError === "save" ? t.settingsSaveFailed : t.settingsLoading}</span>
345
+ {settingsError === "load" && <button type="button" className="pw-chip" onClick={() => void loadRemoteSettings()}>{t.settingsRetry}</button>}
346
+ </div>
347
+ )}
348
+ <Row title={t.language}>
349
+ <SelectOption
350
+ value={lang}
351
+ options={[
352
+ { value: "zh", label: "中文" },
353
+ { value: "en", label: "English" },
354
+ ]}
355
+ onChange={(v) => setLang(v as "zh" | "en")}
356
+ />
357
+ </Row>
358
+ <RowColumn title={t.appearance}>
359
+ <div className="flex w-full flex-wrap gap-2">
360
+ {themes.map((th) => (
361
+ <button
362
+ key={th.id}
363
+ className="flex flex-col items-center justify-center gap-1 transition-colors"
364
+ style={{
365
+ flex: "1 1 150px",
366
+ padding: "20px 32px",
367
+ borderRadius: 20,
368
+ border: `0.5px solid ${theme === th.id ? "var(--dsw-border-strong)" : "var(--dsw-border-l4)"}`,
369
+ background: theme === th.id ? "var(--dsw-module-platform)" : "transparent",
370
+ color: "var(--dsw-label-primary)",
371
+ fontSize: 14,
372
+ lineHeight: "22px",
373
+ }}
374
+ aria-pressed={theme === th.id}
375
+ onClick={() => pickTheme(th.id)}
376
+ >
377
+ <span className="flex items-center overflow-hidden rounded-md" style={{ border: "0.5px solid var(--dsw-border-l3)", width: 42, height: 20 }}>
378
+ <span style={{ background: th.light, flex: 1, height: "100%" }} />
379
+ <span style={{ background: th.dark, flex: 1, height: "100%" }} />
380
+ </span>
381
+ <span className="flex items-center gap-1.5">
382
+ <span className="inline-block rounded-full" style={{ width: 8, height: 8, background: th.accent }} />
383
+ {th.label}
384
+ </span>
385
+ </button>
386
+ ))}
387
+ </div>
388
+ </RowColumn>
389
+ <RowColumn title={t.appearanceMode}>
390
+ <div className="flex w-full flex-wrap gap-2">
391
+ {([
392
+ { id: "light", label: t.themeLight },
393
+ { id: "dark", label: t.themeDark },
394
+ { id: "system", label: t.themeSystem },
395
+ ] as { id: ThemeMode; label: string }[]).map((mode) => (
396
+ <button
397
+ key={mode.id}
398
+ className="flex flex-col items-center justify-center gap-1 transition-colors"
399
+ style={{
400
+ flex: "1 1 120px",
401
+ padding: "16px 24px",
402
+ borderRadius: 20,
403
+ border: `0.5px solid ${themeMode === mode.id ? "var(--dsw-border-strong)" : "var(--dsw-border-l4)"}`,
404
+ background: themeMode === mode.id ? "var(--dsw-module-platform)" : "transparent",
405
+ color: "var(--dsw-label-primary)",
406
+ fontSize: 14,
407
+ lineHeight: "22px",
408
+ }}
409
+ aria-pressed={themeMode === mode.id}
410
+ onClick={() => pickThemeMode(mode.id)}
411
+ >
412
+ {mode.label}
413
+ </button>
414
+ ))}
415
+ </div>
416
+ </RowColumn>
417
+ <Row title={t.fontUiSize} desc={t.fontUiSizeDesc}>
418
+ <FontSizeStepper value={fontSize} onChange={pickFont} max={18} />
419
+ </Row>
420
+ <Row title={t.fontChatSize} desc={t.fontChatSizeDesc}>
421
+ <FontSizeStepper value={chatFontSize} onChange={pickChatFont} max={20} />
422
+ </Row>
423
+ <Row title={t.enterBehavior} desc={t.enterBehaviorDesc}>
424
+ <SelectOption
425
+ value={enterBehavior}
426
+ options={[
427
+ { value: "steer", label: t.enterSteer },
428
+ { value: "queue", label: t.enterQueue },
429
+ ]}
430
+ onChange={(v) => pickEnter(v as EnterBehavior)}
431
+ />
432
+ </Row>
433
+ <Row title={t.autoCompact} desc={t.autoCompactDesc}>
434
+ <div className="flex items-center gap-2">
435
+ <button disabled={!piSettings || settingsSaving} className="relative h-5 w-9 flex-none rounded-full transition-colors" style={{ background: piSettings?.compaction.enabled ? "var(--dsw-accent)" : "var(--dsw-border-l3)" }} role="switch" aria-checked={piSettings?.compaction.enabled ?? false} onClick={() => void patchPiSettings({ compaction: { enabled: !piSettings?.compaction.enabled } })}>
436
+ <span className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all" style={{ left: piSettings?.compaction.enabled ? 18 : 2 }} />
437
+ </button>
438
+ {numberInput("compaction", "reserveTokens", t.reserveTokens)}
439
+ {numberInput("compaction", "keepRecentTokens", t.keepRecentTokens)}
440
+ </div>
441
+ </Row>
442
+ <Row title={t.autoRetry} desc={t.autoRetryDesc}>
443
+ <div className="flex items-center gap-2">
444
+ <button disabled={!piSettings || settingsSaving} className="relative h-5 w-9 flex-none rounded-full transition-colors" style={{ background: piSettings?.retry.enabled ? "var(--dsw-accent)" : "var(--dsw-border-l3)" }} role="switch" aria-checked={piSettings?.retry.enabled ?? false} onClick={() => void patchPiSettings({ retry: { enabled: !piSettings?.retry.enabled } })}>
445
+ <span className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all" style={{ left: piSettings?.retry.enabled ? 18 : 2 }} />
446
+ </button>
447
+ {numberInput("retry", "maxRetries", t.maxRetries)}
448
+ {numberInput("retry", "baseDelayMs", t.baseDelayMs)}
449
+ </div>
450
+ </Row>
451
+ <Row title={t.securityTrust} desc={t.securityTrustDesc}>
452
+ <SelectOption
453
+ value={trust}
454
+ disabled={!trustLoaded || settingsLoading || settingsSaving}
455
+ options={[
456
+ { value: "ask", label: t.trustAsk },
457
+ { value: "always", label: t.trustAlways },
458
+ { value: "never", label: t.trustNever },
459
+ ]}
460
+ onChange={(v) => pickTrust(v as "ask" | "always" | "never")}
461
+ />
462
+ </Row>
463
+ <Row title={t.versionPiweb} desc={t.versionPiwebDesc}>
464
+ <div className="flex items-center gap-2.5">
465
+ <VersionLink href="https://github.com/RexVane/PiWeb" value={versions?.piWeb} />
466
+ <UpdateControl target="piweb" />
467
+ </div>
468
+ </Row>
469
+ <Row title={t.versionPi} desc={t.versionPiDesc}>
470
+ <div className="flex items-center gap-2.5">
471
+ <VersionLink href="https://github.com/earendil-works/pi" value={versions?.piEngine} />
472
+ <UpdateControl target="pi" />
473
+ </div>
474
+ </Row>
475
+ {webAuth?.enabled ? (
476
+ <Row title={t.webAuth} desc={t.webAuthOn}>
477
+ <button
478
+ className="btn-outline"
479
+ style={{ height: 30, padding: "0 14px", fontSize: 12.5 }}
480
+ onClick={async () => {
481
+ await fetch("/api/web-auth", { method: "DELETE" }).catch(() => {});
482
+ window.location.href = "/login";
483
+ }}
484
+ >
485
+ {t.logout}
486
+ </button>
487
+ </Row>
488
+ ) : null}
489
+ </div>
490
+ );
491
+ }
492
+
493
+ /** 版本号文本链接:等宽字体,悬停变主题色(新窗口打开对应仓库) */
494
+ function VersionLink({ href, value }: { href: string; value?: string }) {
495
+ return (
496
+ <a
497
+ href={href}
498
+ target="_blank"
499
+ rel="noreferrer"
500
+ style={{ fontFamily: "var(--font-mono)", fontSize: 13, color: "var(--dsw-label-secondary)", textDecoration: "none" }}
501
+ onMouseEnter={(e) => (e.currentTarget.style.color = "var(--dsw-accent)")}
502
+ onMouseLeave={(e) => (e.currentTarget.style.color = "var(--dsw-label-secondary)")}
503
+ >
504
+ {value ?? "—"}
505
+ </a>
506
+ );
507
+ }
508
+
509
+ type UpdatePhase = "idle" | "checking" | "available" | "updating" | "latest" | "updated" | "error";
510
+
511
+ /** 检查更新 / 更新控件:piweb 走 git pull + npm install,pi 走 npm install @latest(服务端固定参数) */
512
+ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
513
+ const { t } = useI18n();
514
+ const [phase, setPhase] = useState<UpdatePhase>("idle");
515
+ const [latest, setLatest] = useState("");
516
+ const [error, setError] = useState("");
517
+ const busy = phase === "checking" || phase === "updating";
518
+
519
+ const post = async (action: "check" | "update") => {
520
+ const r = await fetch("/api/update", {
521
+ method: "POST",
522
+ headers: { "Content-Type": "application/json" },
523
+ body: JSON.stringify({ target, action }),
524
+ });
525
+ return r.json();
526
+ };
527
+
528
+ const check = async () => {
529
+ setPhase("checking");
530
+ setError("");
531
+ try {
532
+ const j = await post("check");
533
+ if (!j.success) throw new Error(j.error);
534
+ if (j.data.canUpdate) {
535
+ setLatest(j.data.latest);
536
+ setPhase("available");
537
+ } else {
538
+ setPhase("latest");
539
+ }
540
+ } catch (e: any) {
541
+ setError(String(e?.message ?? e));
542
+ setPhase("error");
543
+ }
544
+ };
545
+
546
+ const run = async () => {
547
+ setPhase("updating");
548
+ try {
549
+ const j = await post("update");
550
+ if (!j.success) throw new Error(j.error);
551
+ setLatest(j.data.version ?? latest);
552
+ setPhase("updated");
553
+ } catch (e: any) {
554
+ setError(String(e?.message ?? e));
555
+ setPhase("error");
556
+ }
557
+ };
558
+
559
+ if (phase === "idle" || phase === "latest" || phase === "error") {
560
+ const label = phase === "latest" ? t.upToDate : phase === "error" ? t.updateFailed : t.checkUpdate;
561
+ const color = phase === "latest" ? "var(--dsw-label-tertiary)" : phase === "error" ? "var(--dsw-danger)" : "var(--dsw-label-primary)";
562
+ return (
563
+ <button
564
+ type="button"
565
+ className="btn-outline"
566
+ style={{ height: 26, padding: "0 10px", fontSize: 12, color }}
567
+ title={error || undefined}
568
+ disabled={busy}
569
+ onClick={() => void check()}
570
+ >
571
+ {label}
572
+ </button>
573
+ );
574
+ }
575
+ if (phase === "available") {
576
+ return (
577
+ <button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void run()}>
578
+ {t.updateTo.replace("{v}", latest)}
579
+ </button>
580
+ );
581
+ }
582
+ if (phase === "updated") {
583
+ return <span style={{ fontSize: 12, color: "var(--dsw-success)", maxWidth: 260, textAlign: "right" }}>{t.updatedTo.replace("{v}", latest)}</span>;
584
+ }
585
+ return (
586
+ <span className="flex items-center gap-1.5" style={{ fontSize: 12, color: "var(--dsw-label-tertiary)" }}>
587
+ <span className="h-3 w-3 flex-none animate-spin rounded-full border-2 border-current border-t-transparent" />
588
+ {phase === "updating" ? t.updatingNow : t.checkingUpdate}
589
+ </span>
590
+ );
591
+ }
592
+
593
+ /** 通用下选芯片:自定义深色圆角浮层,替代原生 select */
594
+ function SelectOption({
595
+ value,
596
+ options,
597
+ onChange,
598
+ width,
599
+ disabled,
600
+ }: {
601
+ value: string;
602
+ options: { value: string; label: string }[];
603
+ onChange: (v: string) => void;
604
+ width?: number;
605
+ disabled?: boolean;
606
+ }) {
607
+ const [open, setOpen] = useState(false);
608
+ const ref = useRef<HTMLDivElement>(null);
609
+ useEffect(() => {
610
+ if (!open) return;
611
+ const h = (e: MouseEvent) => {
612
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
613
+ };
614
+ document.addEventListener("mousedown", h);
615
+ return () => document.removeEventListener("mousedown", h);
616
+ }, [open]);
617
+ useEffect(() => {
618
+ if (disabled) setOpen(false);
619
+ }, [disabled]);
620
+ const current = options.find((o) => o.value === value);
621
+ return (
622
+ <div ref={ref} className="relative">
623
+ <button className="select-chip" data-open={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
624
+ {current?.label ?? value}
625
+ <span className="chevron">
626
+ <IconChevronDown14 size={14} />
627
+ </span>
628
+ </button>
629
+ {open && !disabled && (
630
+ <div
631
+ // left-0:根容器在表单里占满整行宽,right-0 会把弹层甩到行右缘(远离 chip);
632
+ // chip 恒在容器左缘,左对齐两种用法(Row 的 flex-none / Field 的整行)都正确
633
+ className="popover absolute left-0 top-10 z-50 p-1.5 shadow-xl"
634
+ style={{ minWidth: width ?? 140, width: "max-content", maxHeight: 280, overflowY: "auto" }}
635
+ >
636
+ {options.map((o) => {
637
+ const selected = o.value === value;
638
+ return (
639
+ <button
640
+ key={o.value}
641
+ className="flex w-full items-center justify-between gap-3 rounded-xl px-3.5 py-2 text-left transition-colors"
642
+ style={{
643
+ background: selected ? "var(--dsw-accent-soft)" : "transparent",
644
+ }}
645
+ onMouseEnter={(e) => {
646
+ if (!selected) e.currentTarget.style.background = "var(--dsw-hover)";
647
+ }}
648
+ onMouseLeave={(e) => {
649
+ if (!selected) e.currentTarget.style.background = "transparent";
650
+ }}
651
+ onClick={() => {
652
+ onChange(o.value);
653
+ setOpen(false);
654
+ }}
655
+ >
656
+ <span style={{ fontSize: 13.5, color: selected ? "var(--dsw-accent)" : "var(--dsw-label-primary)", whiteSpace: "nowrap" }}>
657
+ {o.label}
658
+ </span>
659
+ {selected && (
660
+ <span style={{ display: "inline-flex", color: "var(--dsw-accent)", flex: "none" }}>
661
+ <IconCheckOutline14 size={14} />
662
+ </span>
663
+ )}
664
+ </button>
665
+ );
666
+ })}
667
+ </div>
668
+ )}
669
+ </div>
670
+ );
671
+ }
672
+
673
+ function Row({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) {
674
+ return (
675
+ <div className="hairline-b flex items-center justify-between gap-6 pb-5">
676
+ <div className="min-w-0">
677
+ <div style={{ fontSize: 14, fontWeight: 400 }}>{title}</div>
678
+ {desc && (
679
+ <div className="mt-0.5" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>
680
+ {desc}
681
+ </div>
682
+ )}
683
+ </div>
684
+ <div className="flex-none">{children}</div>
685
+ </div>
686
+ );
687
+ }
688
+
689
+ /** 标题在上、内容整行在下(dsh 外观三卡的排布) */
690
+ function RowColumn({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) {
691
+ return (
692
+ <div className="hairline-b flex flex-col gap-3 pb-5">
693
+ <div>
694
+ <div style={{ fontSize: 14, fontWeight: 400 }}>{title}</div>
695
+ {desc && (
696
+ <div className="mt-0.5" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>
697
+ {desc}
698
+ </div>
699
+ )}
700
+ </div>
701
+ {children}
702
+ </div>
703
+ );
704
+ }
705
+
706
+ /** 字号步进器:− 值 +(越界禁用;点中间数值回到 14 默认) */
707
+ function FontSizeStepper({ value, onChange, max }: { value: number; onChange: (v: number) => void; max: number }) {
708
+ return (
709
+ <div className="flex items-center gap-2">
710
+ <button
711
+ className="icon-btn"
712
+ style={{ width: 26, height: 26, border: "0.5px solid var(--dsw-border-l3)", borderRadius: 8 }}
713
+ disabled={value <= 12}
714
+ onClick={() => onChange(value - 1)}
715
+ aria-label="decrease font size"
716
+ >
717
+
718
+ </button>
719
+ <button
720
+ style={{ minWidth: 48, textAlign: "center", fontSize: 13, color: value === 14 ? "var(--dsw-label-tertiary)" : "var(--dsw-label-primary)" }}
721
+ title="reset"
722
+ onClick={() => onChange(14)}
723
+ >
724
+ {value} px
725
+ </button>
726
+ <button
727
+ className="icon-btn"
728
+ style={{ width: 26, height: 26, border: "0.5px solid var(--dsw-border-l3)", borderRadius: 8 }}
729
+ disabled={value >= max}
730
+ onClick={() => onChange(value + 1)}
731
+ aria-label="increase font size"
732
+ >
733
+
734
+ </button>
735
+ </div>
736
+ );
737
+ }
738
+
739
+ // ---------- Models ----------
740
+
741
+ function ModelsSection() {
742
+ const { t } = useI18n();
743
+ const [providers, setProviders] = useState<ProviderView[]>([]);
744
+ const [custom, setCustom] = useState<{ providers: Record<string, any>; [key: string]: unknown }>({ providers: {} });
745
+ const [customRevision, setCustomRevision] = useState<string | null>(null);
746
+ const [secretProviderIds, setSecretProviderIds] = useState<Set<string>>(new Set());
747
+ const [editing, setEditing] = useState<string | null>(null);
748
+ const [keyDraft, setKeyDraft] = useState("");
749
+ const [adding, setAdding] = useState<"builtin" | "custom" | null>(null);
750
+ const [cId, setCId] = useState("");
751
+ const [cBaseUrl, setCBaseUrl] = useState("");
752
+ const [cApi, setCApi] = useState("openai-completions");
753
+ const [cKey, setCKey] = useState("");
754
+ const [cModels, setCModels] = useState<ModelDraft[]>([]);
755
+ const [cName, setCName] = useState("");
756
+ const [bBaseUrl, setBBaseUrl] = useState("");
757
+ const [bModels, setBModels] = useState<ModelDraft[]>([]);
758
+ const [editBusy, setEditBusy] = useState(false);
759
+ const [editAdvanced, setEditAdvanced] = useState(false);
760
+ const [toast, setToast] = useState<{ ok: boolean; msg: string } | null>(null);
761
+ const [oauthProvider, setOauthProvider] = useState<string | null>(null);
762
+ const [oauthPrompt, setOauthPrompt] = useState<{ type: string; message: string; placeholder?: string; options?: Array<{ label: string; value: string }> } | null>(null);
763
+ const [oauthLog, setOauthLog] = useState<string[]>([]);
764
+ const [oauthInput, setOauthInput] = useState("");
765
+ const [oauthDone, setOauthDone] = useState<{ ok: boolean; msg: string } | null>(null);
766
+ const [oauthCodeCopied, setOauthCodeCopied] = useState(false);
767
+
768
+ // 设备码/授权链接流程(xAI/Codex/Copilot/Kimi 发 device_code;Anthropic/OpenRouter 发 auth_url):
769
+ // SDK 经 notify 发 JSON 事件,解析出授权链接(设备码流程附用户码)
770
+ const oauthDeviceCode = useMemo(() => {
771
+ for (let i = oauthLog.length - 1; i >= 0; i--) {
772
+ const line = oauthLog[i];
773
+ if (!line.startsWith("{")) continue;
774
+ try {
775
+ const parsed = JSON.parse(line) as { type?: string; userCode?: string; verificationUri?: string; url?: string; instructions?: string };
776
+ if (parsed.type === "device_code" && typeof parsed.verificationUri === "string") {
777
+ return { userCode: typeof parsed.userCode === "string" ? parsed.userCode : "", verificationUri: parsed.verificationUri, instructions: "" };
778
+ }
779
+ if (parsed.type === "auth_url" && typeof parsed.url === "string") {
780
+ return { userCode: "", verificationUri: parsed.url, instructions: typeof parsed.instructions === "string" ? parsed.instructions : "" };
781
+ }
782
+ } catch {
783
+ /* 非 JSON 的 notify 行忽略 */
784
+ }
785
+ }
786
+ return null;
787
+ }, [oauthLog]);
788
+
789
+ // 拿到设备码后自动打开授权页一次;被弹窗拦截时仍有可见链接可点
790
+ const openedDeviceUriRef = useRef<string | null>(null);
791
+ useEffect(() => {
792
+ if (!oauthDeviceCode?.verificationUri || openedDeviceUriRef.current === oauthDeviceCode.verificationUri) return;
793
+ openedDeviceUriRef.current = oauthDeviceCode.verificationUri;
794
+ window.open(oauthDeviceCode.verificationUri, "_blank", "noopener");
795
+ }, [oauthDeviceCode]);
796
+
797
+ const copyDeviceCode = () => {
798
+ if (!oauthDeviceCode?.userCode) return;
799
+ const text = oauthDeviceCode.userCode;
800
+ const done = () => {
801
+ setOauthCodeCopied(true);
802
+ setTimeout(() => setOauthCodeCopied(false), 2000);
803
+ };
804
+ void navigator.clipboard.writeText(text).then(done).catch(() => {
805
+ const area = document.createElement("textarea");
806
+ area.value = text;
807
+ document.body.appendChild(area);
808
+ area.select();
809
+ document.execCommand("copy");
810
+ area.remove();
811
+ done();
812
+ });
813
+ };
814
+
815
+ const load = useCallback(async () => {
816
+ try {
817
+ const r = await fetch("/api/models?custom=1");
818
+ const j = await r.json();
819
+ if (!r.ok || !j.success) throw new Error(j.error || "failed to load model configuration");
820
+ const parsed = JSON.parse(j.data.customProviders?.content ?? "{}");
821
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || (parsed.providers !== undefined && (!parsed.providers || typeof parsed.providers !== "object" || Array.isArray(parsed.providers)))) {
822
+ throw new Error("invalid model configuration");
823
+ }
824
+ setProviders(j.data.providers);
825
+ setSecretProviderIds(new Set(j.data.customProviders?.secretProviderIds ?? []));
826
+ setCustom({ ...parsed, providers: parsed.providers ?? {} });
827
+ setCustomRevision(typeof j.data.customProviders?.revision === "string" ? j.data.customProviders.revision : null);
828
+ } catch (error) {
829
+ setCustomRevision(null);
830
+ setToast({ ok: false, msg: error instanceof Error ? error.message : "failed to load model configuration" });
831
+ }
832
+ }, []);
833
+
834
+ useEffect(() => {
835
+ void load();
836
+ }, [load]);
837
+
838
+ // OAuth 状态轮询
839
+ useEffect(() => {
840
+ if (!oauthProvider) return;
841
+ let alive = true;
842
+ let finished = false;
843
+ let timer: ReturnType<typeof setTimeout> | undefined;
844
+ const poll = async () => {
845
+ try {
846
+ const r = await fetch("/api/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "loginState", providerId: oauthProvider }) });
847
+ const j = await r.json();
848
+ if (!alive || !j.success) return;
849
+ setOauthPrompt(j.data.prompt);
850
+ setOauthLog(j.data.notifyLog ?? []);
851
+ if (j.data.done) {
852
+ finished = true;
853
+ setOauthDone({ ok: !j.data.error, msg: j.data.error ?? (lang_or_default(j)) });
854
+ if (!j.data.error) void load();
855
+ }
856
+ } catch {
857
+ // 短暂断线后继续轮询,不能留下未处理的 Promise 拒绝。
858
+ } finally {
859
+ if (alive && !finished) timer = setTimeout(poll, 1500);
860
+ }
861
+ };
862
+ void poll();
863
+ return () => { alive = false; if (timer) clearTimeout(timer); };
864
+ function lang_or_default(j: any) {
865
+ return oauthProvider + " ✓";
866
+ }
867
+ }, [oauthProvider, load]);
868
+
869
+ // xAI 订阅配额(x-ratelimit-* 响应头;后端 5 分钟缓存,force 跳过)
870
+ const [xaiUsage, setXaiUsage] = useState<ProviderUsage | null>(null);
871
+ const [usageBusy, setUsageBusy] = useState(false);
872
+ const [usageError, setUsageError] = useState(false);
873
+ const fetchUsage = useCallback(async (force: boolean) => {
874
+ setUsageBusy(true);
875
+ setUsageError(false);
876
+ try {
877
+ const r = await fetch("/api/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "providerUsage", providerId: "xai", force }) });
878
+ const j = await r.json();
879
+ if (!r.ok || !j.success) throw new Error(j.error || "usage probe failed");
880
+ setXaiUsage(j.data.usage ?? null);
881
+ } catch {
882
+ setUsageError(true);
883
+ } finally {
884
+ setUsageBusy(false);
885
+ }
886
+ }, []);
887
+
888
+ const notify = (ok: boolean, msg?: string) => {
889
+ setToast({ ok, msg: msg ?? (ok ? t.toastSaved : t.toastError) });
890
+ setTimeout(() => setToast(null), 2400);
891
+ };
892
+
893
+ const request = async (body: Record<string, unknown>) => {
894
+ try {
895
+ const r = await fetch("/api/models", {
896
+ method: "POST",
897
+ headers: { "Content-Type": "application/json" },
898
+ body: JSON.stringify(body),
899
+ });
900
+ const result = await r.json();
901
+ return r.ok ? result : { ...result, success: false, error: result.error || `request failed (${r.status})` };
902
+ } catch (error) {
903
+ return { success: false, error: error instanceof Error ? error.message : t.toastError };
904
+ }
905
+ };
906
+
907
+ const call = async (body: Record<string, unknown>) => {
908
+ const j = await request(body);
909
+ notify(j.success, j.success && j.data?.requiresSessionReopen ? t.modelConfigReopen : j.error);
910
+ if (j.success) await load();
911
+ return j;
912
+ };
913
+
914
+ const answerOAuth = async (value: string) => {
915
+ if (!oauthProvider) return;
916
+ try {
917
+ const response = await request({ action: "loginAnswer", providerId: oauthProvider, text: value });
918
+ if (!response.success || response.data?.delivered !== true) throw new Error(response.error || t.toastError);
919
+ setOauthInput("");
920
+ } catch (error) {
921
+ notify(false, error instanceof Error ? error.message : t.toastError);
922
+ }
923
+ };
924
+
925
+ const cancelOAuth = async () => {
926
+ if (!oauthProvider) return;
927
+ try {
928
+ const response = await request({ action: "loginCancel", providerId: oauthProvider });
929
+ if (!response.success) throw new Error(response.error || t.toastError);
930
+ setOauthProvider(null);
931
+ } catch (error) {
932
+ notify(false, error instanceof Error ? error.message : t.toastError);
933
+ }
934
+ };
935
+
936
+ const customIds = new Set(Object.keys(custom.providers ?? {}));
937
+ // 显示为卡片的 provider:已配置认证 或 自定义
938
+ const shown = providers.filter((p) => p.authReady || p.authConfigured || p.keyManaged || customIds.has(p.id));
939
+ // 添加提供方下拉:内置目录里未显示的
940
+ const addable = providers.filter((p) => p.builtIn && !shown.some((s) => s.id === p.id));
941
+
942
+ const saveCustom = async (content: Record<string, any>) => {
943
+ if (customRevision === null) {
944
+ const result = { success: false, error: "Reload model configuration before saving." };
945
+ notify(false, result.error);
946
+ return result;
947
+ }
948
+ return call({ action: "saveCustomProviders", revision: customRevision, content: JSON.stringify({ ...custom, providers: content }, null, 2) });
949
+ };
950
+
951
+ const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
952
+ const credentialSource = (provider: ProviderView) => {
953
+ const source = provider.authSource;
954
+ if (source === "stored") return "auth.json";
955
+ if (source === "runtime") return t.authSourceRuntime;
956
+ if (source === "environment") return t.authSourceEnvironment;
957
+ if (source === "models_json_key") return "models.json";
958
+ if (source === "models_json_command") return t.authSourceCommand;
959
+ if (source === "fallback") return t.authSourceProvider;
960
+ return provider.resolvedAuthSource || t.authUnavailable;
961
+ };
962
+
963
+ const startEdit = (id: string) => {
964
+ setEditing(editing === id ? null : id);
965
+ setKeyDraft("");
966
+ setEditBusy(false);
967
+ setEditAdvanced(false);
968
+ const override = custom.providers[id];
969
+ // 与卡片渲染的 isCustom 同口径:内置 provider 即使有 models.json 覆盖层也走内置编辑分支,
970
+ // 否则 OAuth 后添加的内置提供商会填错状态、编辑时模型列表显示为空
971
+ const isCustomEntry = customIds.has(id) && !providers.find((p) => p.id === id)?.builtIn;
972
+ if (isCustomEntry) {
973
+ setCId(id);
974
+ setCBaseUrl(override?.baseUrl ?? "");
975
+ setCApi(override?.api ?? "openai-completions");
976
+ setCKey("");
977
+ setCName(typeof override?.name === "string" ? override.name : "");
978
+ setCModels((override?.models ?? []).map(modelDraftFromConfig));
979
+ } else {
980
+ // 内置 provider:从 models.json 覆盖层初始化(若有)
981
+ setBBaseUrl(typeof override?.baseUrl === "string" ? override.baseUrl : "");
982
+ setBModels((override?.models ?? []).map(modelDraftFromConfig));
983
+ }
984
+ };
985
+
986
+ const saveCustomEdit = async () => {
987
+ if (editBusy) return;
988
+ const invalid = validateModelDrafts(cModels);
989
+ if (invalid || !cBaseUrl.trim()) return;
990
+ setEditBusy(true);
991
+ try {
992
+ const next = { ...custom.providers };
993
+ const previousId = editing ?? cId.trim();
994
+ const previous = next[previousId] ?? {};
995
+ if (previousId !== cId.trim()) delete next[previousId];
996
+ next[cId.trim()] = serializeProviderDraft(previous, {
997
+ name: cName,
998
+ baseUrl: cBaseUrl,
999
+ api: cApi,
1000
+ apiKey: cKey,
1001
+ models: cModels,
1002
+ });
1003
+ const j = await saveCustom(next);
1004
+ if (j.success) setEditing(null);
1005
+ } finally {
1006
+ setEditBusy(false);
1007
+ }
1008
+ };
1009
+
1010
+ /** 内置 provider 的编辑保存:写 models.json 覆盖层 + 可选 setKey(多步认证走登录弹窗) */
1011
+ const saveBuiltinEdit = async (provider: { id: string; apis: string[] }) => {
1012
+ if (editBusy) return;
1013
+ const invalid = validateModelDrafts(bModels);
1014
+ if (invalid) return;
1015
+ setEditBusy(true);
1016
+ try {
1017
+ const next = { ...custom.providers };
1018
+ next[provider.id] = serializeProviderDraft(next[provider.id] ?? {}, {
1019
+ baseUrl: bBaseUrl,
1020
+ models: bModels,
1021
+ }, provider.apis[0] ?? "openai-completions");
1022
+ const j = await saveCustom(next);
1023
+ if (j.success && keyDraft.trim()) {
1024
+ void call({ action: "setKey", providerId: provider.id, apiKey: keyDraft.trim() });
1025
+ setOauthDone(null);
1026
+ setOauthProvider(provider.id);
1027
+ }
1028
+ if (j.success) setEditing(null);
1029
+ } finally {
1030
+ setEditBusy(false);
1031
+ }
1032
+ };
1033
+
1034
+ const deleteCustom = async (id: string) => {
1035
+ if (!window.confirm(t.confirmRemoveProvider.replace("{name}", providerName(id)))) return;
1036
+ const next = { ...custom.providers };
1037
+ delete next[id];
1038
+ // 内置 provider(“添加提供方”加入)只写有 models.json 覆盖:删除时
1039
+ // 连带移除认证密钥,否则 authReady 仍为真、行不会从列表消失
1040
+ const target = providers.find((provider) => provider.id === id);
1041
+ if (target?.builtIn) {
1042
+ try {
1043
+ await call({ action: "removeKey", providerId: id });
1044
+ } catch {
1045
+ /* 无已存密钥时忽略 */
1046
+ }
1047
+ }
1048
+ const j = await saveCustom(next);
1049
+ if (j.success) setEditing(null);
1050
+ };
1051
+
1052
+ const saveBuiltinSetup = async ({ providerId, apiKey, config }: BuiltinProviderSetup) => {
1053
+ const next = { ...custom.providers, [providerId]: { ...custom.providers[providerId], ...config } };
1054
+ const result = await saveCustom(next);
1055
+ if (result.success && apiKey) {
1056
+ // 多步认证(Vertex/Bedrock/Cloudflare 等)的后续提示经登录弹窗应答;
1057
+ // 不能 await setKey —— 它会挂起到全部提示答完。
1058
+ void request({ action: "setKey", providerId, apiKey });
1059
+ setOauthDone(null);
1060
+ setOauthProvider(providerId);
1061
+ }
1062
+ return result;
1063
+ };
1064
+
1065
+ const saveCustomSetup = async ({ providerId, config }: CustomProviderSetup) => {
1066
+ const next = { ...custom.providers, [providerId]: config };
1067
+ return saveCustom(next);
1068
+ };
1069
+
1070
+ /** 添加弹窗里的 OAuth 订阅登录:启动后由下方已有的 OAuth 轮询弹窗接管交互 */
1071
+ const oauthSetupLogin = async (providerId: string) => {
1072
+ const result = await request({ action: "loginStart", providerId });
1073
+ if (result.success) {
1074
+ setOauthDone(null);
1075
+ setOauthProvider(providerId);
1076
+ return { success: true };
1077
+ }
1078
+ return { success: false, error: result.error };
1079
+ };
1080
+
1081
+ return (
1082
+ <div className="flex flex-col gap-5">
1083
+ <div>
1084
+ <div style={{ fontSize: 14, fontWeight: 400 }}>{t.modelsTitle}</div>
1085
+ <div className="mt-0.5" style={{ fontSize: 12.5, color: "var(--dsw-label-caption)" }}>
1086
+ {t.modelsDesc}
1087
+ </div>
1088
+ <div className="mt-1" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>
1089
+ {t.customModelIdentityNotice}
1090
+ </div>
1091
+ </div>
1092
+
1093
+ {toast && (
1094
+ <div
1095
+ className="rounded-xl px-3 py-2"
1096
+ style={{
1097
+ fontSize: 12.5,
1098
+ background: toast.ok ? "rgba(34,197,94,.1)" : "rgba(236,19,19,.1)",
1099
+ color: toast.ok ? "var(--dsw-success)" : "var(--dsw-danger)",
1100
+ }}
1101
+ >
1102
+ {toast.msg}
1103
+ </div>
1104
+ )}
1105
+
1106
+ {/* Provider 卡片 */}
1107
+ <div className="flex flex-col gap-2.5">
1108
+ {shown.map((p) => {
1109
+ const isCustom = customIds.has(p.id) && !p.builtIn;
1110
+ const editingThis = editing === p.id;
1111
+ return (
1112
+ <div key={p.id}>
1113
+ <div className="flex items-center gap-3 rounded-2xl px-4 py-3" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1114
+ <ProviderBrand id={p.id} name={providerName(p.id)} size={34} />
1115
+ <div className="min-w-0 flex-1">
1116
+ <div className="truncate" style={{ fontSize: 14, fontWeight: 500 }}>{providerName(p.id)}</div>
1117
+ <div className="truncate" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
1118
+ {p.id}{p.baseUrl ? ` · ${p.baseUrl}` : ""}
1119
+ </div>
1120
+ </div>
1121
+ {isCustom && (
1122
+ <span
1123
+ className="rounded-md px-1.5 py-0.5"
1124
+ style={{ fontSize: 10.5, border: "0.5px solid var(--dsw-border-l3)", color: "var(--dsw-label-tertiary)" }}
1125
+ >
1126
+ {t.customBadge}
1127
+ </span>
1128
+ )}
1129
+ <span
1130
+ className="inline-block h-2 w-2 rounded-full"
1131
+ style={{ background: p.authReady ? "var(--dsw-success)" : "var(--dsw-danger)" }}
1132
+ title={p.authError || (p.authReady ? t.providerReady : t.authUnavailable)}
1133
+ />
1134
+ <span
1135
+ className="rounded-md px-1.5 py-0.5"
1136
+ style={{ fontSize: 10.5, border: "0.5px solid var(--dsw-border-l3)", color: "var(--dsw-label-tertiary)" }}
1137
+ >
1138
+ {t.credentialSource.replace("{source}", credentialSource(p))}
1139
+ </span>
1140
+ <button className="btn-outline" style={{ height: 28, padding: "0 12px", fontSize: 12 }} onClick={() => startEdit(p.id)}>
1141
+ {t.edit}
1142
+ </button>
1143
+ {p.authTypes?.includes("oauth") && (
1144
+ <button
1145
+ className="btn-outline"
1146
+ style={{ height: 28, padding: "0 12px", fontSize: 12 }}
1147
+ onClick={() => {
1148
+ setOauthDone(null);
1149
+ void call({ action: "loginStart", providerId: p.id });
1150
+ setOauthProvider(p.id);
1151
+ }}
1152
+ >
1153
+ {t.oauthLogin}
1154
+ </button>
1155
+ )}
1156
+ {customIds.has(p.id) && (
1157
+ <button style={{ fontSize: 12.5, color: "var(--dsw-danger)" }} onClick={() => deleteCustom(p.id)}>
1158
+ {t.delete}
1159
+ </button>
1160
+ )}
1161
+ </div>
1162
+
1163
+ {p.id === "xai" && p.authReady && (
1164
+ <div className="mt-1.5 flex items-center gap-2 px-4" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>
1165
+ {xaiUsage ? (
1166
+ <span>{t.usageQuotaLabel} · {xaiUsage.model} · {t.usageRequests} {xaiUsage.requestRemaining.toLocaleString()}/{xaiUsage.requestLimit.toLocaleString()} · Tokens {fmtQuotaTokens(xaiUsage.tokenRemaining)}/{fmtQuotaTokens(xaiUsage.tokenLimit)}</span>
1167
+ ) : <span>{usageError ? t.usageProbeFailed : t.usageProbeDisclosure}</span>}
1168
+ <button type="button" className="pw-chip" disabled={usageBusy} title={t.usageProbeDisclosure} onClick={() => void fetchUsage(true)}>
1169
+ {usageBusy ? t.usageProbing : xaiUsage ? t.usageRefresh : t.usageProbe}
1170
+ </button>
1171
+ </div>
1172
+ )}
1173
+
1174
+ {editingThis && (
1175
+ <div className="mt-2 rounded-2xl p-4" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1176
+ {isCustom ? (
1177
+ <div className="flex flex-col gap-3">
1178
+ <Field label={t.apiKey}>
1179
+ <input
1180
+ type="password"
1181
+ value={cKey}
1182
+ onChange={(e) => setCKey(e.target.value)}
1183
+ className={fieldCls}
1184
+ placeholder={secretProviderIds.has(p.id) ? t.apiKeyStored : t.apiKeyOptional}
1185
+ />
1186
+ </Field>
1187
+ <Field label={t.displayName}>
1188
+ <input value={cName} onChange={(e) => setCName(e.target.value)} className={fieldCls} placeholder={p.id} />
1189
+ </Field>
1190
+ <Field label={t.baseUrl}>
1191
+ <input value={cBaseUrl} onChange={(e) => setCBaseUrl(e.target.value)} className={fieldCls} placeholder="https://api.example.com/v1" />
1192
+ </Field>
1193
+ <Field label={t.apiType}>
1194
+ <SelectOption
1195
+ value={cApi}
1196
+ options={customApiOptions([cApi, ...p.apis]).map((api) => ({ value: api, label: api }))}
1197
+ onChange={setCApi}
1198
+ />
1199
+ </Field>
1200
+ <ModelCatalog
1201
+ models={cModels}
1202
+ setModels={setCModels}
1203
+ updateModel={(index, patch) => setCModels((current) => current.map((model, at) => (at === index ? { ...model, ...patch } : model)))}
1204
+ discover={async () => {
1205
+ const response = await fetch("/api/models", {
1206
+ method: "POST",
1207
+ headers: { "Content-Type": "application/json" },
1208
+ // providerId:key 留空(保持不变)时后端回退到已存密钥
1209
+ body: JSON.stringify({ action: "discoverModels", providerId: p.id, baseUrl: cBaseUrl.trim(), api: cApi, apiKey: cKey.trim() }),
1210
+ });
1211
+ const result = await response.json();
1212
+ if (!result.success) throw new Error(result.error || t.toastError);
1213
+ return (result.data.models ?? []).map((model: { id: string; name?: string }) => ({ id: model.id, name: model.name ?? "" }));
1214
+ }}
1215
+ discoverDisabled={!cBaseUrl.trim()}
1216
+ />
1217
+ <div className="flex justify-end gap-2">
1218
+ <button className="btn-outline" style={{ height: 32 }} disabled={editBusy} onClick={() => setEditing(null)}>{t.cancel}</button>
1219
+ <button
1220
+ className="btn-primary-white"
1221
+ disabled={editBusy || !cBaseUrl.trim() || !!validateModelDrafts(cModels) || !cModels.some((m) => m.id.trim())}
1222
+ onClick={() => void saveCustomEdit()}
1223
+ >
1224
+ {editBusy ? t.saving : t.save}
1225
+ </button>
1226
+ </div>
1227
+ </div>
1228
+ ) : (
1229
+ <div className="flex flex-col gap-3">
1230
+ <Field label={t.apiKey}>
1231
+ <input
1232
+ type="password"
1233
+ value={keyDraft}
1234
+ onChange={(e) => setKeyDraft(e.target.value)}
1235
+ className={fieldCls}
1236
+ placeholder={p.keyManaged ? t.apiKeyStored : (p.apiKeyLabel ?? t.apiKey)}
1237
+ />
1238
+ </Field>
1239
+ <div>
1240
+ <button
1241
+ className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left transition-colors"
1242
+ style={{ fontSize: 13, color: "var(--dsw-label-secondary)", background: editAdvanced ? "var(--dsw-hover)" : "transparent" }}
1243
+ onClick={() => setEditAdvanced((v) => !v)}
1244
+ >
1245
+ <span style={{ display: "inline-flex", transform: editAdvanced ? "none" : "rotate(-90deg)", transition: "transform 120ms ease" }}>
1246
+ <IconChevronDown14 size={13} />
1247
+ </span>
1248
+ {t.customSettings}
1249
+ </button>
1250
+ {editAdvanced ? (
1251
+ <div className="flex flex-col gap-3 pl-1 pt-2">
1252
+ <Field label={t.baseUrl}>
1253
+ <input value={bBaseUrl} onChange={(e) => setBBaseUrl(e.target.value)} className={fieldCls} placeholder={p.baseUrl ?? t.providerDefault} />
1254
+ </Field>
1255
+ <ModelCatalog
1256
+ models={bModels}
1257
+ setModels={setBModels}
1258
+ updateModel={(index, patch) => setBModels((current) => current.map((model, at) => (at === index ? { ...model, ...patch } : model)))}
1259
+ inheritedCount={p.modelCount}
1260
+ discover={async () => {
1261
+ const response = await fetch("/api/models", {
1262
+ method: "POST",
1263
+ headers: { "Content-Type": "application/json" },
1264
+ // providerId:key 留空(保持不变)时后端回退到已存密钥
1265
+ body: JSON.stringify({ action: "discoverModels", providerId: p.id, baseUrl: (bBaseUrl.trim() || p.baseUrl || ""), api: p.apis[0], apiKey: keyDraft.trim() }),
1266
+ });
1267
+ const result = await response.json();
1268
+ if (!result.success) throw new Error(result.error || t.toastError);
1269
+ return (result.data.models ?? []).map((model: { id: string; name?: string }) => ({ id: model.id, name: model.name ?? "" }));
1270
+ }}
1271
+ discoverDisabled={!(bBaseUrl.trim() || p.baseUrl)}
1272
+ />
1273
+ </div>
1274
+ ) : null}
1275
+ </div>
1276
+ <div className="flex items-center justify-end gap-2">
1277
+ {p.keyManaged && (
1278
+ <button
1279
+ style={{ fontSize: 12.5, color: "var(--dsw-danger)" }}
1280
+ disabled={editBusy}
1281
+ onClick={async () => {
1282
+ if (!window.confirm(t.confirmRemoveKey.replace("{name}", p.name))) return;
1283
+ const j = await call({ action: "removeKey", providerId: p.id });
1284
+ if (j.success) setEditing(null);
1285
+ }}
1286
+ >
1287
+ {t.removeKey}
1288
+ </button>
1289
+ )}
1290
+ <button className="btn-outline" style={{ height: 32 }} disabled={editBusy} onClick={() => setEditing(null)}>{t.cancel}</button>
1291
+ <button
1292
+ className="btn-primary-white"
1293
+ disabled={editBusy || customRevision === null || !!validateModelDrafts(bModels)}
1294
+ onClick={() => void saveBuiltinEdit({ id: p.id, apis: p.apis })}
1295
+ >
1296
+ {editBusy ? t.saving : t.save}
1297
+ </button>
1298
+ </div>
1299
+ </div>
1300
+ )}
1301
+ </div>
1302
+ )}
1303
+ </div>
1304
+ );
1305
+ })}
1306
+ </div>
1307
+
1308
+ {/* 两个添加按钮 */}
1309
+ <div className="flex gap-3">
1310
+ <button
1311
+ className="flex flex-1 items-center justify-center gap-2 rounded-2xl px-4 py-3.5 transition-colors"
1312
+ style={{ border: "0.5px solid var(--dsw-border-l3)", fontSize: 14 }}
1313
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
1314
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
1315
+ onClick={() => setAdding("builtin")}
1316
+ >
1317
+ <IconPlusOutline16 size={15} />
1318
+ {t.addProvider}
1319
+ </button>
1320
+ <button
1321
+ className="flex flex-1 items-center justify-center gap-2 rounded-2xl px-4 py-3.5 transition-colors"
1322
+ style={{ border: "0.5px solid var(--dsw-border-l3)", fontSize: 14 }}
1323
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
1324
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
1325
+ onClick={() => setAdding("custom")}
1326
+ >
1327
+ <IconPlusOutline16 size={15} />
1328
+ {t.addCustomProvider}
1329
+ </button>
1330
+ </div>
1331
+
1332
+ {adding ? (
1333
+ <ProviderSetupModal
1334
+ mode={adding}
1335
+ providers={addable}
1336
+ existingIds={providers.map((provider) => provider.id)}
1337
+ onClose={() => setAdding(null)}
1338
+ onSaveBuiltin={saveBuiltinSetup}
1339
+ onSaveCustom={saveCustomSetup}
1340
+ onOAuthLogin={oauthSetupLogin}
1341
+ />
1342
+ ) : null}
1343
+
1344
+ {/* OAuth 登录弹窗 */}
1345
+ {oauthProvider && (
1346
+ <div className="modal-mask fixed inset-0 z-[120] flex items-center justify-center" onMouseDown={(e) => { if (e.target === e.currentTarget) void cancelOAuth(); }}>
1347
+ <div className="glass-modal flex flex-col gap-4 p-6" style={{ width: 460, maxWidth: "92vw", borderRadius: 24, boxShadow: "var(--dsw-elevation-prominent)" }}>
1348
+ <div style={{ fontSize: 15, fontWeight: 600 }}>{t.oauthLogin} · {oauthProvider}</div>
1349
+ {oauthDeviceCode && !oauthDone ? (
1350
+ <div className="flex flex-col gap-2">
1351
+ <div style={{ fontSize: 13, color: "var(--dsw-label-primary)" }}>{t.oauthDevicePrompt}</div>
1352
+ {oauthDeviceCode.instructions ? (
1353
+ <div style={{ fontSize: 12, color: "var(--dsw-label-tertiary)" }}>{oauthDeviceCode.instructions}</div>
1354
+ ) : null}
1355
+ <div className="flex flex-wrap items-center gap-2">
1356
+ <a
1357
+ href={oauthDeviceCode.verificationUri}
1358
+ target="_blank"
1359
+ rel="noreferrer"
1360
+ className="btn-primary-white"
1361
+ style={{ fontSize: 12.5, textDecoration: "none" }}
1362
+ >
1363
+ {t.oauthOpenAuth}
1364
+ </a>
1365
+ {oauthDeviceCode.userCode ? (
1366
+ <button className="btn-outline" style={{ height: 32 }} onClick={copyDeviceCode}>
1367
+ {oauthCodeCopied ? t.oauthCodeCopied : `${t.oauthDeviceCode}: ${oauthDeviceCode.userCode}`}
1368
+ </button>
1369
+ ) : null}
1370
+ </div>
1371
+ </div>
1372
+ ) : oauthLog.length > 0 ? (
1373
+ <div className="flex flex-col gap-0.5" style={{ fontSize: 12, color: "var(--dsw-label-tertiary)" }}>
1374
+ {oauthLog.slice(-3).map((l, i) => (
1375
+ <div key={i} className="truncate">{l}</div>
1376
+ ))}
1377
+ </div>
1378
+ ) : null}
1379
+ {oauthDone ? (
1380
+ <div style={{ fontSize: 13, color: oauthDone.ok ? "var(--dsw-success)" : "var(--dsw-danger)" }}>{oauthDone.ok ? oauthProvider + " ✓" : oauthDone.msg}</div>
1381
+ ) : oauthPrompt ? (
1382
+ <>
1383
+ <div style={{ fontSize: 13.5, color: "var(--dsw-label-primary)" }}>{oauthPrompt.message}</div>
1384
+ {oauthPrompt.message.match(/https?:\/\/[^\s)]+/) && (
1385
+ <a
1386
+ href={oauthPrompt.message.match(/https?:\/\/[^\s)]+/)![0]}
1387
+ target="_blank"
1388
+ rel="noreferrer"
1389
+ className="break-all"
1390
+ style={{ fontSize: 12.5, color: "var(--dsw-accent)" }}
1391
+ >
1392
+ {oauthPrompt.message.match(/https?:\/\/[^\s)]+/)![0]}
1393
+ </a>
1394
+ )}
1395
+ {oauthPrompt.options?.length ? (
1396
+ // select 型提示:点选项即作答(回传 SDK 的原始 value)
1397
+ <div className="flex flex-col gap-1.5">
1398
+ {oauthPrompt.options.map((opt) => (
1399
+ <button
1400
+ key={opt.value}
1401
+ className="btn-outline"
1402
+ style={{ height: 34, justifyContent: "flex-start", textAlign: "left" }}
1403
+ onClick={() => { void answerOAuth(opt.value); }}
1404
+ >
1405
+ {opt.label}
1406
+ </button>
1407
+ ))}
1408
+ </div>
1409
+ ) : (
1410
+ <input
1411
+ autoFocus
1412
+ type={oauthPrompt.type === "secret" ? "password" : "text"}
1413
+ value={oauthInput}
1414
+ onChange={(e) => setOauthInput(e.target.value)}
1415
+ placeholder={oauthPrompt.placeholder ?? ""}
1416
+ className="w-full rounded-xl px-3 py-2"
1417
+ style={{ fontSize: 13, background: "var(--dsw-hover)" }}
1418
+ onKeyDown={(e) => {
1419
+ if (e.key === "Enter" && !e.nativeEvent.isComposing && oauthInput.trim()) {
1420
+ void answerOAuth(oauthInput.trim());
1421
+ }
1422
+ }}
1423
+ />
1424
+ )}
1425
+ <div className="flex items-center justify-between gap-2">
1426
+ <span style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{t.oauthWait}</span>
1427
+ <div className="flex gap-2">
1428
+ <button
1429
+ className="btn-outline"
1430
+ style={{ height: 32 }}
1431
+ onClick={() => { void cancelOAuth(); }}
1432
+ >
1433
+ {t.oauthCancel}
1434
+ </button>
1435
+ {!oauthPrompt.options?.length && (
1436
+ <button
1437
+ className="btn-primary-white"
1438
+ disabled={!oauthInput.trim()}
1439
+ style={{ opacity: oauthInput.trim() ? 1 : 0.5 }}
1440
+ onClick={() => { void answerOAuth(oauthInput.trim()); }}
1441
+ >
1442
+ {t.confirm}
1443
+ </button>
1444
+ )}
1445
+ </div>
1446
+ </div>
1447
+ </>
1448
+ ) : (
1449
+ <div style={{ fontSize: 13, color: "var(--dsw-label-tertiary)" }}>{t.oauthWait}</div>
1450
+ )}
1451
+ {oauthDone && (
1452
+ <div className="flex justify-end">
1453
+ <button className="btn-primary-white" onClick={() => setOauthProvider(null)}>{t.close}</button>
1454
+ </div>
1455
+ )}
1456
+ </div>
1457
+ </div>
1458
+ )}
1459
+
1460
+ </div>
1461
+ );
1462
+ }const fieldCls = "w-full rounded-xl px-3 py-2";
1463
+
1464
+ /** 配额展示:token 数转 M(53,000,000 → 53M),小数值原样 */
1465
+ function fmtQuotaTokens(value: number): string {
1466
+ if (value >= 1_000_000) return `${Math.round(value / 100_000) / 10}M`;
1467
+ return value.toLocaleString();
1468
+ }
1469
+
1470
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
1471
+ return (
1472
+ <div>
1473
+ <div className="mb-1" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>
1474
+ {label}
1475
+ </div>
1476
+ {children}
1477
+ </div>
1478
+ );
1479
+ }
1480
+
1481
+ // ---------- 工具启用情况 ----------
1482
+
1483
+ /** 内置工具的一句话说明(未知/扩展工具只显示原名) */
1484
+ const TOOL_DESCRIPTIONS: Record<string, { zh: string; en: string }> = {
1485
+ read: { zh: "读取文件", en: "Read files" },
1486
+ bash: { zh: "执行 Shell 命令", en: "Run shell commands" },
1487
+ powershell: { zh: "执行 PowerShell", en: "Run PowerShell" },
1488
+ edit: { zh: "编辑文件", en: "Edit files" },
1489
+ write: { zh: "写入文件", en: "Write files" },
1490
+ grep: { zh: "搜索文件内容", en: "Search file contents" },
1491
+ find: { zh: "按名称查找文件", en: "Find files by name" },
1492
+ ls: { zh: "列出目录内容", en: "List directories" },
1493
+ };
1494
+
1495
+ function ToolsSection({
1496
+ toolPreset,
1497
+ onToolPresetChange,
1498
+ tools,
1499
+ onSetTools,
1500
+ }: {
1501
+ toolPreset: ToolPreset;
1502
+ onToolPresetChange: (preset: ToolPreset) => void;
1503
+ tools?: { active: string[]; all: { name: string; description?: string }[] } | null;
1504
+ onSetTools?: (names: string[]) => void;
1505
+ }) {
1506
+ const { t, lang } = useI18n();
1507
+ const active = new Set(tools?.active ?? []);
1508
+ const all = tools?.all ?? [];
1509
+
1510
+ const toggle = (name: string) => {
1511
+ if (!onSetTools) return;
1512
+ const next = active.has(name) ? [...active].filter((n) => n !== name) : [...active, name];
1513
+ onSetTools(next);
1514
+ };
1515
+
1516
+ const describe = (name: string) => {
1517
+ const d = TOOL_DESCRIPTIONS[name];
1518
+ return d ? (lang === "zh" ? d.zh : d.en) : "";
1519
+ };
1520
+
1521
+ return (
1522
+ <div className="flex flex-col gap-5">
1523
+ <div>
1524
+ <div style={{ fontSize: 14, fontWeight: 400 }}>{t.toolsSection}</div>
1525
+ <div className="mt-0.5" style={{ fontSize: 12.5, color: "var(--dsw-label-caption)" }}>
1526
+ {t.toolsSectionDesc}
1527
+ </div>
1528
+ </div>
1529
+
1530
+ <Row title={t.securityToolPreset} desc={t.securityToolPresetDesc}>
1531
+ <SelectOption
1532
+ value={toolPreset}
1533
+ options={[
1534
+ { value: "readonly", label: t.toolReadonly },
1535
+ { value: "standard", label: t.toolStandard },
1536
+ { value: "full", label: t.toolFull },
1537
+ ]}
1538
+ onChange={(value) => onToolPresetChange(value as ToolPreset)}
1539
+ />
1540
+ </Row>
1541
+
1542
+ {all.length === 0 ? (
1543
+ <div className="rounded-2xl p-4" style={{ border: "0.5px dashed var(--dsw-border-l3)", fontSize: 12.5, color: "var(--dsw-label-caption)" }}>
1544
+ {t.toolsNoSession}
1545
+ </div>
1546
+ ) : (
1547
+ <div className="flex flex-col gap-2.5">
1548
+ <div className="flex items-center gap-2" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>
1549
+ <span style={{ fontWeight: 600 }}>{t.toolsCurrentSession}</span>
1550
+ <span>{t.toolsActiveCount.replace("{n}", String(active.size)).replace("{total}", String(all.length))}</span>
1551
+ </div>
1552
+ {all.map((tool) => {
1553
+ const on = active.has(tool.name);
1554
+ const desc = tool.description || describe(tool.name);
1555
+ return (
1556
+ <div key={tool.name} className="flex items-center gap-3 rounded-2xl px-4 py-2.5" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1557
+ <div className="min-w-0 flex-1">
1558
+ <div style={{ fontSize: 13.5, fontWeight: 500, fontFamily: "var(--font-mono)" }}>{tool.name}</div>
1559
+ {desc && (
1560
+ <div className="truncate" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
1561
+ {desc}
1562
+ </div>
1563
+ )}
1564
+ </div>
1565
+ <button
1566
+ className="relative h-5 w-9 flex-none rounded-full transition-colors"
1567
+ style={{ background: on ? "var(--dsw-accent)" : "var(--dsw-border-l3)" }}
1568
+ role="switch"
1569
+ aria-checked={on}
1570
+ aria-label={tool.name}
1571
+ disabled={!onSetTools || (on && active.size <= 1)}
1572
+ onClick={() => toggle(tool.name)}
1573
+ >
1574
+ <span className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all" style={{ left: on ? 18 : 2 }} />
1575
+ </button>
1576
+ </div>
1577
+ );
1578
+ })}
1579
+ </div>
1580
+ )}
1581
+ </div>
1582
+ );
1583
+ }
1584
+
1585
+ // ---------- Skills ----------
1586
+
1587
+ interface SkillRow {
1588
+ name: string;
1589
+ description: string;
1590
+ filePath: string;
1591
+ disabled: boolean;
1592
+ scope: "global" | "project" | "package";
1593
+ }
1594
+
1595
+ function SkillsSection({ cwd, onOpenFileContent }: { cwd: string; onOpenFileContent?: (path: string, content: string) => void }) {
1596
+ const { t } = useI18n();
1597
+ const [skills, setSkills] = useState<SkillRow[]>([]);
1598
+
1599
+ const load = useCallback(async () => {
1600
+ const r = await fetch(`/api/skills${cwd ? `?cwd=${encodeURIComponent(cwd)}` : ""}`);
1601
+ const j = await r.json();
1602
+ if (j.success) setSkills(j.data.skills);
1603
+ }, [cwd]);
1604
+
1605
+ useEffect(() => {
1606
+ void load();
1607
+ }, [load]);
1608
+
1609
+ const toggle = async (s: SkillRow) => {
1610
+ const r = await fetch("/api/skills", {
1611
+ method: "POST",
1612
+ headers: { "Content-Type": "application/json" },
1613
+ body: JSON.stringify({ action: "toggle", filePath: s.filePath, disabled: !s.disabled, cwd }),
1614
+ });
1615
+ if ((await r.json()).success) void load();
1616
+ };
1617
+
1618
+ // 查看技能文档:弹大窗口查看器(与项目文件查看器同款,Markdown 可切渲染/源码)
1619
+ const view = async (s: SkillRow) => {
1620
+ const r = await fetch("/api/skills", {
1621
+ method: "POST",
1622
+ headers: { "Content-Type": "application/json" },
1623
+ body: JSON.stringify({ action: "read", filePath: s.filePath, cwd }),
1624
+ });
1625
+ const j = await r.json();
1626
+ if (j.success) onOpenFileContent?.(s.filePath, j.data.content);
1627
+ };
1628
+
1629
+ const scopeLabel = (s: SkillRow["scope"]) => (s === "global" ? t.scopeGlobal : s === "project" ? t.scopeProject : t.scopePackage);
1630
+
1631
+ return (
1632
+ <div className="flex flex-col gap-4">
1633
+ <div style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{t.skillsDesc}</div>
1634
+ {skills.length === 0 && (
1635
+ <div style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>—</div>
1636
+ )}
1637
+ <div className="flex flex-col gap-2">
1638
+ {skills.map((s) => (
1639
+ <div key={s.filePath} className="rounded-2xl px-4 py-3" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1640
+ <div className="flex items-center gap-3">
1641
+ <div className="min-w-0 flex-1">
1642
+ <div className="flex items-center gap-2">
1643
+ <span style={{ fontSize: 13.5, fontWeight: 500 }}>{s.name}</span>
1644
+ <span
1645
+ className="rounded-full px-2 py-0.5"
1646
+ style={{ fontSize: 10, background: "var(--dsw-hover)", color: "var(--dsw-label-tertiary)" }}
1647
+ >
1648
+ {scopeLabel(s.scope)}
1649
+ </span>
1650
+ </div>
1651
+ <div className="mt-0.5 truncate" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }} title={s.description}>
1652
+ {s.description}
1653
+ </div>
1654
+ </div>
1655
+ <button
1656
+ className="rounded-lg px-2.5 py-1"
1657
+ style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}
1658
+ onClick={() => view(s)}
1659
+ >
1660
+ {t.viewSkillFile}
1661
+ </button>
1662
+ <button
1663
+ className="relative h-5 w-9 flex-none rounded-full transition-colors"
1664
+ style={{ background: s.disabled ? "var(--dsw-border-l3)" : "var(--dsw-accent)" }}
1665
+ role="switch"
1666
+ aria-checked={!s.disabled}
1667
+ onClick={() => toggle(s)}
1668
+ >
1669
+ <span
1670
+ className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all"
1671
+ style={{ left: s.disabled ? 2 : 18 }}
1672
+ />
1673
+ </button>
1674
+ </div>
1675
+ </div>
1676
+ ))}
1677
+ </div>
1678
+ </div>
1679
+ );
1680
+ }
1681
+
1682
+ // ---------- Plugins ----------
1683
+
1684
+ function PluginsSection({ cwd }: { cwd: string }) {
1685
+ const { t, lang } = useI18n();
1686
+ const confirmUninstall = (source: string) => window.confirm(t.confirmUninstallPackage.replace("{source}", source));
1687
+ const [tab, setTab] = useState<"config" | "list">("config");
1688
+ const [packages, setPackages] = useState<any[]>([]);
1689
+ const [extensions, setExtensions] = useState<any[]>([]);
1690
+ const [q, setQ] = useState("");
1691
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
1692
+ const [source, setSource] = useState("");
1693
+ const [local, setLocal] = useState(false);
1694
+ const [busy, setBusy] = useState(false);
1695
+ const [busyKey, setBusyKey] = useState<string | null>(null);
1696
+ const [toast, setToast] = useState<{ ok: boolean; msg: string; loading?: boolean } | null>(null);
1697
+ const [menuKey, setMenuKey] = useState<string | null>(null);
1698
+ const menuRef = useRef<HTMLDivElement | null>(null);
1699
+
1700
+ useEffect(() => {
1701
+ const close = (event: PointerEvent) => {
1702
+ if (event.target instanceof Node && menuRef.current?.contains(event.target)) return;
1703
+ setMenuKey(null);
1704
+ };
1705
+ window.addEventListener("pointerdown", close);
1706
+ return () => window.removeEventListener("pointerdown", close);
1707
+ }, []);
1708
+
1709
+ const load = useCallback(async () => {
1710
+ const r = await fetch(`/api/plugins${cwd ? `?cwd=${encodeURIComponent(cwd)}` : ""}`);
1711
+ const j = await r.json();
1712
+ if (j.success) {
1713
+ setPackages(j.data.packages);
1714
+ setExtensions(j.data.extensions);
1715
+ }
1716
+ }, [cwd]);
1717
+
1718
+ useEffect(() => {
1719
+ void load();
1720
+ }, [load]);
1721
+
1722
+ const notify = (ok: boolean, msg?: string) => {
1723
+ setToast({ ok, msg: msg ?? (ok ? t.toastSaved : t.toastError), loading: false });
1724
+ setTimeout(() => setToast(null), 2600);
1725
+ };
1726
+
1727
+ const act = async (
1728
+ action: string,
1729
+ payload: Record<string, unknown> = {},
1730
+ itemKey?: string,
1731
+ loadingMsg?: string,
1732
+ successMsg?: string
1733
+ ) => {
1734
+ setBusy(true);
1735
+ if (itemKey) setBusyKey(itemKey);
1736
+ if (loadingMsg) setToast({ ok: true, msg: loadingMsg, loading: true });
1737
+ try {
1738
+ const r = await fetch("/api/plugins", {
1739
+ method: "POST",
1740
+ headers: { "Content-Type": "application/json" },
1741
+ body: JSON.stringify({ action, cwd, ...payload }),
1742
+ });
1743
+ const j = await r.json();
1744
+ if (j.success) {
1745
+ notify(true, successMsg ?? (action === "toggle" ? (payload.disabled ? t.disabled : t.enabled) : t.toastSaved));
1746
+ await load();
1747
+ return true;
1748
+ } else {
1749
+ notify(false, j.error ?? t.toastError);
1750
+ return false;
1751
+ }
1752
+ } catch (e: any) {
1753
+ notify(false, e?.message ?? t.toastError);
1754
+ return false;
1755
+ } finally {
1756
+ setBusy(false);
1757
+ setBusyKey(null);
1758
+ }
1759
+ };
1760
+
1761
+ const toggle = (key: string) =>
1762
+ setExpanded((prev) => {
1763
+ const next = new Set(prev);
1764
+ if (next.has(key)) next.delete(key);
1765
+ else next.add(key);
1766
+ return next;
1767
+ });
1768
+
1769
+ const classify = (path: string): "global" | "project" | "package" => {
1770
+ const norm = path.replace(/\\/g, "/");
1771
+ if (norm.includes("/.pi/agent/npm/") || norm.includes("/.pi/agent/git/")) return "package";
1772
+ if (norm.includes("/.pi/agent/")) return "global";
1773
+ if (norm.includes("/.pi/")) return "project";
1774
+ return "project";
1775
+ };
1776
+
1777
+ const scopeLabel = (s: "global" | "project" | "package") =>
1778
+ s === "global" ? t.scopeGlobal : s === "project" ? t.scopeProject : t.scopePackage;
1779
+
1780
+ // 独立扩展 = 排除随包安装的(包已在 packages 里展示)
1781
+ const standalone = extensions.filter((e: any) => classify(e.path) !== "package");
1782
+ const query = q.trim().toLowerCase();
1783
+
1784
+ const listItems = [
1785
+ ...packages.map((p) => ({
1786
+ key: `pkg:${p.scope}:${p.source}`,
1787
+ kind: "package" as const,
1788
+ name: p.source.replace(/^npm:/, "").replace(/^git[^:]*:/, ""),
1789
+ p,
1790
+ })),
1791
+ ...standalone.map((e: any) => ({
1792
+ key: `ext:${e.path}`,
1793
+ kind: "extension" as const,
1794
+ name: e.name,
1795
+ e,
1796
+ })),
1797
+ ].filter((x) => !query || x.name.toLowerCase().includes(query));
1798
+
1799
+ const badge = (
1800
+ <span
1801
+ className="flex flex-none items-center gap-1.5 rounded-md px-1.5 py-0.5"
1802
+ style={{ fontSize: 10.5, background: "rgba(34,197,94,.12)", color: "var(--dsw-success)" }}
1803
+ >
1804
+ <span className="h-1.5 w-1.5 rounded-full" style={{ background: "var(--dsw-success)" }} />
1805
+ {t.enabled}
1806
+ </span>
1807
+ );
1808
+
1809
+ return (
1810
+ <div className="flex flex-col gap-5">
1811
+ <div>
1812
+ <div style={{ fontSize: 20, fontWeight: 600, color: "var(--dsw-label-primary)" }}>{t.setPlugins}</div>
1813
+ <div className="mt-1" style={{ fontSize: 13, color: "var(--dsw-label-tertiary)" }}>
1814
+ {t.pluginPageDesc}
1815
+ </div>
1816
+ </div>
1817
+
1818
+ {toast && (
1819
+ <div
1820
+ className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] flex items-center gap-2.5 px-4 py-2 rounded-xl shadow-2xl transition-all pointer-events-none"
1821
+ style={{
1822
+ fontSize: 13,
1823
+ fontWeight: 500,
1824
+ background: "var(--dsw-surface-l1, #1e2025)",
1825
+ border: toast.loading
1826
+ ? "1px solid var(--dsw-border-l3)"
1827
+ : toast.ok
1828
+ ? "1px solid rgba(34,197,94,.4)"
1829
+ : "1px solid rgba(236,19,19,.4)",
1830
+ color: toast.loading
1831
+ ? "var(--dsw-label-primary)"
1832
+ : toast.ok
1833
+ ? "var(--dsw-success)"
1834
+ : "var(--dsw-danger)",
1835
+ backdropFilter: "blur(12px)",
1836
+ boxShadow: "0 10px 30px rgba(0,0,0,0.5)",
1837
+ }}
1838
+ >
1839
+ {toast.loading && (
1840
+ <span className="h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent" />
1841
+ )}
1842
+ <span>{toast.msg}</span>
1843
+ </div>
1844
+ )}
1845
+
1846
+ {/* 双标签(dsh:插件配置 / 插件列表,白色下划线) */}
1847
+ <div className="hairline-b flex items-center gap-6">
1848
+ <button className="settings-tab-underline" data-active={tab === "config"} onClick={() => setTab("config")}>
1849
+ {t.pluginConfig}
1850
+ </button>
1851
+ <button className="settings-tab-underline" data-active={tab === "list"} onClick={() => setTab("list")}>
1852
+ {t.pluginList}
1853
+ </button>
1854
+ </div>
1855
+
1856
+ {tab === "config" && (
1857
+ <div className="flex flex-col gap-2.5">
1858
+ {/* 安装入口:pi 包管理器支持 npm:包名 / git 地址 / 本地路径;安装后自动重载加载器与活跃会话 */}
1859
+ <div className="flex flex-col gap-2 rounded-2xl px-4 py-3" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1860
+ <div style={{ fontSize: 13.5, fontWeight: 500 }}>{t.addPackage}</div>
1861
+ <div className="flex flex-wrap items-center gap-2">
1862
+ <input
1863
+ value={source}
1864
+ onChange={(e) => setSource(e.target.value)}
1865
+ placeholder={t.addPackagePlaceholder}
1866
+ className="min-w-0 flex-1 rounded-xl px-3 py-2"
1867
+ style={{ fontSize: 13, background: "var(--dsw-hover)", border: "0.5px solid var(--dsw-border-l2)", fontFamily: "var(--font-mono)" }}
1868
+ onKeyDown={(e) => {
1869
+ if (e.key === "Enter" && source.trim() && !busy) void act("install", { source: source.trim(), local }, undefined, t.installing, t.installSuccess).then((ok) => { if (ok) setSource(""); });
1870
+ }}
1871
+ />
1872
+ <label className="flex items-center gap-1.5" style={{ fontSize: 12.5, color: "var(--dsw-label-secondary)" }}>
1873
+ <input type="checkbox" checked={local} onChange={(e) => setLocal(e.target.checked)} disabled={!cwd} />
1874
+ {t.installLocal}
1875
+ </label>
1876
+ <button
1877
+ className="btn-primary-white"
1878
+ style={{ height: 32, padding: "0 14px" }}
1879
+ disabled={!source.trim() || busy}
1880
+ onClick={() => void act("install", { source: source.trim(), local }, undefined, t.installing, t.installSuccess).then((ok) => { if (ok) setSource(""); })}
1881
+ >
1882
+ {t.install}
1883
+ </button>
1884
+ </div>
1885
+ </div>
1886
+ <div className="flex justify-end">
1887
+ <button
1888
+ className="btn-outline"
1889
+ style={{ height: 30, fontSize: 12.5 }}
1890
+ disabled={busy}
1891
+ onClick={async () => {
1892
+ setBusy(true);
1893
+ try {
1894
+ const r = await fetch("/api/plugins", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "reload", cwd }) });
1895
+ const j = await r.json();
1896
+ notify(j.success, j.success ? t.reloadedSessions.replace("{loaders}", String(j.data?.loaders ?? 0)).replace("{sessions}", String(j.data?.sessions ?? 0)) : j.error);
1897
+ if (j.success) await load();
1898
+ } finally {
1899
+ setBusy(false);
1900
+ }
1901
+ }}
1902
+ >
1903
+ <IconRefreshOutline14 size={13} /> {t.reloadExtensions}
1904
+ </button>
1905
+ </div>
1906
+ {standalone.length === 0 && (
1907
+ <div style={{ fontSize: 12.5, color: "var(--dsw-label-caption)" }}>{t.extensionsEmpty}</div>
1908
+ )}
1909
+ {standalone.map((e: any) => {
1910
+ const key = `ext:${e.path}`;
1911
+ const open = expanded.has(key);
1912
+ const scope = classify(e.path);
1913
+ return (
1914
+ <div key={key} className="rounded-2xl" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1915
+ <button className="flex w-full items-center gap-3 px-4 py-3.5 text-left" onClick={() => toggle(key)}>
1916
+ <div className="min-w-0 flex-1">
1917
+ <div className="flex items-center gap-2" style={{ fontSize: 14, fontWeight: 500 }}>
1918
+ <span>{e.name}</span>
1919
+ {e.error && (
1920
+ <span className="rounded px-1.5 py-0.5" style={{ fontSize: 10.5, color: "var(--dsw-danger)", background: "var(--pw-danger-soft, rgba(236,19,19,.12))" }}>{t.extensionLoadError}</span>
1921
+ )}
1922
+ {e.disabled && !e.error && (
1923
+ <span className="rounded px-1.5 py-0.5" style={{ fontSize: 10.5, color: "var(--dsw-label-tertiary)", background: "var(--dsw-selector)" }}>{t.disabled}</span>
1924
+ )}
1925
+ </div>
1926
+ <div className="truncate" style={{ fontSize: 12, color: e.error ? "var(--dsw-danger)" : "var(--dsw-label-caption)" }} title={e.error ?? e.path}>
1927
+ {e.error ?? e.path}
1928
+ </div>
1929
+ </div>
1930
+ <span
1931
+ className="chevron"
1932
+ style={{ color: "var(--dsw-label-tertiary)", display: "inline-flex", transform: open ? "rotate(180deg)" : "none", transition: "transform 120ms var(--ds-ease-in-out)" }}
1933
+ >
1934
+ <IconChevronDown14 size={14} />
1935
+ </span>
1936
+ </button>
1937
+ {open && (
1938
+ <div className="hairline-t flex flex-col gap-1.5 px-4 py-3" style={{ fontSize: 12, color: "var(--dsw-label-secondary)" }}>
1939
+ <div className="flex items-center gap-2">
1940
+ <span
1941
+ className="rounded-md px-1.5 py-0.5"
1942
+ style={{ fontSize: 10.5, border: "0.5px solid var(--dsw-border-l3)", color: "var(--dsw-label-tertiary)" }}
1943
+ >
1944
+ {scopeLabel(scope)}
1945
+ </span>
1946
+ {badge}
1947
+ </div>
1948
+ <div className="break-all" style={{ fontFamily: "var(--font-mono)", fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
1949
+ {e.path}
1950
+ </div>
1951
+ </div>
1952
+ )}
1953
+ </div>
1954
+ );
1955
+ })}
1956
+ </div>
1957
+ )}
1958
+
1959
+ {tab === "list" && (
1960
+ <div className="flex flex-col gap-3.5">
1961
+ <div
1962
+ className="flex items-center gap-2.5 rounded-xl px-3.5 py-2.5 transition-colors"
1963
+ style={{ background: "var(--dsw-hover)", border: "0.5px solid var(--dsw-border-l2)" }}
1964
+ >
1965
+ <IconSearchOutline16 size={15} style={{ color: "var(--dsw-label-caption)" }} />
1966
+ <input
1967
+ value={q}
1968
+ onChange={(e) => setQ(e.target.value)}
1969
+ placeholder={t.searchPlugins}
1970
+ className="w-full bg-transparent outline-none"
1971
+ style={{ fontSize: 13, color: "var(--dsw-label-primary)" }}
1972
+ />
1973
+ </div>
1974
+
1975
+ <div className="flex items-baseline gap-2">
1976
+ <span style={{ fontSize: 13.5, fontWeight: 600, color: "var(--dsw-label-primary)" }}>{t.pluginList}</span>
1977
+ <span style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{listItems.length}</span>
1978
+ </div>
1979
+
1980
+ {listItems.length === 0 ? (
1981
+ <div style={{ fontSize: 12.5, color: "var(--dsw-label-caption)" }}>{t.packagesEmpty}</div>
1982
+ ) : (
1983
+ <div className="grid grid-cols-1 items-start sm:grid-cols-2 gap-2.5">
1984
+ {listItems.map((x) => {
1985
+ const open = expanded.has(x.key);
1986
+ const isDisabled = x.kind === "package" ? x.p.disabled === true : x.e.disabled === true;
1987
+ const isMenuOpen = menuKey === x.key;
1988
+ const isBusy = busyKey === x.key;
1989
+ return (
1990
+ <div
1991
+ key={x.key}
1992
+ className="flex flex-col rounded-xl transition-colors"
1993
+ style={{
1994
+ background: "var(--dsw-hover)",
1995
+ border: "0.5px solid var(--dsw-border-l2)",
1996
+ }}
1997
+ >
1998
+ <div className="flex h-11 items-center justify-between px-3.5 gap-2">
1999
+ <span
2000
+ className="min-w-0 flex-1 truncate font-medium text-[var(--dsw-label-primary)] cursor-pointer select-none"
2001
+ style={{ fontSize: 13.5 }}
2002
+ onClick={() => toggle(x.key)}
2003
+ title={x.name}
2004
+ >
2005
+ {x.name}
2006
+ </span>
2007
+ <div ref={isMenuOpen ? menuRef : undefined} className="relative flex-none">
2008
+ <button
2009
+ type="button"
2010
+ className="flex min-w-[86px] items-center justify-center rounded-lg px-3 transition-colors cursor-pointer select-none disabled:cursor-wait"
2011
+ style={{
2012
+ gap: 7,
2013
+ height: 30,
2014
+ background: "rgba(255, 255, 255, 0.04)",
2015
+ border: "1px solid rgba(255, 255, 255, 0.08)",
2016
+ }}
2017
+ aria-haspopup="menu"
2018
+ aria-expanded={isMenuOpen}
2019
+ disabled={isBusy}
2020
+ onClick={(e) => {
2021
+ e.stopPropagation();
2022
+ setMenuKey((prev) => (prev === x.key ? null : x.key));
2023
+ }}
2024
+ >
2025
+ {isBusy ? (
2026
+ <span className="h-3 w-3 flex-none animate-spin rounded-full border border-[var(--dsw-label-tertiary)] border-t-transparent" />
2027
+ ) : !isDisabled ? (
2028
+ <span
2029
+ className="h-2 w-2 flex-none rounded-full"
2030
+ style={{ background: "#22c55e", boxShadow: "0 0 5px rgba(34, 197, 94, 0.4)" }}
2031
+ />
2032
+ ) : null}
2033
+ <span
2034
+ style={{
2035
+ fontSize: 12,
2036
+ fontWeight: 500,
2037
+ color: isDisabled ? "var(--dsw-label-tertiary)" : "var(--dsw-success)",
2038
+ lineHeight: 1,
2039
+ }}
2040
+ >
2041
+ {isDisabled ? t.disabled : t.enabled}
2042
+ </span>
2043
+ <IconChevronDown14
2044
+ size={13}
2045
+ style={{
2046
+ color: "var(--dsw-label-tertiary)",
2047
+ transform: isMenuOpen ? "rotate(180deg)" : "none",
2048
+ transition: "transform 140ms ease",
2049
+ }}
2050
+ />
2051
+ </button>
2052
+
2053
+ {isMenuOpen && (
2054
+ <div
2055
+ className="popover absolute right-0 top-full mt-1.5 z-50 w-36 py-1 rounded-xl shadow-xl"
2056
+ role="menu"
2057
+ style={{
2058
+ background: "var(--dsw-surface-l1, #1e2025)",
2059
+ border: "0.5px solid var(--dsw-border-l3)",
2060
+ boxShadow: "0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.5)",
2061
+ }}
2062
+ >
2063
+ <button
2064
+ type="button"
2065
+ role="menuitem"
2066
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-[var(--dsw-hover)] cursor-pointer"
2067
+ style={{ color: isDisabled ? "var(--dsw-success)" : "var(--dsw-label-primary)" }}
2068
+ disabled={busy}
2069
+ onClick={() => {
2070
+ setMenuKey(null);
2071
+ void act(
2072
+ "toggle",
2073
+ x.kind === "package"
2074
+ ? { kind: "package", source: x.p.source, scope: x.p.scope, disabled: !isDisabled }
2075
+ : { kind: "extension", path: x.e.path, disabled: !isDisabled },
2076
+ x.key,
2077
+ isDisabled ? (lang === "zh" ? "正在启用..." : "Enabling...") : (lang === "zh" ? "正在停用..." : "Disabling..."),
2078
+ isDisabled ? t.enabled : t.disabled
2079
+ );
2080
+ }}
2081
+ >
2082
+ {isDisabled ? t.enablePlugin : t.disablePlugin}
2083
+ </button>
2084
+ {x.kind === "package" && (
2085
+ <>
2086
+ <button
2087
+ type="button"
2088
+ role="menuitem"
2089
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-[var(--dsw-hover)] cursor-pointer disabled:opacity-50"
2090
+ style={{ color: "var(--dsw-label-secondary)" }}
2091
+ disabled={busy}
2092
+ onClick={() => {
2093
+ setMenuKey(null);
2094
+ void act(
2095
+ "update",
2096
+ { source: x.p.source },
2097
+ x.key,
2098
+ t.updating,
2099
+ t.updateSuccess
2100
+ );
2101
+ }}
2102
+ >
2103
+ {t.update}
2104
+ </button>
2105
+ <button
2106
+ type="button"
2107
+ role="menuitem"
2108
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-[var(--dsw-hover)] cursor-pointer disabled:opacity-50"
2109
+ style={{ color: "var(--dsw-danger)" }}
2110
+ disabled={busy}
2111
+ onClick={() => {
2112
+ setMenuKey(null);
2113
+ if (!confirmUninstall(x.p.source)) return;
2114
+ void act(
2115
+ "remove",
2116
+ { source: x.p.source, local: x.p.scope === "project" },
2117
+ x.key,
2118
+ t.uninstalling,
2119
+ t.uninstallSuccess
2120
+ );
2121
+ }}
2122
+ >
2123
+ {t.uninstall}
2124
+ </button>
2125
+ </>
2126
+ )}
2127
+ <div className="my-1 border-t border-[var(--dsw-border-l1)]" />
2128
+ <button
2129
+ type="button"
2130
+ role="menuitem"
2131
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-[var(--dsw-hover)] cursor-pointer"
2132
+ style={{ color: "var(--dsw-label-tertiary)" }}
2133
+ onClick={() => {
2134
+ toggle(x.key);
2135
+ setMenuKey(null);
2136
+ }}
2137
+ >
2138
+ {open ? (lang === "zh" ? "收起详情" : "Hide details") : (lang === "zh" ? "查看详情" : "View details")}
2139
+ </button>
2140
+ </div>
2141
+ )}
2142
+ </div>
2143
+ </div>
2144
+
2145
+ {open && (
2146
+ <div className="hairline-t flex flex-col gap-2 px-3.5 py-3" style={{ fontSize: 12 }}>
2147
+ {x.kind === "package" ? (
2148
+ <>
2149
+ <div className="break-all" style={{ color: "var(--dsw-label-tertiary)", fontSize: 11.5 }}>
2150
+ {x.p.source}
2151
+ </div>
2152
+ <div className="flex items-center gap-2">
2153
+ <span
2154
+ className="rounded-md px-1.5 py-0.5"
2155
+ style={{ fontSize: 10.5, border: "0.5px solid var(--dsw-border-l3)", color: "var(--dsw-label-tertiary)" }}
2156
+ >
2157
+ {x.p.scope === "user" ? t.scopeGlobal : t.scopeProject}
2158
+ </span>
2159
+ <span style={{ color: "var(--dsw-label-caption)", fontSize: 11.5 }}>
2160
+ {t.resources}: {x.p.resources.extensions} ext · {x.p.resources.skills} skills · {x.p.resources.prompts} prompts
2161
+ </span>
2162
+ </div>
2163
+ <div className="flex items-center justify-end gap-3 pt-1">
2164
+ <button
2165
+ disabled={busy}
2166
+ style={{ fontSize: 12, color: "var(--dsw-label-secondary)" }}
2167
+ className="hover:underline cursor-pointer disabled:opacity-50"
2168
+ onClick={() =>
2169
+ void act(
2170
+ "update",
2171
+ { source: x.p.source },
2172
+ x.key,
2173
+ t.updating,
2174
+ t.updateSuccess
2175
+ )
2176
+ }
2177
+ >
2178
+ {t.update}
2179
+ </button>
2180
+ <button
2181
+ disabled={busy}
2182
+ style={{ fontSize: 12, color: "var(--dsw-danger)" }}
2183
+ className="hover:underline cursor-pointer disabled:opacity-50"
2184
+ onClick={() => {
2185
+ if (!confirmUninstall(x.p.source)) return;
2186
+ void act(
2187
+ "remove",
2188
+ { source: x.p.source, local: x.p.scope === "project" },
2189
+ x.key,
2190
+ t.uninstalling,
2191
+ t.uninstallSuccess
2192
+ );
2193
+ }}
2194
+ >
2195
+ {t.uninstall}
2196
+ </button>
2197
+ </div>
2198
+ </>
2199
+ ) : (
2200
+ <div className="break-all" style={{ fontFamily: "var(--font-mono)", fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
2201
+ {x.e.path}
2202
+ </div>
2203
+ )}
2204
+ </div>
2205
+ )}
2206
+ </div>
2207
+ );
2208
+ })}
2209
+ </div>
2210
+ )}
2211
+
2212
+ </div>
2213
+ )}
2214
+ </div>
2215
+ );
2216
+ }