@seqyuan/annodex 0.1.10 → 0.1.12

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 (519) hide show
  1. package/app/api/agent/[id]/events/route.ts +94 -0
  2. package/app/api/agent/[id]/route.ts +83 -0
  3. package/app/api/agent/new/route.ts +53 -0
  4. package/app/api/auth/all-providers/route.ts +21 -0
  5. package/app/api/auth/api-key/[provider]/route.ts +7 -0
  6. package/app/api/auth/login/[provider]/route.ts +7 -0
  7. package/app/api/auth/login/route.ts +22 -0
  8. package/app/api/auth/logout/[provider]/route.ts +7 -0
  9. package/app/api/auth/providers/route.ts +15 -0
  10. package/app/api/auth/status/route.ts +6 -0
  11. package/app/api/default-cwd/route.ts +22 -0
  12. package/app/api/files/[...path]/route.ts +621 -0
  13. package/app/api/harness/route.ts +47 -0
  14. package/app/api/home/route.ts +6 -0
  15. package/app/api/internal/runtime/route.ts +26 -0
  16. package/app/api/models/route.ts +67 -0
  17. package/app/api/models-config/discover/route.ts +42 -0
  18. package/app/api/models-config/route.ts +152 -0
  19. package/app/api/models-config/test/route.ts +154 -0
  20. package/app/api/projects/browse/route.ts +51 -0
  21. package/app/api/projects/route.ts +83 -0
  22. package/app/api/reports/[id]/route.ts +108 -0
  23. package/app/api/search/route.ts +122 -0
  24. package/app/api/sessions/[id]/context/route.ts +23 -0
  25. package/app/api/sessions/[id]/route.ts +124 -0
  26. package/app/api/sessions/new/route.ts +5 -0
  27. package/app/api/sessions/route.ts +16 -0
  28. package/app/api/settings/route.ts +51 -0
  29. package/app/api/skills/install/route.ts +249 -0
  30. package/app/api/skills/route.ts +161 -0
  31. package/app/api/skills/search/route.ts +121 -0
  32. package/app/api/soul/route.ts +47 -0
  33. package/app/api/version/route.ts +55 -0
  34. package/app/globals.css +736 -0
  35. package/app/layout.tsx +40 -0
  36. package/app/login/page.tsx +133 -0
  37. package/app/page.tsx +10 -0
  38. package/components/AppShell.tsx +1058 -0
  39. package/components/ChatInput.tsx +1103 -0
  40. package/components/ChatMinimap.tsx +381 -0
  41. package/components/ChatWindow.tsx +576 -0
  42. package/components/CodeMirrorEditor.tsx +137 -0
  43. package/components/ConversationSearch.tsx +369 -0
  44. package/components/DataTableViewer.tsx +248 -0
  45. package/components/FileExplorer.tsx +758 -0
  46. package/components/FileIcons.tsx +241 -0
  47. package/components/FileViewer.tsx +1273 -0
  48. package/components/GlobalFileEditor.tsx +98 -0
  49. package/components/MarkdownRenderer.tsx +331 -0
  50. package/components/MermaidDiagram.tsx +80 -0
  51. package/components/MessageView.tsx +1141 -0
  52. package/components/ModelsConfig.tsx +1991 -0
  53. package/components/ProjectContext.tsx +252 -0
  54. package/components/ProjectFolderPicker.tsx +202 -0
  55. package/components/ProjectsConfig.tsx +288 -0
  56. package/components/ProviderIcons.tsx +91 -0
  57. package/components/ReportPanel.tsx +237 -0
  58. package/components/ResizeHandle.tsx +105 -0
  59. package/components/SessionSidebar.tsx +1464 -0
  60. package/components/SettingsDialog.tsx +287 -0
  61. package/components/SkillsConfig.tsx +1093 -0
  62. package/components/SubagentPanel.tsx +191 -0
  63. package/components/TabBar.tsx +115 -0
  64. package/components/ToolPanel.tsx +131 -0
  65. package/components/WidgetRenderer.tsx +505 -0
  66. package/components/viewers/DocumentToolbar.tsx +78 -0
  67. package/components/viewers/DocxViewer.tsx +97 -0
  68. package/components/viewers/PdfViewer.tsx +206 -0
  69. package/components/viewers/PptxViewer.tsx +240 -0
  70. package/components/viewers/XlsxViewer.tsx +143 -0
  71. package/hooks/useAgentSession.ts +710 -0
  72. package/hooks/useAudio.ts +50 -0
  73. package/hooks/useDragDrop.ts +52 -0
  74. package/hooks/useResizable.ts +60 -0
  75. package/hooks/useTheme.ts +85 -0
  76. package/lib/agent-client.ts +39 -0
  77. package/lib/annodex-config.ts +556 -0
  78. package/lib/auth-token.ts +74 -0
  79. package/lib/auth.ts +90 -0
  80. package/lib/brand.ts +5 -0
  81. package/lib/code-theme.ts +32 -0
  82. package/lib/codex-compat-proxy.ts +1603 -0
  83. package/lib/codex-home.ts +6 -0
  84. package/lib/codex-server.ts +796 -0
  85. package/lib/codex-session.ts +590 -0
  86. package/lib/codex-usage.ts +213 -0
  87. package/lib/file-paths.ts +34 -0
  88. package/lib/model-discovery.ts +379 -0
  89. package/lib/normalize.ts +30 -0
  90. package/lib/npx.ts +87 -0
  91. package/lib/pi-types.ts +49 -0
  92. package/lib/projects.ts +269 -0
  93. package/lib/provider-api.ts +88 -0
  94. package/lib/report-prompt.ts +61 -0
  95. package/lib/report-store.ts +597 -0
  96. package/lib/report-update-parser.ts +66 -0
  97. package/lib/rpc-manager.ts +668 -0
  98. package/lib/runtime-state.ts +117 -0
  99. package/lib/session-reader.ts +903 -0
  100. package/lib/session-runtime.ts +105 -0
  101. package/lib/subagent-progress.ts +279 -0
  102. package/lib/types.ts +241 -0
  103. package/lib/widget-export.ts +318 -0
  104. package/lib/widget-guidelines.ts +288 -0
  105. package/lib/widget-prompt.ts +76 -0
  106. package/lib/widget-utils.ts +523 -0
  107. package/package.json +23 -18
  108. package/postcss.config.mjs +8 -0
  109. package/proxy.ts +64 -0
  110. package/scripts/postinstall.cjs +25 -0
  111. package/tsconfig.json +41 -0
  112. package/.next/BUILD_ID +0 -1
  113. package/.next/app-path-routes-manifest.json +0 -39
  114. package/.next/build-manifest.json +0 -20
  115. package/.next/diagnostics/build-diagnostics.json +0 -6
  116. package/.next/diagnostics/framework.json +0 -1
  117. package/.next/export-marker.json +0 -6
  118. package/.next/images-manifest.json +0 -68
  119. package/.next/next-minimal-server.js.nft.json +0 -1
  120. package/.next/next-server.js.nft.json +0 -1
  121. package/.next/package.json +0 -1
  122. package/.next/prerender-manifest.json +0 -109
  123. package/.next/react-loadable-manifest.json +0 -2320
  124. package/.next/required-server-files.js +0 -343
  125. package/.next/required-server-files.json +0 -343
  126. package/.next/routes-manifest.json +0 -286
  127. package/.next/server/app/_global-error/page.js +0 -32
  128. package/.next/server/app/_global-error/page.js.nft.json +0 -1
  129. package/.next/server/app/_global-error/page_client-reference-manifest.js +0 -1
  130. package/.next/server/app/_global-error.html +0 -1
  131. package/.next/server/app/_global-error.meta +0 -16
  132. package/.next/server/app/_global-error.rsc +0 -14
  133. package/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  134. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +0 -5
  135. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +0 -5
  136. package/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  137. package/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  138. package/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  139. package/.next/server/app/_not-found/page.js +0 -2
  140. package/.next/server/app/_not-found/page.js.nft.json +0 -1
  141. package/.next/server/app/_not-found/page_client-reference-manifest.js +0 -1
  142. package/.next/server/app/_not-found.html +0 -1
  143. package/.next/server/app/_not-found.meta +0 -16
  144. package/.next/server/app/_not-found.rsc +0 -18
  145. package/.next/server/app/_not-found.segments/_full.segment.rsc +0 -18
  146. package/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  147. package/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  148. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  149. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  150. package/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -4
  151. package/.next/server/app/api/agent/[id]/events/route.js +0 -3
  152. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +0 -1
  153. package/.next/server/app/api/agent/[id]/events/route_client-reference-manifest.js +0 -1
  154. package/.next/server/app/api/agent/[id]/route.js +0 -1
  155. package/.next/server/app/api/agent/[id]/route.js.nft.json +0 -1
  156. package/.next/server/app/api/agent/[id]/route_client-reference-manifest.js +0 -1
  157. package/.next/server/app/api/agent/new/route.js +0 -1
  158. package/.next/server/app/api/agent/new/route.js.nft.json +0 -1
  159. package/.next/server/app/api/agent/new/route_client-reference-manifest.js +0 -1
  160. package/.next/server/app/api/auth/all-providers/route.js +0 -1
  161. package/.next/server/app/api/auth/all-providers/route.js.nft.json +0 -1
  162. package/.next/server/app/api/auth/all-providers/route_client-reference-manifest.js +0 -1
  163. package/.next/server/app/api/auth/api-key/[provider]/route.js +0 -1
  164. package/.next/server/app/api/auth/api-key/[provider]/route.js.nft.json +0 -1
  165. package/.next/server/app/api/auth/api-key/[provider]/route_client-reference-manifest.js +0 -1
  166. package/.next/server/app/api/auth/login/[provider]/route.js +0 -1
  167. package/.next/server/app/api/auth/login/[provider]/route.js.nft.json +0 -1
  168. package/.next/server/app/api/auth/login/[provider]/route_client-reference-manifest.js +0 -1
  169. package/.next/server/app/api/auth/login/route.js +0 -1
  170. package/.next/server/app/api/auth/login/route.js.nft.json +0 -1
  171. package/.next/server/app/api/auth/login/route_client-reference-manifest.js +0 -1
  172. package/.next/server/app/api/auth/logout/[provider]/route.js +0 -1
  173. package/.next/server/app/api/auth/logout/[provider]/route.js.nft.json +0 -1
  174. package/.next/server/app/api/auth/logout/[provider]/route_client-reference-manifest.js +0 -1
  175. package/.next/server/app/api/auth/providers/route.js +0 -1
  176. package/.next/server/app/api/auth/providers/route.js.nft.json +0 -1
  177. package/.next/server/app/api/auth/providers/route_client-reference-manifest.js +0 -1
  178. package/.next/server/app/api/auth/status/route.js +0 -1
  179. package/.next/server/app/api/auth/status/route.js.nft.json +0 -1
  180. package/.next/server/app/api/auth/status/route_client-reference-manifest.js +0 -1
  181. package/.next/server/app/api/default-cwd/route.js +0 -1
  182. package/.next/server/app/api/default-cwd/route.js.nft.json +0 -1
  183. package/.next/server/app/api/default-cwd/route_client-reference-manifest.js +0 -1
  184. package/.next/server/app/api/files/[...path]/route.js +0 -4
  185. package/.next/server/app/api/files/[...path]/route.js.nft.json +0 -1
  186. package/.next/server/app/api/files/[...path]/route_client-reference-manifest.js +0 -1
  187. package/.next/server/app/api/harness/route.js +0 -1
  188. package/.next/server/app/api/harness/route.js.nft.json +0 -1
  189. package/.next/server/app/api/harness/route_client-reference-manifest.js +0 -1
  190. package/.next/server/app/api/home/route.js +0 -1
  191. package/.next/server/app/api/home/route.js.nft.json +0 -1
  192. package/.next/server/app/api/home/route_client-reference-manifest.js +0 -1
  193. package/.next/server/app/api/internal/runtime/route.js +0 -1
  194. package/.next/server/app/api/internal/runtime/route.js.nft.json +0 -1
  195. package/.next/server/app/api/internal/runtime/route_client-reference-manifest.js +0 -1
  196. package/.next/server/app/api/models/route.js +0 -1
  197. package/.next/server/app/api/models/route.js.nft.json +0 -1
  198. package/.next/server/app/api/models/route_client-reference-manifest.js +0 -1
  199. package/.next/server/app/api/models-config/discover/route.js +0 -1
  200. package/.next/server/app/api/models-config/discover/route.js.nft.json +0 -1
  201. package/.next/server/app/api/models-config/discover/route_client-reference-manifest.js +0 -1
  202. package/.next/server/app/api/models-config/route.js +0 -1
  203. package/.next/server/app/api/models-config/route.js.nft.json +0 -1
  204. package/.next/server/app/api/models-config/route_client-reference-manifest.js +0 -1
  205. package/.next/server/app/api/models-config/test/route.js +0 -1
  206. package/.next/server/app/api/models-config/test/route.js.nft.json +0 -1
  207. package/.next/server/app/api/models-config/test/route_client-reference-manifest.js +0 -1
  208. package/.next/server/app/api/projects/browse/route.js +0 -1
  209. package/.next/server/app/api/projects/browse/route.js.nft.json +0 -1
  210. package/.next/server/app/api/projects/browse/route_client-reference-manifest.js +0 -1
  211. package/.next/server/app/api/projects/route.js +0 -1
  212. package/.next/server/app/api/projects/route.js.nft.json +0 -1
  213. package/.next/server/app/api/projects/route_client-reference-manifest.js +0 -1
  214. package/.next/server/app/api/reports/[id]/route.js +0 -10
  215. package/.next/server/app/api/reports/[id]/route.js.nft.json +0 -1
  216. package/.next/server/app/api/reports/[id]/route_client-reference-manifest.js +0 -1
  217. package/.next/server/app/api/search/route.js +0 -1
  218. package/.next/server/app/api/search/route.js.nft.json +0 -1
  219. package/.next/server/app/api/search/route_client-reference-manifest.js +0 -1
  220. package/.next/server/app/api/sessions/[id]/context/route.js +0 -1
  221. package/.next/server/app/api/sessions/[id]/context/route.js.nft.json +0 -1
  222. package/.next/server/app/api/sessions/[id]/context/route_client-reference-manifest.js +0 -1
  223. package/.next/server/app/api/sessions/[id]/route.js +0 -1
  224. package/.next/server/app/api/sessions/[id]/route.js.nft.json +0 -1
  225. package/.next/server/app/api/sessions/[id]/route_client-reference-manifest.js +0 -1
  226. package/.next/server/app/api/sessions/new/route.js +0 -1
  227. package/.next/server/app/api/sessions/new/route.js.nft.json +0 -1
  228. package/.next/server/app/api/sessions/new/route_client-reference-manifest.js +0 -1
  229. package/.next/server/app/api/sessions/route.js +0 -1
  230. package/.next/server/app/api/sessions/route.js.nft.json +0 -1
  231. package/.next/server/app/api/sessions/route_client-reference-manifest.js +0 -1
  232. package/.next/server/app/api/settings/route.js +0 -1
  233. package/.next/server/app/api/settings/route.js.nft.json +0 -1
  234. package/.next/server/app/api/settings/route_client-reference-manifest.js +0 -1
  235. package/.next/server/app/api/skills/install/route.js +0 -5
  236. package/.next/server/app/api/skills/install/route.js.nft.json +0 -1
  237. package/.next/server/app/api/skills/install/route_client-reference-manifest.js +0 -1
  238. package/.next/server/app/api/skills/route.js +0 -6
  239. package/.next/server/app/api/skills/route.js.nft.json +0 -1
  240. package/.next/server/app/api/skills/route_client-reference-manifest.js +0 -1
  241. package/.next/server/app/api/skills/search/route.js +0 -1
  242. package/.next/server/app/api/skills/search/route.js.nft.json +0 -1
  243. package/.next/server/app/api/skills/search/route_client-reference-manifest.js +0 -1
  244. package/.next/server/app/api/soul/route.js +0 -1
  245. package/.next/server/app/api/soul/route.js.nft.json +0 -1
  246. package/.next/server/app/api/soul/route_client-reference-manifest.js +0 -1
  247. package/.next/server/app/api/version/route.js +0 -1
  248. package/.next/server/app/api/version/route.js.nft.json +0 -1
  249. package/.next/server/app/api/version/route_client-reference-manifest.js +0 -1
  250. package/.next/server/app/index.html +0 -1
  251. package/.next/server/app/index.meta +0 -14
  252. package/.next/server/app/index.rsc +0 -17
  253. package/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -6
  254. package/.next/server/app/index.segments/_full.segment.rsc +0 -17
  255. package/.next/server/app/index.segments/_head.segment.rsc +0 -6
  256. package/.next/server/app/index.segments/_index.segment.rsc +0 -5
  257. package/.next/server/app/index.segments/_tree.segment.rsc +0 -4
  258. package/.next/server/app/login/page.js +0 -2
  259. package/.next/server/app/login/page.js.nft.json +0 -1
  260. package/.next/server/app/login/page_client-reference-manifest.js +0 -1
  261. package/.next/server/app/login.html +0 -1
  262. package/.next/server/app/login.meta +0 -15
  263. package/.next/server/app/login.rsc +0 -22
  264. package/.next/server/app/login.segments/_full.segment.rsc +0 -22
  265. package/.next/server/app/login.segments/_head.segment.rsc +0 -6
  266. package/.next/server/app/login.segments/_index.segment.rsc +0 -5
  267. package/.next/server/app/login.segments/_tree.segment.rsc +0 -4
  268. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +0 -9
  269. package/.next/server/app/login.segments/login.segment.rsc +0 -5
  270. package/.next/server/app/page.js +0 -261
  271. package/.next/server/app/page.js.nft.json +0 -1
  272. package/.next/server/app/page_client-reference-manifest.js +0 -1
  273. package/.next/server/app-paths-manifest.json +0 -39
  274. package/.next/server/chunks/1048.js +0 -1
  275. package/.next/server/chunks/1367.js +0 -77
  276. package/.next/server/chunks/1381.js +0 -1
  277. package/.next/server/chunks/165.js +0 -1
  278. package/.next/server/chunks/1681.js +0 -215
  279. package/.next/server/chunks/1688.js +0 -45
  280. package/.next/server/chunks/1703.js +0 -79
  281. package/.next/server/chunks/1712.js +0 -43
  282. package/.next/server/chunks/1813.js +0 -1
  283. package/.next/server/chunks/2325.js +0 -80
  284. package/.next/server/chunks/258.js +0 -1
  285. package/.next/server/chunks/2671.js +0 -287
  286. package/.next/server/chunks/2778.js +0 -1
  287. package/.next/server/chunks/2943.js +0 -1
  288. package/.next/server/chunks/3031.js +0 -226
  289. package/.next/server/chunks/3181.js +0 -1
  290. package/.next/server/chunks/3493.js +0 -1
  291. package/.next/server/chunks/3672.js +0 -1
  292. package/.next/server/chunks/3701.js +0 -104
  293. package/.next/server/chunks/4013.js +0 -1
  294. package/.next/server/chunks/402.js +0 -2
  295. package/.next/server/chunks/4035.js +0 -80
  296. package/.next/server/chunks/4248.js +0 -153
  297. package/.next/server/chunks/4367.js +0 -1
  298. package/.next/server/chunks/4406.js +0 -141
  299. package/.next/server/chunks/4741.js +0 -18
  300. package/.next/server/chunks/4768.js +0 -1
  301. package/.next/server/chunks/4858.js +0 -148
  302. package/.next/server/chunks/4980.js +0 -1
  303. package/.next/server/chunks/5155.js +0 -5
  304. package/.next/server/chunks/5293.js +0 -166
  305. package/.next/server/chunks/5399.js +0 -8
  306. package/.next/server/chunks/5409.js +0 -1
  307. package/.next/server/chunks/5797.js +0 -93
  308. package/.next/server/chunks/5851.js +0 -36
  309. package/.next/server/chunks/6206.js +0 -1
  310. package/.next/server/chunks/6296.js +0 -1
  311. package/.next/server/chunks/63.js +0 -45
  312. package/.next/server/chunks/6346.js +0 -1
  313. package/.next/server/chunks/6406.js +0 -23
  314. package/.next/server/chunks/642.js +0 -1
  315. package/.next/server/chunks/6429.js +0 -50
  316. package/.next/server/chunks/6729.js +0 -64
  317. package/.next/server/chunks/6907.js +0 -115
  318. package/.next/server/chunks/6980.js +0 -1
  319. package/.next/server/chunks/7073.js +0 -24
  320. package/.next/server/chunks/7233.js +0 -24
  321. package/.next/server/chunks/7307.js +0 -1
  322. package/.next/server/chunks/7362.js +0 -9
  323. package/.next/server/chunks/7567.js +0 -29
  324. package/.next/server/chunks/7765.js +0 -1
  325. package/.next/server/chunks/7890.js +0 -1
  326. package/.next/server/chunks/8065.js +0 -1
  327. package/.next/server/chunks/8238.js +0 -34
  328. package/.next/server/chunks/8276.js +0 -1
  329. package/.next/server/chunks/8336.js +0 -1
  330. package/.next/server/chunks/8477.js +0 -3
  331. package/.next/server/chunks/8490.js +0 -1
  332. package/.next/server/chunks/8916.js +0 -1
  333. package/.next/server/chunks/9280.js +0 -252
  334. package/.next/server/chunks/9315.js +0 -1
  335. package/.next/server/chunks/9537.js +0 -90
  336. package/.next/server/chunks/966.js +0 -1
  337. package/.next/server/chunks/9818.js +0 -21
  338. package/.next/server/chunks/static/media/pdf.worker.min.c476e1a0.mjs +0 -6
  339. package/.next/server/functions-config-manifest.json +0 -16
  340. package/.next/server/interception-route-rewrite-manifest.js +0 -1
  341. package/.next/server/middleware-build-manifest.js +0 -1
  342. package/.next/server/middleware-manifest.json +0 -6
  343. package/.next/server/middleware-react-loadable-manifest.js +0 -1
  344. package/.next/server/middleware.js +0 -18
  345. package/.next/server/middleware.js.nft.json +0 -1
  346. package/.next/server/next-font-manifest.js +0 -1
  347. package/.next/server/next-font-manifest.json +0 -1
  348. package/.next/server/pages/404.html +0 -1
  349. package/.next/server/pages/500.html +0 -1
  350. package/.next/server/pages-manifest.json +0 -4
  351. package/.next/server/prefetch-hints.json +0 -1
  352. package/.next/server/server-reference-manifest.js +0 -1
  353. package/.next/server/server-reference-manifest.json +0 -1
  354. package/.next/server/webpack-runtime.js +0 -1
  355. package/.next/static/6cuMSvcr0FVO-GiK5RJZh/_buildManifest.js +0 -1
  356. package/.next/static/6cuMSvcr0FVO-GiK5RJZh/_ssgManifest.js +0 -1
  357. package/.next/static/chunks/0b9a0da7.9075af772487e743.js +0 -62
  358. package/.next/static/chunks/1413.922d232de90c0c41.js +0 -115
  359. package/.next/static/chunks/1643.467a526a1f24f54d.js +0 -24
  360. package/.next/static/chunks/1852.5543122f11aa7fed.js +0 -1
  361. package/.next/static/chunks/1960.b1e26436d7a5f586.js +0 -1
  362. package/.next/static/chunks/2170a4aa.4213bb2183c9cdf9.js +0 -1
  363. package/.next/static/chunks/2274.6cd173f80a1405a2.js +0 -21
  364. package/.next/static/chunks/2419.347fdfe3c170854d.js +0 -166
  365. package/.next/static/chunks/2619.9aac8983f30c7c8a.js +0 -1
  366. package/.next/static/chunks/2623.d20fabd8e18197c6.js +0 -287
  367. package/.next/static/chunks/2729.f5365061a849d659.js +0 -34
  368. package/.next/static/chunks/2821.934bcf60fbdc28c6.js +0 -1
  369. package/.next/static/chunks/2918becc.abff2ece1de37bc1.js +0 -153
  370. package/.next/static/chunks/2947.114e51cb06d1c01a.js +0 -23
  371. package/.next/static/chunks/3079.4c511fa1144e3adf.js +0 -79
  372. package/.next/static/chunks/3274.208ca44844cd7d95.js +0 -148
  373. package/.next/static/chunks/3308.465a94263d04bfea.js +0 -73
  374. package/.next/static/chunks/3325.e4bfe1ca657f3b5b.js +0 -80
  375. package/.next/static/chunks/3506.2a7eaa08b9f55337.js +0 -90
  376. package/.next/static/chunks/363642f4-043c1475ab9af70e.js +0 -1
  377. package/.next/static/chunks/3794-123fdf632563f469.js +0 -32
  378. package/.next/static/chunks/3837.a755ccfe6f9c1c1c.js +0 -5
  379. package/.next/static/chunks/394.91597771688df6d0.js +0 -1
  380. package/.next/static/chunks/3997.1009c06025691712.js +0 -1
  381. package/.next/static/chunks/4453.91a357dc43c21745.js +0 -1
  382. package/.next/static/chunks/4491.44fdf20580ac72bd.js +0 -24
  383. package/.next/static/chunks/4829.cf1d50e43e6d9db5.js +0 -1
  384. package/.next/static/chunks/498.fe1d9da9ecad6c36.js +0 -1
  385. package/.next/static/chunks/4bd1b696-e356ca5ba0218e27.js +0 -1
  386. package/.next/static/chunks/5019.b5a1a2b8daf17525.js +0 -1
  387. package/.next/static/chunks/5034.8f16c3fa3ce75411.js +0 -1
  388. package/.next/static/chunks/5074.d16651da01ec4e02.js +0 -1
  389. package/.next/static/chunks/51fb665c.0950e1b79671348d.js +0 -45
  390. package/.next/static/chunks/532.5956ed631aff722b.js +0 -9
  391. package/.next/static/chunks/5326.69460442bdcd6cd3.js +0 -1
  392. package/.next/static/chunks/5403.ff110bf5bf600758.js +0 -64
  393. package/.next/static/chunks/547.902a733488cfe3f7.js +0 -77
  394. package/.next/static/chunks/5567.540d7fc108ad6ee5.js +0 -215
  395. package/.next/static/chunks/5590.ef62922166d308b4.js +0 -1
  396. package/.next/static/chunks/5690.9d6eb1edb1399995.js +0 -1
  397. package/.next/static/chunks/5749.25faee4a1e55b854.js +0 -226
  398. package/.next/static/chunks/58bb9007.1ccb6bba34b4c635.js +0 -80
  399. package/.next/static/chunks/6121.f3f43f1896ea0cd9.js +0 -1
  400. package/.next/static/chunks/6600.583c88eef37aa524.js +0 -1
  401. package/.next/static/chunks/6696.a41aec266e657d54.js +0 -141
  402. package/.next/static/chunks/6922.42148793782d2fe7.js +0 -1
  403. package/.next/static/chunks/7006.e191611ffc2b9528.js +0 -43
  404. package/.next/static/chunks/7343.9fbb58204d8ac681.js +0 -1
  405. package/.next/static/chunks/73972abe.25a4cffa03b2bcef.js +0 -119
  406. package/.next/static/chunks/7547.58bda8a2aabba0d4.js +0 -93
  407. package/.next/static/chunks/7648.4ae2f183b4db0353.js +0 -1
  408. package/.next/static/chunks/7874.8db6929b94cdf697.js +0 -1
  409. package/.next/static/chunks/7959.1f20a35df316216a.js +0 -104
  410. package/.next/static/chunks/83.85d62d7fc9850b75.js +0 -29
  411. package/.next/static/chunks/8436.cab94b59cca0a8ff.js +0 -1
  412. package/.next/static/chunks/8451.ff6ff72b57dc52e1.js +0 -1
  413. package/.next/static/chunks/8489.45f22859734f514f.js +0 -36
  414. package/.next/static/chunks/8568.f85d8b36fc9a9037.js +0 -1
  415. package/.next/static/chunks/8771-3e14b6810486df1f.js +0 -1
  416. package/.next/static/chunks/8863.be51033a67436277.js +0 -1
  417. package/.next/static/chunks/90542734.dc1a2723e4f6affb.js +0 -1
  418. package/.next/static/chunks/9500.1488aec06ee78127.js +0 -1
  419. package/.next/static/chunks/9633.155548b5fca6e580.js +0 -1
  420. package/.next/static/chunks/9779.673004a62d70e36a.js +0 -1
  421. package/.next/static/chunks/app/_global-error/page-cc518af6b1ffb191.js +0 -1
  422. package/.next/static/chunks/app/_not-found/page-c72daab99269beff.js +0 -1
  423. package/.next/static/chunks/app/api/agent/[id]/events/route-cc518af6b1ffb191.js +0 -1
  424. package/.next/static/chunks/app/api/agent/[id]/route-cc518af6b1ffb191.js +0 -1
  425. package/.next/static/chunks/app/api/agent/new/route-cc518af6b1ffb191.js +0 -1
  426. package/.next/static/chunks/app/api/auth/all-providers/route-cc518af6b1ffb191.js +0 -1
  427. package/.next/static/chunks/app/api/auth/api-key/[provider]/route-cc518af6b1ffb191.js +0 -1
  428. package/.next/static/chunks/app/api/auth/login/[provider]/route-cc518af6b1ffb191.js +0 -1
  429. package/.next/static/chunks/app/api/auth/login/route-cc518af6b1ffb191.js +0 -1
  430. package/.next/static/chunks/app/api/auth/logout/[provider]/route-cc518af6b1ffb191.js +0 -1
  431. package/.next/static/chunks/app/api/auth/providers/route-cc518af6b1ffb191.js +0 -1
  432. package/.next/static/chunks/app/api/auth/status/route-cc518af6b1ffb191.js +0 -1
  433. package/.next/static/chunks/app/api/default-cwd/route-cc518af6b1ffb191.js +0 -1
  434. package/.next/static/chunks/app/api/files/[...path]/route-cc518af6b1ffb191.js +0 -1
  435. package/.next/static/chunks/app/api/harness/route-cc518af6b1ffb191.js +0 -1
  436. package/.next/static/chunks/app/api/home/route-cc518af6b1ffb191.js +0 -1
  437. package/.next/static/chunks/app/api/internal/runtime/route-cc518af6b1ffb191.js +0 -1
  438. package/.next/static/chunks/app/api/models/route-cc518af6b1ffb191.js +0 -1
  439. package/.next/static/chunks/app/api/models-config/discover/route-cc518af6b1ffb191.js +0 -1
  440. package/.next/static/chunks/app/api/models-config/route-cc518af6b1ffb191.js +0 -1
  441. package/.next/static/chunks/app/api/models-config/test/route-cc518af6b1ffb191.js +0 -1
  442. package/.next/static/chunks/app/api/projects/browse/route-cc518af6b1ffb191.js +0 -1
  443. package/.next/static/chunks/app/api/projects/route-cc518af6b1ffb191.js +0 -1
  444. package/.next/static/chunks/app/api/reports/[id]/route-cc518af6b1ffb191.js +0 -1
  445. package/.next/static/chunks/app/api/search/route-cc518af6b1ffb191.js +0 -1
  446. package/.next/static/chunks/app/api/sessions/[id]/context/route-cc518af6b1ffb191.js +0 -1
  447. package/.next/static/chunks/app/api/sessions/[id]/route-cc518af6b1ffb191.js +0 -1
  448. package/.next/static/chunks/app/api/sessions/new/route-cc518af6b1ffb191.js +0 -1
  449. package/.next/static/chunks/app/api/sessions/route-cc518af6b1ffb191.js +0 -1
  450. package/.next/static/chunks/app/api/settings/route-cc518af6b1ffb191.js +0 -1
  451. package/.next/static/chunks/app/api/skills/install/route-cc518af6b1ffb191.js +0 -1
  452. package/.next/static/chunks/app/api/skills/route-cc518af6b1ffb191.js +0 -1
  453. package/.next/static/chunks/app/api/skills/search/route-cc518af6b1ffb191.js +0 -1
  454. package/.next/static/chunks/app/api/soul/route-cc518af6b1ffb191.js +0 -1
  455. package/.next/static/chunks/app/api/version/route-cc518af6b1ffb191.js +0 -1
  456. package/.next/static/chunks/app/layout-be148b7ae915b22a.js +0 -1
  457. package/.next/static/chunks/app/login/page-ebf0e6de99062783.js +0 -1
  458. package/.next/static/chunks/app/page-c45d98ea81c548ca.js +0 -260
  459. package/.next/static/chunks/d3ac728e.7964f816a1ca64e5.js +0 -1
  460. package/.next/static/chunks/framework-711ef29bc66f648c.js +0 -1
  461. package/.next/static/chunks/main-app-45a0f19af99d61b6.js +0 -1
  462. package/.next/static/chunks/main-f74964b7ae52493e.js +0 -5
  463. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cc518af6b1ffb191.js +0 -1
  464. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cc518af6b1ffb191.js +0 -1
  465. package/.next/static/chunks/next/dist/client/components/builtin/global-error-9bfa08b9491621f2.js +0 -1
  466. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cc518af6b1ffb191.js +0 -1
  467. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cc518af6b1ffb191.js +0 -1
  468. package/.next/static/chunks/polyfills-42372ed130431b0a.js +0 -1
  469. package/.next/static/chunks/webpack-fcf4a889ecbd753c.js +0 -1
  470. package/.next/static/css/45029451a1d7255d.css +0 -3
  471. package/.next/static/media/15605e25b523335c-s.woff2 +0 -0
  472. package/.next/static/media/1a3dce5cfb5f7760-s.woff2 +0 -0
  473. package/.next/static/media/1cdd02902f937a18-s.woff2 +0 -0
  474. package/.next/static/media/4c4b3b30b6bcb2be-s.woff2 +0 -0
  475. package/.next/static/media/641a7b8a5800ee0e-s.woff2 +0 -0
  476. package/.next/static/media/7deddc85b7ffd1dc-s.p.woff2 +0 -0
  477. package/.next/static/media/ec14413c594b3356-s.p.woff2 +0 -0
  478. package/.next/static/media/pdf.worker.min.29aaf158.mjs +0 -6
  479. package/.next/trace +0 -74
  480. package/.next/trace-build +0 -1
  481. package/.next/types/app/api/agent/[id]/events/route.ts +0 -351
  482. package/.next/types/app/api/agent/[id]/route.ts +0 -351
  483. package/.next/types/app/api/agent/new/route.ts +0 -351
  484. package/.next/types/app/api/auth/all-providers/route.ts +0 -351
  485. package/.next/types/app/api/auth/api-key/[provider]/route.ts +0 -351
  486. package/.next/types/app/api/auth/login/[provider]/route.ts +0 -351
  487. package/.next/types/app/api/auth/login/route.ts +0 -351
  488. package/.next/types/app/api/auth/logout/[provider]/route.ts +0 -351
  489. package/.next/types/app/api/auth/providers/route.ts +0 -351
  490. package/.next/types/app/api/auth/status/route.ts +0 -351
  491. package/.next/types/app/api/default-cwd/route.ts +0 -351
  492. package/.next/types/app/api/files/[...path]/route.ts +0 -351
  493. package/.next/types/app/api/harness/route.ts +0 -351
  494. package/.next/types/app/api/home/route.ts +0 -351
  495. package/.next/types/app/api/internal/runtime/route.ts +0 -351
  496. package/.next/types/app/api/models/route.ts +0 -351
  497. package/.next/types/app/api/models-config/discover/route.ts +0 -351
  498. package/.next/types/app/api/models-config/route.ts +0 -351
  499. package/.next/types/app/api/models-config/test/route.ts +0 -351
  500. package/.next/types/app/api/projects/browse/route.ts +0 -351
  501. package/.next/types/app/api/projects/route.ts +0 -351
  502. package/.next/types/app/api/reports/[id]/route.ts +0 -351
  503. package/.next/types/app/api/search/route.ts +0 -351
  504. package/.next/types/app/api/sessions/[id]/context/route.ts +0 -351
  505. package/.next/types/app/api/sessions/[id]/route.ts +0 -351
  506. package/.next/types/app/api/sessions/new/route.ts +0 -351
  507. package/.next/types/app/api/sessions/route.ts +0 -351
  508. package/.next/types/app/api/settings/route.ts +0 -351
  509. package/.next/types/app/api/skills/install/route.ts +0 -351
  510. package/.next/types/app/api/skills/route.ts +0 -351
  511. package/.next/types/app/api/skills/search/route.ts +0 -351
  512. package/.next/types/app/api/soul/route.ts +0 -351
  513. package/.next/types/app/api/version/route.ts +0 -351
  514. package/.next/types/app/layout.ts +0 -87
  515. package/.next/types/app/login/page.ts +0 -87
  516. package/.next/types/app/page.ts +0 -87
  517. package/.next/types/package.json +0 -1
  518. package/.next/types/routes.d.ts +0 -106
  519. package/.next/types/validator.ts +0 -376
