wyxrouter 0.4.71

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 (763) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/LICENSE +21 -0
  3. package/README.md +96 -0
  4. package/README.zh-CN.md +1311 -0
  5. package/assets/pixel-router-2d.png +0 -0
  6. package/cli/LICENSE +42 -0
  7. package/cli/README.md +125 -0
  8. package/cli/app/package.json +58 -0
  9. package/cli/cli.js +806 -0
  10. package/cli/package.json +48 -0
  11. package/i18n/README.ja-JP.md +1209 -0
  12. package/i18n/README.ru.md +1311 -0
  13. package/i18n/README.vi.md +1310 -0
  14. package/i18n/README.zh-CN.md +1306 -0
  15. package/images/9router.png +0 -0
  16. package/jsconfig.json +12 -0
  17. package/next.config.mjs +71 -0
  18. package/open-sse/config/appConstants.js +203 -0
  19. package/open-sse/config/codexInstructions.js +119 -0
  20. package/open-sse/config/constants.js +4 -0
  21. package/open-sse/config/defaultThinkingSignature.js +12 -0
  22. package/open-sse/config/errorConfig.js +85 -0
  23. package/open-sse/config/googleTtsLanguages.js +62 -0
  24. package/open-sse/config/kiroConstants.js +322 -0
  25. package/open-sse/config/models.js +13 -0
  26. package/open-sse/config/ollamaModels.js +19 -0
  27. package/open-sse/config/providerModels.js +944 -0
  28. package/open-sse/config/providers.js +458 -0
  29. package/open-sse/config/runtimeConfig.js +72 -0
  30. package/open-sse/config/ttsModels.js +129 -0
  31. package/open-sse/executors/antigravity.js +504 -0
  32. package/open-sse/executors/azure.js +57 -0
  33. package/open-sse/executors/base.js +185 -0
  34. package/open-sse/executors/codex.js +469 -0
  35. package/open-sse/executors/commandcode.js +88 -0
  36. package/open-sse/executors/cursor.js +795 -0
  37. package/open-sse/executors/default.js +497 -0
  38. package/open-sse/executors/gemini-cli.js +89 -0
  39. package/open-sse/executors/github.js +379 -0
  40. package/open-sse/executors/grok-web.js +345 -0
  41. package/open-sse/executors/iflow.js +108 -0
  42. package/open-sse/executors/index.js +75 -0
  43. package/open-sse/executors/kiro.js +508 -0
  44. package/open-sse/executors/ollama-local.js +14 -0
  45. package/open-sse/executors/opencode-go.js +41 -0
  46. package/open-sse/executors/opencode.js +32 -0
  47. package/open-sse/executors/perplexity-web.js +507 -0
  48. package/open-sse/executors/qoder.js +450 -0
  49. package/open-sse/executors/qwen.js +129 -0
  50. package/open-sse/executors/vertex.js +131 -0
  51. package/open-sse/executors/xiaomi-tokenplan.js +19 -0
  52. package/open-sse/handlers/chatCore/nonStreamingHandler.js +230 -0
  53. package/open-sse/handlers/chatCore/requestDetail.js +102 -0
  54. package/open-sse/handlers/chatCore/sseToJsonHandler.js +231 -0
  55. package/open-sse/handlers/chatCore/streamingHandler.js +103 -0
  56. package/open-sse/handlers/chatCore.js +287 -0
  57. package/open-sse/handlers/embeddingProviders/_base.js +4 -0
  58. package/open-sse/handlers/embeddingProviders/gemini.js +54 -0
  59. package/open-sse/handlers/embeddingProviders/index.js +23 -0
  60. package/open-sse/handlers/embeddingProviders/openai.js +39 -0
  61. package/open-sse/handlers/embeddingProviders/openaiCompatNode.js +13 -0
  62. package/open-sse/handlers/embeddingsCore.js +126 -0
  63. package/open-sse/handlers/fetch/index.js +237 -0
  64. package/open-sse/handlers/imageGenerationCore.js +189 -0
  65. package/open-sse/handlers/imageProviders/_base.js +31 -0
  66. package/open-sse/handlers/imageProviders/blackForestLabs.js +43 -0
  67. package/open-sse/handlers/imageProviders/cloudflareAi.js +178 -0
  68. package/open-sse/handlers/imageProviders/codex.js +198 -0
  69. package/open-sse/handlers/imageProviders/comfyui.js +8 -0
  70. package/open-sse/handlers/imageProviders/falAi.js +41 -0
  71. package/open-sse/handlers/imageProviders/gemini.js +25 -0
  72. package/open-sse/handlers/imageProviders/huggingface.js +22 -0
  73. package/open-sse/handlers/imageProviders/index.js +40 -0
  74. package/open-sse/handlers/imageProviders/nanobanana.js +58 -0
  75. package/open-sse/handlers/imageProviders/openai.js +40 -0
  76. package/open-sse/handlers/imageProviders/runwayml.js +47 -0
  77. package/open-sse/handlers/imageProviders/sdwebui.js +17 -0
  78. package/open-sse/handlers/imageProviders/stabilityAi.js +34 -0
  79. package/open-sse/handlers/responsesHandler.js +103 -0
  80. package/open-sse/handlers/search/callers.js +371 -0
  81. package/open-sse/handlers/search/chatSearch.js +409 -0
  82. package/open-sse/handlers/search/index.js +201 -0
  83. package/open-sse/handlers/search/normalizers.js +223 -0
  84. package/open-sse/handlers/sttCore.js +194 -0
  85. package/open-sse/handlers/ttsCore.js +74 -0
  86. package/open-sse/handlers/ttsProviders/_base.js +39 -0
  87. package/open-sse/handlers/ttsProviders/edgeTts.js +89 -0
  88. package/open-sse/handlers/ttsProviders/elevenlabs.js +48 -0
  89. package/open-sse/handlers/ttsProviders/gemini.js +117 -0
  90. package/open-sse/handlers/ttsProviders/genericFormats.js +169 -0
  91. package/open-sse/handlers/ttsProviders/googleTts.js +54 -0
  92. package/open-sse/handlers/ttsProviders/index.js +50 -0
  93. package/open-sse/handlers/ttsProviders/localDevice.js +87 -0
  94. package/open-sse/handlers/ttsProviders/minimax.js +59 -0
  95. package/open-sse/handlers/ttsProviders/openai.js +30 -0
  96. package/open-sse/handlers/ttsProviders/openrouter.js +70 -0
  97. package/open-sse/index.js +82 -0
  98. package/open-sse/rtk/applyFilter.js +15 -0
  99. package/open-sse/rtk/autodetect.js +111 -0
  100. package/open-sse/rtk/caveman.js +100 -0
  101. package/open-sse/rtk/cavemanPrompts.js +78 -0
  102. package/open-sse/rtk/constants.js +55 -0
  103. package/open-sse/rtk/filters/buildOutput.js +127 -0
  104. package/open-sse/rtk/filters/dedupLog.js +44 -0
  105. package/open-sse/rtk/filters/find.js +49 -0
  106. package/open-sse/rtk/filters/gitDiff.js +92 -0
  107. package/open-sse/rtk/filters/gitStatus.js +117 -0
  108. package/open-sse/rtk/filters/grep.js +48 -0
  109. package/open-sse/rtk/filters/ls.js +79 -0
  110. package/open-sse/rtk/filters/readNumbered.js +27 -0
  111. package/open-sse/rtk/filters/searchList.js +52 -0
  112. package/open-sse/rtk/filters/smartTruncate.js +15 -0
  113. package/open-sse/rtk/filters/tree.js +32 -0
  114. package/open-sse/rtk/index.js +155 -0
  115. package/open-sse/rtk/registry.js +38 -0
  116. package/open-sse/services/accountFallback.js +238 -0
  117. package/open-sse/services/combo.js +198 -0
  118. package/open-sse/services/compact.js +71 -0
  119. package/open-sse/services/kiroModels.js +332 -0
  120. package/open-sse/services/model.js +261 -0
  121. package/open-sse/services/oauthCredentialManager.js +151 -0
  122. package/open-sse/services/projectId.js +306 -0
  123. package/open-sse/services/provider.js +356 -0
  124. package/open-sse/services/qoderModels.js +214 -0
  125. package/open-sse/services/tokenRefresh.js +939 -0
  126. package/open-sse/services/usage.js +1496 -0
  127. package/open-sse/transformer/responsesTransformer.js +439 -0
  128. package/open-sse/transformer/streamToJsonConverter.js +103 -0
  129. package/open-sse/translator/formats.js +36 -0
  130. package/open-sse/translator/helpers/claudeHelper.js +216 -0
  131. package/open-sse/translator/helpers/geminiHelper.js +372 -0
  132. package/open-sse/translator/helpers/imageHelper.js +34 -0
  133. package/open-sse/translator/helpers/maxTokensHelper.js +27 -0
  134. package/open-sse/translator/helpers/openaiHelper.js +130 -0
  135. package/open-sse/translator/helpers/responsesApiHelper.js +139 -0
  136. package/open-sse/translator/helpers/toolCallHelper.js +148 -0
  137. package/open-sse/translator/index.js +251 -0
  138. package/open-sse/translator/request/antigravity-to-openai.js +229 -0
  139. package/open-sse/translator/request/claude-to-openai.js +232 -0
  140. package/open-sse/translator/request/gemini-to-openai.js +147 -0
  141. package/open-sse/translator/request/openai-responses.js +318 -0
  142. package/open-sse/translator/request/openai-to-claude.js +401 -0
  143. package/open-sse/translator/request/openai-to-commandcode.js +170 -0
  144. package/open-sse/translator/request/openai-to-cursor.js +183 -0
  145. package/open-sse/translator/request/openai-to-gemini.js +470 -0
  146. package/open-sse/translator/request/openai-to-kiro.js +629 -0
  147. package/open-sse/translator/request/openai-to-kiro.old.js +278 -0
  148. package/open-sse/translator/request/openai-to-ollama.js +192 -0
  149. package/open-sse/translator/request/openai-to-vertex.js +42 -0
  150. package/open-sse/translator/response/claude-to-openai.js +206 -0
  151. package/open-sse/translator/response/commandcode-to-openai.js +197 -0
  152. package/open-sse/translator/response/cursor-to-openai.js +30 -0
  153. package/open-sse/translator/response/gemini-to-openai.js +245 -0
  154. package/open-sse/translator/response/kiro-to-openai.js +195 -0
  155. package/open-sse/translator/response/ollama-to-openai.js +152 -0
  156. package/open-sse/translator/response/openai-responses.js +590 -0
  157. package/open-sse/translator/response/openai-to-antigravity.js +122 -0
  158. package/open-sse/translator/response/openai-to-claude.js +266 -0
  159. package/open-sse/utils/bypassHandler.js +298 -0
  160. package/open-sse/utils/claudeCloaking.js +155 -0
  161. package/open-sse/utils/claudeHeaderCache.js +70 -0
  162. package/open-sse/utils/clientDetector.js +63 -0
  163. package/open-sse/utils/cursorChecksum.js +149 -0
  164. package/open-sse/utils/cursorProtobuf.js +904 -0
  165. package/open-sse/utils/debugLog.js +14 -0
  166. package/open-sse/utils/error.js +147 -0
  167. package/open-sse/utils/ollamaTransform.js +85 -0
  168. package/open-sse/utils/proxyFetch.js +368 -0
  169. package/open-sse/utils/reasoningContentInjector.js +79 -0
  170. package/open-sse/utils/requestLogger.js +260 -0
  171. package/open-sse/utils/responsesStreamHelpers.js +49 -0
  172. package/open-sse/utils/sessionManager.js +82 -0
  173. package/open-sse/utils/stream.js +462 -0
  174. package/open-sse/utils/streamHandler.js +250 -0
  175. package/open-sse/utils/streamHelpers.js +122 -0
  176. package/open-sse/utils/toolDeduper.js +49 -0
  177. package/open-sse/utils/usageTracking.js +347 -0
  178. package/package.json +100 -0
  179. package/postcss.config.mjs +12 -0
  180. package/public/favicon.svg +11 -0
  181. package/public/file.svg +1 -0
  182. package/public/globe.svg +1 -0
  183. package/public/i18n/literals/ar.json +195 -0
  184. package/public/i18n/literals/bn.json +195 -0
  185. package/public/i18n/literals/cs.json +195 -0
  186. package/public/i18n/literals/da.json +195 -0
  187. package/public/i18n/literals/de.json +195 -0
  188. package/public/i18n/literals/el.json +195 -0
  189. package/public/i18n/literals/es.json +195 -0
  190. package/public/i18n/literals/fi.json +195 -0
  191. package/public/i18n/literals/fr.json +195 -0
  192. package/public/i18n/literals/he.json +195 -0
  193. package/public/i18n/literals/hi.json +195 -0
  194. package/public/i18n/literals/hu.json +195 -0
  195. package/public/i18n/literals/id.json +195 -0
  196. package/public/i18n/literals/it.json +195 -0
  197. package/public/i18n/literals/ja.json +195 -0
  198. package/public/i18n/literals/ko.json +195 -0
  199. package/public/i18n/literals/nl.json +195 -0
  200. package/public/i18n/literals/no.json +195 -0
  201. package/public/i18n/literals/pl.json +195 -0
  202. package/public/i18n/literals/pt-BR.json +195 -0
  203. package/public/i18n/literals/pt-PT.json +195 -0
  204. package/public/i18n/literals/ro.json +195 -0
  205. package/public/i18n/literals/ru.json +195 -0
  206. package/public/i18n/literals/sv.json +195 -0
  207. package/public/i18n/literals/th.json +195 -0
  208. package/public/i18n/literals/tl.json +195 -0
  209. package/public/i18n/literals/tr.json +195 -0
  210. package/public/i18n/literals/uk.json +195 -0
  211. package/public/i18n/literals/ur.json +195 -0
  212. package/public/i18n/literals/vi.json +195 -0
  213. package/public/i18n/literals/zh-CN.json +772 -0
  214. package/public/i18n/literals/zh-TW.json +195 -0
  215. package/public/icons/discord.svg +4 -0
  216. package/public/icons/icon-192.svg +4 -0
  217. package/public/icons/icon-512.svg +4 -0
  218. package/public/next.svg +1 -0
  219. package/public/providers/alicode-intl.png +0 -0
  220. package/public/providers/alicode.png +0 -0
  221. package/public/providers/amp.png +0 -0
  222. package/public/providers/anthropic-m.png +0 -0
  223. package/public/providers/anthropic.png +0 -0
  224. package/public/providers/antigravity.png +0 -0
  225. package/public/providers/assemblyai.png +0 -0
  226. package/public/providers/aws-polly.png +0 -0
  227. package/public/providers/azure.png +0 -0
  228. package/public/providers/black-forest-labs.png +0 -0
  229. package/public/providers/blackbox.png +0 -0
  230. package/public/providers/brave-search.png +0 -0
  231. package/public/providers/byteplus.png +0 -0
  232. package/public/providers/cartesia.png +0 -0
  233. package/public/providers/cerebras.png +0 -0
  234. package/public/providers/chutes.png +0 -0
  235. package/public/providers/claude.png +0 -0
  236. package/public/providers/cline.png +0 -0
  237. package/public/providers/cloudflare-ai.png +0 -0
  238. package/public/providers/codebuddy.svg +37 -0
  239. package/public/providers/codex.png +0 -0
  240. package/public/providers/cohere.png +0 -0
  241. package/public/providers/comfyui.png +0 -0
  242. package/public/providers/commandcode.png +0 -0
  243. package/public/providers/continue.png +0 -0
  244. package/public/providers/copilot.png +0 -0
  245. package/public/providers/coqui.png +0 -0
  246. package/public/providers/cursor.png +0 -0
  247. package/public/providers/deepgram.png +0 -0
  248. package/public/providers/deepseek-tui.png +0 -0
  249. package/public/providers/deepseek.png +0 -0
  250. package/public/providers/droid.png +0 -0
  251. package/public/providers/edge-tts.png +0 -0
  252. package/public/providers/elevenlabs.png +0 -0
  253. package/public/providers/exa.png +0 -0
  254. package/public/providers/fal-ai.png +0 -0
  255. package/public/providers/firecrawl.png +0 -0
  256. package/public/providers/fireworks.png +0 -0
  257. package/public/providers/gemini-cli.png +0 -0
  258. package/public/providers/gemini.png +0 -0
  259. package/public/providers/github.png +0 -0
  260. package/public/providers/glm-cn.png +0 -0
  261. package/public/providers/glm.png +0 -0
  262. package/public/providers/google-pse.png +0 -0
  263. package/public/providers/google-tts.png +0 -0
  264. package/public/providers/grok-web.png +0 -0
  265. package/public/providers/groq.png +0 -0
  266. package/public/providers/hermes.png +0 -0
  267. package/public/providers/huggingface.png +0 -0
  268. package/public/providers/hyperbolic.png +0 -0
  269. package/public/providers/iflow.png +0 -0
  270. package/public/providers/inworld.png +0 -0
  271. package/public/providers/jcode.png +0 -0
  272. package/public/providers/jina-ai.png +0 -0
  273. package/public/providers/jina-reader.png +0 -0
  274. package/public/providers/kilocode.png +0 -0
  275. package/public/providers/kimi-coding.png +0 -0
  276. package/public/providers/kimi.png +0 -0
  277. package/public/providers/kiro.png +0 -0
  278. package/public/providers/linkup.png +0 -0
  279. package/public/providers/local-device.png +0 -0
  280. package/public/providers/minimax-cn.png +0 -0
  281. package/public/providers/minimax.png +0 -0
  282. package/public/providers/mistral.png +0 -0
  283. package/public/providers/nanobanana.png +0 -0
  284. package/public/providers/nebius.png +0 -0
  285. package/public/providers/nvidia.png +0 -0
  286. package/public/providers/oai-cc.png +0 -0
  287. package/public/providers/oai-r.png +0 -0
  288. package/public/providers/ollama-local.png +0 -0
  289. package/public/providers/ollama.png +0 -0
  290. package/public/providers/openai.png +0 -0
  291. package/public/providers/openclaw.png +0 -0
  292. package/public/providers/opencode-go.png +0 -0
  293. package/public/providers/opencode.png +0 -0
  294. package/public/providers/openrouter.png +0 -0
  295. package/public/providers/perplexity-web.png +0 -0
  296. package/public/providers/perplexity.png +0 -0
  297. package/public/providers/playht.png +0 -0
  298. package/public/providers/qoder.png +0 -0
  299. package/public/providers/qwen.png +0 -0
  300. package/public/providers/recraft.png +0 -0
  301. package/public/providers/roo.png +0 -0
  302. package/public/providers/runwayml.png +0 -0
  303. package/public/providers/sdwebui.png +0 -0
  304. package/public/providers/searchapi.png +0 -0
  305. package/public/providers/searxng.png +0 -0
  306. package/public/providers/serper.png +0 -0
  307. package/public/providers/siliconflow.png +0 -0
  308. package/public/providers/stability-ai.png +0 -0
  309. package/public/providers/tavily.png +0 -0
  310. package/public/providers/together.png +0 -0
  311. package/public/providers/topaz.png +0 -0
  312. package/public/providers/tortoise.png +0 -0
  313. package/public/providers/vertex-partner.png +0 -0
  314. package/public/providers/vertex.png +0 -0
  315. package/public/providers/volcengine-ark.png +0 -0
  316. package/public/providers/voyage-ai.png +0 -0
  317. package/public/providers/xai.png +0 -0
  318. package/public/providers/xiaomi-mimo.png +0 -0
  319. package/public/providers/xiaomi-tokenplan.png +0 -0
  320. package/public/providers/youcom.png +0 -0
  321. package/public/sw.js +22 -0
  322. package/public/vercel.svg +1 -0
  323. package/public/window.svg +1 -0
  324. package/scripts/compact-request-details.mjs +71 -0
  325. package/scripts/import-codex-gptjson.mjs +342 -0
  326. package/scripts/start-standalone.mjs +25 -0
  327. package/scripts/translate-readme.js +201 -0
  328. package/src/app/(dashboard)/dashboard/automation/page.js +294 -0
  329. package/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +967 -0
  330. package/src/app/(dashboard)/dashboard/basic-chat/page.js +5 -0
  331. package/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +66 -0
  332. package/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +173 -0
  333. package/src/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js +11 -0
  334. package/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +481 -0
  335. package/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js +66 -0
  336. package/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +174 -0
  337. package/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +390 -0
  338. package/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js +301 -0
  339. package/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +458 -0
  340. package/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +323 -0
  341. package/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +640 -0
  342. package/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js +338 -0
  343. package/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +271 -0
  344. package/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +410 -0
  345. package/src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js +128 -0
  346. package/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +317 -0
  347. package/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js +380 -0
  348. package/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js +275 -0
  349. package/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js +40 -0
  350. package/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +329 -0
  351. package/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +318 -0
  352. package/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +388 -0
  353. package/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +500 -0
  354. package/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js +39 -0
  355. package/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js +13 -0
  356. package/src/app/(dashboard)/dashboard/cli-tools/components/index.js +19 -0
  357. package/src/app/(dashboard)/dashboard/cli-tools/page.js +7 -0
  358. package/src/app/(dashboard)/dashboard/combos/page.js +612 -0
  359. package/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js +91 -0
  360. package/src/app/(dashboard)/dashboard/console-log/page.js +8 -0
  361. package/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +1555 -0
  362. package/src/app/(dashboard)/dashboard/endpoint/page.js +7 -0
  363. package/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +1903 -0
  364. package/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +289 -0
  365. package/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +410 -0
  366. package/src/app/(dashboard)/dashboard/media-providers/web/page.js +209 -0
  367. package/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js +117 -0
  368. package/src/app/(dashboard)/dashboard/mitm/page.js +5 -0
  369. package/src/app/(dashboard)/dashboard/page.js +7 -0
  370. package/src/app/(dashboard)/dashboard/profile/page.js +1140 -0
  371. package/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +389 -0
  372. package/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js +125 -0
  373. package/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js +250 -0
  374. package/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +299 -0
  375. package/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js +42 -0
  376. package/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js +161 -0
  377. package/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +95 -0
  378. package/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js +183 -0
  379. package/src/app/(dashboard)/dashboard/providers/[id]/page.js +2053 -0
  380. package/src/app/(dashboard)/dashboard/providers/[id]/page.new.js +1724 -0
  381. package/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js +497 -0
  382. package/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js +185 -0
  383. package/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +294 -0
  384. package/src/app/(dashboard)/dashboard/providers/new/page.js +220 -0
  385. package/src/app/(dashboard)/dashboard/providers/page.js +1365 -0
  386. package/src/app/(dashboard)/dashboard/proxy-pools/page.js +1092 -0
  387. package/src/app/(dashboard)/dashboard/quota/page.js +11 -0
  388. package/src/app/(dashboard)/dashboard/skills/page.js +112 -0
  389. package/src/app/(dashboard)/dashboard/translator/page.js +303 -0
  390. package/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js +35 -0
  391. package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +185 -0
  392. package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js +127 -0
  393. package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +259 -0
  394. package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +1394 -0
  395. package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +244 -0
  396. package/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +327 -0
  397. package/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +433 -0
  398. package/src/app/(dashboard)/dashboard/usage/components/UsageChart.js +141 -0
  399. package/src/app/(dashboard)/dashboard/usage/components/UsageTable.js +247 -0
  400. package/src/app/(dashboard)/dashboard/usage/page.js +75 -0
  401. package/src/app/(dashboard)/layout.js +6 -0
  402. package/src/app/api/auth/login/route.js +76 -0
  403. package/src/app/api/auth/logout/route.js +12 -0
  404. package/src/app/api/auth/oidc/callback/route.js +87 -0
  405. package/src/app/api/auth/oidc/start/route.js +52 -0
  406. package/src/app/api/auth/oidc/test/route.js +84 -0
  407. package/src/app/api/auth/status/route.js +45 -0
  408. package/src/app/api/cli-tools/all-statuses/route.js +46 -0
  409. package/src/app/api/cli-tools/antigravity-mitm/alias/route.js +53 -0
  410. package/src/app/api/cli-tools/antigravity-mitm/route.js +202 -0
  411. package/src/app/api/cli-tools/claude-settings/route.js +203 -0
  412. package/src/app/api/cli-tools/cline-settings/route.js +133 -0
  413. package/src/app/api/cli-tools/codex-gateway/accounts/route.js +16 -0
  414. package/src/app/api/cli-tools/codex-settings/route.js +239 -0
  415. package/src/app/api/cli-tools/copilot-settings/route.js +148 -0
  416. package/src/app/api/cli-tools/cowork-mcp-registry/route.js +77 -0
  417. package/src/app/api/cli-tools/cowork-mcp-tools/route.js +95 -0
  418. package/src/app/api/cli-tools/cowork-settings/route.js +412 -0
  419. package/src/app/api/cli-tools/deepseek-tui-settings/route.js +164 -0
  420. package/src/app/api/cli-tools/droid-settings/route.js +213 -0
  421. package/src/app/api/cli-tools/hermes-settings/route.js +175 -0
  422. package/src/app/api/cli-tools/jcode-settings/route.js +216 -0
  423. package/src/app/api/cli-tools/kilo-settings/route.js +131 -0
  424. package/src/app/api/cli-tools/openclaw-settings/route.js +292 -0
  425. package/src/app/api/cli-tools/opencode-settings/route.js +259 -0
  426. package/src/app/api/combos/[id]/route.js +81 -0
  427. package/src/app/api/combos/route.js +48 -0
  428. package/src/app/api/health/route.js +15 -0
  429. package/src/app/api/init/route.js +4 -0
  430. package/src/app/api/keys/[id]/route.js +58 -0
  431. package/src/app/api/keys/route.js +42 -0
  432. package/src/app/api/locale/route.js +30 -0
  433. package/src/app/api/mcp/[plugin]/message/route.js +21 -0
  434. package/src/app/api/mcp/[plugin]/sse/route.js +37 -0
  435. package/src/app/api/media-providers/tts/deepgram/voices/route.js +65 -0
  436. package/src/app/api/media-providers/tts/elevenlabs/voices/route.js +71 -0
  437. package/src/app/api/media-providers/tts/inworld/voices/route.js +61 -0
  438. package/src/app/api/media-providers/tts/minimax/voices/route.js +113 -0
  439. package/src/app/api/media-providers/tts/voices/route.js +99 -0
  440. package/src/app/api/models/alias/route.js +53 -0
  441. package/src/app/api/models/availability/route.js +103 -0
  442. package/src/app/api/models/custom/route.js +48 -0
  443. package/src/app/api/models/disabled/route.js +50 -0
  444. package/src/app/api/models/route.js +64 -0
  445. package/src/app/api/models/test/ping.js +191 -0
  446. package/src/app/api/models/test/route.js +14 -0
  447. package/src/app/api/oauth/[provider]/[action]/route.js +343 -0
  448. package/src/app/api/oauth/codebuddy/bulk-import/[jobId]/cancel/route.js +19 -0
  449. package/src/app/api/oauth/codebuddy/bulk-import/[jobId]/manual/[workerId]/route.js +30 -0
  450. package/src/app/api/oauth/codebuddy/bulk-import/[jobId]/route.js +23 -0
  451. package/src/app/api/oauth/codebuddy/bulk-import/latest/route.js +25 -0
  452. package/src/app/api/oauth/codebuddy/bulk-import/route.js +49 -0
  453. package/src/app/api/oauth/codebuddy/quota-cookie/route.js +133 -0
  454. package/src/app/api/oauth/codex/import-token/route.js +96 -0
  455. package/src/app/api/oauth/cursor/auto-import/route.js +258 -0
  456. package/src/app/api/oauth/cursor/import/route.js +100 -0
  457. package/src/app/api/oauth/gitlab/pat/route.js +62 -0
  458. package/src/app/api/oauth/iflow/cookie/route.js +137 -0
  459. package/src/app/api/oauth/kiro/auto-import/route.js +85 -0
  460. package/src/app/api/oauth/kiro/bulk-import/[jobId]/cancel/route.js +18 -0
  461. package/src/app/api/oauth/kiro/bulk-import/[jobId]/manual/[workerId]/route.js +29 -0
  462. package/src/app/api/oauth/kiro/bulk-import/[jobId]/route.js +22 -0
  463. package/src/app/api/oauth/kiro/bulk-import/latest/route.js +25 -0
  464. package/src/app/api/oauth/kiro/bulk-import/route.js +49 -0
  465. package/src/app/api/oauth/kiro/import/route.js +110 -0
  466. package/src/app/api/oauth/kiro/social-authorize/route.js +27 -0
  467. package/src/app/api/oauth/kiro/social-exchange/route.js +41 -0
  468. package/src/app/api/pricing/route.js +134 -0
  469. package/src/app/api/provider-nodes/[id]/route.js +101 -0
  470. package/src/app/api/provider-nodes/route.js +104 -0
  471. package/src/app/api/provider-nodes/validate/route.js +201 -0
  472. package/src/app/api/providers/[id]/models/route.js +526 -0
  473. package/src/app/api/providers/[id]/route.js +189 -0
  474. package/src/app/api/providers/[id]/test/route.js +23 -0
  475. package/src/app/api/providers/[id]/test/testUtils.js +714 -0
  476. package/src/app/api/providers/[id]/test-models/route.js +66 -0
  477. package/src/app/api/providers/client/route.js +126 -0
  478. package/src/app/api/providers/kilo/free-models/route.js +55 -0
  479. package/src/app/api/providers/route.js +206 -0
  480. package/src/app/api/providers/suggested-models/filters.js +20 -0
  481. package/src/app/api/providers/suggested-models/route.js +32 -0
  482. package/src/app/api/providers/test-batch/route.js +131 -0
  483. package/src/app/api/providers/validate/route.js +637 -0
  484. package/src/app/api/proxy-pools/[id]/route.js +123 -0
  485. package/src/app/api/proxy-pools/[id]/test/route.js +70 -0
  486. package/src/app/api/proxy-pools/cloudflare-deploy/route.js +145 -0
  487. package/src/app/api/proxy-pools/deno-deploy/route.js +175 -0
  488. package/src/app/api/proxy-pools/route.js +93 -0
  489. package/src/app/api/proxy-pools/vercel-deploy/route.js +142 -0
  490. package/src/app/api/settings/database/route.js +36 -0
  491. package/src/app/api/settings/proxy-test/route.js +23 -0
  492. package/src/app/api/settings/require-login/route.js +15 -0
  493. package/src/app/api/settings/route.js +100 -0
  494. package/src/app/api/shutdown/route.js +24 -0
  495. package/src/app/api/tags/route.js +18 -0
  496. package/src/app/api/translator/console-logs/route.js +24 -0
  497. package/src/app/api/translator/console-logs/stream/route.js +79 -0
  498. package/src/app/api/translator/load/route.js +45 -0
  499. package/src/app/api/translator/save/route.js +44 -0
  500. package/src/app/api/translator/send/route.js +94 -0
  501. package/src/app/api/translator/translate/route.js +90 -0
  502. package/src/app/api/tunnel/disable/route.js +12 -0
  503. package/src/app/api/tunnel/enable/route.js +16 -0
  504. package/src/app/api/tunnel/status/route.js +13 -0
  505. package/src/app/api/tunnel/tailscale-check/route.js +50 -0
  506. package/src/app/api/tunnel/tailscale-disable/route.js +12 -0
  507. package/src/app/api/tunnel/tailscale-enable/route.js +12 -0
  508. package/src/app/api/tunnel/tailscale-install/route.js +72 -0
  509. package/src/app/api/usage/[connectionId]/route.js +188 -0
  510. package/src/app/api/usage/chart/route.js +21 -0
  511. package/src/app/api/usage/history/route.js +12 -0
  512. package/src/app/api/usage/logs/route.js +12 -0
  513. package/src/app/api/usage/providers/route.js +42 -0
  514. package/src/app/api/usage/request-details/route.js +57 -0
  515. package/src/app/api/usage/request-logs/route.js +13 -0
  516. package/src/app/api/usage/stats/route.js +23 -0
  517. package/src/app/api/usage/stream/route.js +79 -0
  518. package/src/app/api/v1/api/chat/route.js +37 -0
  519. package/src/app/api/v1/audio/speech/route.js +16 -0
  520. package/src/app/api/v1/audio/transcriptions/route.js +19 -0
  521. package/src/app/api/v1/audio/voices/route.js +68 -0
  522. package/src/app/api/v1/chat/completions/route.js +35 -0
  523. package/src/app/api/v1/embeddings/route.js +21 -0
  524. package/src/app/api/v1/images/generations/route.js +16 -0
  525. package/src/app/api/v1/messages/count_tokens/route.js +52 -0
  526. package/src/app/api/v1/messages/route.js +36 -0
  527. package/src/app/api/v1/models/[kind]/route.js +55 -0
  528. package/src/app/api/v1/models/info/route.js +110 -0
  529. package/src/app/api/v1/models/route.js +451 -0
  530. package/src/app/api/v1/responses/compact/route.js +37 -0
  531. package/src/app/api/v1/responses/route.js +30 -0
  532. package/src/app/api/v1/route.js +1 -0
  533. package/src/app/api/v1/search/route.js +21 -0
  534. package/src/app/api/v1/web/fetch/route.js +21 -0
  535. package/src/app/api/v1beta/models/[...path]/route.js +328 -0
  536. package/src/app/api/v1beta/models/route.js +44 -0
  537. package/src/app/api/version/route.js +45 -0
  538. package/src/app/api/version/shutdown/route.js +15 -0
  539. package/src/app/api/version/update/route.js +21 -0
  540. package/src/app/callback/page.js +148 -0
  541. package/src/app/dashboard/settings/pricing/page.js +173 -0
  542. package/src/app/favicon.ico +0 -0
  543. package/src/app/globals.css +496 -0
  544. package/src/app/landing/components/AnimatedBackground.js +57 -0
  545. package/src/app/landing/components/Features.js +133 -0
  546. package/src/app/landing/components/FlowAnimation.js +175 -0
  547. package/src/app/landing/components/Footer.js +61 -0
  548. package/src/app/landing/components/GetStarted.js +97 -0
  549. package/src/app/landing/components/HeroSection.js +47 -0
  550. package/src/app/landing/components/HowItWorks.js +66 -0
  551. package/src/app/landing/components/Navigation.js +72 -0
  552. package/src/app/landing/page.js +106 -0
  553. package/src/app/layout.js +49 -0
  554. package/src/app/login/page.js +197 -0
  555. package/src/app/manifest.js +30 -0
  556. package/src/app/page.js +5 -0
  557. package/src/dashboardGuard.js +242 -0
  558. package/src/i18n/RuntimeI18nProvider.js +27 -0
  559. package/src/i18n/config.js +146 -0
  560. package/src/i18n/runtime.js +162 -0
  561. package/src/lib/appUpdater.js +200 -0
  562. package/src/lib/auth/dashboardSession.js +68 -0
  563. package/src/lib/auth/loginLimiter.js +52 -0
  564. package/src/lib/auth/oidc.js +234 -0
  565. package/src/lib/consoleLogBuffer.js +79 -0
  566. package/src/lib/dataDir.js +29 -0
  567. package/src/lib/db/adapters/betterSqliteAdapter.js +55 -0
  568. package/src/lib/db/adapters/bunSqliteAdapter.js +63 -0
  569. package/src/lib/db/adapters/nodeSqliteAdapter.js +84 -0
  570. package/src/lib/db/adapters/sqljsAdapter.js +115 -0
  571. package/src/lib/db/backup.js +35 -0
  572. package/src/lib/db/driver.js +85 -0
  573. package/src/lib/db/helpers/jsonCol.js +9 -0
  574. package/src/lib/db/helpers/kvStore.js +39 -0
  575. package/src/lib/db/helpers/metaStore.js +22 -0
  576. package/src/lib/db/index.js +171 -0
  577. package/src/lib/db/migrate.js +286 -0
  578. package/src/lib/db/migrations/001-initial.js +14 -0
  579. package/src/lib/db/migrations/index.js +10 -0
  580. package/src/lib/db/paths.js +18 -0
  581. package/src/lib/db/repos/aliasRepo.js +62 -0
  582. package/src/lib/db/repos/apiKeysRepo.js +75 -0
  583. package/src/lib/db/repos/combosRepo.js +73 -0
  584. package/src/lib/db/repos/connectionsRepo.js +226 -0
  585. package/src/lib/db/repos/disabledModelsRepo.js +56 -0
  586. package/src/lib/db/repos/nodesRepo.js +95 -0
  587. package/src/lib/db/repos/pricingRepo.js +108 -0
  588. package/src/lib/db/repos/proxyPoolsRepo.js +103 -0
  589. package/src/lib/db/repos/requestDetailsRepo.js +259 -0
  590. package/src/lib/db/repos/settingsRepo.js +104 -0
  591. package/src/lib/db/repos/usageRepo.js +731 -0
  592. package/src/lib/db/schema.js +157 -0
  593. package/src/lib/db/version.js +21 -0
  594. package/src/lib/disabledModelsDb.js +4 -0
  595. package/src/lib/localDb.js +21 -0
  596. package/src/lib/mcp/stdioSseBridge.js +198 -0
  597. package/src/lib/mitmAliasCache.js +46 -0
  598. package/src/lib/network/connectionProxy.js +160 -0
  599. package/src/lib/network/initOutboundProxy.js +25 -0
  600. package/src/lib/network/outboundProxy.js +68 -0
  601. package/src/lib/network/proxyTest.js +91 -0
  602. package/src/lib/oauth/constants/oauth.js +284 -0
  603. package/src/lib/oauth/constants/xai.js +61 -0
  604. package/src/lib/oauth/providers.js +1506 -0
  605. package/src/lib/oauth/services/antigravity.js +321 -0
  606. package/src/lib/oauth/services/claude.js +136 -0
  607. package/src/lib/oauth/services/codebuddyBulkImportManager.js +454 -0
  608. package/src/lib/oauth/services/codex.js +144 -0
  609. package/src/lib/oauth/services/cursor.js +179 -0
  610. package/src/lib/oauth/services/gemini.js +240 -0
  611. package/src/lib/oauth/services/github.js +225 -0
  612. package/src/lib/oauth/services/iflow.js +202 -0
  613. package/src/lib/oauth/services/index.js +17 -0
  614. package/src/lib/oauth/services/kiro.js +334 -0
  615. package/src/lib/oauth/services/kiroBulkImportManager.js +778 -0
  616. package/src/lib/oauth/services/kiroConnections.js +93 -0
  617. package/src/lib/oauth/services/kiroGoogleAutomation.js +1136 -0
  618. package/src/lib/oauth/services/oauth.js +157 -0
  619. package/src/lib/oauth/services/openai.js +123 -0
  620. package/src/lib/oauth/services/qoder.js +216 -0
  621. package/src/lib/oauth/services/qwen.js +170 -0
  622. package/src/lib/oauth/services/xai.js +238 -0
  623. package/src/lib/oauth/utils/banner.js +63 -0
  624. package/src/lib/oauth/utils/pkce.js +39 -0
  625. package/src/lib/oauth/utils/server.js +415 -0
  626. package/src/lib/oauth/utils/ui.js +48 -0
  627. package/src/lib/providerNormalization.js +45 -0
  628. package/src/lib/qoder/constants.js +64 -0
  629. package/src/lib/qoder/cosy.js +175 -0
  630. package/src/lib/qoder/encoding.js +55 -0
  631. package/src/lib/requestDetailsDb.js +4 -0
  632. package/src/lib/tunnel/cloudflare/cloudflared.js +449 -0
  633. package/src/lib/tunnel/cloudflare/config.js +9 -0
  634. package/src/lib/tunnel/cloudflare/healthCheck.js +29 -0
  635. package/src/lib/tunnel/cloudflare/manager.js +151 -0
  636. package/src/lib/tunnel/cloudflare/pid.js +23 -0
  637. package/src/lib/tunnel/index.js +48 -0
  638. package/src/lib/tunnel/shared/dnsResolver.js +17 -0
  639. package/src/lib/tunnel/shared/internetCheck.js +26 -0
  640. package/src/lib/tunnel/shared/state.js +41 -0
  641. package/src/lib/tunnel/shared/watchdogConfig.js +8 -0
  642. package/src/lib/tunnel/tailscale/config.js +7 -0
  643. package/src/lib/tunnel/tailscale/healthCheck.js +29 -0
  644. package/src/lib/tunnel/tailscale/manager.js +129 -0
  645. package/src/lib/tunnel/tailscale/tailscale.js +790 -0
  646. package/src/lib/updater/updater.js +235 -0
  647. package/src/lib/usage/fetcher.js +208 -0
  648. package/src/lib/usageDb.js +7 -0
  649. package/src/mitm/antigravityIdeVersion.js +50 -0
  650. package/src/mitm/cert/generate.js +32 -0
  651. package/src/mitm/cert/install.js +269 -0
  652. package/src/mitm/cert/rootCA.js +173 -0
  653. package/src/mitm/config.js +87 -0
  654. package/src/mitm/dbReader.js +22 -0
  655. package/src/mitm/dns/dnsConfig.js +266 -0
  656. package/src/mitm/handlers/antigravity.js +33 -0
  657. package/src/mitm/handlers/base.js +226 -0
  658. package/src/mitm/handlers/copilot.js +35 -0
  659. package/src/mitm/handlers/cursor.js +15 -0
  660. package/src/mitm/handlers/kiro.js +526 -0
  661. package/src/mitm/logger.js +106 -0
  662. package/src/mitm/manager.js +851 -0
  663. package/src/mitm/paths.js +32 -0
  664. package/src/mitm/server.js +435 -0
  665. package/src/mitm/winElevated.js +81 -0
  666. package/src/models/index.js +38 -0
  667. package/src/proxy.js +5 -0
  668. package/src/shared/components/AddCustomEmbeddingModal.js +183 -0
  669. package/src/shared/components/Avatar.js +88 -0
  670. package/src/shared/components/Badge.js +54 -0
  671. package/src/shared/components/BulkAccountAutomationModal.js +508 -0
  672. package/src/shared/components/Button.js +56 -0
  673. package/src/shared/components/Card.js +116 -0
  674. package/src/shared/components/ChangelogModal.js +97 -0
  675. package/src/shared/components/CodeBuddyQuotaCookieModal.js +109 -0
  676. package/src/shared/components/ComboFormModal.js +176 -0
  677. package/src/shared/components/CursorAuthModal.js +212 -0
  678. package/src/shared/components/DonateModal.js +136 -0
  679. package/src/shared/components/Drawer.js +82 -0
  680. package/src/shared/components/EditConnectionModal.js +286 -0
  681. package/src/shared/components/Footer.js +132 -0
  682. package/src/shared/components/GitLabAuthModal.js +194 -0
  683. package/src/shared/components/Header.js +380 -0
  684. package/src/shared/components/HeaderLanguage.js +46 -0
  685. package/src/shared/components/HeaderMenu.js +126 -0
  686. package/src/shared/components/IFlowCookieModal.js +132 -0
  687. package/src/shared/components/Input.js +65 -0
  688. package/src/shared/components/KiroAuthModal.js +1171 -0
  689. package/src/shared/components/KiroOAuthWrapper.js +149 -0
  690. package/src/shared/components/KiroSocialOAuthModal.js +205 -0
  691. package/src/shared/components/LanguageSwitcher.js +190 -0
  692. package/src/shared/components/Loading.js +75 -0
  693. package/src/shared/components/ManualConfigModal.js +44 -0
  694. package/src/shared/components/McpMarketplaceModal.js +255 -0
  695. package/src/shared/components/Modal.js +146 -0
  696. package/src/shared/components/ModelSelectModal.js +537 -0
  697. package/src/shared/components/NineRemoteButton.js +23 -0
  698. package/src/shared/components/NineRemotePromoModal.js +99 -0
  699. package/src/shared/components/NoAuthProxyCard.js +86 -0
  700. package/src/shared/components/OAuthModal.js +682 -0
  701. package/src/shared/components/Pagination.js +150 -0
  702. package/src/shared/components/PricingModal.js +208 -0
  703. package/src/shared/components/ProviderIcon.js +63 -0
  704. package/src/shared/components/ProviderInfoCard.js +82 -0
  705. package/src/shared/components/RequestLogger.js +121 -0
  706. package/src/shared/components/SegmentedControl.js +48 -0
  707. package/src/shared/components/Select.js +67 -0
  708. package/src/shared/components/Sidebar.js +441 -0
  709. package/src/shared/components/ThemeProvider.js +15 -0
  710. package/src/shared/components/ThemeToggle.js +42 -0
  711. package/src/shared/components/Toggle.js +69 -0
  712. package/src/shared/components/Tooltip.js +25 -0
  713. package/src/shared/components/UsageStats.js +505 -0
  714. package/src/shared/components/index.js +46 -0
  715. package/src/shared/components/layouts/AuthLayout.js +29 -0
  716. package/src/shared/components/layouts/DashboardLayout.js +104 -0
  717. package/src/shared/components/layouts/index.js +4 -0
  718. package/src/shared/constants/cliTools.js +397 -0
  719. package/src/shared/constants/colors.js +77 -0
  720. package/src/shared/constants/config.js +99 -0
  721. package/src/shared/constants/coworkPlugins.js +75 -0
  722. package/src/shared/constants/index.js +4 -0
  723. package/src/shared/constants/locales.js +36 -0
  724. package/src/shared/constants/mitmToolHosts.js +12 -0
  725. package/src/shared/constants/models.js +38 -0
  726. package/src/shared/constants/pricing.js +303 -0
  727. package/src/shared/constants/providers.js +289 -0
  728. package/src/shared/constants/skills.js +78 -0
  729. package/src/shared/constants/ttsProviders.js +138 -0
  730. package/src/shared/hooks/index.js +2 -0
  731. package/src/shared/hooks/useCopyToClipboard.js +43 -0
  732. package/src/shared/hooks/useTheme.js +60 -0
  733. package/src/shared/services/bootstrap.js +12 -0
  734. package/src/shared/services/initializeApp.js +268 -0
  735. package/src/shared/utils/api.js +93 -0
  736. package/src/shared/utils/apiKey.js +98 -0
  737. package/src/shared/utils/clineAuth.js +37 -0
  738. package/src/shared/utils/cn.js +11 -0
  739. package/src/shared/utils/connectionStatus.js +162 -0
  740. package/src/shared/utils/index.js +40 -0
  741. package/src/shared/utils/machine.js +6 -0
  742. package/src/shared/utils/machineId.js +66 -0
  743. package/src/shared/utils/providerModelsFetcher.js +30 -0
  744. package/src/sse/handlers/chat.js +261 -0
  745. package/src/sse/handlers/embeddings.js +141 -0
  746. package/src/sse/handlers/fetch.js +213 -0
  747. package/src/sse/handlers/imageGeneration.js +142 -0
  748. package/src/sse/handlers/search.js +206 -0
  749. package/src/sse/handlers/stt.js +88 -0
  750. package/src/sse/handlers/tts.js +114 -0
  751. package/src/sse/services/auth.js +346 -0
  752. package/src/sse/services/codexGateway.js +215 -0
  753. package/src/sse/services/model.js +99 -0
  754. package/src/sse/services/tokenRefresh.js +319 -0
  755. package/src/sse/utils/logger.js +75 -0
  756. package/src/store/headerSearchStore.js +19 -0
  757. package/src/store/index.js +6 -0
  758. package/src/store/notificationStore.js +45 -0
  759. package/src/store/providerStore.js +55 -0
  760. package/src/store/settingsStore.js +51 -0
  761. package/src/store/themeStore.js +54 -0
  762. package/src/store/userStore.js +20 -0
  763. package/start.sh +4 -0
