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.
- package/CHANGELOG.md +222 -0
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/README.zh-CN.md +1311 -0
- package/assets/pixel-router-2d.png +0 -0
- package/cli/LICENSE +42 -0
- package/cli/README.md +125 -0
- package/cli/app/package.json +58 -0
- package/cli/cli.js +806 -0
- package/cli/package.json +48 -0
- package/i18n/README.ja-JP.md +1209 -0
- package/i18n/README.ru.md +1311 -0
- package/i18n/README.vi.md +1310 -0
- package/i18n/README.zh-CN.md +1306 -0
- package/images/9router.png +0 -0
- package/jsconfig.json +12 -0
- package/next.config.mjs +71 -0
- package/open-sse/config/appConstants.js +203 -0
- package/open-sse/config/codexInstructions.js +119 -0
- package/open-sse/config/constants.js +4 -0
- package/open-sse/config/defaultThinkingSignature.js +12 -0
- package/open-sse/config/errorConfig.js +85 -0
- package/open-sse/config/googleTtsLanguages.js +62 -0
- package/open-sse/config/kiroConstants.js +322 -0
- package/open-sse/config/models.js +13 -0
- package/open-sse/config/ollamaModels.js +19 -0
- package/open-sse/config/providerModels.js +944 -0
- package/open-sse/config/providers.js +458 -0
- package/open-sse/config/runtimeConfig.js +72 -0
- package/open-sse/config/ttsModels.js +129 -0
- package/open-sse/executors/antigravity.js +504 -0
- package/open-sse/executors/azure.js +57 -0
- package/open-sse/executors/base.js +185 -0
- package/open-sse/executors/codex.js +469 -0
- package/open-sse/executors/commandcode.js +88 -0
- package/open-sse/executors/cursor.js +795 -0
- package/open-sse/executors/default.js +497 -0
- package/open-sse/executors/gemini-cli.js +89 -0
- package/open-sse/executors/github.js +379 -0
- package/open-sse/executors/grok-web.js +345 -0
- package/open-sse/executors/iflow.js +108 -0
- package/open-sse/executors/index.js +75 -0
- package/open-sse/executors/kiro.js +508 -0
- package/open-sse/executors/ollama-local.js +14 -0
- package/open-sse/executors/opencode-go.js +41 -0
- package/open-sse/executors/opencode.js +32 -0
- package/open-sse/executors/perplexity-web.js +507 -0
- package/open-sse/executors/qoder.js +450 -0
- package/open-sse/executors/qwen.js +129 -0
- package/open-sse/executors/vertex.js +131 -0
- package/open-sse/executors/xiaomi-tokenplan.js +19 -0
- package/open-sse/handlers/chatCore/nonStreamingHandler.js +230 -0
- package/open-sse/handlers/chatCore/requestDetail.js +102 -0
- package/open-sse/handlers/chatCore/sseToJsonHandler.js +231 -0
- package/open-sse/handlers/chatCore/streamingHandler.js +103 -0
- package/open-sse/handlers/chatCore.js +287 -0
- package/open-sse/handlers/embeddingProviders/_base.js +4 -0
- package/open-sse/handlers/embeddingProviders/gemini.js +54 -0
- package/open-sse/handlers/embeddingProviders/index.js +23 -0
- package/open-sse/handlers/embeddingProviders/openai.js +39 -0
- package/open-sse/handlers/embeddingProviders/openaiCompatNode.js +13 -0
- package/open-sse/handlers/embeddingsCore.js +126 -0
- package/open-sse/handlers/fetch/index.js +237 -0
- package/open-sse/handlers/imageGenerationCore.js +189 -0
- package/open-sse/handlers/imageProviders/_base.js +31 -0
- package/open-sse/handlers/imageProviders/blackForestLabs.js +43 -0
- package/open-sse/handlers/imageProviders/cloudflareAi.js +178 -0
- package/open-sse/handlers/imageProviders/codex.js +198 -0
- package/open-sse/handlers/imageProviders/comfyui.js +8 -0
- package/open-sse/handlers/imageProviders/falAi.js +41 -0
- package/open-sse/handlers/imageProviders/gemini.js +25 -0
- package/open-sse/handlers/imageProviders/huggingface.js +22 -0
- package/open-sse/handlers/imageProviders/index.js +40 -0
- package/open-sse/handlers/imageProviders/nanobanana.js +58 -0
- package/open-sse/handlers/imageProviders/openai.js +40 -0
- package/open-sse/handlers/imageProviders/runwayml.js +47 -0
- package/open-sse/handlers/imageProviders/sdwebui.js +17 -0
- package/open-sse/handlers/imageProviders/stabilityAi.js +34 -0
- package/open-sse/handlers/responsesHandler.js +103 -0
- package/open-sse/handlers/search/callers.js +371 -0
- package/open-sse/handlers/search/chatSearch.js +409 -0
- package/open-sse/handlers/search/index.js +201 -0
- package/open-sse/handlers/search/normalizers.js +223 -0
- package/open-sse/handlers/sttCore.js +194 -0
- package/open-sse/handlers/ttsCore.js +74 -0
- package/open-sse/handlers/ttsProviders/_base.js +39 -0
- package/open-sse/handlers/ttsProviders/edgeTts.js +89 -0
- package/open-sse/handlers/ttsProviders/elevenlabs.js +48 -0
- package/open-sse/handlers/ttsProviders/gemini.js +117 -0
- package/open-sse/handlers/ttsProviders/genericFormats.js +169 -0
- package/open-sse/handlers/ttsProviders/googleTts.js +54 -0
- package/open-sse/handlers/ttsProviders/index.js +50 -0
- package/open-sse/handlers/ttsProviders/localDevice.js +87 -0
- package/open-sse/handlers/ttsProviders/minimax.js +59 -0
- package/open-sse/handlers/ttsProviders/openai.js +30 -0
- package/open-sse/handlers/ttsProviders/openrouter.js +70 -0
- package/open-sse/index.js +82 -0
- package/open-sse/rtk/applyFilter.js +15 -0
- package/open-sse/rtk/autodetect.js +111 -0
- package/open-sse/rtk/caveman.js +100 -0
- package/open-sse/rtk/cavemanPrompts.js +78 -0
- package/open-sse/rtk/constants.js +55 -0
- package/open-sse/rtk/filters/buildOutput.js +127 -0
- package/open-sse/rtk/filters/dedupLog.js +44 -0
- package/open-sse/rtk/filters/find.js +49 -0
- package/open-sse/rtk/filters/gitDiff.js +92 -0
- package/open-sse/rtk/filters/gitStatus.js +117 -0
- package/open-sse/rtk/filters/grep.js +48 -0
- package/open-sse/rtk/filters/ls.js +79 -0
- package/open-sse/rtk/filters/readNumbered.js +27 -0
- package/open-sse/rtk/filters/searchList.js +52 -0
- package/open-sse/rtk/filters/smartTruncate.js +15 -0
- package/open-sse/rtk/filters/tree.js +32 -0
- package/open-sse/rtk/index.js +155 -0
- package/open-sse/rtk/registry.js +38 -0
- package/open-sse/services/accountFallback.js +238 -0
- package/open-sse/services/combo.js +198 -0
- package/open-sse/services/compact.js +71 -0
- package/open-sse/services/kiroModels.js +332 -0
- package/open-sse/services/model.js +261 -0
- package/open-sse/services/oauthCredentialManager.js +151 -0
- package/open-sse/services/projectId.js +306 -0
- package/open-sse/services/provider.js +356 -0
- package/open-sse/services/qoderModels.js +214 -0
- package/open-sse/services/tokenRefresh.js +939 -0
- package/open-sse/services/usage.js +1496 -0
- package/open-sse/transformer/responsesTransformer.js +439 -0
- package/open-sse/transformer/streamToJsonConverter.js +103 -0
- package/open-sse/translator/formats.js +36 -0
- package/open-sse/translator/helpers/claudeHelper.js +216 -0
- package/open-sse/translator/helpers/geminiHelper.js +372 -0
- package/open-sse/translator/helpers/imageHelper.js +34 -0
- package/open-sse/translator/helpers/maxTokensHelper.js +27 -0
- package/open-sse/translator/helpers/openaiHelper.js +130 -0
- package/open-sse/translator/helpers/responsesApiHelper.js +139 -0
- package/open-sse/translator/helpers/toolCallHelper.js +148 -0
- package/open-sse/translator/index.js +251 -0
- package/open-sse/translator/request/antigravity-to-openai.js +229 -0
- package/open-sse/translator/request/claude-to-openai.js +232 -0
- package/open-sse/translator/request/gemini-to-openai.js +147 -0
- package/open-sse/translator/request/openai-responses.js +318 -0
- package/open-sse/translator/request/openai-to-claude.js +401 -0
- package/open-sse/translator/request/openai-to-commandcode.js +170 -0
- package/open-sse/translator/request/openai-to-cursor.js +183 -0
- package/open-sse/translator/request/openai-to-gemini.js +470 -0
- package/open-sse/translator/request/openai-to-kiro.js +629 -0
- package/open-sse/translator/request/openai-to-kiro.old.js +278 -0
- package/open-sse/translator/request/openai-to-ollama.js +192 -0
- package/open-sse/translator/request/openai-to-vertex.js +42 -0
- package/open-sse/translator/response/claude-to-openai.js +206 -0
- package/open-sse/translator/response/commandcode-to-openai.js +197 -0
- package/open-sse/translator/response/cursor-to-openai.js +30 -0
- package/open-sse/translator/response/gemini-to-openai.js +245 -0
- package/open-sse/translator/response/kiro-to-openai.js +195 -0
- package/open-sse/translator/response/ollama-to-openai.js +152 -0
- package/open-sse/translator/response/openai-responses.js +590 -0
- package/open-sse/translator/response/openai-to-antigravity.js +122 -0
- package/open-sse/translator/response/openai-to-claude.js +266 -0
- package/open-sse/utils/bypassHandler.js +298 -0
- package/open-sse/utils/claudeCloaking.js +155 -0
- package/open-sse/utils/claudeHeaderCache.js +70 -0
- package/open-sse/utils/clientDetector.js +63 -0
- package/open-sse/utils/cursorChecksum.js +149 -0
- package/open-sse/utils/cursorProtobuf.js +904 -0
- package/open-sse/utils/debugLog.js +14 -0
- package/open-sse/utils/error.js +147 -0
- package/open-sse/utils/ollamaTransform.js +85 -0
- package/open-sse/utils/proxyFetch.js +368 -0
- package/open-sse/utils/reasoningContentInjector.js +79 -0
- package/open-sse/utils/requestLogger.js +260 -0
- package/open-sse/utils/responsesStreamHelpers.js +49 -0
- package/open-sse/utils/sessionManager.js +82 -0
- package/open-sse/utils/stream.js +462 -0
- package/open-sse/utils/streamHandler.js +250 -0
- package/open-sse/utils/streamHelpers.js +122 -0
- package/open-sse/utils/toolDeduper.js +49 -0
- package/open-sse/utils/usageTracking.js +347 -0
- package/package.json +100 -0
- package/postcss.config.mjs +12 -0
- package/public/favicon.svg +11 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/i18n/literals/ar.json +195 -0
- package/public/i18n/literals/bn.json +195 -0
- package/public/i18n/literals/cs.json +195 -0
- package/public/i18n/literals/da.json +195 -0
- package/public/i18n/literals/de.json +195 -0
- package/public/i18n/literals/el.json +195 -0
- package/public/i18n/literals/es.json +195 -0
- package/public/i18n/literals/fi.json +195 -0
- package/public/i18n/literals/fr.json +195 -0
- package/public/i18n/literals/he.json +195 -0
- package/public/i18n/literals/hi.json +195 -0
- package/public/i18n/literals/hu.json +195 -0
- package/public/i18n/literals/id.json +195 -0
- package/public/i18n/literals/it.json +195 -0
- package/public/i18n/literals/ja.json +195 -0
- package/public/i18n/literals/ko.json +195 -0
- package/public/i18n/literals/nl.json +195 -0
- package/public/i18n/literals/no.json +195 -0
- package/public/i18n/literals/pl.json +195 -0
- package/public/i18n/literals/pt-BR.json +195 -0
- package/public/i18n/literals/pt-PT.json +195 -0
- package/public/i18n/literals/ro.json +195 -0
- package/public/i18n/literals/ru.json +195 -0
- package/public/i18n/literals/sv.json +195 -0
- package/public/i18n/literals/th.json +195 -0
- package/public/i18n/literals/tl.json +195 -0
- package/public/i18n/literals/tr.json +195 -0
- package/public/i18n/literals/uk.json +195 -0
- package/public/i18n/literals/ur.json +195 -0
- package/public/i18n/literals/vi.json +195 -0
- package/public/i18n/literals/zh-CN.json +772 -0
- package/public/i18n/literals/zh-TW.json +195 -0
- package/public/icons/discord.svg +4 -0
- package/public/icons/icon-192.svg +4 -0
- package/public/icons/icon-512.svg +4 -0
- package/public/next.svg +1 -0
- package/public/providers/alicode-intl.png +0 -0
- package/public/providers/alicode.png +0 -0
- package/public/providers/amp.png +0 -0
- package/public/providers/anthropic-m.png +0 -0
- package/public/providers/anthropic.png +0 -0
- package/public/providers/antigravity.png +0 -0
- package/public/providers/assemblyai.png +0 -0
- package/public/providers/aws-polly.png +0 -0
- package/public/providers/azure.png +0 -0
- package/public/providers/black-forest-labs.png +0 -0
- package/public/providers/blackbox.png +0 -0
- package/public/providers/brave-search.png +0 -0
- package/public/providers/byteplus.png +0 -0
- package/public/providers/cartesia.png +0 -0
- package/public/providers/cerebras.png +0 -0
- package/public/providers/chutes.png +0 -0
- package/public/providers/claude.png +0 -0
- package/public/providers/cline.png +0 -0
- package/public/providers/cloudflare-ai.png +0 -0
- package/public/providers/codebuddy.svg +37 -0
- package/public/providers/codex.png +0 -0
- package/public/providers/cohere.png +0 -0
- package/public/providers/comfyui.png +0 -0
- package/public/providers/commandcode.png +0 -0
- package/public/providers/continue.png +0 -0
- package/public/providers/copilot.png +0 -0
- package/public/providers/coqui.png +0 -0
- package/public/providers/cursor.png +0 -0
- package/public/providers/deepgram.png +0 -0
- package/public/providers/deepseek-tui.png +0 -0
- package/public/providers/deepseek.png +0 -0
- package/public/providers/droid.png +0 -0
- package/public/providers/edge-tts.png +0 -0
- package/public/providers/elevenlabs.png +0 -0
- package/public/providers/exa.png +0 -0
- package/public/providers/fal-ai.png +0 -0
- package/public/providers/firecrawl.png +0 -0
- package/public/providers/fireworks.png +0 -0
- package/public/providers/gemini-cli.png +0 -0
- package/public/providers/gemini.png +0 -0
- package/public/providers/github.png +0 -0
- package/public/providers/glm-cn.png +0 -0
- package/public/providers/glm.png +0 -0
- package/public/providers/google-pse.png +0 -0
- package/public/providers/google-tts.png +0 -0
- package/public/providers/grok-web.png +0 -0
- package/public/providers/groq.png +0 -0
- package/public/providers/hermes.png +0 -0
- package/public/providers/huggingface.png +0 -0
- package/public/providers/hyperbolic.png +0 -0
- package/public/providers/iflow.png +0 -0
- package/public/providers/inworld.png +0 -0
- package/public/providers/jcode.png +0 -0
- package/public/providers/jina-ai.png +0 -0
- package/public/providers/jina-reader.png +0 -0
- package/public/providers/kilocode.png +0 -0
- package/public/providers/kimi-coding.png +0 -0
- package/public/providers/kimi.png +0 -0
- package/public/providers/kiro.png +0 -0
- package/public/providers/linkup.png +0 -0
- package/public/providers/local-device.png +0 -0
- package/public/providers/minimax-cn.png +0 -0
- package/public/providers/minimax.png +0 -0
- package/public/providers/mistral.png +0 -0
- package/public/providers/nanobanana.png +0 -0
- package/public/providers/nebius.png +0 -0
- package/public/providers/nvidia.png +0 -0
- package/public/providers/oai-cc.png +0 -0
- package/public/providers/oai-r.png +0 -0
- package/public/providers/ollama-local.png +0 -0
- package/public/providers/ollama.png +0 -0
- package/public/providers/openai.png +0 -0
- package/public/providers/openclaw.png +0 -0
- package/public/providers/opencode-go.png +0 -0
- package/public/providers/opencode.png +0 -0
- package/public/providers/openrouter.png +0 -0
- package/public/providers/perplexity-web.png +0 -0
- package/public/providers/perplexity.png +0 -0
- package/public/providers/playht.png +0 -0
- package/public/providers/qoder.png +0 -0
- package/public/providers/qwen.png +0 -0
- package/public/providers/recraft.png +0 -0
- package/public/providers/roo.png +0 -0
- package/public/providers/runwayml.png +0 -0
- package/public/providers/sdwebui.png +0 -0
- package/public/providers/searchapi.png +0 -0
- package/public/providers/searxng.png +0 -0
- package/public/providers/serper.png +0 -0
- package/public/providers/siliconflow.png +0 -0
- package/public/providers/stability-ai.png +0 -0
- package/public/providers/tavily.png +0 -0
- package/public/providers/together.png +0 -0
- package/public/providers/topaz.png +0 -0
- package/public/providers/tortoise.png +0 -0
- package/public/providers/vertex-partner.png +0 -0
- package/public/providers/vertex.png +0 -0
- package/public/providers/volcengine-ark.png +0 -0
- package/public/providers/voyage-ai.png +0 -0
- package/public/providers/xai.png +0 -0
- package/public/providers/xiaomi-mimo.png +0 -0
- package/public/providers/xiaomi-tokenplan.png +0 -0
- package/public/providers/youcom.png +0 -0
- package/public/sw.js +22 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/compact-request-details.mjs +71 -0
- package/scripts/import-codex-gptjson.mjs +342 -0
- package/scripts/start-standalone.mjs +25 -0
- package/scripts/translate-readme.js +201 -0
- package/src/app/(dashboard)/dashboard/automation/page.js +294 -0
- package/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +967 -0
- package/src/app/(dashboard)/dashboard/basic-chat/page.js +5 -0
- package/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +66 -0
- package/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +173 -0
- package/src/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js +11 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +481 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js +66 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +174 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +390 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js +301 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +458 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +323 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +640 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js +338 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +271 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +410 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js +128 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +317 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js +380 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js +275 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js +40 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +329 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +318 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +388 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +500 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js +39 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js +13 -0
- package/src/app/(dashboard)/dashboard/cli-tools/components/index.js +19 -0
- package/src/app/(dashboard)/dashboard/cli-tools/page.js +7 -0
- package/src/app/(dashboard)/dashboard/combos/page.js +612 -0
- package/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js +91 -0
- package/src/app/(dashboard)/dashboard/console-log/page.js +8 -0
- package/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +1555 -0
- package/src/app/(dashboard)/dashboard/endpoint/page.js +7 -0
- package/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +1903 -0
- package/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +289 -0
- package/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +410 -0
- package/src/app/(dashboard)/dashboard/media-providers/web/page.js +209 -0
- package/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js +117 -0
- package/src/app/(dashboard)/dashboard/mitm/page.js +5 -0
- package/src/app/(dashboard)/dashboard/page.js +7 -0
- package/src/app/(dashboard)/dashboard/profile/page.js +1140 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +389 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js +125 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js +250 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +299 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js +42 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js +161 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +95 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js +183 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/page.js +2053 -0
- package/src/app/(dashboard)/dashboard/providers/[id]/page.new.js +1724 -0
- package/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js +497 -0
- package/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js +185 -0
- package/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +294 -0
- package/src/app/(dashboard)/dashboard/providers/new/page.js +220 -0
- package/src/app/(dashboard)/dashboard/providers/page.js +1365 -0
- package/src/app/(dashboard)/dashboard/proxy-pools/page.js +1092 -0
- package/src/app/(dashboard)/dashboard/quota/page.js +11 -0
- package/src/app/(dashboard)/dashboard/skills/page.js +112 -0
- package/src/app/(dashboard)/dashboard/translator/page.js +303 -0
- package/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js +35 -0
- package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +185 -0
- package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js +127 -0
- package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +259 -0
- package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +1394 -0
- package/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +244 -0
- package/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +327 -0
- package/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +433 -0
- package/src/app/(dashboard)/dashboard/usage/components/UsageChart.js +141 -0
- package/src/app/(dashboard)/dashboard/usage/components/UsageTable.js +247 -0
- package/src/app/(dashboard)/dashboard/usage/page.js +75 -0
- package/src/app/(dashboard)/layout.js +6 -0
- package/src/app/api/auth/login/route.js +76 -0
- package/src/app/api/auth/logout/route.js +12 -0
- package/src/app/api/auth/oidc/callback/route.js +87 -0
- package/src/app/api/auth/oidc/start/route.js +52 -0
- package/src/app/api/auth/oidc/test/route.js +84 -0
- package/src/app/api/auth/status/route.js +45 -0
- package/src/app/api/cli-tools/all-statuses/route.js +46 -0
- package/src/app/api/cli-tools/antigravity-mitm/alias/route.js +53 -0
- package/src/app/api/cli-tools/antigravity-mitm/route.js +202 -0
- package/src/app/api/cli-tools/claude-settings/route.js +203 -0
- package/src/app/api/cli-tools/cline-settings/route.js +133 -0
- package/src/app/api/cli-tools/codex-gateway/accounts/route.js +16 -0
- package/src/app/api/cli-tools/codex-settings/route.js +239 -0
- package/src/app/api/cli-tools/copilot-settings/route.js +148 -0
- package/src/app/api/cli-tools/cowork-mcp-registry/route.js +77 -0
- package/src/app/api/cli-tools/cowork-mcp-tools/route.js +95 -0
- package/src/app/api/cli-tools/cowork-settings/route.js +412 -0
- package/src/app/api/cli-tools/deepseek-tui-settings/route.js +164 -0
- package/src/app/api/cli-tools/droid-settings/route.js +213 -0
- package/src/app/api/cli-tools/hermes-settings/route.js +175 -0
- package/src/app/api/cli-tools/jcode-settings/route.js +216 -0
- package/src/app/api/cli-tools/kilo-settings/route.js +131 -0
- package/src/app/api/cli-tools/openclaw-settings/route.js +292 -0
- package/src/app/api/cli-tools/opencode-settings/route.js +259 -0
- package/src/app/api/combos/[id]/route.js +81 -0
- package/src/app/api/combos/route.js +48 -0
- package/src/app/api/health/route.js +15 -0
- package/src/app/api/init/route.js +4 -0
- package/src/app/api/keys/[id]/route.js +58 -0
- package/src/app/api/keys/route.js +42 -0
- package/src/app/api/locale/route.js +30 -0
- package/src/app/api/mcp/[plugin]/message/route.js +21 -0
- package/src/app/api/mcp/[plugin]/sse/route.js +37 -0
- package/src/app/api/media-providers/tts/deepgram/voices/route.js +65 -0
- package/src/app/api/media-providers/tts/elevenlabs/voices/route.js +71 -0
- package/src/app/api/media-providers/tts/inworld/voices/route.js +61 -0
- package/src/app/api/media-providers/tts/minimax/voices/route.js +113 -0
- package/src/app/api/media-providers/tts/voices/route.js +99 -0
- package/src/app/api/models/alias/route.js +53 -0
- package/src/app/api/models/availability/route.js +103 -0
- package/src/app/api/models/custom/route.js +48 -0
- package/src/app/api/models/disabled/route.js +50 -0
- package/src/app/api/models/route.js +64 -0
- package/src/app/api/models/test/ping.js +191 -0
- package/src/app/api/models/test/route.js +14 -0
- package/src/app/api/oauth/[provider]/[action]/route.js +343 -0
- package/src/app/api/oauth/codebuddy/bulk-import/[jobId]/cancel/route.js +19 -0
- package/src/app/api/oauth/codebuddy/bulk-import/[jobId]/manual/[workerId]/route.js +30 -0
- package/src/app/api/oauth/codebuddy/bulk-import/[jobId]/route.js +23 -0
- package/src/app/api/oauth/codebuddy/bulk-import/latest/route.js +25 -0
- package/src/app/api/oauth/codebuddy/bulk-import/route.js +49 -0
- package/src/app/api/oauth/codebuddy/quota-cookie/route.js +133 -0
- package/src/app/api/oauth/codex/import-token/route.js +96 -0
- package/src/app/api/oauth/cursor/auto-import/route.js +258 -0
- package/src/app/api/oauth/cursor/import/route.js +100 -0
- package/src/app/api/oauth/gitlab/pat/route.js +62 -0
- package/src/app/api/oauth/iflow/cookie/route.js +137 -0
- package/src/app/api/oauth/kiro/auto-import/route.js +85 -0
- package/src/app/api/oauth/kiro/bulk-import/[jobId]/cancel/route.js +18 -0
- package/src/app/api/oauth/kiro/bulk-import/[jobId]/manual/[workerId]/route.js +29 -0
- package/src/app/api/oauth/kiro/bulk-import/[jobId]/route.js +22 -0
- package/src/app/api/oauth/kiro/bulk-import/latest/route.js +25 -0
- package/src/app/api/oauth/kiro/bulk-import/route.js +49 -0
- package/src/app/api/oauth/kiro/import/route.js +110 -0
- package/src/app/api/oauth/kiro/social-authorize/route.js +27 -0
- package/src/app/api/oauth/kiro/social-exchange/route.js +41 -0
- package/src/app/api/pricing/route.js +134 -0
- package/src/app/api/provider-nodes/[id]/route.js +101 -0
- package/src/app/api/provider-nodes/route.js +104 -0
- package/src/app/api/provider-nodes/validate/route.js +201 -0
- package/src/app/api/providers/[id]/models/route.js +526 -0
- package/src/app/api/providers/[id]/route.js +189 -0
- package/src/app/api/providers/[id]/test/route.js +23 -0
- package/src/app/api/providers/[id]/test/testUtils.js +714 -0
- package/src/app/api/providers/[id]/test-models/route.js +66 -0
- package/src/app/api/providers/client/route.js +126 -0
- package/src/app/api/providers/kilo/free-models/route.js +55 -0
- package/src/app/api/providers/route.js +206 -0
- package/src/app/api/providers/suggested-models/filters.js +20 -0
- package/src/app/api/providers/suggested-models/route.js +32 -0
- package/src/app/api/providers/test-batch/route.js +131 -0
- package/src/app/api/providers/validate/route.js +637 -0
- package/src/app/api/proxy-pools/[id]/route.js +123 -0
- package/src/app/api/proxy-pools/[id]/test/route.js +70 -0
- package/src/app/api/proxy-pools/cloudflare-deploy/route.js +145 -0
- package/src/app/api/proxy-pools/deno-deploy/route.js +175 -0
- package/src/app/api/proxy-pools/route.js +93 -0
- package/src/app/api/proxy-pools/vercel-deploy/route.js +142 -0
- package/src/app/api/settings/database/route.js +36 -0
- package/src/app/api/settings/proxy-test/route.js +23 -0
- package/src/app/api/settings/require-login/route.js +15 -0
- package/src/app/api/settings/route.js +100 -0
- package/src/app/api/shutdown/route.js +24 -0
- package/src/app/api/tags/route.js +18 -0
- package/src/app/api/translator/console-logs/route.js +24 -0
- package/src/app/api/translator/console-logs/stream/route.js +79 -0
- package/src/app/api/translator/load/route.js +45 -0
- package/src/app/api/translator/save/route.js +44 -0
- package/src/app/api/translator/send/route.js +94 -0
- package/src/app/api/translator/translate/route.js +90 -0
- package/src/app/api/tunnel/disable/route.js +12 -0
- package/src/app/api/tunnel/enable/route.js +16 -0
- package/src/app/api/tunnel/status/route.js +13 -0
- package/src/app/api/tunnel/tailscale-check/route.js +50 -0
- package/src/app/api/tunnel/tailscale-disable/route.js +12 -0
- package/src/app/api/tunnel/tailscale-enable/route.js +12 -0
- package/src/app/api/tunnel/tailscale-install/route.js +72 -0
- package/src/app/api/usage/[connectionId]/route.js +188 -0
- package/src/app/api/usage/chart/route.js +21 -0
- package/src/app/api/usage/history/route.js +12 -0
- package/src/app/api/usage/logs/route.js +12 -0
- package/src/app/api/usage/providers/route.js +42 -0
- package/src/app/api/usage/request-details/route.js +57 -0
- package/src/app/api/usage/request-logs/route.js +13 -0
- package/src/app/api/usage/stats/route.js +23 -0
- package/src/app/api/usage/stream/route.js +79 -0
- package/src/app/api/v1/api/chat/route.js +37 -0
- package/src/app/api/v1/audio/speech/route.js +16 -0
- package/src/app/api/v1/audio/transcriptions/route.js +19 -0
- package/src/app/api/v1/audio/voices/route.js +68 -0
- package/src/app/api/v1/chat/completions/route.js +35 -0
- package/src/app/api/v1/embeddings/route.js +21 -0
- package/src/app/api/v1/images/generations/route.js +16 -0
- package/src/app/api/v1/messages/count_tokens/route.js +52 -0
- package/src/app/api/v1/messages/route.js +36 -0
- package/src/app/api/v1/models/[kind]/route.js +55 -0
- package/src/app/api/v1/models/info/route.js +110 -0
- package/src/app/api/v1/models/route.js +451 -0
- package/src/app/api/v1/responses/compact/route.js +37 -0
- package/src/app/api/v1/responses/route.js +30 -0
- package/src/app/api/v1/route.js +1 -0
- package/src/app/api/v1/search/route.js +21 -0
- package/src/app/api/v1/web/fetch/route.js +21 -0
- package/src/app/api/v1beta/models/[...path]/route.js +328 -0
- package/src/app/api/v1beta/models/route.js +44 -0
- package/src/app/api/version/route.js +45 -0
- package/src/app/api/version/shutdown/route.js +15 -0
- package/src/app/api/version/update/route.js +21 -0
- package/src/app/callback/page.js +148 -0
- package/src/app/dashboard/settings/pricing/page.js +173 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +496 -0
- package/src/app/landing/components/AnimatedBackground.js +57 -0
- package/src/app/landing/components/Features.js +133 -0
- package/src/app/landing/components/FlowAnimation.js +175 -0
- package/src/app/landing/components/Footer.js +61 -0
- package/src/app/landing/components/GetStarted.js +97 -0
- package/src/app/landing/components/HeroSection.js +47 -0
- package/src/app/landing/components/HowItWorks.js +66 -0
- package/src/app/landing/components/Navigation.js +72 -0
- package/src/app/landing/page.js +106 -0
- package/src/app/layout.js +49 -0
- package/src/app/login/page.js +197 -0
- package/src/app/manifest.js +30 -0
- package/src/app/page.js +5 -0
- package/src/dashboardGuard.js +242 -0
- package/src/i18n/RuntimeI18nProvider.js +27 -0
- package/src/i18n/config.js +146 -0
- package/src/i18n/runtime.js +162 -0
- package/src/lib/appUpdater.js +200 -0
- package/src/lib/auth/dashboardSession.js +68 -0
- package/src/lib/auth/loginLimiter.js +52 -0
- package/src/lib/auth/oidc.js +234 -0
- package/src/lib/consoleLogBuffer.js +79 -0
- package/src/lib/dataDir.js +29 -0
- package/src/lib/db/adapters/betterSqliteAdapter.js +55 -0
- package/src/lib/db/adapters/bunSqliteAdapter.js +63 -0
- package/src/lib/db/adapters/nodeSqliteAdapter.js +84 -0
- package/src/lib/db/adapters/sqljsAdapter.js +115 -0
- package/src/lib/db/backup.js +35 -0
- package/src/lib/db/driver.js +85 -0
- package/src/lib/db/helpers/jsonCol.js +9 -0
- package/src/lib/db/helpers/kvStore.js +39 -0
- package/src/lib/db/helpers/metaStore.js +22 -0
- package/src/lib/db/index.js +171 -0
- package/src/lib/db/migrate.js +286 -0
- package/src/lib/db/migrations/001-initial.js +14 -0
- package/src/lib/db/migrations/index.js +10 -0
- package/src/lib/db/paths.js +18 -0
- package/src/lib/db/repos/aliasRepo.js +62 -0
- package/src/lib/db/repos/apiKeysRepo.js +75 -0
- package/src/lib/db/repos/combosRepo.js +73 -0
- package/src/lib/db/repos/connectionsRepo.js +226 -0
- package/src/lib/db/repos/disabledModelsRepo.js +56 -0
- package/src/lib/db/repos/nodesRepo.js +95 -0
- package/src/lib/db/repos/pricingRepo.js +108 -0
- package/src/lib/db/repos/proxyPoolsRepo.js +103 -0
- package/src/lib/db/repos/requestDetailsRepo.js +259 -0
- package/src/lib/db/repos/settingsRepo.js +104 -0
- package/src/lib/db/repos/usageRepo.js +731 -0
- package/src/lib/db/schema.js +157 -0
- package/src/lib/db/version.js +21 -0
- package/src/lib/disabledModelsDb.js +4 -0
- package/src/lib/localDb.js +21 -0
- package/src/lib/mcp/stdioSseBridge.js +198 -0
- package/src/lib/mitmAliasCache.js +46 -0
- package/src/lib/network/connectionProxy.js +160 -0
- package/src/lib/network/initOutboundProxy.js +25 -0
- package/src/lib/network/outboundProxy.js +68 -0
- package/src/lib/network/proxyTest.js +91 -0
- package/src/lib/oauth/constants/oauth.js +284 -0
- package/src/lib/oauth/constants/xai.js +61 -0
- package/src/lib/oauth/providers.js +1506 -0
- package/src/lib/oauth/services/antigravity.js +321 -0
- package/src/lib/oauth/services/claude.js +136 -0
- package/src/lib/oauth/services/codebuddyBulkImportManager.js +454 -0
- package/src/lib/oauth/services/codex.js +144 -0
- package/src/lib/oauth/services/cursor.js +179 -0
- package/src/lib/oauth/services/gemini.js +240 -0
- package/src/lib/oauth/services/github.js +225 -0
- package/src/lib/oauth/services/iflow.js +202 -0
- package/src/lib/oauth/services/index.js +17 -0
- package/src/lib/oauth/services/kiro.js +334 -0
- package/src/lib/oauth/services/kiroBulkImportManager.js +778 -0
- package/src/lib/oauth/services/kiroConnections.js +93 -0
- package/src/lib/oauth/services/kiroGoogleAutomation.js +1136 -0
- package/src/lib/oauth/services/oauth.js +157 -0
- package/src/lib/oauth/services/openai.js +123 -0
- package/src/lib/oauth/services/qoder.js +216 -0
- package/src/lib/oauth/services/qwen.js +170 -0
- package/src/lib/oauth/services/xai.js +238 -0
- package/src/lib/oauth/utils/banner.js +63 -0
- package/src/lib/oauth/utils/pkce.js +39 -0
- package/src/lib/oauth/utils/server.js +415 -0
- package/src/lib/oauth/utils/ui.js +48 -0
- package/src/lib/providerNormalization.js +45 -0
- package/src/lib/qoder/constants.js +64 -0
- package/src/lib/qoder/cosy.js +175 -0
- package/src/lib/qoder/encoding.js +55 -0
- package/src/lib/requestDetailsDb.js +4 -0
- package/src/lib/tunnel/cloudflare/cloudflared.js +449 -0
- package/src/lib/tunnel/cloudflare/config.js +9 -0
- package/src/lib/tunnel/cloudflare/healthCheck.js +29 -0
- package/src/lib/tunnel/cloudflare/manager.js +151 -0
- package/src/lib/tunnel/cloudflare/pid.js +23 -0
- package/src/lib/tunnel/index.js +48 -0
- package/src/lib/tunnel/shared/dnsResolver.js +17 -0
- package/src/lib/tunnel/shared/internetCheck.js +26 -0
- package/src/lib/tunnel/shared/state.js +41 -0
- package/src/lib/tunnel/shared/watchdogConfig.js +8 -0
- package/src/lib/tunnel/tailscale/config.js +7 -0
- package/src/lib/tunnel/tailscale/healthCheck.js +29 -0
- package/src/lib/tunnel/tailscale/manager.js +129 -0
- package/src/lib/tunnel/tailscale/tailscale.js +790 -0
- package/src/lib/updater/updater.js +235 -0
- package/src/lib/usage/fetcher.js +208 -0
- package/src/lib/usageDb.js +7 -0
- package/src/mitm/antigravityIdeVersion.js +50 -0
- package/src/mitm/cert/generate.js +32 -0
- package/src/mitm/cert/install.js +269 -0
- package/src/mitm/cert/rootCA.js +173 -0
- package/src/mitm/config.js +87 -0
- package/src/mitm/dbReader.js +22 -0
- package/src/mitm/dns/dnsConfig.js +266 -0
- package/src/mitm/handlers/antigravity.js +33 -0
- package/src/mitm/handlers/base.js +226 -0
- package/src/mitm/handlers/copilot.js +35 -0
- package/src/mitm/handlers/cursor.js +15 -0
- package/src/mitm/handlers/kiro.js +526 -0
- package/src/mitm/logger.js +106 -0
- package/src/mitm/manager.js +851 -0
- package/src/mitm/paths.js +32 -0
- package/src/mitm/server.js +435 -0
- package/src/mitm/winElevated.js +81 -0
- package/src/models/index.js +38 -0
- package/src/proxy.js +5 -0
- package/src/shared/components/AddCustomEmbeddingModal.js +183 -0
- package/src/shared/components/Avatar.js +88 -0
- package/src/shared/components/Badge.js +54 -0
- package/src/shared/components/BulkAccountAutomationModal.js +508 -0
- package/src/shared/components/Button.js +56 -0
- package/src/shared/components/Card.js +116 -0
- package/src/shared/components/ChangelogModal.js +97 -0
- package/src/shared/components/CodeBuddyQuotaCookieModal.js +109 -0
- package/src/shared/components/ComboFormModal.js +176 -0
- package/src/shared/components/CursorAuthModal.js +212 -0
- package/src/shared/components/DonateModal.js +136 -0
- package/src/shared/components/Drawer.js +82 -0
- package/src/shared/components/EditConnectionModal.js +286 -0
- package/src/shared/components/Footer.js +132 -0
- package/src/shared/components/GitLabAuthModal.js +194 -0
- package/src/shared/components/Header.js +380 -0
- package/src/shared/components/HeaderLanguage.js +46 -0
- package/src/shared/components/HeaderMenu.js +126 -0
- package/src/shared/components/IFlowCookieModal.js +132 -0
- package/src/shared/components/Input.js +65 -0
- package/src/shared/components/KiroAuthModal.js +1171 -0
- package/src/shared/components/KiroOAuthWrapper.js +149 -0
- package/src/shared/components/KiroSocialOAuthModal.js +205 -0
- package/src/shared/components/LanguageSwitcher.js +190 -0
- package/src/shared/components/Loading.js +75 -0
- package/src/shared/components/ManualConfigModal.js +44 -0
- package/src/shared/components/McpMarketplaceModal.js +255 -0
- package/src/shared/components/Modal.js +146 -0
- package/src/shared/components/ModelSelectModal.js +537 -0
- package/src/shared/components/NineRemoteButton.js +23 -0
- package/src/shared/components/NineRemotePromoModal.js +99 -0
- package/src/shared/components/NoAuthProxyCard.js +86 -0
- package/src/shared/components/OAuthModal.js +682 -0
- package/src/shared/components/Pagination.js +150 -0
- package/src/shared/components/PricingModal.js +208 -0
- package/src/shared/components/ProviderIcon.js +63 -0
- package/src/shared/components/ProviderInfoCard.js +82 -0
- package/src/shared/components/RequestLogger.js +121 -0
- package/src/shared/components/SegmentedControl.js +48 -0
- package/src/shared/components/Select.js +67 -0
- package/src/shared/components/Sidebar.js +441 -0
- package/src/shared/components/ThemeProvider.js +15 -0
- package/src/shared/components/ThemeToggle.js +42 -0
- package/src/shared/components/Toggle.js +69 -0
- package/src/shared/components/Tooltip.js +25 -0
- package/src/shared/components/UsageStats.js +505 -0
- package/src/shared/components/index.js +46 -0
- package/src/shared/components/layouts/AuthLayout.js +29 -0
- package/src/shared/components/layouts/DashboardLayout.js +104 -0
- package/src/shared/components/layouts/index.js +4 -0
- package/src/shared/constants/cliTools.js +397 -0
- package/src/shared/constants/colors.js +77 -0
- package/src/shared/constants/config.js +99 -0
- package/src/shared/constants/coworkPlugins.js +75 -0
- package/src/shared/constants/index.js +4 -0
- package/src/shared/constants/locales.js +36 -0
- package/src/shared/constants/mitmToolHosts.js +12 -0
- package/src/shared/constants/models.js +38 -0
- package/src/shared/constants/pricing.js +303 -0
- package/src/shared/constants/providers.js +289 -0
- package/src/shared/constants/skills.js +78 -0
- package/src/shared/constants/ttsProviders.js +138 -0
- package/src/shared/hooks/index.js +2 -0
- package/src/shared/hooks/useCopyToClipboard.js +43 -0
- package/src/shared/hooks/useTheme.js +60 -0
- package/src/shared/services/bootstrap.js +12 -0
- package/src/shared/services/initializeApp.js +268 -0
- package/src/shared/utils/api.js +93 -0
- package/src/shared/utils/apiKey.js +98 -0
- package/src/shared/utils/clineAuth.js +37 -0
- package/src/shared/utils/cn.js +11 -0
- package/src/shared/utils/connectionStatus.js +162 -0
- package/src/shared/utils/index.js +40 -0
- package/src/shared/utils/machine.js +6 -0
- package/src/shared/utils/machineId.js +66 -0
- package/src/shared/utils/providerModelsFetcher.js +30 -0
- package/src/sse/handlers/chat.js +261 -0
- package/src/sse/handlers/embeddings.js +141 -0
- package/src/sse/handlers/fetch.js +213 -0
- package/src/sse/handlers/imageGeneration.js +142 -0
- package/src/sse/handlers/search.js +206 -0
- package/src/sse/handlers/stt.js +88 -0
- package/src/sse/handlers/tts.js +114 -0
- package/src/sse/services/auth.js +346 -0
- package/src/sse/services/codexGateway.js +215 -0
- package/src/sse/services/model.js +99 -0
- package/src/sse/services/tokenRefresh.js +319 -0
- package/src/sse/utils/logger.js +75 -0
- package/src/store/headerSearchStore.js +19 -0
- package/src/store/index.js +6 -0
- package/src/store/notificationStore.js +45 -0
- package/src/store/providerStore.js +55 -0
- package/src/store/settingsStore.js +51 -0
- package/src/store/themeStore.js +54 -0
- package/src/store/userStore.js +20 -0
- 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">⚡ {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">⚡ {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">⚡ {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">⚡ {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
|
+
}
|