@@ -0,0 +1,1991 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { inferProviderApi, isProviderApi, type ProviderApi } from "@/lib/provider-api";
5
+ // Color icons (have their own fill colors — no background needed)
6
+ import {
7
+ AnthropicIcon, OpenAIIcon, GoogleIcon, DeepSeekIcon, GroqIcon, MistralIcon,
8
+ MoonshotIcon, MinimaxIcon, FireworksIcon, HuggingFaceIcon, CerebrasIcon,
9
+ OpenRouterIcon, XAIIcon, CloudflareIcon, VercelIcon, GithubCopilotIcon,
10
+ AwsIcon, AzureIcon, KimiIcon, QwenIcon, ZhipuIcon, CohereIcon,
11
+ PerplexityIcon, TogetherIcon, GrokIcon,
12
+ } from "@/components/ProviderIcons";
13
+
14
+ type IconComponent = React.ComponentType<{ size?: number | string; style?: React.CSSProperties }>;
15
+
16
+ // hasColor=true → Color icon (self-colored SVG, no wrapper)
17
+ // hasColor=false → Mono icon (rendered with currentColor, inherits theme text color)
18
+ const PROVIDER_ICONS: Record<string, { Icon: IconComponent; hasColor: boolean }> = {
19
+ "anthropic": { Icon: AnthropicIcon, hasColor: false },
20
+ "openai": { Icon: OpenAIIcon, hasColor: false },
21
+ "openai-codex": { Icon: OpenAIIcon, hasColor: false },
22
+ "google": { Icon: GoogleIcon, hasColor: true },
23
+ "google-vertex": { Icon: GoogleIcon, hasColor: true },
24
+ "deepseek": { Icon: DeepSeekIcon, hasColor: true },
25
+ "groq": { Icon: GroqIcon, hasColor: false },
26
+ "mistral": { Icon: MistralIcon, hasColor: true },
27
+ "moonshotai": { Icon: MoonshotIcon, hasColor: false },
28
+ "moonshotai-cn": { Icon: MoonshotIcon, hasColor: false },
29
+ "moonshot": { Icon: MoonshotIcon, hasColor: false },
30
+ "minimax": { Icon: MinimaxIcon, hasColor: true },
31
+ "minimax-cn": { Icon: MinimaxIcon, hasColor: true },
32
+ "fireworks": { Icon: FireworksIcon, hasColor: true },
33
+ "huggingface": { Icon: HuggingFaceIcon, hasColor: true },
34
+ "cerebras": { Icon: CerebrasIcon, hasColor: true },
35
+ "openrouter": { Icon: OpenRouterIcon, hasColor: false },
36
+ "xai": { Icon: XAIIcon, hasColor: false },
37
+ "cloudflare-ai-gateway": { Icon: CloudflareIcon, hasColor: true },
38
+ "cloudflare-workers-ai": { Icon: CloudflareIcon, hasColor: true },
39
+ "vercel-ai-gateway": { Icon: VercelIcon, hasColor: false },
40
+ "github-copilot": { Icon: GithubCopilotIcon, hasColor: false },
41
+ "amazon-bedrock": { Icon: AwsIcon, hasColor: true },
42
+ "azure-openai-responses": { Icon: AzureIcon, hasColor: true },
43
+ "kimi-coding": { Icon: KimiIcon, hasColor: true },
44
+ "qwen": { Icon: QwenIcon, hasColor: true },
45
+ "zai": { Icon: ZhipuIcon, hasColor: true },
46
+ "cohere": { Icon: CohereIcon, hasColor: true },
47
+ "perplexity": { Icon: PerplexityIcon, hasColor: true },
48
+ "together": { Icon: TogetherIcon, hasColor: true },
49
+ "grok": { Icon: GrokIcon, hasColor: false },
50
+ };
51
+
52
+ // ── Types ─────────────────────────────────────────────────────────────────────
53
+
54
+ interface OAuthProvider {
55
+ id: string;
56
+ name: string;
57
+ usesCallbackServer: boolean;
58
+ loggedIn: boolean;
59
+ }
60
+
61
+ interface ApiKeyProvider {
62
+ id: string;
63
+ displayName: string;
64
+ configured: boolean;
65
+ source?: string;
66
+ modelCount: number;
67
+ }
68
+
69
+ type OAuthLoginState =
70
+ | { phase: "idle" }
71
+ | { phase: "connecting" }
72
+ | { phase: "auth"; url: string; instructions: string | null; token: string }
73
+ | { phase: "device_code"; userCode: string; verificationUri: string; intervalSeconds: number | null; expiresInSeconds: number | null }
74
+ | { phase: "prompt"; message: string; placeholder: string | null; token: string }
75
+ | { phase: "select"; message: string; options: { id: string; label: string }[]; token: string }
76
+ | { phase: "progress"; message: string }
77
+ | { phase: "success" }
78
+ | { phase: "error"; message: string };
79
+
80
+ interface ModelEntry {
81
+ id: string;
82
+ name?: string;
83
+ api?: ProviderApi;
84
+ reasoning?: boolean;
85
+ thinkingLevelMap?: Record<string, string | null>;
86
+ input?: string[];
87
+ contextWindow?: number;
88
+ maxTokens?: number;
89
+ cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
90
+ compat?: Record<string, unknown>;
91
+ }
92
+
93
+ interface DiscoveredModel {
94
+ id: string;
95
+ name: string;
96
+ ownedBy: string;
97
+ input?: string[];
98
+ contextWindow?: number;
99
+ maxTokens?: number;
100
+ }
101
+
102
+ interface ProviderEntry {
103
+ baseUrl?: string;
104
+ api?: ProviderApi;
105
+ apiKey?: string;
106
+ requiresOpenAiAuth?: boolean;
107
+ source?: string;
108
+ sourceProviderId?: string;
109
+ headers?: Record<string, string>;
110
+ compat?: Record<string, unknown>;
111
+ models?: ModelEntry[];
112
+ modelOverrides?: Record<string, unknown>;
113
+ }
114
+
115
+ interface ModelsJson {
116
+ providers?: Record<string, ProviderEntry>;
117
+ defaultProvider?: string;
118
+ defaultModel?: string;
119
+ }
120
+
121
+ type ModelTestState =
122
+ | { phase: "idle" }
123
+ | { phase: "testing" }
124
+ | { phase: "success"; latencyMs?: number; status?: number; responseText?: string }
125
+ | { phase: "error"; message: string; latencyMs?: number; status?: number };
126
+
127
+ type Selection =
128
+ | { type: "provider"; name: string }
129
+ | { type: "model"; providerName: string; index: number }
130
+ | { type: "oauth"; providerId: string }
131
+ | { type: "apikey"; providerId: string };
132
+
133
+ type DiscoverState =
134
+ | { phase: "idle" }
135
+ | { phase: "loading" }
136
+ | { phase: "success"; endpoint: string; models: DiscoveredModel[]; piModels: ModelEntry[] }
137
+ | { phase: "error"; message: string };
138
+
139
+ const API_OPTIONS: Array<{ value: ProviderApi; label: string; description: string }> = [
140
+ {
141
+ value: "openai-responses",
142
+ label: "OpenAI Responses",
143
+ description: "OpenAI / deeprouter GPT relay",
144
+ },
145
+ {
146
+ value: "openai-completions",
147
+ label: "Chat Completions",
148
+ description: "DeepSeek / Kimi / Qwen via compat proxy",
149
+ },
150
+ ];
151
+
152
+ // ── Form field helpers ────────────────────────────────────────────────────────
153
+
154
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
155
+ return (
156
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
157
+ <label style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}>{label}</label>
158
+ {children}
159
+ </div>
160
+ );
161
+ }
162
+
163
+ const inputStyle = {
164
+ padding: "6px 9px",
165
+ background: "var(--bg-panel)",
166
+ border: "1px solid var(--border)",
167
+ borderRadius: 5,
168
+ color: "var(--text)",
169
+ fontSize: 12,
170
+ outline: "none",
171
+ width: "100%",
172
+ boxSizing: "border-box" as const,
173
+ };
174
+
175
+ function TextInput({
176
+ value,
177
+ onChange,
178
+ placeholder,
179
+ mono,
180
+ onKeyDown,
181
+ style,
182
+ }: {
183
+ value: string;
184
+ onChange: (v: string) => void;
185
+ placeholder?: string;
186
+ mono?: boolean;
187
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
188
+ style?: React.CSSProperties;
189
+ }) {
190
+ return <input value={value} onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown} placeholder={placeholder}
191
+ style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit", ...style }} />;
192
+ }
193
+
194
+ function SecretTextInput({
195
+ value,
196
+ onChange,
197
+ placeholder,
198
+ mono,
199
+ onKeyDown,
200
+ autoComplete = "off",
201
+ spellCheck = false,
202
+ style,
203
+ }: {
204
+ value: string;
205
+ onChange: (v: string) => void;
206
+ placeholder?: string;
207
+ mono?: boolean;
208
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
209
+ autoComplete?: string;
210
+ spellCheck?: boolean;
211
+ style?: React.CSSProperties;
212
+ }) {
213
+ const [visible, setVisible] = useState(false);
214
+
215
+ useEffect(() => {
216
+ if (!value) setVisible(false);
217
+ }, [value]);
218
+
219
+ return (
220
+ <div style={{ position: "relative", width: "100%", ...style }}>
221
+ <input
222
+ type={visible ? "text" : "password"}
223
+ value={value}
224
+ onChange={(e) => onChange(e.target.value)}
225
+ onKeyDown={onKeyDown}
226
+ placeholder={placeholder}
227
+ style={{ ...inputStyle, paddingRight: 34, fontFamily: mono ? "var(--font-mono)" : "inherit" }}
228
+ autoComplete={autoComplete}
229
+ spellCheck={spellCheck}
230
+ />
231
+ <button
232
+ type="button"
233
+ onClick={() => setVisible((v) => !v)}
234
+ aria-label={visible ? "Hide API key" : "Show API key"}
235
+ title={visible ? "Hide API key" : "Show API key"}
236
+ style={{
237
+ position: "absolute",
238
+ right: 5,
239
+ top: "50%",
240
+ transform: "translateY(-50%)",
241
+ width: 24,
242
+ height: 24,
243
+ padding: 0,
244
+ border: "none",
245
+ background: "transparent",
246
+ color: "var(--text-dim)",
247
+ cursor: "pointer",
248
+ display: "flex",
249
+ alignItems: "center",
250
+ justifyContent: "center",
251
+ }}
252
+ >
253
+ {visible ? (
254
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
255
+ <path d="M17.94 17.94A10.94 10.94 0 0 1 12 20C7 20 2.73 16.89 1 12a18.45 18.45 0 0 1 5.06-6.94" />
256
+ <path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c5 0 9.27 3.11 11 8a18.5 18.5 0 0 1-2.16 3.19" />
257
+ <path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
258
+ <path d="M1 1l22 22" />
259
+ </svg>
260
+ ) : (
261
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
262
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12Z" />
263
+ <circle cx="12" cy="12" r="3" />
264
+ </svg>
265
+ )}
266
+ </button>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
272
+ return <input type="number" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} style={inputStyle} />;
273
+ }
274
+
275
+ function ApiSelect({ value, onChange, required }: { value: string; onChange: (v: ProviderApi | "") => void; required?: boolean }) {
276
+ return (
277
+ <select value={value} onChange={(e) => {
278
+ const next = e.target.value;
279
+ onChange(isProviderApi(next) ? next : "");
280
+ }}
281
+ style={{ ...inputStyle, color: value ? "var(--text)" : "var(--text-dim)" }}>
282
+ {!required && <option value="">— inherit / none —</option>}
283
+ {API_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
284
+ </select>
285
+ );
286
+ }
287
+
288
+ function ProtocolHint({ api }: { api?: ProviderApi | "" }) {
289
+ const option = API_OPTIONS.find((item) => item.value === api);
290
+ return (
291
+ <span style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>
292
+ {option ? option.description : "Leave empty to inherit the provider protocol."}
293
+ </span>
294
+ );
295
+ }
296
+
297
+ function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
298
+ return (
299
+ <label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 12, color: "var(--text-muted)" }}>
300
+ <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
301
+ style={{ width: 13, height: 13, accentColor: "var(--accent)", cursor: "pointer" }} />
302
+ {label}
303
+ </label>
304
+ );
305
+ }
306
+
307
+ function SectionTitle({ children }: { children: React.ReactNode }) {
308
+ return <div style={{ fontSize: 11, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>{children}</div>;
309
+ }
310
+
311
+ // ── Provider detail ───────────────────────────────────────────────────────────
312
+
313
+ function ProviderDetail({ name, provider, onChange, onRename, onDelete }: {
314
+ name: string; provider: ProviderEntry;
315
+ onChange: (p: ProviderEntry) => void; onRename: (n: string) => void; onDelete: () => void;
316
+ }) {
317
+ const [editingName, setEditingName] = useState(name);
318
+ const [quickModelId, setQuickModelId] = useState("");
319
+ const readOnly = provider.source === "codex";
320
+ useEffect(() => setEditingName(name), [name]);
321
+ const set = <K extends keyof ProviderEntry>(k: K, v: ProviderEntry[K]) => onChange({ ...provider, [k]: v });
322
+ const addQuickModel = () => {
323
+ if (readOnly) return;
324
+ const id = quickModelId.trim();
325
+ if (!id) return;
326
+ const models = provider.models ?? [];
327
+ if (models.some((model) => model.id === id)) {
328
+ setQuickModelId("");
329
+ return;
330
+ }
331
+ onChange({
332
+ ...provider,
333
+ models: [...models, { id, name: id }],
334
+ });
335
+ setQuickModelId("");
336
+ };
337
+
338
+ return (
339
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
340
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
341
+ <SectionTitle>Provider</SectionTitle>
342
+ <button onClick={() => !readOnly && onDelete()} disabled={readOnly}
343
+ style={{ padding: "3px 8px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 4, color: "#ef4444", cursor: readOnly ? "not-allowed" : "pointer", fontSize: 11, opacity: readOnly ? 0.45 : 1 }}>
344
+ Delete
345
+ </button>
346
+ </div>
347
+
348
+ {readOnly && (
349
+ <div style={{ padding: "7px 10px", borderRadius: 6, border: "1px solid var(--border)", background: "var(--bg-panel)", color: "var(--text-dim)", fontSize: 12 }}>
350
+ Loaded from ~/.codex/config.toml and auth.json. Add a UI provider to override it.
351
+ </div>
352
+ )}
353
+
354
+ <Field label="Provider name">
355
+ <TextInput value={editingName} onChange={(v) => !readOnly && setEditingName(v)} placeholder="provider-name" mono />
356
+ {!readOnly && editingName !== name && editingName.trim() && (
357
+ <button onClick={() => onRename(editingName.trim())}
358
+ style={{ marginTop: 4, padding: "3px 10px", background: "var(--accent)", border: "none", borderRadius: 4, color: "#fff", cursor: "pointer", fontSize: 11, alignSelf: "flex-start" }}>
359
+ Rename
360
+ </button>
361
+ )}
362
+ </Field>
363
+
364
+ <Field label="Base URL">
365
+ <TextInput value={provider.baseUrl ?? ""} onChange={(v) => !readOnly && set("baseUrl", v || undefined)}
366
+ placeholder="https://api.example.com/v1" mono />
367
+ </Field>
368
+
369
+ <Field label="API Key">
370
+ <SecretTextInput value={provider.apiKey ?? ""} onChange={(v) => !readOnly && set("apiKey", v || undefined)}
371
+ placeholder="ENV_VAR_NAME, !shell-command, or literal key" mono />
372
+ <span style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>
373
+ Prefix with <code style={{ fontFamily: "var(--font-mono)" }}>!</code> to run a shell command, or use an env var name
374
+ </span>
375
+ </Field>
376
+
377
+ <Field label="Protocol">
378
+ <ApiSelect value={provider.api ?? inferProviderApi(provider.baseUrl, name)} onChange={(v) => !readOnly && set("api", v || undefined)} required />
379
+ <ProtocolHint api={provider.api ?? inferProviderApi(provider.baseUrl, name)} />
380
+ </Field>
381
+
382
+ <Field label="Model ID">
383
+ <div style={{ display: "flex", gap: 6 }}>
384
+ <TextInput
385
+ value={quickModelId}
386
+ onChange={(v) => !readOnly && setQuickModelId(v)}
387
+ onKeyDown={(event) => {
388
+ if (event.key === "Enter" && !readOnly) addQuickModel();
389
+ }}
390
+ placeholder="gpt-5.5"
391
+ mono
392
+ style={{ flex: 1 }}
393
+ />
394
+ <button
395
+ type="button"
396
+ onClick={addQuickModel}
397
+ disabled={readOnly || !quickModelId.trim()}
398
+ style={{
399
+ padding: "0 12px",
400
+ border: "none",
401
+ borderRadius: 5,
402
+ background: !readOnly && quickModelId.trim() ? "var(--accent)" : "var(--bg-panel)",
403
+ color: !readOnly && quickModelId.trim() ? "#fff" : "var(--text-dim)",
404
+ cursor: !readOnly && quickModelId.trim() ? "pointer" : "not-allowed",
405
+ fontSize: 12,
406
+ fontWeight: 600,
407
+ flexShrink: 0,
408
+ }}
409
+ >
410
+ Add
411
+ </button>
412
+ </div>
413
+ <span style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>
414
+ Fetching the model list is optional. Add the model ID your provider supports, then save.
415
+ </span>
416
+ </Field>
417
+ </div>
418
+ );
419
+ }
420
+
421
+ // ── ThinkingLevelMap editor ───────────────────────────────────────────────────
422
+
423
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
424
+ type ThinkingLevel = typeof THINKING_LEVELS[number];
425
+
426
+ const LEVEL_COLORS: Record<ThinkingLevel, string> = {
427
+ off: "var(--text-dim)",
428
+ minimal: "#6b7280",
429
+ low: "#60a5fa",
430
+ medium: "#a78bfa",
431
+ high: "#f472b6",
432
+ xhigh: "#fb923c",
433
+ };
434
+
435
+ function ThinkingLevelMapEditor({
436
+ value,
437
+ onChange,
438
+ }: {
439
+ value: Record<string, string | null> | undefined;
440
+ onChange: (v: Record<string, string | null> | undefined) => void;
441
+ }) {
442
+ const map = value ?? {};
443
+
444
+ const setLevel = (level: ThinkingLevel, entry: string | null | "omit") => {
445
+ const next = { ...map };
446
+ if (entry === "omit") {
447
+ delete next[level];
448
+ } else {
449
+ next[level] = entry;
450
+ }
451
+ onChange(Object.keys(next).length ? next : undefined);
452
+ };
453
+
454
+ return (
455
+ <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
456
+ {THINKING_LEVELS.map((level) => {
457
+ const raw = map[level];
458
+ const state: "omit" | "null" | "string" =
459
+ !(level in map) ? "omit" : raw === null ? "null" : "string";
460
+ const strVal = typeof raw === "string" ? raw : "";
461
+ const color = LEVEL_COLORS[level];
462
+
463
+ const btnBase: React.CSSProperties = {
464
+ padding: "4px 10px",
465
+ fontSize: 10,
466
+ border: "none",
467
+ cursor: "pointer",
468
+ fontWeight: 400,
469
+ transition: "background 0.1s, color 0.1s",
470
+ whiteSpace: "nowrap",
471
+ background: "var(--bg-panel)",
472
+ color: "var(--text-dim)",
473
+ };
474
+ const btnActive: React.CSSProperties = {
475
+ background: "var(--accent)",
476
+ color: "#fff",
477
+ fontWeight: 600,
478
+ };
479
+ const btnActiveDisabled: React.CSSProperties = {
480
+ background: "#ef4444",
481
+ color: "#fff",
482
+ fontWeight: 600,
483
+ };
484
+
485
+ return (
486
+ <div
487
+ key={level}
488
+ style={{
489
+ display: "flex",
490
+ alignItems: "center",
491
+ gap: 8,
492
+ padding: "5px 4px",
493
+ borderRadius: 6,
494
+ background: "transparent",
495
+ border: "1px solid transparent",
496
+ }}
497
+ >
498
+ {/* Level badge */}
499
+ <div style={{ display: "flex", alignItems: "center", gap: 5, width: 68, flexShrink: 0 }}>
500
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: color, flexShrink: 0, opacity: state === "null" ? 0.3 : 1 }} />
501
+ <span style={{
502
+ fontSize: 11,
503
+ fontFamily: "var(--font-mono)",
504
+ color: state === "null" ? "var(--text-dim)" : "var(--text-muted)",
505
+ textDecoration: state === "null" ? "line-through" : "none",
506
+ }}>
507
+ {level}
508
+ </span>
509
+ </div>
510
+
511
+ {/* Default + Disabled buttons */}
512
+ <div style={{ display: "flex", borderRadius: 5, border: "1px solid var(--border)", overflow: "hidden", flexShrink: 0 }}>
513
+ <button
514
+ onClick={() => setLevel(level, "omit")}
515
+ style={{ ...btnBase, ...(state === "omit" ? btnActive : {}) }}
516
+ >
517
+ Default
518
+ </button>
519
+ <button
520
+ onClick={() => setLevel(level, null)}
521
+ style={{ ...btnBase, borderLeft: "1px solid var(--border)", ...(state === "null" ? btnActiveDisabled : {}) }}
522
+ >
523
+ Disabled
524
+ </button>
525
+ </div>
526
+
527
+ {/* Custom button + input fused */}
528
+ <div style={{ display: "flex", borderRadius: 5, border: `1px solid ${state === "string" ? "var(--accent)" : "var(--border)"}`, overflow: "hidden", transition: "border-color 0.1s" }}>
529
+ <button
530
+ onClick={() => setLevel(level, strVal || level)}
531
+ style={{ ...btnBase, ...(state === "string" ? btnActive : {}), borderRight: "1px solid var(--border)", flexShrink: 0 }}
532
+ >
533
+ Custom
534
+ </button>
535
+ <input
536
+ value={strVal}
537
+ onChange={(e) => setLevel(level, e.target.value)}
538
+ onFocus={() => { if (state !== "string") setLevel(level, strVal || level); }}
539
+ placeholder={level}
540
+ maxLength={10}
541
+ style={{
542
+ width: "12ch",
543
+ background: state === "string" ? "var(--bg)" : "var(--bg-panel)",
544
+ border: "none",
545
+ outline: "none",
546
+ color: state === "string" ? "var(--text)" : "var(--text-dim)",
547
+ fontFamily: "var(--font-mono)",
548
+ fontSize: 11,
549
+ padding: "4px 7px",
550
+ transition: "background 0.1s, color 0.1s",
551
+ }}
552
+ />
553
+ </div>
554
+ </div>
555
+ );
556
+ })}
557
+ </div>
558
+ );
559
+ }
560
+
561
+ // ── Model detail ──────────────────────────────────────────────────────────────
562
+
563
+ const DEEPSEEK_COMPAT = {
564
+ thinkingFormat: "deepseek",
565
+ requiresReasoningContentOnAssistantMessages: true,
566
+ } as const;
567
+
568
+ function hasDeepseekCompat(model: ModelEntry): boolean {
569
+ return model.compat?.thinkingFormat === "deepseek";
570
+ }
571
+
572
+ function setDeepseekCompat(model: ModelEntry, enabled: boolean): ModelEntry {
573
+ if (enabled) {
574
+ return { ...model, compat: { ...(model.compat ?? {}), ...DEEPSEEK_COMPAT } };
575
+ }
576
+ if (!model.compat) return model;
577
+ const rest = { ...model.compat };
578
+ delete rest.thinkingFormat;
579
+ delete rest.requiresReasoningContentOnAssistantMessages;
580
+ return { ...model, compat: Object.keys(rest).length ? rest : undefined };
581
+ }
582
+
583
+ function ModelDetail({
584
+ providerName,
585
+ provider,
586
+ model,
587
+ onChange,
588
+ onDelete,
589
+ }: {
590
+ providerName: string;
591
+ provider: ProviderEntry;
592
+ model: ModelEntry;
593
+ onChange: (m: ModelEntry) => void;
594
+ onDelete: () => void;
595
+ }) {
596
+ const [testState, setTestState] = useState<ModelTestState>({ phase: "idle" });
597
+ const set = <K extends keyof ModelEntry>(k: K, v: ModelEntry[K]) => onChange({ ...model, [k]: v });
598
+ const costVal = (k: keyof NonNullable<ModelEntry["cost"]>) => model.cost?.[k] !== undefined ? String(model.cost[k]) : "";
599
+ const setCost = (k: keyof NonNullable<ModelEntry["cost"]>, v: string) => {
600
+ const n = parseFloat(v);
601
+ onChange({ ...model, cost: { ...(model.cost ?? {}), [k]: isNaN(n) ? undefined : n } });
602
+ };
603
+ const testSummary = (() => {
604
+ if (testState.phase === "idle") return null;
605
+ if (testState.phase === "testing") return "Testing model connection...";
606
+ const meta = [
607
+ testState.latencyMs !== undefined ? `${testState.latencyMs}ms` : null,
608
+ testState.status !== undefined ? `HTTP ${testState.status}` : null,
609
+ ].filter(Boolean);
610
+ if (testState.phase === "success") {
611
+ return ["Connected", ...meta, testState.responseText || null].filter(Boolean).join(" · ");
612
+ }
613
+ return ["Failed", ...meta, testState.message].filter(Boolean).join(" · ");
614
+ })();
615
+
616
+ useEffect(() => {
617
+ setTestState({ phase: "idle" });
618
+ }, [providerName, provider.baseUrl, provider.api, provider.apiKey, model.id, model.api]);
619
+
620
+ const handleTest = useCallback(async () => {
621
+ if (!model.id.trim() || testState.phase === "testing") return;
622
+ setTestState({ phase: "testing" });
623
+ try {
624
+ const res = await fetch("/api/models-config/test", {
625
+ method: "POST",
626
+ headers: { "Content-Type": "application/json" },
627
+ body: JSON.stringify({ providerName, provider, model }),
628
+ });
629
+ const d = await res.json() as {
630
+ ok?: boolean;
631
+ error?: string;
632
+ latencyMs?: number;
633
+ status?: number;
634
+ responseText?: string;
635
+ };
636
+ if (!res.ok || !d.ok) {
637
+ setTestState({
638
+ phase: "error",
639
+ message: d.error ?? `HTTP ${res.status}`,
640
+ latencyMs: d.latencyMs,
641
+ status: d.status,
642
+ });
643
+ return;
644
+ }
645
+ setTestState({
646
+ phase: "success",
647
+ latencyMs: d.latencyMs,
648
+ status: d.status,
649
+ responseText: d.responseText,
650
+ });
651
+ } catch (e) {
652
+ setTestState({ phase: "error", message: e instanceof Error ? e.message : String(e) });
653
+ }
654
+ }, [model, provider, providerName, testState.phase]);
655
+
656
+ return (
657
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
658
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
659
+ <SectionTitle>Model</SectionTitle>
660
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
661
+ {testSummary && (
662
+ <span
663
+ title={testSummary}
664
+ style={{
665
+ maxWidth: 260,
666
+ height: 24,
667
+ padding: "0 8px",
668
+ border: `1px solid ${testState.phase === "error" ? "#fecaca" : testState.phase === "success" ? "#bbf7d0" : "var(--border)"}`,
669
+ borderRadius: 4,
670
+ background: testState.phase === "error" ? "#fee2e2" : testState.phase === "success" ? "#dcfce7" : "#e5e7eb",
671
+ color: "#111827",
672
+ fontSize: 11,
673
+ display: "inline-flex",
674
+ alignItems: "center",
675
+ whiteSpace: "nowrap",
676
+ overflow: "hidden",
677
+ textOverflow: "ellipsis",
678
+ boxSizing: "border-box",
679
+ }}
680
+ >
681
+ {testSummary}
682
+ </span>
683
+ )}
684
+ <button
685
+ onClick={handleTest}
686
+ disabled={!model.id.trim() || testState.phase === "testing"}
687
+ title="Test model connection"
688
+ style={{
689
+ height: 24,
690
+ padding: "0 8px",
691
+ background: testState.phase === "success" ? "#16a34a" : "none",
692
+ border: `1px solid ${testState.phase === "success" ? "#16a34a" : "var(--border)"}`,
693
+ borderRadius: 4,
694
+ color: testState.phase === "success" ? "#fff" : (!model.id.trim() || testState.phase === "testing") ? "var(--text-dim)" : "var(--text-muted)",
695
+ cursor: (!model.id.trim() || testState.phase === "testing") ? "not-allowed" : "pointer",
696
+ fontSize: 11,
697
+ display: "inline-flex",
698
+ alignItems: "center",
699
+ justifyContent: "center",
700
+ boxSizing: "border-box",
701
+ gap: 5,
702
+ }}
703
+ >
704
+ {testState.phase === "success" && (
705
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
706
+ <polyline points="20 6 9 17 4 12" />
707
+ </svg>
708
+ )}
709
+ {testState.phase === "testing" ? "Testing…" : testState.phase === "success" ? "OK" : "Test"}
710
+ </button>
711
+ <button onClick={onDelete}
712
+ style={{ height: 24, padding: "0 8px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 4, color: "#ef4444", cursor: "pointer", fontSize: 11, boxSizing: "border-box" }}>
713
+ Remove
714
+ </button>
715
+ </div>
716
+ </div>
717
+
718
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
719
+ <Field label="ID *"><TextInput value={model.id} onChange={(v) => set("id", v)} placeholder="model-id" mono /></Field>
720
+ <Field label="Name"><TextInput value={model.name ?? ""} onChange={(v) => set("name", v || undefined)} placeholder="Display name" /></Field>
721
+ </div>
722
+
723
+ <Field label="API override">
724
+ <ApiSelect value={model.api ?? ""} onChange={(v) => set("api", v || undefined)} />
725
+ <ProtocolHint api={model.api ?? ""} />
726
+ </Field>
727
+
728
+ <div style={{ display: "flex", gap: 20, flexWrap: "wrap" }}>
729
+ <Check label="Reasoning / thinking" checked={model.reasoning ?? false} onChange={(v) => set("reasoning", v || undefined)} />
730
+ <Check label="Image input" checked={model.input?.includes("image") ?? false}
731
+ onChange={(v) => set("input", v ? ["text", "image"] : undefined)} />
732
+ </div>
733
+
734
+ {model.reasoning && (
735
+ <>
736
+ <Check
737
+ label="DeepSeek thinking compat"
738
+ checked={hasDeepseekCompat(model)}
739
+ onChange={(v) => onChange(setDeepseekCompat(model, v))}
740
+ />
741
+ <div>
742
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
743
+ <SectionTitle>Thinking level map</SectionTitle>
744
+ {model.thinkingLevelMap && (
745
+ <button
746
+ onClick={() => set("thinkingLevelMap", undefined)}
747
+ style={{ fontSize: 10, padding: "2px 7px", background: "none", border: "1px solid var(--border)", borderRadius: 4, color: "var(--text-dim)", cursor: "pointer" }}
748
+ >
749
+ clear all
750
+ </button>
751
+ )}
752
+ </div>
753
+ <ThinkingLevelMapEditor
754
+ value={model.thinkingLevelMap}
755
+ onChange={(v) => set("thinkingLevelMap", v)}
756
+ />
757
+ </div>
758
+ </>
759
+ )}
760
+
761
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
762
+ <Field label="Context window (tokens)">
763
+ <NumInput value={model.contextWindow !== undefined ? String(model.contextWindow) : ""}
764
+ onChange={(v) => set("contextWindow", v ? parseInt(v) : undefined)} placeholder="128000" />
765
+ </Field>
766
+ <Field label="Max output tokens">
767
+ <NumInput value={model.maxTokens !== undefined ? String(model.maxTokens) : ""}
768
+ onChange={(v) => set("maxTokens", v ? parseInt(v) : undefined)} placeholder="16384" />
769
+ </Field>
770
+ </div>
771
+
772
+ <div>
773
+ <SectionTitle>Cost (per million tokens)</SectionTitle>
774
+ <div style={{ marginTop: 8, display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 8 }}>
775
+ {(["input", "output", "cacheRead", "cacheWrite"] as const).map((k) => (
776
+ <Field key={k} label={k}>
777
+ <NumInput value={costVal(k)} onChange={(v) => setCost(k, v)} placeholder="0" />
778
+ </Field>
779
+ ))}
780
+ </div>
781
+ </div>
782
+ </div>
783
+ );
784
+ }
785
+
786
+ // ── OAuth detail ──────────────────────────────────────────────────────────────
787
+
788
+ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefresh: () => void }) {
789
+ const [loginState, setLoginState] = useState<OAuthLoginState>({ phase: "idle" });
790
+ const [inputValue, setInputValue] = useState("");
791
+ const eventSourceRef = useRef<EventSource | null>(null);
792
+ const inputRef = useRef<HTMLInputElement>(null);
793
+
794
+ useEffect(() => {
795
+ if (loginState.phase === "auth" || loginState.phase === "prompt") {
796
+ setTimeout(() => inputRef.current?.focus(), 50);
797
+ }
798
+ }, [loginState.phase]);
799
+
800
+ // Reset state when provider changes
801
+ useEffect(() => {
802
+ setLoginState({ phase: "idle" });
803
+ setInputValue("");
804
+ eventSourceRef.current?.close();
805
+ eventSourceRef.current = null;
806
+ }, [provider.id]);
807
+
808
+ useEffect(() => {
809
+ return () => { eventSourceRef.current?.close(); };
810
+ }, []);
811
+
812
+ const handleLogin = useCallback(() => {
813
+ eventSourceRef.current?.close();
814
+ setLoginState({ phase: "connecting" });
815
+ setInputValue("");
816
+
817
+ const es = new EventSource(`/api/auth/login/${encodeURIComponent(provider.id)}`);
818
+ eventSourceRef.current = es;
819
+
820
+ es.onmessage = (e) => {
821
+ const data = JSON.parse(e.data) as {
822
+ type: string; url?: string; instructions?: string | null;
823
+ token?: string; message?: string; placeholder?: string | null;
824
+ userCode?: string; verificationUri?: string; intervalSeconds?: number | null; expiresInSeconds?: number | null;
825
+ options?: { id: string; label: string }[];
826
+ };
827
+ if (data.type === "auth") {
828
+ setLoginState({ phase: "auth", url: data.url!, instructions: data.instructions ?? null, token: data.token! });
829
+ window.open(data.url!, "_blank", "noopener,noreferrer");
830
+ } else if (data.type === "device_code") {
831
+ setLoginState({
832
+ phase: "device_code",
833
+ userCode: data.userCode!,
834
+ verificationUri: data.verificationUri!,
835
+ intervalSeconds: data.intervalSeconds ?? null,
836
+ expiresInSeconds: data.expiresInSeconds ?? null,
837
+ });
838
+ window.open(data.verificationUri!, "_blank", "noopener,noreferrer");
839
+ } else if (data.type === "prompt_request") {
840
+ setLoginState({ phase: "prompt", message: data.message!, placeholder: data.placeholder ?? null, token: data.token! });
841
+ } else if (data.type === "select_request") {
842
+ setLoginState({ phase: "select", message: data.message!, options: data.options ?? [], token: data.token! });
843
+ } else if (data.type === "progress") {
844
+ setLoginState({ phase: "progress", message: data.message! });
845
+ } else if (data.type === "success") {
846
+ es.close();
847
+ setLoginState({ phase: "success" });
848
+ onRefresh();
849
+ } else if (data.type === "error") {
850
+ es.close();
851
+ setLoginState({ phase: "error", message: data.message! });
852
+ } else if (data.type === "cancelled") {
853
+ es.close();
854
+ setLoginState({ phase: "idle" });
855
+ }
856
+ };
857
+ es.onerror = () => {
858
+ es.close();
859
+ setLoginState((prev) => prev.phase === "success" ? prev : { phase: "error", message: "Connection lost" });
860
+ };
861
+ }, [provider.id, onRefresh]);
862
+
863
+ const handleLogout = useCallback(async () => {
864
+ await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { method: "POST" });
865
+ setLoginState({ phase: "idle" });
866
+ onRefresh();
867
+ }, [provider.id, onRefresh]);
868
+
869
+ const submitCode = useCallback(async (token: string, code: string) => {
870
+ if (!code.trim()) return;
871
+ setLoginState({ phase: "progress", message: "Verifying…" });
872
+ try {
873
+ const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, {
874
+ method: "POST",
875
+ headers: { "Content-Type": "application/json" },
876
+ body: JSON.stringify({ token, code: code.trim() }),
877
+ });
878
+ if (!res.ok) {
879
+ const d = await res.json().catch(() => ({})) as { error?: string };
880
+ setLoginState({ phase: "error", message: d.error ?? `Server error ${res.status}` });
881
+ return;
882
+ }
883
+ setInputValue("");
884
+ // Success path: SSE stream will emit "success" and update state
885
+ } catch (e) {
886
+ setLoginState({ phase: "error", message: e instanceof Error ? e.message : "Network error" });
887
+ }
888
+ }, [provider.id]);
889
+
890
+ const submitSelection = useCallback(async (token: string, value: string) => {
891
+ setLoginState({ phase: "progress", message: "Continuing…" });
892
+ try {
893
+ const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, {
894
+ method: "POST",
895
+ headers: { "Content-Type": "application/json" },
896
+ body: JSON.stringify({ token, code: value }),
897
+ });
898
+ if (!res.ok) {
899
+ const d = await res.json().catch(() => ({})) as { error?: string };
900
+ setLoginState({ phase: "error", message: d.error ?? `Server error ${res.status}` });
901
+ }
902
+ } catch (e) {
903
+ setLoginState({ phase: "error", message: e instanceof Error ? e.message : "Network error" });
904
+ }
905
+ }, [provider.id]);
906
+
907
+ const isWorking = loginState.phase === "connecting" || loginState.phase === "progress" ||
908
+ loginState.phase === "auth" || loginState.phase === "device_code" ||
909
+ loginState.phase === "prompt" || loginState.phase === "select";
910
+
911
+ return (
912
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
913
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
914
+ <SectionTitle>Subscription</SectionTitle>
915
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
916
+ <span style={{ width: 7, height: 7, borderRadius: "50%", background: provider.loggedIn ? "#4ade80" : "var(--border)", display: "inline-block" }} />
917
+ <span style={{ fontSize: 11, color: provider.loggedIn ? "#4ade80" : "var(--text-dim)" }}>
918
+ {provider.loggedIn ? "connected" : "not connected"}
919
+ </span>
920
+ </div>
921
+ </div>
922
+
923
+ {/* Status */}
924
+ <div style={{ minHeight: 48 }}>
925
+ {loginState.phase === "idle" && (
926
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
927
+ {provider.loggedIn ? "Already connected. You can re-login or disconnect." : `Connect your ${provider.name} account.`}
928
+ </p>
929
+ )}
930
+ {loginState.phase === "connecting" && (
931
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)" }}>Opening browser…</p>
932
+ )}
933
+ {loginState.phase === "select" && (
934
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
935
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
936
+ {loginState.message}
937
+ </p>
938
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
939
+ {loginState.options.map((option) => (
940
+ <button
941
+ key={option.id}
942
+ onClick={() => submitSelection(loginState.token, option.id)}
943
+ style={{ padding: "6px 9px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", cursor: "pointer", fontSize: 12, textAlign: "left" }}
944
+ >
945
+ {option.label}
946
+ </button>
947
+ ))}
948
+ </div>
949
+ </div>
950
+ )}
951
+ {(loginState.phase === "auth" || loginState.phase === "prompt") && (
952
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
953
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
954
+ {loginState.phase === "auth"
955
+ ? "Complete sign-in in the browser, then copy the redirect URL from the address bar and paste it below."
956
+ : loginState.message}
957
+ </p>
958
+ {loginState.phase === "auth" && (
959
+ <p style={{ margin: 0, fontSize: 11, color: "var(--text-dim)", lineHeight: 1.5 }}>
960
+ If the browser window did not open,{" "}
961
+ <a href={loginState.url} target="_blank" rel="noopener noreferrer" style={{ color: "var(--accent)", wordBreak: "break-all" }}>
962
+ click here to open the login page
963
+ </a>
964
+ .
965
+ </p>
966
+ )}
967
+ <div style={{ display: "flex", gap: 6 }}>
968
+ <input
969
+ ref={inputRef}
970
+ value={inputValue}
971
+ onChange={(e) => setInputValue(e.target.value)}
972
+ onKeyDown={(e) => { if (e.key === "Enter") submitCode(loginState.token, inputValue); }}
973
+ placeholder={loginState.phase === "auth" ? "http://localhost:1455/auth/callback?code=…" : (loginState.placeholder ?? "Enter value…")}
974
+ style={{ flex: 1, padding: "6px 9px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 12, outline: "none", fontFamily: "var(--font-mono)", boxSizing: "border-box" }}
975
+ />
976
+ <button
977
+ onClick={() => submitCode(loginState.token, inputValue)}
978
+ disabled={!inputValue.trim()}
979
+ style={{ padding: "6px 12px", background: inputValue.trim() ? "var(--accent)" : "var(--bg-panel)", border: "none", borderRadius: 5, color: inputValue.trim() ? "#fff" : "var(--text-dim)", cursor: inputValue.trim() ? "pointer" : "not-allowed", fontSize: 12, fontWeight: 600, flexShrink: 0 }}
980
+ >
981
+ Submit
982
+ </button>
983
+ </div>
984
+ </div>
985
+ )}
986
+ {loginState.phase === "device_code" && (
987
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
988
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
989
+ Open the verification page and enter this code:
990
+ </p>
991
+ <div style={{ padding: "8px 10px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 16, fontWeight: 700, fontFamily: "var(--font-mono)", letterSpacing: 0 }}>
992
+ {loginState.userCode}
993
+ </div>
994
+ <p style={{ margin: 0, fontSize: 11, color: "var(--text-dim)", lineHeight: 1.5 }}>
995
+ <a href={loginState.verificationUri} target="_blank" rel="noopener noreferrer" style={{ color: "var(--accent)", wordBreak: "break-all" }}>
996
+ {loginState.verificationUri}
997
+ </a>
998
+ {loginState.expiresInSeconds ? ` Expires in ${Math.ceil(loginState.expiresInSeconds / 60)} minutes.` : ""}
999
+ </p>
1000
+ </div>
1001
+ )}
1002
+ {loginState.phase === "progress" && (
1003
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)" }}>{loginState.message}</p>
1004
+ )}
1005
+ {loginState.phase === "success" && (
1006
+ <p style={{ margin: 0, fontSize: 12, color: "#4ade80" }}>Connected successfully.</p>
1007
+ )}
1008
+ {loginState.phase === "error" && (
1009
+ <p style={{ margin: 0, fontSize: 12, color: "#f87171" }}>{loginState.message}</p>
1010
+ )}
1011
+ </div>
1012
+
1013
+ {/* Actions */}
1014
+ <div style={{ display: "flex", gap: 8 }}>
1015
+ {isWorking ? (
1016
+ <button
1017
+ onClick={() => { eventSourceRef.current?.close(); setLoginState({ phase: "idle" }); }}
1018
+ style={{ padding: "5px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text-muted)", cursor: "pointer", fontSize: 12 }}
1019
+ >
1020
+ Cancel
1021
+ </button>
1022
+ ) : (
1023
+ <>
1024
+ <button
1025
+ onClick={handleLogin}
1026
+ style={{ padding: "5px 14px", background: "var(--accent)", border: "none", borderRadius: 5, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 600 }}
1027
+ >
1028
+ {provider.loggedIn ? "Re-login" : "Login"}
1029
+ </button>
1030
+ {provider.loggedIn && (
1031
+ <button
1032
+ onClick={handleLogout}
1033
+ style={{ padding: "5px 12px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 5, color: "#ef4444", cursor: "pointer", fontSize: 12 }}
1034
+ >
1035
+ Disconnect
1036
+ </button>
1037
+ )}
1038
+ </>
1039
+ )}
1040
+ </div>
1041
+ </div>
1042
+ );
1043
+ }
1044
+
1045
+ // ── API Key detail ────────────────────────────────────────────────────────────
1046
+
1047
+ function ApiKeyDetail({ provider, onRefresh }: { provider: ApiKeyProvider; onRefresh: () => void }) {
1048
+ const [apiKey, setApiKey] = useState("");
1049
+ const [saving, setSaving] = useState(false);
1050
+ const [removing, setRemoving] = useState(false);
1051
+ const [error, setError] = useState<string | null>(null);
1052
+ const [savedOk, setSavedOk] = useState(false);
1053
+
1054
+ // Reset state when provider changes
1055
+ useEffect(() => {
1056
+ setApiKey("");
1057
+ setError(null);
1058
+ setSavedOk(false);
1059
+ }, [provider.id]);
1060
+
1061
+ const handleSave = useCallback(async () => {
1062
+ if (!apiKey.trim()) return;
1063
+ setSaving(true);
1064
+ setError(null);
1065
+ setSavedOk(false);
1066
+ try {
1067
+ const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, {
1068
+ method: "POST",
1069
+ headers: { "Content-Type": "application/json" },
1070
+ body: JSON.stringify({ apiKey: apiKey.trim() }),
1071
+ });
1072
+ const d = await res.json() as { success?: boolean; error?: string };
1073
+ if (!res.ok || d.error) {
1074
+ setError(d.error ?? `HTTP ${res.status}`);
1075
+ } else {
1076
+ setApiKey("");
1077
+ setSavedOk(true);
1078
+ setTimeout(() => setSavedOk(false), 2000);
1079
+ onRefresh();
1080
+ }
1081
+ } catch (e) {
1082
+ setError(String(e));
1083
+ } finally {
1084
+ setSaving(false);
1085
+ }
1086
+ }, [apiKey, provider.id, onRefresh]);
1087
+
1088
+ const handleRemove = useCallback(async () => {
1089
+ setRemoving(true);
1090
+ setError(null);
1091
+ try {
1092
+ const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { method: "DELETE" });
1093
+ const d = await res.json() as { success?: boolean; error?: string };
1094
+ if (!res.ok || d.error) setError(d.error ?? `HTTP ${res.status}`);
1095
+ else onRefresh();
1096
+ } catch (e) {
1097
+ setError(String(e));
1098
+ } finally {
1099
+ setRemoving(false);
1100
+ }
1101
+ }, [provider.id, onRefresh]);
1102
+
1103
+ return (
1104
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1105
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
1106
+ <SectionTitle>API Key</SectionTitle>
1107
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
1108
+ <span style={{ width: 7, height: 7, borderRadius: "50%", background: provider.configured ? "#4ade80" : "var(--border)", display: "inline-block" }} />
1109
+ <span style={{ fontSize: 11, color: provider.configured ? "#4ade80" : "var(--text-dim)" }}>
1110
+ {provider.configured ? "configured" : "not configured"}
1111
+ </span>
1112
+ </div>
1113
+ </div>
1114
+
1115
+ <p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
1116
+ {provider.configured
1117
+ ? `API key is stored. Enter a new key below to replace it, or disconnect to remove it.`
1118
+ : `Enter your ${provider.displayName} API key to enable ${provider.modelCount} model${provider.modelCount !== 1 ? "s" : ""}.`}
1119
+ </p>
1120
+
1121
+ <Field label="API Key">
1122
+ <div style={{ display: "flex", gap: 6 }}>
1123
+ <SecretTextInput
1124
+ value={apiKey}
1125
+ onChange={setApiKey}
1126
+ onKeyDown={(e) => { if (e.key === "Enter" && apiKey.trim()) handleSave(); }}
1127
+ placeholder={provider.configured ? "Enter new key to replace…" : "sk-…"}
1128
+ style={{ flex: 1 }}
1129
+ autoComplete="off"
1130
+ spellCheck={false}
1131
+ mono
1132
+ />
1133
+ <button
1134
+ onClick={handleSave}
1135
+ disabled={saving || !apiKey.trim() || savedOk}
1136
+ style={{
1137
+ padding: "6px 12px",
1138
+ background: savedOk ? "#16a34a" : apiKey.trim() ? "var(--accent)" : "var(--bg-panel)",
1139
+ border: "none", borderRadius: 5,
1140
+ color: (apiKey.trim() || savedOk) ? "#fff" : "var(--text-dim)",
1141
+ cursor: (saving || !apiKey.trim() || savedOk) ? "not-allowed" : "pointer",
1142
+ fontSize: 12, fontWeight: 600, flexShrink: 0,
1143
+ display: "flex", alignItems: "center", gap: 5,
1144
+ }}
1145
+ >
1146
+ {savedOk && (
1147
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
1148
+ <polyline points="20 6 9 17 4 12" />
1149
+ </svg>
1150
+ )}
1151
+ {savedOk ? "Saved" : saving ? "Saving…" : "Save"}
1152
+ </button>
1153
+ </div>
1154
+ </Field>
1155
+
1156
+ {error && <p style={{ margin: 0, fontSize: 12, color: "#f87171" }}>{error}</p>}
1157
+
1158
+ {provider.configured && (
1159
+ <button
1160
+ onClick={handleRemove}
1161
+ disabled={removing}
1162
+ style={{
1163
+ alignSelf: "flex-start", padding: "5px 12px",
1164
+ background: "none", border: "1px solid rgba(239,68,68,0.3)",
1165
+ borderRadius: 5, color: "#ef4444",
1166
+ cursor: removing ? "not-allowed" : "pointer", fontSize: 12,
1167
+ }}
1168
+ >
1169
+ {removing ? "Removing…" : "Disconnect"}
1170
+ </button>
1171
+ )}
1172
+ </div>
1173
+ );
1174
+ }
1175
+
1176
+ // ── Provider icon ─────────────────────────────────────────────────────────────
1177
+
1178
+ function ProviderIcon({ id, size }: { id: string; size: number }) {
1179
+ const pi = PROVIDER_ICONS[id];
1180
+ if (!pi) return null;
1181
+ // Color icons: self-colored SVG, no wrapper needed
1182
+ if (pi.hasColor) return <pi.Icon size={size} />;
1183
+ // Mono icons: use currentColor so they adapt to light/dark theme
1184
+ return <pi.Icon size={size} style={{ color: "var(--text-muted)" }} />;
1185
+ }
1186
+
1187
+ // ── Add provider picker ───────────────────────────────────────────────────────
1188
+
1189
+ interface AddProviderPickerProps {
1190
+ oauthProviders: OAuthProvider[];
1191
+ apiKeyProviders: ApiKeyProvider[];
1192
+ onSelectOAuth: (id: string) => void;
1193
+ onSelectApiKey: (id: string) => void;
1194
+ onAddCustom: () => void;
1195
+ onDiscover: () => void;
1196
+ onClose: () => void;
1197
+ }
1198
+
1199
+ function AddProviderPicker({
1200
+ oauthProviders, apiKeyProviders,
1201
+ onSelectOAuth, onSelectApiKey, onAddCustom, onDiscover, onClose,
1202
+ }: AddProviderPickerProps) {
1203
+ const [search, setSearch] = useState("");
1204
+ const inputRef = useRef<HTMLInputElement>(null);
1205
+
1206
+ useEffect(() => { setTimeout(() => inputRef.current?.focus(), 30); }, []);
1207
+
1208
+ const q = search.trim().toLowerCase();
1209
+
1210
+ const availableOAuth = oauthProviders.filter((p) => !p.loggedIn && (!q || p.name.toLowerCase().includes(q)));
1211
+ const availableApiKey = apiKeyProviders.filter((p) => !p.configured && (!q || p.displayName.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)));
1212
+ const showCustom = !q || "custom".includes(q) || "openai-compatible".includes(q) || "deepseek".includes(q) || "deeprouter".includes(q);
1213
+ const showDiscover = !q || "discover".includes(q) || "base url".includes(q) || "model import".includes(q);
1214
+
1215
+ const totalCount = availableOAuth.length + availableApiKey.length + (showCustom ? 1 : 0) + (showDiscover ? 1 : 0);
1216
+
1217
+ const cardStyle: React.CSSProperties = {
1218
+ display: "flex", flexDirection: "row", alignItems: "center", gap: 8,
1219
+ padding: "10px 12px",
1220
+ background: "var(--bg-panel)",
1221
+ border: "1px solid var(--border)",
1222
+ borderRadius: 7,
1223
+ boxSizing: "border-box",
1224
+ cursor: "pointer",
1225
+ minWidth: 0,
1226
+ textAlign: "left",
1227
+ transition: "border-color 0.12s, background 0.12s",
1228
+ width: "100%",
1229
+ };
1230
+
1231
+
1232
+
1233
+ return (
1234
+ <div
1235
+ style={{ position: "fixed", inset: 0, zIndex: 1100, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center" }}
1236
+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
1237
+ >
1238
+ <div style={{ width: 820, maxWidth: "calc(100vw - 32px)", maxHeight: "min(72vh, calc(100vh - 32px))", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 10, display: "flex", flexDirection: "column", boxShadow: "0 8px 32px rgba(0,0,0,0.22)", overflow: "hidden" }}>
1239
+ {/* Search */}
1240
+ <div style={{ padding: "10px 14px", borderBottom: "1px solid var(--border)", flexShrink: 0, display: "flex", alignItems: "center", gap: 8 }}>
1241
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-dim)", flexShrink: 0 }}>
1242
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
1243
+ </svg>
1244
+ <input
1245
+ ref={inputRef}
1246
+ value={search}
1247
+ onChange={(e) => setSearch(e.target.value)}
1248
+ onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
1249
+ placeholder="Search providers…"
1250
+ style={{ flex: 1, background: "none", border: "none", outline: "none", color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
1251
+ />
1252
+ </div>
1253
+
1254
+ {/* Card grid */}
1255
+ <div style={{ flex: 1, overflowY: "auto", padding: 14 }}>
1256
+ {totalCount === 0 ? (
1257
+ <div style={{ padding: "20px 0", fontSize: 12, color: "var(--text-dim)", textAlign: "center" }}>No providers match</div>
1258
+ ) : (
1259
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(240px, 100%), 1fr))", gap: 8 }}>
1260
+ {showCustom && (
1261
+ <div style={{ gridColumn: "1 / -1", fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>Custom</div>
1262
+ )}
1263
+ {showCustom && (
1264
+ <button
1265
+ onClick={() => { onAddCustom(); onClose(); }}
1266
+ style={cardStyle}
1267
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
1268
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
1269
+ >
1270
+ <div style={{ flex: 1, minWidth: 0 }}>
1271
+ <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>OpenAI-compatible endpoint</div>
1272
+ <div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>DeepSeek, deeprouter, Kimi, Qwen</div>
1273
+ </div>
1274
+ <span style={{ width: 26, height: 26, borderRadius: 5, background: "var(--bg-hover)", border: "1px dashed var(--border)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
1275
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-dim)" }}>
1276
+ <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
1277
+ </svg>
1278
+ </span>
1279
+ </button>
1280
+ )}
1281
+ {showDiscover && (
1282
+ <button
1283
+ onClick={() => { onDiscover(); onClose(); }}
1284
+ style={cardStyle}
1285
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
1286
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
1287
+ >
1288
+ <div style={{ flex: 1, minWidth: 0 }}>
1289
+ <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>Discover models from Base URL</div>
1290
+ <div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>Optional OpenAI-compatible / Ollama catalog</div>
1291
+ </div>
1292
+ <span style={{ width: 26, height: 26, borderRadius: 5, background: "var(--bg-hover)", border: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
1293
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-muted)" }}>
1294
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
1295
+ </svg>
1296
+ </span>
1297
+ </button>
1298
+ )}
1299
+
1300
+ {availableOAuth.length > 0 && (
1301
+ <div style={{ gridColumn: "1 / -1", paddingTop: showCustom ? 6 : 0, fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>Subscriptions</div>
1302
+ )}
1303
+ {availableOAuth.map((p) => (
1304
+ <button key={p.id} onClick={() => { onSelectOAuth(p.id); onClose(); }}
1305
+ style={cardStyle}
1306
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
1307
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
1308
+ >
1309
+ <div style={{ flex: 1, minWidth: 0 }}>
1310
+ <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</div>
1311
+ <div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>OAuth</div>
1312
+ </div>
1313
+ <ProviderIcon id={p.id} size={28} />
1314
+ </button>
1315
+ ))}
1316
+
1317
+ {availableApiKey.length > 0 && (
1318
+ <div style={{ gridColumn: "1 / -1", paddingTop: availableOAuth.length > 0 ? 6 : 0, fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>API Key</div>
1319
+ )}
1320
+ {availableApiKey.map((p) => (
1321
+ <button key={p.id} onClick={() => { onSelectApiKey(p.id); onClose(); }}
1322
+ style={cardStyle}
1323
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
1324
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
1325
+ >
1326
+ <div style={{ flex: 1, minWidth: 0 }}>
1327
+ <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.displayName}</div>
1328
+ <div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>{p.modelCount} models</div>
1329
+ </div>
1330
+ <ProviderIcon id={p.id} size={28} />
1331
+ </button>
1332
+ ))}
1333
+
1334
+ </div>
1335
+ )}
1336
+ </div>
1337
+ </div>
1338
+ </div>
1339
+ );
1340
+ }
1341
+
1342
+ // ── Discover models from Base URL ─────────────────────────────────────────────
1343
+
1344
+ function DiscoverModelsDialog({
1345
+ existingProviders,
1346
+ onImport,
1347
+ onClose,
1348
+ }: {
1349
+ existingProviders: Record<string, ProviderEntry>;
1350
+ onImport: (providerName: string, provider: ProviderEntry, selectedCount: number) => void;
1351
+ onClose: () => void;
1352
+ }) {
1353
+ const [baseUrl, setBaseUrl] = useState("");
1354
+ const [apiKey, setApiKey] = useState("");
1355
+ const [providerName, setProviderName] = useState("custom-provider");
1356
+ const [selected, setSelected] = useState<Set<string>>(new Set());
1357
+ const [state, setState] = useState<DiscoverState>({ phase: "idle" });
1358
+ const discover = useCallback(async () => {
1359
+ const trimmedBaseUrl = baseUrl.trim();
1360
+ if (!trimmedBaseUrl || state.phase === "loading") return;
1361
+ setState({ phase: "loading" });
1362
+ try {
1363
+ const res = await fetch("/api/models-config/discover", {
1364
+ method: "POST",
1365
+ headers: { "Content-Type": "application/json" },
1366
+ body: JSON.stringify({ baseUrl: trimmedBaseUrl, apiKey, provider: providerName, enrich: false }),
1367
+ });
1368
+ const d = await res.json() as {
1369
+ ok?: boolean;
1370
+ error?: string;
1371
+ endpoint?: string;
1372
+ models?: DiscoveredModel[];
1373
+ piModels?: ModelEntry[];
1374
+ };
1375
+ if (!res.ok || !d.ok || !d.models || !d.piModels || !d.endpoint) {
1376
+ setState({ phase: "error", message: d.error ?? `HTTP ${res.status}` });
1377
+ return;
1378
+ }
1379
+ setState({ phase: "success", endpoint: d.endpoint, models: d.models, piModels: d.piModels });
1380
+ setSelected(new Set(d.piModels.map((m) => m.id)));
1381
+ } catch (error) {
1382
+ setState({ phase: "error", message: error instanceof Error ? error.message : String(error) });
1383
+ }
1384
+ }, [apiKey, baseUrl, providerName, state.phase]);
1385
+
1386
+ const success = state.phase === "success" ? state : null;
1387
+ const selectedCount = selected.size;
1388
+ const canImport = Boolean(success && providerName.trim() && selectedCount > 0);
1389
+
1390
+ const importSelected = () => {
1391
+ if (!success || !canImport) return;
1392
+ const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
1393
+ const trimmedApiKey = apiKey.trim();
1394
+ const matched = Object.entries(existingProviders).find(([, provider]) =>
1395
+ (provider.baseUrl ?? "").replace(/\/+$/, "") === normalizedBaseUrl &&
1396
+ (provider.apiKey ?? "") === trimmedApiKey
1397
+ );
1398
+ const name = matched?.[0] ?? providerName.trim();
1399
+ const existingProvider = existingProviders[name];
1400
+ const byId = new Map((existingProvider?.models ?? []).map((model) => [model.id, model]));
1401
+ let addedCount = 0;
1402
+ for (const model of success.piModels) {
1403
+ if (selected.has(model.id) && !byId.has(model.id)) {
1404
+ byId.set(model.id, model);
1405
+ addedCount += 1;
1406
+ }
1407
+ }
1408
+ onImport(name, {
1409
+ ...(existingProvider ?? {}),
1410
+ baseUrl: normalizedBaseUrl,
1411
+ api: existingProvider?.api ?? inferProviderApi(normalizedBaseUrl, name),
1412
+ ...(trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
1413
+ models: Array.from(byId.values()),
1414
+ }, addedCount);
1415
+ onClose();
1416
+ };
1417
+
1418
+ const toggleAll = () => {
1419
+ if (!success) return;
1420
+ if (selected.size === success.piModels.length) {
1421
+ setSelected(new Set());
1422
+ return;
1423
+ }
1424
+ setSelected(new Set(success.piModels.map((m) => m.id)));
1425
+ };
1426
+
1427
+ const toggleModel = (id: string) => {
1428
+ setSelected((prev) => {
1429
+ const next = new Set(prev);
1430
+ if (next.has(id)) next.delete(id);
1431
+ else next.add(id);
1432
+ return next;
1433
+ });
1434
+ };
1435
+
1436
+ return (
1437
+ <div
1438
+ style={{ position: "fixed", inset: 0, zIndex: 1100, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center" }}
1439
+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
1440
+ >
1441
+ <div style={{ width: 880, maxWidth: "calc(100vw - 32px)", height: "min(78vh, calc(100vh - 32px))", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 10, display: "flex", flexDirection: "column", boxShadow: "0 8px 32px rgba(0,0,0,0.22)", overflow: "hidden" }}>
1442
+ <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0 }}>
1443
+ <div>
1444
+ <div style={{ fontSize: 14, fontWeight: 700, color: "var(--text)" }}>Discover models</div>
1445
+ <div style={{ fontSize: 11, color: "var(--text-dim)", marginTop: 2 }}>Optional: fetch a model catalog from a Base URL and import selected models.</div>
1446
+ </div>
1447
+ <button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>×</button>
1448
+ </div>
1449
+
1450
+ <div style={{ padding: 16, borderBottom: "1px solid var(--border)", display: "grid", gridTemplateColumns: "1.4fr 1fr 0.9fr auto", gap: 10, alignItems: "end", flexShrink: 0 }}>
1451
+ <Field label="Base URL">
1452
+ <TextInput value={baseUrl} onChange={setBaseUrl} placeholder="https://api.example.com" mono />
1453
+ </Field>
1454
+ <Field label="API Key">
1455
+ <SecretTextInput value={apiKey} onChange={setApiKey} placeholder="optional for local providers" mono />
1456
+ </Field>
1457
+ <Field label="Provider name">
1458
+ <TextInput value={providerName} onChange={setProviderName} placeholder="provider-name" mono />
1459
+ </Field>
1460
+ <button
1461
+ onClick={discover}
1462
+ disabled={!baseUrl.trim() || state.phase === "loading"}
1463
+ style={{
1464
+ height: 31,
1465
+ padding: "0 14px",
1466
+ background: baseUrl.trim() && state.phase !== "loading" ? "var(--accent)" : "var(--bg-panel)",
1467
+ border: "1px solid var(--border)",
1468
+ borderRadius: 5,
1469
+ color: baseUrl.trim() && state.phase !== "loading" ? "#fff" : "var(--text-dim)",
1470
+ cursor: baseUrl.trim() && state.phase !== "loading" ? "pointer" : "not-allowed",
1471
+ fontSize: 12,
1472
+ fontWeight: 600,
1473
+ whiteSpace: "nowrap",
1474
+ }}
1475
+ >
1476
+ {state.phase === "loading" ? "Fetching..." : "Fetch models"}
1477
+ </button>
1478
+ </div>
1479
+
1480
+ <div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
1481
+ {state.phase === "idle" && (
1482
+ <div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>
1483
+ Enter a Base URL to discover available models.
1484
+ </div>
1485
+ )}
1486
+ {state.phase === "loading" && (
1487
+ <div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: 13 }}>
1488
+ Fetching model catalog...
1489
+ </div>
1490
+ )}
1491
+ {state.phase === "error" && (
1492
+ <div style={{ padding: 18, color: "#f87171", fontSize: 12, lineHeight: 1.6 }}>
1493
+ {state.message}
1494
+ </div>
1495
+ )}
1496
+ {success && (
1497
+ <>
1498
+ <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 16px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
1499
+ <span style={{ fontSize: 11, color: "var(--text-dim)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
1500
+ {success.piModels.length} models from {success.endpoint}
1501
+ </span>
1502
+ <button
1503
+ onClick={toggleAll}
1504
+ style={{ padding: "2px 8px", fontSize: 11, background: "var(--bg-hover)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text-muted)", cursor: "pointer" }}
1505
+ >
1506
+ {selected.size === success.piModels.length ? "Clear" : "Select all"}
1507
+ </button>
1508
+ </div>
1509
+ <div style={{ flex: 1, overflow: "auto" }}>
1510
+ {success.piModels.map((model, index) => {
1511
+ const remote = success.models[index];
1512
+ const checked = selected.has(model.id);
1513
+ return (
1514
+ <label
1515
+ key={model.id}
1516
+ style={{
1517
+ display: "grid",
1518
+ gridTemplateColumns: "24px minmax(0, 1fr) 90px 90px 90px 70px",
1519
+ gap: 10,
1520
+ alignItems: "center",
1521
+ padding: "8px 16px",
1522
+ borderBottom: "1px solid rgba(127,127,127,0.12)",
1523
+ background: checked ? "var(--bg-subtle)" : "transparent",
1524
+ cursor: "pointer",
1525
+ }}
1526
+ >
1527
+ <input
1528
+ type="checkbox"
1529
+ checked={checked}
1530
+ onChange={() => toggleModel(model.id)}
1531
+ style={{ width: 14, height: 14, accentColor: "var(--accent)" }}
1532
+ />
1533
+ <div style={{ minWidth: 0 }}>
1534
+ <div style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{model.id}</div>
1535
+ <div style={{ fontSize: 10, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{remote?.ownedBy ?? "unknown"}</div>
1536
+ </div>
1537
+ <span style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{model.contextWindow}</span>
1538
+ <span style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{model.maxTokens}</span>
1539
+ <span style={{ fontSize: 10, color: model.reasoning ? "var(--accent)" : "var(--text-dim)" }}>{model.reasoning ? "reasoning" : ""}</span>
1540
+ <span style={{ fontSize: 10, color: model.input?.includes("image") ? "var(--accent)" : "var(--text-dim)" }}>{model.input?.includes("image") ? "image" : ""}</span>
1541
+ </label>
1542
+ );
1543
+ })}
1544
+ </div>
1545
+ </>
1546
+ )}
1547
+ </div>
1548
+
1549
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, padding: "10px 16px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
1550
+ <span style={{ fontSize: 11, color: "var(--text-dim)" }}>{selectedCount} selected</span>
1551
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
1552
+ <button onClick={onClose} style={{ padding: "6px 14px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", fontSize: 13 }}>
1553
+ Cancel
1554
+ </button>
1555
+ <button
1556
+ onClick={importSelected}
1557
+ disabled={!canImport}
1558
+ style={{
1559
+ padding: "6px 16px",
1560
+ background: canImport ? "var(--accent)" : "var(--bg-panel)",
1561
+ border: "none",
1562
+ borderRadius: 6,
1563
+ color: canImport ? "#fff" : "var(--text-dim)",
1564
+ cursor: canImport ? "pointer" : "not-allowed",
1565
+ fontSize: 13,
1566
+ fontWeight: 600,
1567
+ }}
1568
+ >
1569
+ Import selected
1570
+ </button>
1571
+ </div>
1572
+ </div>
1573
+ </div>
1574
+ </div>
1575
+ );
1576
+ }
1577
+
1578
+ // ── Main component ────────────────────────────────────────────────────────────
1579
+
1580
+ export function ModelsConfig({ onClose, embedded = false }: { onClose: () => void; embedded?: boolean }) {
1581
+ const [config, setConfig] = useState<ModelsJson>({ providers: {} });
1582
+ const [loading, setLoading] = useState(true);
1583
+ const [saving, setSaving] = useState(false);
1584
+ const [saveError, setSaveError] = useState<string | null>(null);
1585
+ const [saveNotice, setSaveNotice] = useState<string | null>(null);
1586
+ const [savedOk, setSavedOk] = useState(false);
1587
+ const [selection, setSelection] = useState<Selection | null>(null);
1588
+ const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]);
1589
+ const [apiKeyProviders, setApiKeyProviders] = useState<ApiKeyProvider[]>([]);
1590
+ const [pickerOpen, setPickerOpen] = useState(false);
1591
+ const [discoverOpen, setDiscoverOpen] = useState(false);
1592
+
1593
+ const loadOAuthProviders = useCallback(() => {
1594
+ fetch("/api/auth/providers")
1595
+ .then((r) => r.json())
1596
+ .then((d: { providers: OAuthProvider[] }) => setOauthProviders(d.providers))
1597
+ .catch(() => {});
1598
+ }, []);
1599
+
1600
+ const loadApiKeyProviders = useCallback(() => {
1601
+ fetch("/api/auth/all-providers")
1602
+ .then((r) => r.json())
1603
+ .then((d: { providers: ApiKeyProvider[] }) => setApiKeyProviders(d.providers))
1604
+ .catch(() => {});
1605
+ }, []);
1606
+
1607
+ useEffect(() => {
1608
+ fetch("/api/models-config")
1609
+ .then((r) => r.json())
1610
+ .then((d: ModelsJson) => {
1611
+ const normalized = d.providers ? d : { ...d, providers: {} };
1612
+ setConfig(normalized);
1613
+ const keys = Object.keys(normalized.providers ?? {});
1614
+ if (keys.length > 0) setSelection({ type: "provider", name: keys[0] });
1615
+ })
1616
+ .catch(() => setConfig({ providers: {} }))
1617
+ .finally(() => setLoading(false));
1618
+ loadOAuthProviders();
1619
+ loadApiKeyProviders();
1620
+ }, [loadOAuthProviders, loadApiKeyProviders]);
1621
+
1622
+ const addCustomProvider = useCallback(() => {
1623
+ let finalName = "custom";
1624
+ let n = 1;
1625
+ while (config.providers?.[finalName]) finalName = `custom-${n++}`;
1626
+ setConfig((prev) => ({ ...prev, providers: { ...(prev.providers ?? {}), [finalName]: { baseUrl: "" } } }));
1627
+ setSelection({ type: "provider", name: finalName });
1628
+ }, [config.providers]);
1629
+
1630
+ const updateProvider = useCallback((name: string, p: ProviderEntry) => {
1631
+ setConfig((prev) => ({ ...prev, providers: { ...(prev.providers ?? {}), [name]: p } }));
1632
+ }, []);
1633
+
1634
+ const renameProvider = useCallback((oldName: string, newName: string) => {
1635
+ setConfig((prev) => {
1636
+ const entries = Object.entries(prev.providers ?? {});
1637
+ const idx = entries.findIndex(([k]) => k === oldName);
1638
+ if (idx === -1) return prev;
1639
+ entries[idx] = [newName, entries[idx][1]];
1640
+ return { ...prev, providers: Object.fromEntries(entries) };
1641
+ });
1642
+ setSelection((prev) => {
1643
+ if (!prev) return prev;
1644
+ if (prev.type === "provider" && prev.name === oldName) return { type: "provider", name: newName };
1645
+ if (prev.type === "model" && prev.providerName === oldName) return { ...prev, providerName: newName };
1646
+ return prev;
1647
+ });
1648
+ }, []);
1649
+
1650
+ const deleteProvider = useCallback((name: string) => {
1651
+ setConfig((prev) => {
1652
+ const providers = { ...(prev.providers ?? {}) };
1653
+ delete providers[name];
1654
+ return { ...prev, providers };
1655
+ });
1656
+ setConfig((prev) => {
1657
+ const remaining = Object.keys(prev.providers ?? {});
1658
+ setSelection(remaining.length > 0 ? { type: "provider", name: remaining[0] } : null);
1659
+ return prev;
1660
+ });
1661
+ }, []);
1662
+
1663
+ const addModel = useCallback((providerName: string) => {
1664
+ setConfig((prev) => {
1665
+ const provider = prev.providers?.[providerName] ?? {};
1666
+ const models = [...(provider.models ?? []), { id: "" }];
1667
+ return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } };
1668
+ });
1669
+ setConfig((prev) => {
1670
+ const idx = (prev.providers?.[providerName]?.models?.length ?? 1) - 1;
1671
+ setSelection({ type: "model", providerName, index: idx });
1672
+ return prev;
1673
+ });
1674
+ }, []);
1675
+
1676
+ const updateModel = useCallback((providerName: string, index: number, m: ModelEntry) => {
1677
+ setConfig((prev) => {
1678
+ const provider = prev.providers?.[providerName] ?? {};
1679
+ const models = [...(provider.models ?? [])];
1680
+ models[index] = m;
1681
+ return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } };
1682
+ });
1683
+ }, []);
1684
+
1685
+ const removeModel = useCallback((providerName: string, index: number) => {
1686
+ setConfig((prev) => {
1687
+ const provider = prev.providers?.[providerName] ?? {};
1688
+ const models = [...(provider.models ?? [])];
1689
+ models.splice(index, 1);
1690
+ return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models: models.length ? models : undefined } } };
1691
+ });
1692
+ setSelection({ type: "provider", name: providerName });
1693
+ }, []);
1694
+
1695
+ const importDiscoveredProvider = useCallback((providerName: string, provider: ProviderEntry, selectedCount: number) => {
1696
+ setConfig((prev) => ({
1697
+ ...prev,
1698
+ providers: {
1699
+ ...(prev.providers ?? {}),
1700
+ [providerName]: provider,
1701
+ },
1702
+ }));
1703
+ setSelection({ type: "provider", name: providerName });
1704
+ setSavedOk(false);
1705
+ setSaveError(null);
1706
+ setSaveNotice(`${selectedCount} model${selectedCount === 1 ? "" : "s"} imported. Click Save to write providers.json.`);
1707
+ }, []);
1708
+
1709
+ const handleSave = useCallback(async () => {
1710
+ setSaving(true);
1711
+ setSaveError(null);
1712
+ setSaveNotice(null);
1713
+ setSavedOk(false);
1714
+ try {
1715
+ const res = await fetch("/api/models-config", {
1716
+ method: "PUT",
1717
+ headers: { "Content-Type": "application/json" },
1718
+ body: JSON.stringify({
1719
+ providers: config.providers ?? {},
1720
+ }),
1721
+ });
1722
+ const d = await res.json() as { success?: boolean; error?: string };
1723
+ if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`);
1724
+ else { setSavedOk(true); setTimeout(() => setSavedOk(false), 2000); }
1725
+ } catch (e) {
1726
+ setSaveError(String(e));
1727
+ } finally {
1728
+ setSaving(false);
1729
+ }
1730
+ }, [config]);
1731
+
1732
+ const providers = Object.entries(config.providers ?? {});
1733
+ const activeOAuth = oauthProviders.filter((p) => p.loggedIn);
1734
+ const activeApiKey = apiKeyProviders.filter((p) => p.configured);
1735
+
1736
+ // Resolve current detail
1737
+ const detailContent = (() => {
1738
+ if (!selection) return null;
1739
+ if (selection.type === "oauth") {
1740
+ const p = oauthProviders.find((p) => p.id === selection.providerId);
1741
+ if (!p) return null;
1742
+ return <OAuthDetail key={p.id} provider={p} onRefresh={loadOAuthProviders} />;
1743
+ }
1744
+ if (selection.type === "apikey") {
1745
+ const p = apiKeyProviders.find((p) => p.id === selection.providerId);
1746
+ if (!p) return null;
1747
+ return <ApiKeyDetail key={p.id} provider={p} onRefresh={loadApiKeyProviders} />;
1748
+ }
1749
+ if (selection.type === "provider") {
1750
+ const provider = config.providers?.[selection.name];
1751
+ if (!provider) return null;
1752
+ return (
1753
+ <ProviderDetail
1754
+ key={selection.name}
1755
+ name={selection.name}
1756
+ provider={provider}
1757
+ onChange={(p) => updateProvider(selection.name, p)}
1758
+ onRename={(n) => renameProvider(selection.name, n)}
1759
+ onDelete={() => deleteProvider(selection.name)}
1760
+ />
1761
+ );
1762
+ }
1763
+ const provider = config.providers?.[selection.providerName];
1764
+ const model = provider?.models?.[selection.index];
1765
+ if (!model) return null;
1766
+ return (
1767
+ <ModelDetail
1768
+ key={`${selection.providerName}-${selection.index}`}
1769
+ providerName={selection.providerName}
1770
+ provider={provider}
1771
+ model={model}
1772
+ onChange={(m) => updateModel(selection.providerName, selection.index, m)}
1773
+ onDelete={() => removeModel(selection.providerName, selection.index)}
1774
+ />
1775
+ );
1776
+ })();
1777
+
1778
+ return (
1779
+ <>
1780
+ <div
1781
+ style={embedded
1782
+ ? { height: "100%", minHeight: 0, display: "flex", alignItems: "stretch", justifyContent: "stretch" }
1783
+ : { position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
1784
+ onClick={(e) => { if (!embedded && e.target === e.currentTarget) onClose(); }}>
1785
+ <div style={{
1786
+ width: embedded ? "100%" : 860,
1787
+ height: embedded ? "100%" : "78vh",
1788
+ background: "var(--bg)",
1789
+ border: embedded ? "none" : "1px solid var(--border)",
1790
+ borderRadius: embedded ? 0 : 10,
1791
+ display: "flex",
1792
+ flexDirection: "column",
1793
+ boxShadow: embedded ? "none" : "0 8px 32px rgba(0,0,0,0.18)",
1794
+ overflow: "hidden",
1795
+ }}>
1796
+
1797
+ {/* Header */}
1798
+ {!embedded && <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
1799
+ <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
1800
+ <span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>Models</span>
1801
+ <code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>~/.config/annodex/providers.json</code>
1802
+ </div>
1803
+ <button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>×</button>
1804
+ </div>}
1805
+
1806
+ {/* Body */}
1807
+ <div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
1808
+
1809
+ {/* Left: tree */}
1810
+ <div style={{ width: 210, borderRight: "1px solid var(--border)", display: "flex", flexDirection: "column", flexShrink: 0, background: "var(--bg-panel)" }}>
1811
+ <div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
1812
+ {/* Active OAuth subscriptions */}
1813
+ {activeOAuth.map((p) => {
1814
+ const isSelected = selection?.type === "oauth" && selection.providerId === p.id;
1815
+ return (
1816
+ <div
1817
+ key={p.id}
1818
+ onClick={() => setSelection({ type: "oauth", providerId: p.id })}
1819
+ style={{ display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
1820
+ onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }}
1821
+ onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }}
1822
+ >
1823
+ <ProviderIcon id={p.id} size={16} />
1824
+ <span style={{ fontSize: 12, color: "var(--text)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</span>
1825
+ </div>
1826
+ );
1827
+ })}
1828
+
1829
+ {/* Active API key providers */}
1830
+ {activeApiKey.map((p) => {
1831
+ const isSelected = selection?.type === "apikey" && selection.providerId === p.id;
1832
+ return (
1833
+ <div
1834
+ key={p.id}
1835
+ onClick={() => setSelection({ type: "apikey", providerId: p.id })}
1836
+ style={{ display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
1837
+ onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }}
1838
+ onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }}
1839
+ >
1840
+ <ProviderIcon id={p.id} size={16} />
1841
+ <span style={{ fontSize: 12, color: "var(--text)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.displayName}</span>
1842
+ </div>
1843
+ );
1844
+ })}
1845
+
1846
+ {/* Divider before custom providers, only when there are active managed providers */}
1847
+ {(activeOAuth.length > 0 || activeApiKey.length > 0) && providers.length > 0 && (
1848
+ <div style={{ margin: "4px 8px", borderTop: "1px solid var(--border)" }} />
1849
+ )}
1850
+
1851
+ {/* Custom providers */}
1852
+ {loading ? (
1853
+ <div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>Loading…</div>
1854
+ ) : providers.map(([pName, pData]) => {
1855
+ const isProviderSelected = selection?.type === "provider" && selection.name === pName;
1856
+ const models = pData.models ?? [];
1857
+ return (
1858
+ <div key={pName} style={{ marginBottom: 2 }}>
1859
+ {/* Provider row */}
1860
+ <div
1861
+ onClick={() => setSelection({ type: "provider", name: pName })}
1862
+ style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 8px", borderRadius: 5, cursor: "pointer", background: isProviderSelected ? "var(--bg-selected)" : "none" }}
1863
+ onMouseEnter={(e) => { if (!isProviderSelected) e.currentTarget.style.background = "var(--bg-hover)"; }}
1864
+ onMouseLeave={(e) => { if (!isProviderSelected) e.currentTarget.style.background = "none"; }}
1865
+ >
1866
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-dim)", flexShrink: 0 }}>
1867
+ <rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" />
1868
+ <line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
1869
+ <line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
1870
+ <line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
1871
+ <line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
1872
+ </svg>
1873
+ <span style={{ fontSize: 12, fontWeight: isProviderSelected ? 600 : 400, color: "var(--text)", fontFamily: "var(--font-mono)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
1874
+ {pName}
1875
+ </span>
1876
+ </div>
1877
+
1878
+ {/* Model rows */}
1879
+ {models.map((m, i) => {
1880
+ const isModelSelected = selection?.type === "model" && selection.providerName === pName && selection.index === i;
1881
+ return (
1882
+ <div
1883
+ key={i}
1884
+ onClick={() => setSelection({ type: "model", providerName: pName, index: i })}
1885
+ style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 8px 5px 26px", borderRadius: 5, cursor: "pointer", background: isModelSelected ? "var(--bg-selected)" : "none" }}
1886
+ onMouseEnter={(e) => { if (!isModelSelected) e.currentTarget.style.background = "var(--bg-hover)"; }}
1887
+ onMouseLeave={(e) => { if (!isModelSelected) e.currentTarget.style.background = "none"; }}
1888
+ >
1889
+ <span style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: m.id ? "var(--text-muted)" : "var(--text-dim)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
1890
+ {m.id || "new model"}
1891
+ </span>
1892
+ {m.reasoning && (
1893
+ <span style={{ fontSize: 9, padding: "1px 4px", background: "rgba(99,102,241,0.12)", color: "rgba(99,102,241,0.8)", borderRadius: 3, flexShrink: 0 }}>T</span>
1894
+ )}
1895
+ </div>
1896
+ );
1897
+ })}
1898
+
1899
+ {/* Add model button */}
1900
+ <div
1901
+ onClick={(e) => { e.stopPropagation(); addModel(pName); }}
1902
+ style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 8px 4px 26px", borderRadius: 5, cursor: "pointer", color: "var(--text-dim)" }}
1903
+ onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
1904
+ onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; e.currentTarget.style.background = "none"; }}
1905
+ >
1906
+ <span style={{ fontSize: 11 }}>+ model</span>
1907
+ </div>
1908
+ </div>
1909
+ );
1910
+ })}
1911
+ </div>
1912
+
1913
+ {/* Add provider */}
1914
+ <div style={{ borderTop: "1px solid var(--border)", padding: "8px 6px" }}>
1915
+ <button onClick={() => setPickerOpen(true)} style={{
1916
+ display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
1917
+ width: "100%", padding: "6px 0", background: "none", border: "1px dashed var(--border)", borderRadius: 5,
1918
+ color: "var(--text-muted)", cursor: "pointer", fontSize: 12,
1919
+ }}
1920
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.color = "var(--accent)"; }}
1921
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--text-muted)"; }}
1922
+ >
1923
+ + Add provider
1924
+ </button>
1925
+ </div>
1926
+ </div>
1927
+
1928
+ {/* Right: detail */}
1929
+ <div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
1930
+ {loading ? null : detailContent ?? (
1931
+ <div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>
1932
+ Select a provider or model
1933
+ </div>
1934
+ )}
1935
+ </div>
1936
+ </div>
1937
+
1938
+ {/* Footer */}
1939
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 10, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
1940
+ {saveError ? (
1941
+ <span style={{ fontSize: 12, color: "#f87171", flex: 1 }}>{saveError}</span>
1942
+ ) : saveNotice ? (
1943
+ <span style={{ fontSize: 12, color: "var(--text-muted)", flex: 1 }}>{saveNotice}</span>
1944
+ ) : null}
1945
+ <button onClick={onClose} style={{ padding: "6px 14px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", fontSize: 13 }}>
1946
+ Cancel
1947
+ </button>
1948
+ <button onClick={handleSave} disabled={saving || savedOk} style={{
1949
+ position: "relative",
1950
+ padding: "6px 16px",
1951
+ minWidth: 92,
1952
+ background: savedOk ? "#16a34a" : saving ? "var(--bg-panel)" : "var(--accent)",
1953
+ border: "none", borderRadius: 6,
1954
+ color: savedOk ? "#fff" : saving ? "var(--text-muted)" : "#fff",
1955
+ cursor: (saving || savedOk) ? "default" : "pointer", fontSize: 13, fontWeight: 600,
1956
+ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
1957
+ transition: "background-color 0.2s ease, color 0.2s ease",
1958
+ animation: savedOk ? "saved-pop 0.45s ease" : undefined,
1959
+ }}>
1960
+ {savedOk && (
1961
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
1962
+ style={{ strokeDasharray: 18, animation: "saved-check-draw 0.35s ease forwards", flexShrink: 0 }}>
1963
+ <polyline points="20 6 9 17 4 12" />
1964
+ </svg>
1965
+ )}
1966
+ <span>{savedOk ? "Saved" : saving ? "Saving…" : "Save"}</span>
1967
+ </button>
1968
+ </div>
1969
+ </div>
1970
+ </div>
1971
+ {pickerOpen && (
1972
+ <AddProviderPicker
1973
+ oauthProviders={oauthProviders}
1974
+ apiKeyProviders={apiKeyProviders}
1975
+ onSelectOAuth={(id) => setSelection({ type: "oauth", providerId: id })}
1976
+ onSelectApiKey={(id) => setSelection({ type: "apikey", providerId: id })}
1977
+ onAddCustom={addCustomProvider}
1978
+ onDiscover={() => setDiscoverOpen(true)}
1979
+ onClose={() => setPickerOpen(false)}
1980
+ />
1981
+ )}
1982
+ {discoverOpen && (
1983
+ <DiscoverModelsDialog
1984
+ existingProviders={config.providers ?? {}}
1985
+ onImport={importDiscoveredProvider}
1986
+ onClose={() => setDiscoverOpen(false)}
1987
+ />
1988
+ )}
1989
+ </>
1990
+ );
1991
+ }