@@ -0,0 +1,1903 @@
1
+ "use client";
2
+
3
+ import { useParams, notFound, useRouter } from "next/navigation";
4
+ import Link from "next/link";
5
+ import { useState, useEffect } from "react";
6
+ import { Card, Badge, Button, AddCustomEmbeddingModal, NoAuthProxyCard, ProviderInfoCard } from "@/shared/components";
7
+ import ProviderIcon from "@/shared/components/ProviderIcon";
8
+ import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider, resolveProviderId } from "@/shared/constants/providers";
9
+ import { getModelsByProviderId } from "@/shared/constants/models";
10
+ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
11
+ import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
12
+ import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
13
+ import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
14
+ import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
15
+ import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
16
+
17
+ // Shared row layout — defined outside components to avoid re-mount on re-render
18
+ function Row({ label, children }) {
19
+ return (
20
+ <div className="flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
21
+ <span className="w-full text-xs font-medium text-text-muted sm:w-20 sm:shrink-0">{label}</span>
22
+ <div className="w-full min-w-0 flex-1">{children}</div>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
28
+ // Example JSON response (response_format=json):
29
+ {
30
+ "format": "mp3",
31
+ "audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
32
+ }`;
33
+
34
+ const DEFAULT_RESPONSE_EXAMPLE = `{
35
+ "object": "list",
36
+ "data": [{
37
+ "object": "embedding",
38
+ "index": 0,
39
+ "embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...]
40
+ }],
41
+ "model": "...",
42
+ "usage": { "prompt_tokens": 9, "total_tokens": 9 }
43
+ }`;
44
+
45
+ const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png";
46
+ const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png";
47
+
48
+ function getImageEditDefaults(providerId, modelId) {
49
+ if (providerId !== "cloudflare-ai") return {};
50
+ if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
51
+ return { image: CLOUDFLARE_TEST_IMAGE_URL };
52
+ }
53
+ if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") {
54
+ return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL };
55
+ }
56
+ return {};
57
+ }
58
+
59
+ function toImagePreviewSrc(value) {
60
+ const trimmed = typeof value === "string" ? value.trim() : "";
61
+ if (!trimmed) return "";
62
+ if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed;
63
+ return `data:image/png;base64,${trimmed}`;
64
+ }
65
+
66
+ // Config-driven example defaults per kind
67
+ const KIND_EXAMPLE_CONFIG = {
68
+ webSearch: {
69
+ inputLabel: "Query",
70
+ inputPlaceholder: "What is the latest news about AI?",
71
+ defaultInput: "What is the latest news about AI?",
72
+ bodyKey: "query",
73
+ defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`,
74
+ extraFields: [
75
+ { key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] },
76
+ { key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 },
77
+ { key: "country", label: "Country", type: "text", default: "" },
78
+ { key: "language", label: "Language", type: "text", default: "" },
79
+ ],
80
+ },
81
+ webFetch: {
82
+ inputLabel: "URL",
83
+ inputPlaceholder: "https://example.com",
84
+ defaultInput: "https://example.com",
85
+ bodyKey: "url",
86
+ defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`,
87
+ extraFields: [
88
+ { key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] },
89
+ { key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 },
90
+ ],
91
+ },
92
+ image: {
93
+ inputLabel: "Prompt",
94
+ inputPlaceholder: "A cute cat wearing a hat",
95
+ defaultInput: "A cute cat wearing a hat",
96
+ bodyKey: "prompt",
97
+ defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`,
98
+ extraFields: [
99
+ { key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
100
+ { key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] },
101
+ { key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] },
102
+ { key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] },
103
+ { key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
104
+ { key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] },
105
+ { key: "image_detail", label: "Image Detail", type: "select", default: "high", options: ["auto", "low", "high", "original"] },
106
+ { key: "output_format", label: "Codec", type: "select", default: "png", options: ["png", "jpeg", "webp"] },
107
+ ],
108
+ },
109
+ imageToText: {
110
+ inputLabel: "Image URL",
111
+ inputPlaceholder: "https://example.com/image.png",
112
+ defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg",
113
+ bodyKey: "url",
114
+ extraBody: { prompt: "Describe this image in detail" },
115
+ defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`,
116
+ },
117
+ video: {
118
+ inputLabel: "Prompt",
119
+ inputPlaceholder: "A serene lake at sunset",
120
+ defaultInput: "A serene lake at sunset",
121
+ bodyKey: "prompt",
122
+ defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`,
123
+ },
124
+ music: {
125
+ inputLabel: "Prompt",
126
+ inputPlaceholder: "A calm piano melody",
127
+ defaultInput: "A calm piano melody",
128
+ bodyKey: "prompt",
129
+ defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`,
130
+ },
131
+ };
132
+
133
+ // EmbeddingExampleCard
134
+ function EmbeddingExampleCard({ providerId, customAlias }) {
135
+ const isCustom = isCustomEmbeddingProvider(providerId);
136
+ const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId);
137
+ const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
138
+
139
+ const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
140
+ const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
141
+ const [dimensions, setDimensions] = useState("");
142
+ const [apiKey, setApiKey] = useState("");
143
+ const [useTunnel, setUseTunnel] = useState(false);
144
+ const [localEndpoint, setLocalEndpoint] = useState("");
145
+ const [tunnelEndpoint, setTunnelEndpoint] = useState("");
146
+ const [result, setResult] = useState(null);
147
+ const [running, setRunning] = useState(false);
148
+ const [error, setError] = useState("");
149
+ const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
150
+ const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
151
+
152
+ useEffect(() => {
153
+ setLocalEndpoint(window.location.origin);
154
+ fetch("/api/keys")
155
+ .then((r) => r.json())
156
+ .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
157
+ .catch(() => {});
158
+ fetch("/api/tunnel/status")
159
+ .then((r) => r.json())
160
+ .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
161
+ .catch(() => {});
162
+ }, []);
163
+
164
+ const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
165
+ const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
166
+
167
+ // Build request body — include dimensions only if user provided a positive number
168
+ const buildBody = () => {
169
+ const body = { model: modelFull, input: input.trim() };
170
+ const dim = Number(dimensions);
171
+ if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim;
172
+ return body;
173
+ };
174
+
175
+ const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
176
+ -H "Content-Type: application/json" \\
177
+ -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
178
+ -d '${JSON.stringify(buildBody())}'`;
179
+
180
+ const handleRun = async () => {
181
+ if (!input.trim() || !modelFull) return;
182
+ setRunning(true);
183
+ setError("");
184
+ setResult(null);
185
+ const start = Date.now();
186
+ try {
187
+ const headers = { "Content-Type": "application/json" };
188
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
189
+ const res = await fetch("/api/v1/embeddings", {
190
+ method: "POST",
191
+ headers,
192
+ body: JSON.stringify(buildBody()),
193
+ });
194
+ const latencyMs = Date.now() - start;
195
+ const data = await res.json();
196
+ if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; }
197
+ setResult({ data, latencyMs });
198
+ } catch (e) {
199
+ setError(e.message || "Network error");
200
+ } finally {
201
+ setRunning(false);
202
+ }
203
+ };
204
+
205
+ // Compact embedding array: first 4 values + count
206
+ const formatResultJson = (data) => {
207
+ if (!data) return DEFAULT_RESPONSE_EXAMPLE;
208
+ const clone = JSON.parse(JSON.stringify(data));
209
+ (clone.data || []).forEach((item) => {
210
+ if (Array.isArray(item.embedding) && item.embedding.length > 4) {
211
+ item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`];
212
+ }
213
+ });
214
+ return JSON.stringify(clone, null, 2);
215
+ };
216
+
217
+ const resultJson = result ? JSON.stringify(result.data, null, 2) : "";
218
+
219
+ return (
220
+ <Card>
221
+ <h2 className="text-lg font-semibold mb-4">Example</h2>
222
+
223
+ <div className="flex flex-col gap-2.5">
224
+ {/* Model — text input for custom node, dropdown otherwise */}
225
+ <Row label="Model">
226
+ {isCustom ? (
227
+ <input
228
+ value={selectedModel}
229
+ onChange={(e) => setSelectedModel(e.target.value)}
230
+ placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
231
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
232
+ />
233
+ ) : (
234
+ <select
235
+ value={selectedModel}
236
+ onChange={(e) => setSelectedModel(e.target.value)}
237
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
238
+ >
239
+ {embeddingModels.map((m) => (
240
+ <option key={m.id} value={m.id}>{m.name || m.id}</option>
241
+ ))}
242
+ </select>
243
+ )}
244
+ </Row>
245
+
246
+ {/* Endpoint */}
247
+ <Row label="Endpoint">
248
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
249
+ <input
250
+ value={endpoint}
251
+ onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
252
+ className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
253
+ placeholder="http://localhost:3000"
254
+ />
255
+ {/* Tunnel toggle — only show if tunnel URL is available */}
256
+ {tunnelEndpoint && (
257
+ <button
258
+ onClick={() => setUseTunnel((v) => !v)}
259
+ title={useTunnel ? "Using tunnel" : "Using local"}
260
+ className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
261
+ useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
262
+ }`}
263
+ >
264
+ <span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
265
+ Tunnel
266
+ </button>
267
+ )}
268
+ </div>
269
+ </Row>
270
+
271
+ {/* API Key */}
272
+ <Row label="API Key">
273
+ <input
274
+ type="password"
275
+ value={apiKey}
276
+ onChange={(e) => setApiKey(e.target.value)}
277
+ placeholder="sk-..."
278
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
279
+ />
280
+ </Row>
281
+
282
+ {/* Input */}
283
+ <Row label="Input">
284
+ <div className="relative">
285
+ <input
286
+ value={input}
287
+ onChange={(e) => setInput(e.target.value)}
288
+ className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
289
+ />
290
+ {input && (
291
+ <button
292
+ type="button"
293
+ onClick={() => setInput("")}
294
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
295
+ >
296
+ <span className="material-symbols-outlined text-[14px]">close</span>
297
+ </button>
298
+ )}
299
+ </div>
300
+ </Row>
301
+
302
+ {/* Dimensions (optional) — truncate embedding vector length */}
303
+ <Row label="Dimensions">
304
+ <input
305
+ type="number"
306
+ min="1"
307
+ value={dimensions}
308
+ onChange={(e) => setDimensions(e.target.value)}
309
+ placeholder="optional, e.g. 512, 1024 (leave empty for default)"
310
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
311
+ />
312
+ </Row>
313
+
314
+ {/* Curl + Run */}
315
+ <div className="mt-1">
316
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
317
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
318
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
319
+ <button
320
+ onClick={() => copyCurl(curlSnippet)}
321
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
322
+ >
323
+ <span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
324
+ {copiedCurl ? "Copied" : "Copy"}
325
+ </button>
326
+ <button
327
+ onClick={handleRun}
328
+ disabled={running || !input.trim() || !modelFull}
329
+ className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
330
+ >
331
+ <span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
332
+ play_arrow
333
+ </span>
334
+ {running ? "Running..." : "Run"}
335
+ </button>
336
+ </div>
337
+ </div>
338
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
339
+ </div>
340
+
341
+ {/* Error */}
342
+ {error && <p className="text-xs text-red-500 break-words">{error}</p>}
343
+
344
+ {/* Response — default example or real result */}
345
+ <div>
346
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
347
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
348
+ Response {result && <span className="font-normal normal-case">&#9889; {result.latencyMs}ms</span>}
349
+ </span>
350
+ {result && (
351
+ <button
352
+ onClick={() => copyRes(resultJson)}
353
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
354
+ >
355
+ <span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
356
+ {copiedRes ? "Copied" : "Copy"}
357
+ </button>
358
+ )}
359
+ </div>
360
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
361
+ {formatResultJson(result?.data)}
362
+ </pre>
363
+ </div>
364
+ </div>
365
+ </Card>
366
+ );
367
+ }
368
+
369
+ // ─── TTS Example Card ────────────────────────────────────────────────────────
370
+ function TtsExampleCard({ providerId }) {
371
+ const providerAlias = getProviderAlias(providerId);
372
+ const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
373
+
374
+ // Voice state
375
+ const [selectedVoice, setSelectedVoice] = useState(config.defaultVoiceId || "");
376
+ const [selectedVoiceName, setSelectedVoiceName] = useState("");
377
+ const [voiceId, setVoiceId] = useState(config.defaultVoiceId || ""); // editable voice id (elevenlabs/config providers)
378
+ // Voices shown below Voice row after language selected
379
+ const [countryVoices, setCountryVoices] = useState([]);
380
+ const [selectedLang, setSelectedLang] = useState("");
381
+ const [selectedModel, setSelectedModel] = useState(() => {
382
+ const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models;
383
+ if (cfgModels?.length) return cfgModels[0].id;
384
+ if (config.hasModelSelector && config.modelKey) {
385
+ const models = getModelsByProviderId(config.modelKey);
386
+ return models?.[0]?.id || "";
387
+ }
388
+ return "";
389
+ });
390
+
391
+ // Form state
392
+ const [input, setInput] = useState("Hello, this is a text to speech test.");
393
+ const [apiKey, setApiKey] = useState("");
394
+ const [useTunnel, setUseTunnel] = useState(false);
395
+ const [localEndpoint, setLocalEndpoint] = useState("");
396
+ const [tunnelEndpoint, setTunnelEndpoint] = useState("");
397
+ const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
398
+ const [audioUrl, setAudioUrl] = useState("");
399
+ const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
400
+ const [running, setRunning] = useState(false);
401
+ const [error, setError] = useState("");
402
+ const [latency, setLatency] = useState(null);
403
+ const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
404
+
405
+ // Country picker modal state
406
+ const [modalOpen, setModalOpen] = useState(false);
407
+ const [languages, setLanguages] = useState([]);
408
+ const [modalLoading, setModalLoading] = useState(false);
409
+ const [modalSearch, setModalSearch] = useState("");
410
+ const [modalError, setModalError] = useState("");
411
+ const [byLang, setByLang] = useState({});
412
+ // Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
413
+ const [languageHint, setLanguageHint] = useState("");
414
+
415
+ useEffect(() => {
416
+ setLocalEndpoint(window.location.origin);
417
+ fetch("/api/keys")
418
+ .then((r) => r.json())
419
+ .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
420
+ .catch(() => {});
421
+ fetch("/api/tunnel/status")
422
+ .then((r) => r.json())
423
+ .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
424
+ .catch(() => {});
425
+
426
+ // Pre-select default voice based on provider config
427
+ if (config.voiceSource === "hardcoded") {
428
+ const defaultModel = config.hasModelSelector && config.modelKey
429
+ ? (getModelsByProviderId(config.modelKey)?.[0]?.id || "")
430
+ : "";
431
+ // Use per-model voices if available, else flat list
432
+ const voices = (config.voicesPerModel && defaultModel)
433
+ ? (getTtsVoicesForModel(providerId, defaultModel) || [])
434
+ : getModelsByProviderId(config.voiceKey || providerId).filter((m) => m.type === "tts");
435
+ if (voices.length) {
436
+ if (config.hasBrowseButton) {
437
+ // Google TTS: pre-select "en" (English) as default, show as single voice chip
438
+ const defaultVoice = voices.find((v) => v.id === "en") || voices[0];
439
+ setSelectedLang(defaultVoice.id);
440
+ setSelectedVoice(defaultVoice.id);
441
+ setSelectedVoiceName(defaultVoice.name);
442
+ setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
443
+ } else {
444
+ // OpenAI/OpenRouter: set voice chips directly (no language picker)
445
+ setCountryVoices(voices);
446
+ setSelectedVoice(voices[0].id);
447
+ setSelectedVoiceName(voices[0].name || voices[0].id);
448
+ }
449
+ }
450
+ }
451
+ // api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language
452
+ // config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen):
453
+ // use ttsConfig.models for model selector; voice is empty by default (backend uses provider default)
454
+ }, [providerId]);
455
+
456
+ // Update voices when model changes (voicesPerModel providers)
457
+ useEffect(() => {
458
+ if (!config.voicesPerModel || !selectedModel) return;
459
+ const voices = getTtsVoicesForModel(providerId, selectedModel) || [];
460
+ setCountryVoices(voices);
461
+ if (voices.length) {
462
+ setSelectedVoice(voices[0].id);
463
+ setSelectedVoiceName(voices[0].name || voices[0].id);
464
+ }
465
+ }, [selectedModel]);
466
+
467
+ // Open modal — load language list
468
+ const openModal = async () => {
469
+ setModalOpen(true);
470
+ setModalSearch("");
471
+ setModalError("");
472
+ if (languages.length) return; // already loaded
473
+ setModalLoading(true);
474
+ try {
475
+ if (config.voiceSource === "hardcoded") {
476
+ // Build languages/byLang from static providerModels data
477
+ const voiceKey = config.voiceKey || providerId;
478
+ const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
479
+ const byLangMap = {};
480
+ for (const v of voices) {
481
+ if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] };
482
+ }
483
+ setByLang(byLangMap);
484
+ setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name)));
485
+ } else {
486
+ // Use provider-specific apiEndpoint if available, else default to edge-tts voices API
487
+ const url = config.apiEndpoint
488
+ ? config.apiEndpoint
489
+ : `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
490
+ const r = await fetch(url);
491
+ const d = await r.json();
492
+ if (d.error) { setModalError(d.error); return; }
493
+ setLanguages(d.languages || []);
494
+ setByLang(d.byLang || {});
495
+ }
496
+ } catch (e) {
497
+ setModalError(e.message);
498
+ } finally {
499
+ setModalLoading(false);
500
+ }
501
+ };
502
+
503
+ // Click language → close modal → show voices below
504
+ const handlePickLanguage = (lang) => {
505
+ setModalOpen(false);
506
+ setSelectedLang(lang.code);
507
+ const voices = byLang[lang.code]?.voices || [];
508
+ setCountryVoices(voices);
509
+ // Auto-select first voice
510
+ if (voices.length) {
511
+ setSelectedVoice(voices[0].id);
512
+ setSelectedVoiceName(voices[0].name);
513
+ if (config.hasVoiceIdInput) setVoiceId(voices[0].id);
514
+ }
515
+ };
516
+
517
+ const filteredLanguages = modalSearch
518
+ ? languages.filter((c) =>
519
+ c.name.toLowerCase().includes(modalSearch.toLowerCase()) ||
520
+ c.code.toLowerCase().includes(modalSearch.toLowerCase())
521
+ )
522
+ : languages;
523
+
524
+ const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
525
+ // For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
526
+ const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
527
+ const modelFull = (() => {
528
+ if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`;
529
+ if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`;
530
+ if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`;
531
+ return "";
532
+ })();
533
+
534
+ const ttsBody = (() => {
535
+ const b = { model: modelFull, input };
536
+ if (config.hasLanguageHint && languageHint) b.language = languageHint;
537
+ return b;
538
+ })();
539
+ const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
540
+ -H "Content-Type: application/json" \\
541
+ -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
542
+ -d '${JSON.stringify(ttsBody)}' \\
543
+ ${responseFormat === "json" ? "" : "--output speech.mp3"}`;
544
+
545
+ const handleRun = async () => {
546
+ if (!input.trim() || !modelFull) return;
547
+ setRunning(true);
548
+ setError("");
549
+ setAudioUrl("");
550
+ setJsonResponse(null);
551
+ const start = Date.now();
552
+ try {
553
+ const headers = { "Content-Type": "application/json" };
554
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
555
+ const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`;
556
+ const res = await fetch(url, {
557
+ method: "POST",
558
+ headers,
559
+ body: JSON.stringify({ ...ttsBody, input: input.trim() }),
560
+ });
561
+ setLatency(Date.now() - start);
562
+ if (!res.ok) {
563
+ const d = await res.json().catch(() => ({}));
564
+ setError(d?.error?.message || d?.error || `HTTP ${res.status}`);
565
+ return;
566
+ }
567
+
568
+ if (responseFormat === "json") {
569
+ const data = await res.json();
570
+ setJsonResponse(data); // Store full JSON response
571
+ const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
572
+ setAudioUrl(URL.createObjectURL(audioBlob));
573
+ } else {
574
+ const blob = await res.blob();
575
+ setAudioUrl(URL.createObjectURL(blob));
576
+ }
577
+ } catch (e) {
578
+ setError(e.message || "Network error");
579
+ } finally {
580
+ setRunning(false);
581
+ }
582
+ };
583
+
584
+ return (
585
+ <>
586
+ <Card>
587
+ <h2 className="text-lg font-semibold mb-4">Example</h2>
588
+
589
+ <div className="flex flex-col gap-2.5">
590
+ {/* Endpoint + API Key as read-only text */}
591
+ <Row label="Endpoint">
592
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
593
+ <span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
594
+ {endpoint}/v1/audio/speech
595
+ </span>
596
+ {tunnelEndpoint && (
597
+ <button
598
+ onClick={() => setUseTunnel((v) => !v)}
599
+ title={useTunnel ? "Using tunnel" : "Using local"}
600
+ className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
601
+ useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
602
+ }`}
603
+ >
604
+ <span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
605
+ Tunnel
606
+ </button>
607
+ )}
608
+ </div>
609
+ </Row>
610
+ <Row label="API Key">
611
+ <span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
612
+ {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
613
+ </span>
614
+ </Row>
615
+
616
+ {/* Model selector — prefer ttsConfig.models, else providerModels via modelKey */}
617
+ {config.hasModelSelector && (config.modelKey || AI_PROVIDERS[providerId]?.ttsConfig?.models?.length) && (
618
+ <Row label="Model">
619
+ <select
620
+ value={selectedModel}
621
+ onChange={(e) => setSelectedModel(e.target.value)}
622
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
623
+ >
624
+ {((AI_PROVIDERS[providerId]?.ttsConfig?.models?.length
625
+ ? AI_PROVIDERS[providerId].ttsConfig.models
626
+ : getModelsByProviderId(config.modelKey)) || []).map((m) => (
627
+ <option key={m.id} value={m.id}>{m.name || m.id}</option>
628
+ ))}
629
+ </select>
630
+ </Row>
631
+ )}
632
+
633
+ {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
634
+ {config.hasLanguageHint && (
635
+ <Row label="Language">
636
+ <select
637
+ value={languageHint}
638
+ onChange={(e) => setLanguageHint(e.target.value)}
639
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
640
+ >
641
+ <option value="">Auto-detect</option>
642
+ {GOOGLE_TTS_LANGUAGES.map((l) => (
643
+ <option key={l.id} value={l.name}>{l.name}</option>
644
+ ))}
645
+ </select>
646
+ </Row>
647
+ )}
648
+
649
+ {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
650
+ {config.hasBrowseButton && (
651
+ <Row label="Language">
652
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
653
+ <button
654
+ onClick={openModal}
655
+ className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background font-mono truncate text-left hover:border-primary/40 transition-colors"
656
+ >
657
+ {selectedLang
658
+ ? <span className="text-text-main">{languages.find((l) => l.code === selectedLang)?.name || selectedLang}</span>
659
+ : <span className="text-text-muted">No language selected</span>}
660
+ </button>
661
+ <button
662
+ onClick={openModal}
663
+ className="flex w-full items-center justify-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors sm:w-auto sm:shrink-0"
664
+ >
665
+ <span className="material-symbols-outlined text-[14px]">language</span>
666
+ Select language
667
+ </button>
668
+ </div>
669
+ </Row>
670
+ )}
671
+
672
+ {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
673
+ {countryVoices.length > 0 && (
674
+ <Row label="Voice">
675
+ <div className="flex flex-wrap gap-1.5">
676
+ {countryVoices.map((v) => (
677
+ <button
678
+ key={v.id}
679
+ onClick={() => {
680
+ setSelectedVoice(v.id);
681
+ setSelectedVoiceName(v.name);
682
+ if (config.hasVoiceIdInput) setVoiceId(v.id);
683
+ }}
684
+ className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
685
+ selectedVoice === v.id
686
+ ? "bg-primary/15 border-primary/40 text-primary font-medium"
687
+ : "border-border text-text-muted hover:text-primary hover:border-primary/40"
688
+ }`}
689
+ >
690
+ {v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
691
+ {v.free_users_allowed === true && (
692
+ <span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
693
+ )}
694
+ {v.free_users_allowed === false && (
695
+ <span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-amber-500/15 text-amber-600 border border-amber-500/20">Paid</span>
696
+ )}
697
+ </button>
698
+ ))}
699
+ </div>
700
+ </Row>
701
+ )}
702
+
703
+ {/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
704
+ {config.hasVoiceIdInput && (
705
+ <Row label="Voice ID">
706
+ <div className="flex flex-col gap-1">
707
+ <div className="relative">
708
+ <input
709
+ value={voiceId}
710
+ onChange={(e) => {
711
+ setVoiceId(e.target.value);
712
+ setSelectedVoice(e.target.value);
713
+ }}
714
+ placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17"
715
+ className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
716
+ />
717
+ {voiceId && (
718
+ <button
719
+ type="button"
720
+ onClick={() => { setVoiceId(""); setSelectedVoice(""); }}
721
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
722
+ >
723
+ <span className="material-symbols-outlined text-[14px]">close</span>
724
+ </button>
725
+ )}
726
+ </div>
727
+ </div>
728
+ </Row>
729
+ )}
730
+
731
+ {/* Google TTS: Language dropdown */}
732
+ {config.hasLanguageDropdown && (
733
+ <Row label="Language">
734
+ <select
735
+ value={selectedVoice}
736
+ onChange={(e) => {
737
+ const m = getModelsByProviderId(providerId).filter((m) => m.type === "tts").find((m) => m.id === e.target.value);
738
+ setSelectedVoice(e.target.value);
739
+ setSelectedVoiceName(m?.name || e.target.value);
740
+ }}
741
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
742
+ >
743
+ {getModelsByProviderId(providerId).filter((m) => m.type === "tts").map((m) => (
744
+ <option key={m.id} value={m.id}>{m.name || m.id}</option>
745
+ ))}
746
+ </select>
747
+ </Row>
748
+ )}
749
+
750
+ {/* Input */}
751
+ <Row label="Input">
752
+ <div className="relative">
753
+ <input
754
+ value={input}
755
+ onChange={(e) => setInput(e.target.value)}
756
+ className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
757
+ />
758
+ {input && (
759
+ <button
760
+ type="button"
761
+ onClick={() => setInput("")}
762
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
763
+ >
764
+ <span className="material-symbols-outlined text-[14px]">close</span>
765
+ </button>
766
+ )}
767
+ </div>
768
+ </Row>
769
+
770
+ {/* Output Format */}
771
+ <Row label="Output Format">
772
+ <select
773
+ value={responseFormat}
774
+ onChange={(e) => setResponseFormat(e.target.value)}
775
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
776
+ >
777
+ <option value="mp3">MP3 (Binary)</option>
778
+ <option value="json">JSON (Base64)</option>
779
+ </select>
780
+ </Row>
781
+
782
+ {/* Curl + Run */}
783
+ <div className="mt-1">
784
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
785
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
786
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
787
+ <button
788
+ onClick={() => copyCurl(curlSnippet)}
789
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
790
+ >
791
+ <span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
792
+ {copiedCurl ? "Copied" : "Copy"}
793
+ </button>
794
+ <button
795
+ onClick={handleRun}
796
+ disabled={running || !input.trim() || !modelFull}
797
+ className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
798
+ >
799
+ <span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
800
+ play_arrow
801
+ </span>
802
+ {running ? "Generating..." : "Run"}
803
+ </button>
804
+ </div>
805
+ </div>
806
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
807
+ </div>
808
+
809
+ {error && <p className="text-xs text-red-500 break-words">{error}</p>}
810
+
811
+ {/* Audio player */}
812
+ {audioUrl ? (
813
+ <div>
814
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
815
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
816
+ Response {latency && <span className="font-normal normal-case">&#9889; {latency}ms</span>}
817
+ </span>
818
+ <a href={audioUrl} download="speech.mp3" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
819
+ <span className="material-symbols-outlined text-[14px]">download</span>
820
+ Download
821
+ </a>
822
+ </div>
823
+ <audio controls src={audioUrl} className="w-full" />
824
+
825
+ {/* JSON Response (if format is json) */}
826
+ {jsonResponse && (
827
+ <div className="mt-3">
828
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
829
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">JSON Response</span>
830
+ </div>
831
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">
832
+ {JSON.stringify({
833
+ format: jsonResponse.format,
834
+ audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
835
+ }, null, 2)}
836
+ </pre>
837
+ </div>
838
+ )}
839
+ </div>
840
+ ) : (
841
+ <div>
842
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Response</span>
843
+ <pre className="mt-1.5 bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
844
+ </div>
845
+ )}
846
+ </div>
847
+ </Card>
848
+
849
+ {/* Country Picker Modal */}
850
+ {modalOpen && (
851
+ <div
852
+ className="fixed inset-0 z-50 flex items-end justify-center sm:items-center"
853
+ style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(2px)" }}
854
+ onClick={() => setModalOpen(false)}
855
+ >
856
+ <div
857
+ className="border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[80vh]"
858
+ style={{ backgroundColor: "var(--color-bg)", isolation: "isolate" }}
859
+ onClick={(e) => e.stopPropagation()}
860
+ >
861
+ {/* Header */}
862
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 rounded-t-xl">
863
+ <h3 className="text-sm font-semibold">Select Language</h3>
864
+ <button onClick={() => setModalOpen(false)} className="text-text-muted hover:text-primary transition-colors">
865
+ <span className="material-symbols-outlined text-[20px]">close</span>
866
+ </button>
867
+ </div>
868
+
869
+ {/* Search */}
870
+ <div className="px-4 py-2.5 border-b border-border shrink-0">
871
+ <input
872
+ autoFocus
873
+ value={modalSearch}
874
+ onChange={(e) => setModalSearch(e.target.value)}
875
+ placeholder="Search language..."
876
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
877
+ />
878
+ </div>
879
+
880
+ {/* Language list */}
881
+ <div className="overflow-y-auto flex-1 p-2">
882
+ {modalError && <p className="text-xs text-red-500 px-2 py-1">{modalError}</p>}
883
+ {modalLoading ? (
884
+ <p className="text-xs text-text-muted px-2 py-3">Loading...</p>
885
+ ) : (
886
+ <div className="flex flex-col gap-0.5">
887
+ {filteredLanguages.map((c) => (
888
+ <button
889
+ key={c.code}
890
+ onClick={() => handlePickLanguage(c)}
891
+ className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-left hover:bg-sidebar transition-colors ${
892
+ selectedLang === c.code ? "bg-primary/10 text-primary" : ""
893
+ }`}
894
+ >
895
+ <span className="text-sm">{c.name}</span>
896
+ <div className="flex items-center gap-2 shrink-0">
897
+ <span className="text-xs text-text-muted">{c.voices.length} voices</span>
898
+ {selectedLang === c.code && (
899
+ <span className="material-symbols-outlined text-[16px] text-primary">check</span>
900
+ )}
901
+ </div>
902
+ </button>
903
+ ))}
904
+ {filteredLanguages.length === 0 && (
905
+ <p className="text-xs text-text-muted px-2 py-3">No languages found.</p>
906
+ )}
907
+ </div>
908
+ )}
909
+ </div>
910
+ </div>
911
+ </div>
912
+ )}
913
+ </>
914
+ );
915
+ }
916
+
917
+ // Generic Example Card — config-driven for webSearch, webFetch, image, imageToText, stt, video, music
918
+ function GenericExampleCard({ providerId, kind }) {
919
+ const providerAlias = getProviderAlias(providerId);
920
+ const resolvedId = resolveProviderId(providerAlias);
921
+ const safeProviderAlias = resolvedId === providerId ? providerAlias : providerId;
922
+ const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
923
+ const exConfig = KIND_EXAMPLE_CONFIG[kind];
924
+ const safeExConfig = exConfig || {};
925
+
926
+ // Get models for this kind (e.g., type="image")
927
+ const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind);
928
+ // Kinds that need a model identifier in the request (image/video/music)
929
+ const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
930
+ const needsModel = KIND_NEEDS_MODEL.has(kind);
931
+ const allowManualModel = needsModel && kindModels.length === 0;
932
+ const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
933
+ const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
934
+ const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
935
+ const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
936
+
937
+ const [input, setInput] = useState(safeExConfig.defaultInput || "");
938
+ const [refImage, setRefImage] = useState("");
939
+ const [maskImage, setMaskImage] = useState("");
940
+ const [extraValues, setExtraValues] = useState(() =>
941
+ (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
942
+ );
943
+ const [apiKey, setApiKey] = useState("");
944
+ const [useTunnel, setUseTunnel] = useState(false);
945
+ const [localEndpoint, setLocalEndpoint] = useState("");
946
+ const [tunnelEndpoint, setTunnelEndpoint] = useState("");
947
+ const [result, setResult] = useState(null);
948
+ const [progress, setProgress] = useState(null); // { stage, bytesReceived }
949
+ const [partialImage, setPartialImage] = useState(null);
950
+ const [imageOutputFormat, setImageOutputFormat] = useState("json"); // json | binary
951
+ const [binaryImageUrl, setBinaryImageUrl] = useState("");
952
+ const [running, setRunning] = useState(false);
953
+ const [error, setError] = useState("");
954
+ const [connections, setConnections] = useState([]);
955
+ const [pinnedConnectionId, setPinnedConnectionId] = useState("");
956
+ const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
957
+ const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
958
+
959
+ useEffect(() => {
960
+ setLocalEndpoint(window.location.origin);
961
+ fetch("/api/keys")
962
+ .then((r) => r.json())
963
+ .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
964
+ .catch(() => {});
965
+ fetch("/api/tunnel/status")
966
+ .then((r) => r.json())
967
+ .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
968
+ .catch(() => {});
969
+ // Load active connections of this provider for pinning
970
+ fetch("/api/providers/client")
971
+ .then((r) => r.json())
972
+ .then((d) => {
973
+ const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false);
974
+ setConnections(conns);
975
+ })
976
+ .catch(() => {});
977
+ }, [providerId]);
978
+
979
+ // Safe to early-return now that all hooks are declared
980
+ if (!kindConfig || !exConfig) return null;
981
+
982
+ const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
983
+ const apiPath = kindConfig.endpoint.path;
984
+ // webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
985
+ const modelFull = !needsModel
986
+ ? safeProviderAlias
987
+ : (selectedModel ? `${safeProviderAlias}/${selectedModel}` : (allowManualModel ? "" : safeProviderAlias));
988
+ const imageEditDefaults = getImageEditDefaults(providerId, selectedModel);
989
+ const effectiveRefImage = refImage.trim() || imageEditDefaults.image || "";
990
+ const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || "";
991
+ const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage);
992
+ const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage);
993
+
994
+ // Build request body with optional extra fields (only non-empty values)
995
+ const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
996
+ if (v === "" || v === null || v === undefined) return acc;
997
+ if (typeof v === "number" && Number.isNaN(v)) return acc;
998
+ acc[k] = v;
999
+ return acc;
1000
+ }, {});
1001
+ const requestBody = {
1002
+ model: modelFull,
1003
+ [exConfig.bodyKey]: input,
1004
+ ...exConfig.extraBody,
1005
+ ...extraBodyFromFields,
1006
+ ...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
1007
+ ...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
1008
+ };
1009
+
1010
+ // Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested
1011
+ const wantBinary = kind === "image" && imageOutputFormat === "binary";
1012
+ const useStreaming = kind === "image" && providerId === "codex" && !wantBinary;
1013
+ const apiPathWithQuery = `${apiPath}${wantBinary ? "?response_format=binary" : ""}`;
1014
+ const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`;
1015
+ const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPathWithQuery} \\
1016
+ ${headersPreview.replace(/\\\n /g, "\\\n ")} \\
1017
+ -d '${JSON.stringify(requestBody)}'${wantBinary ? " \\\n --output image.png" : ""}`;
1018
+
1019
+ const handleRun = async () => {
1020
+ if (!input.trim() || !modelFull) return;
1021
+ setRunning(true);
1022
+ setError("");
1023
+ setResult(null);
1024
+ setProgress(null);
1025
+ setPartialImage(null);
1026
+ if (binaryImageUrl) { try { URL.revokeObjectURL(binaryImageUrl); } catch {} setBinaryImageUrl(""); }
1027
+ const start = Date.now();
1028
+ try {
1029
+ const headers = { "Content-Type": "application/json" };
1030
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
1031
+ if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId;
1032
+ if (useStreaming) headers["Accept"] = "text/event-stream";
1033
+ const body = { ...requestBody, model: modelFull };
1034
+ const res = await fetch(`/api${apiPathWithQuery}`, {
1035
+ method: kindConfig.endpoint.method,
1036
+ headers,
1037
+ body: JSON.stringify(body),
1038
+ });
1039
+ if (!res.ok) {
1040
+ const data = await res.json().catch(() => ({}));
1041
+ setError(data?.error?.message || data?.error || `HTTP ${res.status}`);
1042
+ return;
1043
+ }
1044
+ const ctype = res.headers.get("content-type") || "";
1045
+ // Binary image response — convert to blob URL
1046
+ if (ctype.startsWith("image/")) {
1047
+ const blob = await res.blob();
1048
+ const objUrl = URL.createObjectURL(blob);
1049
+ setBinaryImageUrl(objUrl);
1050
+ setResult({ data: { binary: true, mime: ctype, size: blob.size }, latencyMs: Date.now() - start });
1051
+ return;
1052
+ }
1053
+ const isSse = ctype.includes("text/event-stream");
1054
+ if (isSse && res.body) {
1055
+ // Parse SSE: progress / partial_image / done / error
1056
+ const reader = res.body.getReader();
1057
+ const decoder = new TextDecoder();
1058
+ let buf = "";
1059
+ let finalData = null;
1060
+ let streamErr = null;
1061
+ while (true) {
1062
+ const { done, value } = await reader.read();
1063
+ if (done) break;
1064
+ buf += decoder.decode(value, { stream: true });
1065
+ let sep;
1066
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
1067
+ const block = buf.slice(0, sep);
1068
+ buf = buf.slice(sep + 2);
1069
+ let evt = null, dataStr = "";
1070
+ for (const line of block.split("\n")) {
1071
+ if (line.startsWith("event:")) evt = line.slice(6).trim();
1072
+ else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
1073
+ }
1074
+ if (!evt) continue;
1075
+ try {
1076
+ const payload = dataStr ? JSON.parse(dataStr) : {};
1077
+ if (evt === "progress") setProgress(payload);
1078
+ else if (evt === "partial_image") setPartialImage(payload);
1079
+ else if (evt === "done") finalData = payload;
1080
+ else if (evt === "error") streamErr = payload?.message || "Stream error";
1081
+ } catch {}
1082
+ }
1083
+ }
1084
+ const latencyMs = Date.now() - start;
1085
+ if (streamErr) { setError(streamErr); return; }
1086
+ if (finalData) setResult({ data: finalData, latencyMs });
1087
+ } else {
1088
+ const data = await res.json();
1089
+ const latencyMs = Date.now() - start;
1090
+ setResult({ data, latencyMs });
1091
+ }
1092
+ } catch (e) {
1093
+ setError(e.message || "Network error");
1094
+ } finally {
1095
+ setRunning(false);
1096
+ }
1097
+ };
1098
+
1099
+ // Mask large b64_json strings in JSON view to keep it readable
1100
+ const maskB64 = (obj) => {
1101
+ if (!obj || typeof obj !== "object") return obj;
1102
+ if (Array.isArray(obj)) return obj.map(maskB64);
1103
+ const out = {};
1104
+ for (const [k, v] of Object.entries(obj)) {
1105
+ out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
1106
+ ? `<${v.length} chars base64>`
1107
+ : maskB64(v);
1108
+ }
1109
+ return out;
1110
+ };
1111
+ const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : "";
1112
+
1113
+ return (
1114
+ <Card>
1115
+ <h2 className="text-lg font-semibold mb-4">Example</h2>
1116
+ <div className="flex flex-col gap-2.5">
1117
+ {/* Model selector — dropdown if presets exist, else manual input for media kinds */}
1118
+ {kindModels.length > 0 ? (
1119
+ <Row label="Model">
1120
+ <select
1121
+ value={selectedModel}
1122
+ onChange={(e) => setSelectedModel(e.target.value)}
1123
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1124
+ >
1125
+ {kindModels.map((m) => (
1126
+ <option key={m.id} value={m.id}>{m.name || m.id}</option>
1127
+ ))}
1128
+ </select>
1129
+ </Row>
1130
+ ) : allowManualModel ? (
1131
+ <Row label="Model">
1132
+ <input
1133
+ value={selectedModel}
1134
+ onChange={(e) => setSelectedModel(e.target.value)}
1135
+ placeholder="Enter model id (provider-specific)"
1136
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
1137
+ />
1138
+ </Row>
1139
+ ) : null}
1140
+
1141
+ {/* Endpoint */}
1142
+ <Row label="Endpoint">
1143
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
1144
+ <span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
1145
+ {endpoint}{apiPath}
1146
+ </span>
1147
+ {tunnelEndpoint && (
1148
+ <button
1149
+ onClick={() => setUseTunnel((v) => !v)}
1150
+ title={useTunnel ? "Using tunnel" : "Using local"}
1151
+ className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
1152
+ useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
1153
+ }`}
1154
+ >
1155
+ <span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
1156
+ Tunnel
1157
+ </button>
1158
+ )}
1159
+ </div>
1160
+ </Row>
1161
+
1162
+ {/* API Key */}
1163
+ <Row label="API Key">
1164
+ <span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
1165
+ {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
1166
+ </span>
1167
+ </Row>
1168
+
1169
+ {/* Connection picker - only show when 2+ connections (or any with email) */}
1170
+ {connections.length > 0 && (
1171
+ <Row label="Connection">
1172
+ <select
1173
+ value={pinnedConnectionId}
1174
+ onChange={(e) => setPinnedConnectionId(e.target.value)}
1175
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1176
+ >
1177
+ <option value="">Auto (by priority)</option>
1178
+ {connections.map((c) => {
1179
+ const plan = c.providerSpecificData?.chatgptPlanType;
1180
+ const label = c.email || c.name || c.id.slice(0, 8);
1181
+ return (
1182
+ <option key={c.id} value={c.id}>
1183
+ {label}{plan ? ` [${plan}]` : ""}
1184
+ </option>
1185
+ );
1186
+ })}
1187
+ </select>
1188
+ </Row>
1189
+ )}
1190
+
1191
+ {/* Input */}
1192
+ <Row label={exConfig.inputLabel}>
1193
+ <div className="relative">
1194
+ <input
1195
+ value={input}
1196
+ onChange={(e) => setInput(e.target.value)}
1197
+ placeholder={exConfig.inputPlaceholder}
1198
+ className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1199
+ />
1200
+ {input && (
1201
+ <button
1202
+ type="button"
1203
+ onClick={() => setInput("")}
1204
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
1205
+ >
1206
+ <span className="material-symbols-outlined text-[14px]">close</span>
1207
+ </button>
1208
+ )}
1209
+ </div>
1210
+ </Row>
1211
+
1212
+ {/* Reference image (only for edit-capable image models) */}
1213
+ {supportsEdit && (
1214
+ <Row label="Ref Image (URL)">
1215
+ <div className="flex flex-col gap-2">
1216
+ <div className="relative">
1217
+ <input
1218
+ value={refImage}
1219
+ onChange={(e) => setRefImage(e.target.value)}
1220
+ placeholder={imageEditDefaults.image || "https://example.com/source.png"}
1221
+ className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1222
+ />
1223
+ {refImage && (
1224
+ <button
1225
+ type="button"
1226
+ onClick={() => setRefImage("")}
1227
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
1228
+ >
1229
+ <span className="material-symbols-outlined text-[14px]">close</span>
1230
+ </button>
1231
+ )}
1232
+ </div>
1233
+ {refImagePreviewSrc && (
1234
+ <img
1235
+ src={refImagePreviewSrc}
1236
+ alt="Reference"
1237
+ className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
1238
+ onError={(e) => { e.currentTarget.style.display = "none"; }}
1239
+ onLoad={(e) => { e.currentTarget.style.display = "block"; }}
1240
+ />
1241
+ )}
1242
+ </div>
1243
+ </Row>
1244
+ )}
1245
+
1246
+ {supportsMask && (
1247
+ <Row label="Mask (URL)">
1248
+ <div className="flex flex-col gap-2">
1249
+ <div className="relative">
1250
+ <input
1251
+ value={maskImage}
1252
+ onChange={(e) => setMaskImage(e.target.value)}
1253
+ placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"}
1254
+ className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1255
+ />
1256
+ {maskImage && (
1257
+ <button
1258
+ type="button"
1259
+ onClick={() => setMaskImage("")}
1260
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
1261
+ >
1262
+ <span className="material-symbols-outlined text-[14px]">close</span>
1263
+ </button>
1264
+ )}
1265
+ </div>
1266
+ {maskImagePreviewSrc && (
1267
+ <img
1268
+ src={maskImagePreviewSrc}
1269
+ alt="Mask"
1270
+ className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
1271
+ onError={(e) => { e.currentTarget.style.display = "none"; }}
1272
+ onLoad={(e) => { e.currentTarget.style.display = "block"; }}
1273
+ />
1274
+ )}
1275
+ </div>
1276
+ </Row>
1277
+ )}
1278
+
1279
+ {/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */}
1280
+ {(exConfig.extraFields || [])
1281
+ .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
1282
+ .map((f) => (
1283
+ <Row key={f.key} label={f.label}>
1284
+ {f.type === "select" ? (
1285
+ <select
1286
+ value={extraValues[f.key] ?? ""}
1287
+ onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
1288
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1289
+ >
1290
+ {(f.options || []).map((opt) => (
1291
+ <option key={opt} value={opt}>{opt === "" ? "(default)" : opt}</option>
1292
+ ))}
1293
+ </select>
1294
+ ) : f.type === "text" ? (
1295
+ <input
1296
+ type="text"
1297
+ value={extraValues[f.key] ?? ""}
1298
+ placeholder={f.placeholder}
1299
+ onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
1300
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1301
+ />
1302
+ ) : (
1303
+ <input
1304
+ type="number"
1305
+ value={extraValues[f.key] ?? ""}
1306
+ min={f.min}
1307
+ max={f.max}
1308
+ onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(e.target.value) }))}
1309
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1310
+ />
1311
+ )}
1312
+ </Row>
1313
+ ))}
1314
+
1315
+ {/* Output Format toggle (image only) — last */}
1316
+ {kind === "image" && (
1317
+ <Row label="Output Format">
1318
+ <select
1319
+ value={imageOutputFormat}
1320
+ onChange={(e) => setImageOutputFormat(e.target.value)}
1321
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1322
+ >
1323
+ <option value="json">JSON (Base64)</option>
1324
+ <option value="binary">Binary File</option>
1325
+ </select>
1326
+ </Row>
1327
+ )}
1328
+
1329
+ {/* Curl + Run */}
1330
+ <div className="mt-1">
1331
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
1332
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
1333
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
1334
+ <button
1335
+ onClick={() => copyCurl(curlSnippet)}
1336
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
1337
+ >
1338
+ <span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
1339
+ {copiedCurl ? "Copied" : "Copy"}
1340
+ </button>
1341
+ <button
1342
+ onClick={handleRun}
1343
+ disabled={running || !input.trim() || !modelFull}
1344
+ className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1345
+ >
1346
+ <span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
1347
+ play_arrow
1348
+ </span>
1349
+ {running ? "Running..." : "Run"}
1350
+ </button>
1351
+ </div>
1352
+ </div>
1353
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
1354
+ </div>
1355
+
1356
+ {/* Streaming progress */}
1357
+ {(running || progress) && useStreaming && (
1358
+ <div className="flex flex-col gap-2 px-3 py-2 rounded-lg bg-sidebar border border-border sm:flex-row sm:items-center sm:gap-3">
1359
+ <span className="material-symbols-outlined text-[16px] text-primary" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
1360
+ {running ? "progress_activity" : "check_circle"}
1361
+ </span>
1362
+ <span className="text-xs text-text-muted">
1363
+ {progress?.stage || "starting"}
1364
+ {!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
1365
+ </span>
1366
+ </div>
1367
+ )}
1368
+
1369
+ {/* Partial image preview (codex stream) */}
1370
+ {partialImage?.b64_json && !result && (
1371
+ <div>
1372
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Partial preview</span>
1373
+ <img
1374
+ src={`data:image/png;base64,${partialImage.b64_json}`}
1375
+ alt="Partial"
1376
+ className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
1377
+ />
1378
+ </div>
1379
+ )}
1380
+
1381
+ {/* Error */}
1382
+ {error && <p className="text-xs text-red-500 break-words">{error}</p>}
1383
+
1384
+ {/* Response */}
1385
+ <div>
1386
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
1387
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
1388
+ Response {result && <span className="font-normal normal-case">&#9889; {result.latencyMs}ms</span>}
1389
+ </span>
1390
+ {result && (
1391
+ <button
1392
+ onClick={() => copyRes(resultJson)}
1393
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
1394
+ >
1395
+ <span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
1396
+ {copiedRes ? "Copied" : "Copy"}
1397
+ </button>
1398
+ )}
1399
+ </div>
1400
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
1401
+ {result ? resultJson : exConfig.defaultResponse}
1402
+ </pre>
1403
+ {kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && (
1404
+ <div className="mt-2">
1405
+ <div className="flex items-center justify-end mb-1.5">
1406
+ <a
1407
+ href={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url || "")}
1408
+ download="image.png"
1409
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
1410
+ >
1411
+ <span className="material-symbols-outlined text-[14px]">download</span>
1412
+ Download
1413
+ </a>
1414
+ </div>
1415
+ <img
1416
+ src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)}
1417
+ alt="Generated"
1418
+ className="max-w-full rounded-lg border border-border"
1419
+ />
1420
+ </div>
1421
+ )}
1422
+ </div>
1423
+ </div>
1424
+ </Card>
1425
+ );
1426
+ }
1427
+
1428
+ // ─── STT Example Card ────────────────────────────────────────────────────────
1429
+ function SttExampleCard({ providerId }) {
1430
+ const providerAlias = getProviderAlias(providerId);
1431
+ const builtinSttModels = getModelsByProviderId(providerId).filter((m) => m.type === "stt");
1432
+ const [customSttModels, setCustomSttModels] = useState([]);
1433
+ const sttModels = [...builtinSttModels, ...customSttModels];
1434
+
1435
+ const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? "");
1436
+ const selectedModelObj = sttModels.find((m) => m.id === selectedModel);
1437
+ const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : [];
1438
+
1439
+ const [audioFile, setAudioFile] = useState(null);
1440
+ const [language, setLanguage] = useState("");
1441
+ const [prompt, setPrompt] = useState("");
1442
+ const [responseFormat, setResponseFormat] = useState("json");
1443
+ const [temperature, setTemperature] = useState("");
1444
+ const [apiKey, setApiKey] = useState("");
1445
+ const [useTunnel, setUseTunnel] = useState(false);
1446
+ const [localEndpoint, setLocalEndpoint] = useState("");
1447
+ const [tunnelEndpoint, setTunnelEndpoint] = useState("");
1448
+ const [result, setResult] = useState(null);
1449
+ const [latency, setLatency] = useState(null);
1450
+ const [running, setRunning] = useState(false);
1451
+ const [error, setError] = useState("");
1452
+ const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
1453
+ const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
1454
+
1455
+ useEffect(() => {
1456
+ setLocalEndpoint(window.location.origin);
1457
+ fetch("/api/keys")
1458
+ .then((r) => r.json())
1459
+ .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
1460
+ .catch(() => {});
1461
+ fetch("/api/tunnel/status")
1462
+ .then((r) => r.json())
1463
+ .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
1464
+ .catch(() => {});
1465
+ const loadCustom = () => {
1466
+ fetch("/api/models/custom", { cache: "no-store" })
1467
+ .then((r) => r.json())
1468
+ .then((d) => {
1469
+ const list = (d.models || []).filter((m) => m.type === "stt" && m.providerAlias === providerAlias);
1470
+ setCustomSttModels(list);
1471
+ })
1472
+ .catch(() => {});
1473
+ };
1474
+ loadCustom();
1475
+ window.addEventListener("focus", loadCustom);
1476
+ window.addEventListener("customModelChanged", loadCustom);
1477
+ return () => {
1478
+ window.removeEventListener("focus", loadCustom);
1479
+ window.removeEventListener("customModelChanged", loadCustom);
1480
+ };
1481
+ }, [providerAlias]);
1482
+
1483
+ const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
1484
+ const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
1485
+
1486
+ const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
1487
+ -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
1488
+ -F "file=@${audioFile?.name || "audio.mp3"}" \\
1489
+ -F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`;
1490
+
1491
+ const handleRun = async () => {
1492
+ if (!audioFile || !modelFull) return;
1493
+ setRunning(true);
1494
+ setError("");
1495
+ setResult(null);
1496
+ const start = Date.now();
1497
+ try {
1498
+ const fd = new FormData();
1499
+ fd.append("file", audioFile);
1500
+ fd.append("model", modelFull);
1501
+ if (allowedParams.includes("language") && language) fd.append("language", language);
1502
+ if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat);
1503
+ if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature);
1504
+ if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt);
1505
+
1506
+ const headers = {};
1507
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
1508
+ const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd });
1509
+ setLatency(Date.now() - start);
1510
+ const ct = res.headers.get("content-type") || "";
1511
+ const data = ct.includes("application/json") ? await res.json() : await res.text();
1512
+ if (!res.ok) {
1513
+ setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`);
1514
+ return;
1515
+ }
1516
+ setResult(data);
1517
+ } catch (e) {
1518
+ setError(e.message || "Network error");
1519
+ } finally {
1520
+ setRunning(false);
1521
+ }
1522
+ };
1523
+
1524
+ const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`);
1525
+
1526
+ return (
1527
+ <Card>
1528
+ <h2 className="text-lg font-semibold mb-4">Example</h2>
1529
+ <div className="flex flex-col gap-2.5">
1530
+ {/* Model */}
1531
+ {sttModels.length > 0 ? (
1532
+ <Row label="Model">
1533
+ <select
1534
+ value={selectedModel}
1535
+ onChange={(e) => setSelectedModel(e.target.value)}
1536
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1537
+ >
1538
+ {sttModels.map((m) => (
1539
+ <option key={m.id} value={m.id}>{m.name || m.id}</option>
1540
+ ))}
1541
+ </select>
1542
+ </Row>
1543
+ ) : (
1544
+ <Row label="Model">
1545
+ <input
1546
+ value={selectedModel}
1547
+ onChange={(e) => setSelectedModel(e.target.value)}
1548
+ placeholder="Enter model id"
1549
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
1550
+ />
1551
+ </Row>
1552
+ )}
1553
+
1554
+ {/* Endpoint */}
1555
+ <Row label="Endpoint">
1556
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
1557
+ <span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
1558
+ {endpoint}/v1/audio/transcriptions
1559
+ </span>
1560
+ {tunnelEndpoint && (
1561
+ <button
1562
+ onClick={() => setUseTunnel((v) => !v)}
1563
+ title={useTunnel ? "Using tunnel" : "Using local"}
1564
+ className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
1565
+ useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
1566
+ }`}
1567
+ >
1568
+ <span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
1569
+ Tunnel
1570
+ </button>
1571
+ )}
1572
+ </div>
1573
+ </Row>
1574
+
1575
+ {/* API Key */}
1576
+ <Row label="API Key">
1577
+ <span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
1578
+ {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
1579
+ </span>
1580
+ </Row>
1581
+
1582
+ {/* Audio file */}
1583
+ <Row label="Audio File">
1584
+ <div className="flex flex-col gap-2">
1585
+ <input
1586
+ type="file"
1587
+ accept="audio/*,video/mp4,.m4a,.mp3,.wav,.ogg,.flac,.webm,.opus"
1588
+ onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
1589
+ className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer"
1590
+ />
1591
+ {audioFile && (
1592
+ <span className="text-xs text-text-muted font-mono">
1593
+ {audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
1594
+ </span>
1595
+ )}
1596
+ </div>
1597
+ </Row>
1598
+
1599
+ {/* Language (if model supports) */}
1600
+ {allowedParams.includes("language") && (
1601
+ <Row label="Language">
1602
+ <input
1603
+ value={language}
1604
+ onChange={(e) => setLanguage(e.target.value)}
1605
+ placeholder="e.g. en, vi, ja (auto-detect if empty)"
1606
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
1607
+ />
1608
+ </Row>
1609
+ )}
1610
+
1611
+ {/* Prompt (if model supports) */}
1612
+ {allowedParams.includes("prompt") && (
1613
+ <Row label="Prompt">
1614
+ <input
1615
+ value={prompt}
1616
+ onChange={(e) => setPrompt(e.target.value)}
1617
+ placeholder="optional context to improve accuracy"
1618
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1619
+ />
1620
+ </Row>
1621
+ )}
1622
+
1623
+ {/* Temperature (if model supports) */}
1624
+ {allowedParams.includes("temperature") && (
1625
+ <Row label="Temperature">
1626
+ <input
1627
+ type="number"
1628
+ step="0.1"
1629
+ min="0"
1630
+ max="1"
1631
+ value={temperature}
1632
+ onChange={(e) => setTemperature(e.target.value)}
1633
+ placeholder="0 - 1 (default 0)"
1634
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1635
+ />
1636
+ </Row>
1637
+ )}
1638
+
1639
+ {/* Response format (if model supports) */}
1640
+ {allowedParams.includes("response_format") && (
1641
+ <Row label="Response Format">
1642
+ <select
1643
+ value={responseFormat}
1644
+ onChange={(e) => setResponseFormat(e.target.value)}
1645
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
1646
+ >
1647
+ <option value="json">json</option>
1648
+ <option value="text">text</option>
1649
+ <option value="srt">srt</option>
1650
+ <option value="verbose_json">verbose_json</option>
1651
+ <option value="vtt">vtt</option>
1652
+ </select>
1653
+ </Row>
1654
+ )}
1655
+
1656
+ {/* Curl + Run */}
1657
+ <div className="mt-1">
1658
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
1659
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
1660
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
1661
+ <button
1662
+ onClick={() => copyCurl(curlSnippet)}
1663
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
1664
+ >
1665
+ <span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
1666
+ {copiedCurl ? "Copied" : "Copy"}
1667
+ </button>
1668
+ <button
1669
+ onClick={handleRun}
1670
+ disabled={running || !audioFile || !modelFull}
1671
+ className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1672
+ >
1673
+ <span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
1674
+ play_arrow
1675
+ </span>
1676
+ {running ? "Transcribing..." : "Run"}
1677
+ </button>
1678
+ </div>
1679
+ </div>
1680
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
1681
+ </div>
1682
+
1683
+ {error && <p className="text-xs text-red-500 break-words">{error}</p>}
1684
+
1685
+ {/* Response */}
1686
+ <div>
1687
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
1688
+ <span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
1689
+ Response {result && latency && <span className="font-normal normal-case">&#9889; {latency}ms</span>}
1690
+ </span>
1691
+ {result && (
1692
+ <button
1693
+ onClick={() => copyRes(resultStr)}
1694
+ className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
1695
+ >
1696
+ <span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
1697
+ {copiedRes ? "Copied" : "Copy"}
1698
+ </button>
1699
+ )}
1700
+ </div>
1701
+ <pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
1702
+ {resultStr}
1703
+ </pre>
1704
+ </div>
1705
+ </div>
1706
+ </Card>
1707
+ );
1708
+ }
1709
+
1710
+ // MediaProviderDetailPage
1711
+ export default function MediaProviderDetailPage() {
1712
+ const { kind, id } = useParams();
1713
+ const router = useRouter();
1714
+ const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
1715
+ const isCustom = isCustomEmbeddingProvider(id) && kind === "embedding";
1716
+
1717
+ const handleDeleteCustom = async () => {
1718
+ if (!confirm("Delete this Custom Embedding node?")) return;
1719
+ try {
1720
+ const res = await fetch(`/api/provider-nodes/${id}`, { method: "DELETE" });
1721
+ if (res.ok) router.push(`/dashboard/media-providers/${kind}`);
1722
+ } catch (error) {
1723
+ console.log("Error deleting custom embedding node:", error);
1724
+ }
1725
+ };
1726
+
1727
+ const [customNode, setCustomNode] = useState(null);
1728
+ const [customLoading, setCustomLoading] = useState(isCustom);
1729
+ const [showEditModal, setShowEditModal] = useState(false);
1730
+
1731
+ // Fetch custom node info from API for custom embedding nodes
1732
+ useEffect(() => {
1733
+ if (!isCustom) return;
1734
+ let cancelled = false;
1735
+ fetch("/api/provider-nodes", { cache: "no-store" })
1736
+ .then((r) => r.json())
1737
+ .then((d) => {
1738
+ if (cancelled) return;
1739
+ setCustomNode((d.nodes || []).find((n) => n.id === id) || null);
1740
+ setCustomLoading(false);
1741
+ })
1742
+ .catch(() => { if (!cancelled) setCustomLoading(false); });
1743
+ return () => { cancelled = true; };
1744
+ }, [id, isCustom]);
1745
+
1746
+ if (!kindConfig) return notFound();
1747
+
1748
+ const builtInProvider = AI_PROVIDERS[id];
1749
+
1750
+ // For custom embedding nodes, build a synthetic provider object
1751
+ const provider = isCustom
1752
+ ? (customNode ? { id, name: customNode.name || "Custom Embedding", color: "#6366F1", textIcon: "CE" } : null)
1753
+ : builtInProvider;
1754
+
1755
+ if (!isCustom && !builtInProvider) return notFound();
1756
+ if (isCustom && !customLoading && !customNode) return notFound();
1757
+ if (isCustom && customLoading) {
1758
+ return <div className="text-text-muted text-sm py-12 text-center">Loading...</div>;
1759
+ }
1760
+
1761
+ const kinds = isCustom ? ["embedding"] : (provider.serviceKinds ?? ["llm"]);
1762
+ if (!isCustom && !kinds.includes(kind)) return notFound();
1763
+
1764
+ return (
1765
+ <div className="flex flex-col gap-8">
1766
+ {/* Back */}
1767
+ <div>
1768
+ <Link
1769
+ href={`/dashboard/media-providers/${kind}`}
1770
+ className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
1771
+ >
1772
+ <span className="material-symbols-outlined text-lg">arrow_back</span>
1773
+ {kindConfig.label}
1774
+ </Link>
1775
+
1776
+ {/* Header */}
1777
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
1778
+ <div className="size-12 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${provider.color}15` }}>
1779
+ <ProviderIcon
1780
+ src={`/providers/${provider.id}.png`}
1781
+ alt={provider.name}
1782
+ size={48}
1783
+ className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
1784
+ fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
1785
+ fallbackColor={provider.color}
1786
+ />
1787
+ </div>
1788
+ <div className="flex-1">
1789
+ <div className="flex flex-wrap items-center gap-2 sm:gap-3">
1790
+ <h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
1791
+ {!isCustom && provider.notice?.apiKeyUrl && (
1792
+ <a
1793
+ href={provider.notice.apiKeyUrl}
1794
+ target="_blank"
1795
+ rel="noopener noreferrer"
1796
+ className="text-xs text-primary hover:underline inline-flex items-center gap-1"
1797
+ >
1798
+ <span className="material-symbols-outlined text-sm">open_in_new</span>
1799
+ Get API Key
1800
+ </a>
1801
+ )}
1802
+ </div>
1803
+ <div className="flex items-center gap-1.5 mt-1 flex-wrap">
1804
+ {isCustom && <Badge variant="default" size="sm">Custom · {customNode?.prefix}</Badge>}
1805
+ {kinds.map((k) => (
1806
+ <Badge key={k} variant={k === kind ? "primary" : "default"} size="sm">
1807
+ {k.toUpperCase()}
1808
+ </Badge>
1809
+ ))}
1810
+ </div>
1811
+ </div>
1812
+ {isCustom && (
1813
+ <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
1814
+ <Button size="sm" variant="secondary" icon="edit" onClick={() => setShowEditModal(true)}>
1815
+ Edit
1816
+ </Button>
1817
+ <Button size="sm" variant="secondary" icon="delete" onClick={handleDeleteCustom}>
1818
+ Delete
1819
+ </Button>
1820
+ </div>
1821
+ )}
1822
+ </div>
1823
+ </div>
1824
+
1825
+ {/* Kind-specific notice (e.g. codex/image requires Plus) */}
1826
+ {!isCustom && provider.kindNotice?.[kind] && (
1827
+ <div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400">
1828
+ <span className="material-symbols-outlined text-[20px] mt-0.5">warning</span>
1829
+ <p className="text-sm">{provider.kindNotice[kind]}</p>
1830
+ </div>
1831
+ )}
1832
+
1833
+ {/* Provider notice text (only when there's actual text content) */}
1834
+ {!isCustom && provider.notice?.text && !provider.deprecated && (
1835
+ <div className="flex flex-col gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2 sm:flex-row sm:items-center">
1836
+ <span className="material-symbols-outlined text-[16px] text-blue-500 shrink-0">info</span>
1837
+ <p className="min-w-0 flex-1 text-xs leading-relaxed text-blue-600 dark:text-blue-400">{provider.notice.text}</p>
1838
+ {provider.notice.apiKeyUrl && (
1839
+ <a
1840
+ href={provider.notice.apiKeyUrl}
1841
+ target="_blank"
1842
+ rel="noopener noreferrer"
1843
+ className="inline-flex justify-center rounded bg-blue-500 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-600 sm:py-0.5"
1844
+ >
1845
+ Get API Key →
1846
+ </a>
1847
+ )}
1848
+ </div>
1849
+ )}
1850
+
1851
+ {/* Connections */}
1852
+ {!isCustom && provider.noAuth ? (
1853
+ <NoAuthProxyCard providerId={id} />
1854
+ ) : (
1855
+ <ConnectionsCard providerId={id} isOAuth={false} />
1856
+ )}
1857
+
1858
+ {/* Models - hidden for tts/webSearch/webFetch (provider IS the model); custom uses prefix as alias */}
1859
+ {kind !== "tts" && kind !== "webSearch" && kind !== "webFetch" && (
1860
+ <ModelsCard
1861
+ providerId={id}
1862
+ kindFilter={kind}
1863
+ providerAliasOverride={isCustom ? customNode?.prefix : undefined}
1864
+ />
1865
+ )}
1866
+
1867
+ {/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, searchViaChat */}
1868
+ {!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.searchViaChat) && (
1869
+ <ProviderInfoCard
1870
+ config={
1871
+ kind === "webFetch" ? provider.fetchConfig
1872
+ : kind === "tts" ? provider.ttsConfig
1873
+ : kind === "stt" ? provider.sttConfig
1874
+ : kind === "embedding" ? provider.embeddingConfig
1875
+ : provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, pricingUrl: provider.searchViaChat?.pricingUrl, freeTier: provider.searchViaChat?.freeTier }
1876
+ }
1877
+ provider={provider}
1878
+ title={`${kindConfig.label} Config`}
1879
+ />
1880
+ )}
1881
+
1882
+ {/* Example — per kind */}
1883
+ {kind === "embedding" && (
1884
+ <EmbeddingExampleCard providerId={id} customAlias={customNode?.prefix} />
1885
+ )}
1886
+ {kind === "tts" && <TtsExampleCard providerId={id} />}
1887
+ {kind === "stt" && !isCustom && <SttExampleCard providerId={id} />}
1888
+ {!isCustom && KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
1889
+
1890
+ {isCustom && (
1891
+ <AddCustomEmbeddingModal
1892
+ isOpen={showEditModal}
1893
+ node={customNode}
1894
+ onClose={() => setShowEditModal(false)}
1895
+ onSaved={(updated) => {
1896
+ setCustomNode(updated);
1897
+ setShowEditModal(false);
1898
+ }}
1899
+ />
1900
+ )}
1901
+ </div>
1902
+ );
1903
+ }