diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index d932d147..abfe6c50 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -212,6 +212,8 @@ export const PROVIDER_MODELS = { // Gemini 2.0 series (retiring June 1, 2026) { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, { id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash Lite" }, + { id: "gemma-4-31b-it", name: "Gemma 4 31B IT" }, + // Embedding models { id: "gemini-embedding-2-preview", name: "Gemini Embedding 2 Preview", type: "embedding" }, { id: "gemini-embedding-001", name: "Gemini Embedding 001", type: "embedding" }, @@ -364,6 +366,63 @@ export const PROVIDER_MODELS = { { id: "qwen/qwen3-next-80b-a3b-instruct-maas", name: "Qwen3 Next 80B Instruct (Vertex)" }, { id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" }, ], + + // Free/noAuth TTS providers + "local-device": [ + { id: "default", name: "System Default Voice", type: "tts" }, + ], + "google-tts": [ + { id: "en", name: "English", type: "tts" }, + { id: "vi", name: "Vietnamese", type: "tts" }, + { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" }, + { id: "fr", name: "French", type: "tts" }, + { id: "de", name: "German", type: "tts" }, + { id: "ja", name: "Japanese", type: "tts" }, + { id: "ko", name: "Korean", type: "tts" }, + ], + // OpenAI TTS voices (hardcoded — no public API to list them) + // Used by ttsCore.js when provider = openai + "openai-tts-voices": [ + { id: "alloy", name: "Alloy", type: "tts" }, + { id: "ash", name: "Ash", type: "tts" }, + { id: "ballad", name: "Ballad", type: "tts" }, + { id: "cedar", name: "Cedar", type: "tts" }, + { id: "coral", name: "Coral", type: "tts" }, + { id: "echo", name: "Echo", type: "tts" }, + { id: "fable", name: "Fable", type: "tts" }, + { id: "marin", name: "Marin", type: "tts" }, + { id: "nova", name: "Nova", type: "tts" }, + { id: "onyx", name: "Onyx", type: "tts" }, + { id: "sage", name: "Sage", type: "tts" }, + { id: "shimmer", name: "Shimmer", type: "tts" }, + { id: "verse", name: "Verse", type: "tts" }, + ], + // OpenAI TTS models + "openai-tts-models": [ + { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" }, + { id: "tts-1-hd", name: "TTS-1 HD", type: "tts" }, + { id: "tts-1", name: "TTS-1", type: "tts" }, + ], + // ElevenLabs TTS models + "elevenlabs-tts-models": [ + { id: "eleven_flash_v2_5", name: "Flash v2.5 (Fastest)", type: "tts" }, + { id: "eleven_turbo_v2_5", name: "Turbo v2.5 (Fast)", type: "tts" }, + { id: "eleven_multilingual_v2", name: "Multilingual v2 (Quality)", type: "tts" }, + { id: "eleven_monolingual_v1", name: "Monolingual v1 (English)", type: "tts" }, + ], + "edge-tts": [ + { id: "en-US-AriaNeural", name: "Aria (en-US)", type: "tts" }, + { id: "en-US-GuyNeural", name: "Guy (en-US)", type: "tts" }, + { id: "en-GB-SoniaNeural", name: "Sonia (en-GB)", type: "tts" }, + { id: "vi-VN-HoaiMyNeural", name: "Hoai My (vi-VN)", type: "tts" }, + { id: "vi-VN-NamMinhNeural", name: "Nam Minh (vi-VN)", type: "tts" }, + { id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (zh-CN)", type: "tts" }, + { id: "zh-CN-YunxiNeural", name: "Yunxi (zh-CN)", type: "tts" }, + { id: "fr-FR-DeniseNeural", name: "Denise (fr-FR)", type: "tts" }, + { id: "de-DE-KatjaNeural", name: "Katja (de-DE)", type: "tts" }, + { id: "ja-JP-NanamiNeural", name: "Nanami (ja-JP)", type: "tts" }, + { id: "ko-KR-SunHiNeural", name: "SunHi (ko-KR)", type: "tts" }, + ], }; // Helper functions diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js new file mode 100644 index 00000000..18c84705 --- /dev/null +++ b/open-sse/handlers/ttsCore.js @@ -0,0 +1,419 @@ +import { createErrorResult } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { mkdtemp, readFile, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +const execFileAsync = promisify(execFile); + +// ── Response Formatter (DRY) ─────────────────────────────────── +function createTtsResponse(base64Audio, format, responseFormat) { + const audioBuffer = Buffer.from(base64Audio, "base64"); + + // JSON format: return base64 encoded audio + if (responseFormat === "json") { + return { + success: true, + response: new Response(JSON.stringify({ audio: base64Audio, format }), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; + } + + // Binary format (default): return raw MP3 + return { + success: true, + response: new Response(audioBuffer, { + headers: { + "Content-Type": `audio/${format}`, + "Content-Length": String(audioBuffer.length), + "Access-Control-Allow-Origin": "*", + }, + }), + }; +} + +// ── Token cache per engine ───────────────────────────────────── +const cache = { + google: { token: null, tokenTime: 0 }, + bing: { token: null, tokenTime: 0 }, +}; + +const GOOGLE_REFRESH = 11 * 60 * 1000; +const BING_REFRESH = 5 * 60 * 1000; // conservative: token TTL is 1h but refresh early + +const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; +const SEC_CH_HEADERS = { + "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + "sec-ch-ua-arch": '"arm"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": '"146.0.7680.178"', + "sec-ch-ua-full-version-list": '"Chromium";v="146.0.7680.178", "Not-A.Brand";v="24.0.0.0", "Google Chrome";v="146.0.7680.178"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-model": '""', + "sec-ch-ua-platform": '"macOS"', + "sec-ch-ua-platform-version": '"15.1.0"', +}; + +// ── Google TTS ───────────────────────────────────────────────── +async function getGoogleToken() { + const now = Date.now(); + if (cache.google.token && now - cache.google.tokenTime < GOOGLE_REFRESH) { + return cache.google.token; + } + const res = await fetch("https://translate.google.com/", { + headers: { "User-Agent": UA }, + }); + if (!res.ok) throw new Error(`Google translate fetch failed: ${res.status}`); + const html = await res.text(); + const fSid = html.match(/"FdrFJe":"(.*?)"/)?.[ 1]; + const bl = html.match(/"cfb2h":"(.*?)"/)?.[ 1]; + if (!fSid || !bl) throw new Error("Failed to parse Google token"); + cache.google.token = { "f.sid": fSid, bl }; + cache.google.tokenTime = now; + return cache.google.token; +} + +let _googleIdx = 0; +async function googleTts(text, lang) { + const token = await getGoogleToken(); + const cleanText = text.replace(/[@^*()\\/\-_+=><"'\u201c\u201d\u3010\u3011]/g, " ").replaceAll(", ", ". "); + const rpcId = "jQ1olc"; + const reqId = (++_googleIdx * 100000) + Math.floor(1000 + Math.random() * 9000); + const query = new URLSearchParams({ + rpcids: rpcId, + "f.sid": token["f.sid"], + bl: token.bl, + hl: lang, + "soc-app": 1, "soc-platform": 1, "soc-device": 1, + _reqid: reqId, + rt: "c", + }); + const payload = [cleanText, lang, null, "undefined", [0]]; + const body = new URLSearchParams(); + body.append("f.req", JSON.stringify([[[rpcId, JSON.stringify(payload), null, "generic"]]])); + const res = await fetch(`https://translate.google.com/_/TranslateWebserverUi/data/batchexecute?${query}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Referer": "https://translate.google.com/" }, + body: body.toString(), + }); + if (!res.ok) throw new Error(`Google TTS failed: ${res.status}`); + const data = await res.text(); + const split = JSON.parse(data.split("\n")[3]); + const base64 = JSON.parse(split[0][2])[0]; + if (!base64 || base64.length < 100) throw new Error("Google TTS returned empty audio"); + return base64; // base64 MP3 +} + +// ── Bing TTS ─────────────────────────────────────────────────── +async function getBingToken() { + const now = Date.now(); + if (cache.bing.token && now - cache.bing.tokenTime < BING_REFRESH) { + return cache.bing.token; + } + const res = await fetch("https://www.bing.com/translator", { + headers: { "User-Agent": UA, "Accept-Language": "vi,en-US;q=0.9,en;q=0.8" }, + }); + if (!res.ok) throw new Error(`Bing translator fetch failed: ${res.status}`); + const rawCookies = res.headers.getSetCookie?.() || []; + const cookie = rawCookies.map((c) => c.split(";")[0]).join("; "); + const html = await res.text(); + const match = html.match(/params_AbusePreventionHelper\s*=\s*\[([^,]+),([^,]+),/); + if (!match) throw new Error("Failed to parse Bing token"); + cache.bing.token = { key: match[1], token: match[2].replace(/"/g, ""), cookie }; + cache.bing.tokenTime = now; + return cache.bing.token; +} + +async function bingTtsRequest(text, voiceId, token) { + const parts = voiceId.split("-"); + const xmlLang = parts.slice(0, 2).join("-"); + const gender = voiceId.toLowerCase().includes("male") ? "Male" : "Female"; + const ssml = `${text}`; + const body = new URLSearchParams(); + body.append("ssml", ssml); + body.append("token", token.token); + body.append("key", token.key); + return fetch("https://www.bing.com/tfettts?isVertical=1&&IG=1&IID=translator.5023&SFX=1", { + method: "POST", + body: body.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "*/*", + "Origin": "https://www.bing.com", + "Referer": "https://www.bing.com/translator", + "User-Agent": UA, + ...(token.cookie ? { "Cookie": token.cookie } : {}), + }, + }); +} + +async function bingTts(text, voiceId) { + let token = await getBingToken(); + let res = await bingTtsRequest(text, voiceId, token); + + // On 429/captcha: invalidate cache and retry once with fresh token + if (res.status === 429 || res.status === 403) { + cache.bing.token = null; + cache.bing.tokenTime = 0; + token = await getBingToken(); + res = await bingTtsRequest(text, voiceId, token); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Bing TTS failed: ${res.status}${body ? " - " + body : ""}`); + } + const buf = await res.arrayBuffer(); + if (buf.byteLength < 1024) throw new Error("Bing TTS returned empty audio"); + return Buffer.from(buf).toString("base64"); // base64 MP3 +} + +// ── Local Device TTS (macOS `say` + ffmpeg) ─────────────────── +let _localVoicesCache = null; + +export async function fetchLocalDeviceVoices() { + if (_localVoicesCache) return _localVoicesCache; + try { + const { stdout } = await execFileAsync("say", ["-v", "?"]); + const voices = []; + for (const line of stdout.split("\n")) { + // Format: "Name locale # sample" + const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/); + if (!m) continue; + const name = m[1].trim(); + const locale = m[2].trim(); // e.g. en_US + const lang = locale.split("_")[0]; + const country = locale.split("_")[1]; + voices.push({ id: name, name, locale, lang, country, gender: "" }); + } + _localVoicesCache = voices; + return voices; + } catch { + return []; + } +} + +async function localDeviceTts(text, voiceId) { + const dir = await mkdtemp(join(tmpdir(), "tts-")); + const aiffPath = join(dir, "out.aiff"); + const mp3Path = join(dir, "out.mp3"); + try { + const args = voiceId ? ["-v", voiceId, "-o", aiffPath, text] : ["-o", aiffPath, text]; + await execFileAsync("say", args); + await execFileAsync("ffmpeg", ["-y", "-i", aiffPath, "-codec:a", "libmp3lame", "-qscale:a", "4", mp3Path]); + const buf = await readFile(mp3Path); + return buf.toString("base64"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// ── Voices list (Edge TTS public endpoint) ───────────────────── +let _voicesCache = null; +let _voicesCacheTime = 0; +const VOICES_TTL = 24 * 60 * 60 * 1000; + +export async function fetchEdgeTtsVoices() { + const now = Date.now(); + if (_voicesCache && now - _voicesCacheTime < VOICES_TTL) return _voicesCache; + const res = await fetch( + "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4", + { headers: { "User-Agent": UA } } + ); + if (!res.ok) throw new Error(`Edge TTS voices fetch failed: ${res.status}`); + const voices = await res.json(); + _voicesCache = voices; + _voicesCacheTime = now; + return voices; +} + +// ── ElevenLabs TTS ───────────────────────────────────────────── +const _elevenlabsVoicesCache = new Map(); // Cache by API key + +export async function fetchElevenLabsVoices(apiKey) { + if (!apiKey) throw new Error("ElevenLabs API key required"); + + const now = Date.now(); + const cached = _elevenlabsVoicesCache.get(apiKey); + if (cached && now - cached.time < VOICES_TTL) { + return cached.voices; + } + + const res = await fetch("https://api.elevenlabs.io/v1/voices", { + headers: { + "xi-api-key": apiKey, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) throw new Error(`ElevenLabs voices fetch failed: ${res.status}`); + const data = await res.json(); + // Normalize: add lang from labels.language for grouping + const voices = (data.voices || []).map((v) => ({ + ...v, + lang: v.labels?.language || "en", + })); + _elevenlabsVoicesCache.set(apiKey, { voices, time: now }); + return voices; +} + +async function elevenlabsTts(text, voiceId, apiKey, modelId = "eleven_flash_v2_5") { + const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { + method: "POST", + headers: { + "xi-api-key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text, + model_id: modelId, + voice_settings: { + stability: 0.5, + similarity_boost: 0.75, + }, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.detail?.message || `ElevenLabs TTS failed: ${res.status}`); + } + + const buf = await res.arrayBuffer(); + if (buf.byteLength < 1024) throw new Error("ElevenLabs TTS returned empty audio"); + return Buffer.from(buf).toString("base64"); +} + +// ── Voice Fetcher Registry (DRY) ─────────────────────────────── +export const VOICE_FETCHERS = { + "edge-tts": fetchEdgeTtsVoices, + "local-device": fetchLocalDeviceVoices, + "elevenlabs": fetchElevenLabsVoices, + // google-tts: uses hardcoded language codes + // openai: uses hardcoded voices from providerModels.js +}; + +// ── OpenAI TTS ─────────────────────────────────────────────────────────────── +async function handleOpenAiTts({ model, input, credentials, responseFormat = "mp3" }) { + if (!credentials?.apiKey) { + return createErrorResult(HTTP_STATUS.UNAUTHORIZED, "No OpenAI API key configured"); + } + + // model format: "tts-model/voice" e.g. "tts-1/alloy" or "gpt-4o-mini-tts/nova" + let ttsModel = "gpt-4o-mini-tts"; + let voice = "alloy"; + if (model && model.includes("/")) { + const parts = model.split("/"); + if (parts.length === 2) { + [ttsModel, voice] = parts; + } + } else if (model) { + voice = model; + } + + const baseUrl = (credentials.baseUrl || "https://api.openai.com").replace(/\/+$/, ""); + const res = await fetch(`${baseUrl}/v1/audio/speech`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify({ model: ttsModel, voice, input }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return createErrorResult(res.status, err?.error?.message || `OpenAI TTS failed: ${res.status}`); + } + + const buf = await res.arrayBuffer(); + const base64 = Buffer.from(buf).toString("base64"); + return createTtsResponse(base64, "mp3", responseFormat); +} + +// ── TTS Provider Registry (DRY) ──────────────────────────────── +const TTS_PROVIDERS = { + "google-tts": { + synthesize: async (text, model) => { + const base64 = await googleTts(text, model || "en"); + return { base64, format: "mp3" }; + }, + requiresCredentials: false, + }, + "edge-tts": { + synthesize: async (text, model) => { + const base64 = await bingTts(text, model || "vi-VN-HoaiMyNeural"); + return { base64, format: "mp3" }; + }, + requiresCredentials: false, + }, + "local-device": { + synthesize: async (text, model) => { + const base64 = await localDeviceTts(text, model); + return { base64, format: "mp3" }; + }, + requiresCredentials: false, + }, + "elevenlabs": { + synthesize: async (text, model, credentials) => { + if (!credentials?.apiKey) { + throw new Error("ElevenLabs API key required"); + } + // model format: "voice_id" or "model_id/voice_id" + let modelId = "eleven_flash_v2_5"; + let voiceId = model; + if (model && model.includes("/")) { + [modelId, voiceId] = model.split("/"); + } + const base64 = await elevenlabsTts(text, voiceId, credentials.apiKey, modelId); + return { base64, format: "mp3" }; + }, + requiresCredentials: true, + }, + "openai": { + synthesize: async (text, model, credentials, responseFormat) => { + return await handleOpenAiTts({ model, input: text, credentials, responseFormat }); + }, + requiresCredentials: true, + }, +}; + +// ── Core handler ─────────────────────────────────────────────── +/** + * Synthesize text to audio. + * @param {object} options + * @param {string} options.provider - "google-tts" | "edge-tts" | "local-device" | "openai" + * @param {string} options.model - voice/lang id + * @param {string} options.input - text to synthesize + * @param {object} [options.credentials] - required for openai + * @param {string} [options.responseFormat] - "mp3" (default) | "json" (base64) + * @returns {Promise<{success, response, status?, error?}>} + */ +export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3" }) { + if (!input?.trim()) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); + } + + const ttsProvider = TTS_PROVIDERS[provider]; + if (!ttsProvider) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support TTS via this route.`); + } + + try { + const result = await ttsProvider.synthesize(input.trim(), model, credentials, responseFormat); + + // OpenAI returns full response object + if (result.success !== undefined) return result; + + // Other providers return { base64, format } + return createTtsResponse(result.base64, result.format, responseFormat); + } catch (err) { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "TTS synthesis failed"); + } +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js index d1a71570..66a45314 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -13,6 +13,8 @@ const ALIAS_TO_PROVIDER_ID = { kmc: "kimi-coding", cl: "cline", oc: "opencode", + // TTS providers + el: "elevenlabs", // API Key providers openai: "openai", anthropic: "anthropic", diff --git a/public/providers/cartesia.png b/public/providers/cartesia.png new file mode 100644 index 00000000..45177c09 Binary files /dev/null and b/public/providers/cartesia.png differ diff --git a/public/providers/cerebras.png b/public/providers/cerebras.png index a4ace23a..0fbe1593 100644 Binary files a/public/providers/cerebras.png and b/public/providers/cerebras.png differ diff --git a/public/providers/cohere.png b/public/providers/cohere.png index 60a0fafe..47e7ab49 100644 Binary files a/public/providers/cohere.png and b/public/providers/cohere.png differ diff --git a/public/providers/comfyui.png b/public/providers/comfyui.png new file mode 100644 index 00000000..df8116bc Binary files /dev/null and b/public/providers/comfyui.png differ diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png index 5df2f504..06036213 100644 Binary files a/public/providers/deepseek.png and b/public/providers/deepseek.png differ diff --git a/public/providers/edge-tts.png b/public/providers/edge-tts.png new file mode 100644 index 00000000..8b1b333f Binary files /dev/null and b/public/providers/edge-tts.png differ diff --git a/public/providers/elevenlabs.png b/public/providers/elevenlabs.png new file mode 100644 index 00000000..c36dcfa1 Binary files /dev/null and b/public/providers/elevenlabs.png differ diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png index f7233caa..2295c0c7 100644 Binary files a/public/providers/fireworks.png and b/public/providers/fireworks.png differ diff --git a/public/providers/google-tts.png b/public/providers/google-tts.png new file mode 100644 index 00000000..68b77439 Binary files /dev/null and b/public/providers/google-tts.png differ diff --git a/public/providers/groq.png b/public/providers/groq.png index b6bb68a1..1773fb66 100644 Binary files a/public/providers/groq.png and b/public/providers/groq.png differ diff --git a/public/providers/huggingface.png b/public/providers/huggingface.png new file mode 100644 index 00000000..9a36b8f6 Binary files /dev/null and b/public/providers/huggingface.png differ diff --git a/public/providers/local-device.png b/public/providers/local-device.png new file mode 100644 index 00000000..201cb10e Binary files /dev/null and b/public/providers/local-device.png differ diff --git a/public/providers/mistral.png b/public/providers/mistral.png index 68001b97..4fcbb0c5 100644 Binary files a/public/providers/mistral.png and b/public/providers/mistral.png differ diff --git a/public/providers/nebius.png b/public/providers/nebius.png index 14c878ec..483b81cb 100644 Binary files a/public/providers/nebius.png and b/public/providers/nebius.png differ diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png index 4e9d56fa..0b5851e5 100644 Binary files a/public/providers/perplexity.png and b/public/providers/perplexity.png differ diff --git a/public/providers/playht.png b/public/providers/playht.png new file mode 100644 index 00000000..1807e9a2 Binary files /dev/null and b/public/providers/playht.png differ diff --git a/public/providers/sdwebui.png b/public/providers/sdwebui.png new file mode 100644 index 00000000..89e9ae2d Binary files /dev/null and b/public/providers/sdwebui.png differ diff --git a/public/providers/together.png b/public/providers/together.png index 7a53d4c4..9acc9017 100644 Binary files a/public/providers/together.png and b/public/providers/together.png differ diff --git a/public/providers/xai.png b/public/providers/xai.png index e75ae250..ef9d7abc 100644 Binary files a/public/providers/xai.png and b/public/providers/xai.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index edfe98c2..564fddfd 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -13,7 +13,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const [showInstallGuide, setShowInstallGuide] = useState(false); const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedModel, setSelectedModel] = useState(""); + const [subagentModel, setSubagentModel] = useState(""); const [modalOpen, setModalOpen] = useState(false); + const [subagentModalOpen, setSubagentModalOpen] = useState(false); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); @@ -46,11 +48,15 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api } }; - // Parse model from config content (don't sync URL - always use baseUrl from props) + // Parse model and subagent settings from config content useEffect(() => { if (codexStatus?.config) { const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m); if (modelMatch) setSelectedModel(modelMatch[1]); + + // Parse subagent settings + const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m); + if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]); } }, [codexStatus]); @@ -96,7 +102,12 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const res = await fetch("/api/cli-tools/codex-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }), + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + subagentModel: subagentModel || selectedModel + }), }); const data = await res.json(); if (res.ok) { @@ -121,6 +132,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api if (res.ok) { setMessage({ type: "success", text: "Settings reset successfully!" }); setSelectedModel(""); + setSubagentModel(""); checkCodexStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); @@ -134,6 +146,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const handleModelSelect = (model) => { setSelectedModel(model.value); + // Auto-set subagent model if not set + if (!subagentModel) { + setSubagentModel(model.value); + } setModalOpen(false); }; @@ -142,6 +158,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); + const effectiveSubagentModel = subagentModel || selectedModel; + const configContent = `# 9Router Configuration for Codex CLI model = "${selectedModel}" model_provider = "9router" @@ -150,6 +168,9 @@ model_provider = "9router" name = "9Router" base_url = "${getEffectiveBaseUrl()}" wire_api = "responses" + +[agents.subagent] +model = "${effectiveSubagentModel}" `; const authContent = JSON.stringify({ @@ -290,6 +311,35 @@ wire_api = "responses" {selectedModel && } + + {/* Subagent Model */} +
+ Subagent Model + arrow_forward + setSubagentModel(e.target.value)} + placeholder={selectedModel || "provider/model-id (defaults to main model)"} + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {subagentModel && ( + + )} +
{message && ( @@ -325,6 +375,16 @@ wire_api = "responses" title="Select Model for Codex" /> + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for Codex" + /> + setShowManualConfigModal(false)} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index 4ab16783..ca6ba00b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -13,7 +13,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, const [showInstallGuide, setShowInstallGuide] = useState(false); const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedModel, setSelectedModel] = useState(""); + const [subagentModel, setSubagentModel] = useState(""); const [modalOpen, setModalOpen] = useState(false); + const [subagentModalOpen, setSubagentModalOpen] = useState(false); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); @@ -36,11 +38,16 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, if (isExpanded) fetchModelAliases(); }, [isExpanded]); - // Sync model from existing config + // Sync model and subagent model from existing config useEffect(() => { if (status?.config?.model?.startsWith("9router/")) { setSelectedModel(status.config.model.replace("9router/", "")); } + + // Parse subagent settings from agent.explorer if exists + if (status?.config?.agent?.explorer?.model?.startsWith("9router/")) { + setSubagentModel(status.config.agent.explorer.model.replace("9router/", "")); + } }, [status]); const fetchModelAliases = async () => { @@ -94,7 +101,12 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, const res = await fetch("/api/cli-tools/opencode-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }), + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + subagentModel: subagentModel || selectedModel + }), }); const data = await res.json(); if (res.ok) { @@ -119,6 +131,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, if (res.ok) { setMessage({ type: "success", text: "Settings reset successfully!" }); setSelectedModel(""); + setSubagentModel(""); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); @@ -135,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); + const effectiveSubagentModel = subagentModel || selectedModel; + return [{ filename: "~/.config/opencode/opencode.json", content: JSON.stringify({ @@ -142,10 +157,20 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, "9router": { npm: "@ai-sdk/openai-compatible", options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse }, - models: { [selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" } }, + models: { + [selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" }, + [effectiveSubagentModel]: { name: effectiveSubagentModel } + }, }, }, model: `9router/${selectedModel || "provider/model-id"}`, + agent: { + explorer: { + description: "Fast explorer subagent for codebase exploration", + mode: "subagent", + model: `9router/${effectiveSubagentModel}` + } + } }, null, 2), }]; }; @@ -262,6 +287,35 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, {selectedModel && } + + {/* Subagent Model */} +
+ Subagent Model + arrow_forward + setSubagentModel(e.target.value)} + placeholder={selectedModel || "provider/model-id (defaults to main model)"} + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {subagentModel && ( + + )} +
{message && ( @@ -290,13 +344,30 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + onSelect={(model) => { + setSelectedModel(model.value); + // Auto-set subagent model if not set + if (!subagentModel) { + setSubagentModel(model.value); + } + setModalOpen(false); + }} selectedModel={selectedModel} activeProviders={activeProviders} modelAliases={modelAliases} title="Select Model for OpenCode" /> + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for OpenCode" + /> + setShowManualConfigModal(false)} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index 110a7666..0bf6e1b4 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -2,19 +2,727 @@ import { useParams, notFound } from "next/navigation"; import Link from "next/link"; -import Image from "next/image"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Card, Badge } from "@/shared/components"; -import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers"; +import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard"; import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard"; +import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders"; +// Shared row layout — defined outside components to avoid re-mount on re-render +function Row({ label, children }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +const DEFAULT_RESPONSE_EXAMPLE = `{ + "object": "list", + "data": [{ + "object": "embedding", + "index": 0, + "embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...] + }], + "model": "...", + "usage": { "prompt_tokens": 9, "total_tokens": 9 } +}`; + +// EmbeddingExampleCard +function EmbeddingExampleCard({ providerId }) { + const providerAlias = getProviderAlias(providerId); + const embeddingModels = getModelsByProviderId(providerId).filter((m) => m.type === "embedding"); + + const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? ""); + const [input, setInput] = useState("The quick brown fox jumps over the lazy dog"); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + }, []); + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : ""; + + const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -d '{"model": "${modelFull}", "input": "${input}"}'`; + + const handleRun = async () => { + if (!input.trim() || !modelFull) return; + setRunning(true); + setError(""); + setResult(null); + const start = Date.now(); + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch("/api/v1/embeddings", { + method: "POST", + headers, + body: JSON.stringify({ model: modelFull, input: input.trim() }), + }); + const latencyMs = Date.now() - start; + const data = await res.json(); + if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; } + setResult({ data, latencyMs }); + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + // Compact embedding array: first 4 values + count + const formatResultJson = (data) => { + if (!data) return DEFAULT_RESPONSE_EXAMPLE; + const clone = JSON.parse(JSON.stringify(data)); + (clone.data || []).forEach((item) => { + if (Array.isArray(item.embedding) && item.embedding.length > 4) { + item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`]; + } + }); + return JSON.stringify(clone, null, 2); + }; + + const resultJson = result ? JSON.stringify(result.data, null, 2) : ""; + + return ( + +

Example

+ +
+ {/* Model */} + + + + + {/* Endpoint */} + +
+ useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)} + className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + placeholder="http://localhost:3000" + /> + {/* Tunnel toggle — only show if tunnel URL is available */} + {tunnelEndpoint && ( + + )} +
+
+ + {/* API Key */} + + setApiKey(e.target.value)} + placeholder="sk-..." + 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" + /> + + + {/* Input */} + +
+ setInput(e.target.value)} + 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" + /> + {input && ( + + )} +
+
+ + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Response — default example or real result */} +
+
+ + Response {result && ⚡ {result.latencyMs}ms} + + {result && ( + + )} +
+
+            {formatResultJson(result?.data)}
+          
+
+
+
+ ); +} + +// ─── TTS Example Card ──────────────────────────────────────────────────────── +function TtsExampleCard({ providerId }) { + const providerAlias = getProviderAlias(providerId); + const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"]; + + // Voice state + const [selectedVoice, setSelectedVoice] = useState(""); + const [selectedVoiceName, setSelectedVoiceName] = useState(""); + const [voiceId, setVoiceId] = useState(""); // editable voice id (elevenlabs) + // Voices shown below Voice row after language selected + const [countryVoices, setCountryVoices] = useState([]); + const [selectedLang, setSelectedLang] = useState(""); + const [selectedModel, setSelectedModel] = useState(() => { + if (config.hasModelSelector && config.modelKey) { + const models = getModelsByProviderId(config.modelKey); + return models?.[0]?.id || ""; + } + return ""; + }); + + // Form state + const [input, setInput] = useState("Hello, this is a text to speech test."); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json + const [audioUrl, setAudioUrl] = useState(""); + const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [latency, setLatency] = useState(null); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + + // Country picker modal state + const [modalOpen, setModalOpen] = useState(false); + const [languages, setLanguages] = useState([]); + const [modalLoading, setModalLoading] = useState(false); + const [modalSearch, setModalSearch] = useState(""); + const [modalError, setModalError] = useState(""); + const [byLang, setByLang] = useState({}); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + + // Pre-select default voice based on provider config + if (config.voiceSource === "hardcoded") { + const voiceKey = config.voiceKey || providerId; + const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts"); + if (voices.length) { + if (config.hasLanguageDropdown) { + // Google TTS: just set voice + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name || voices[0].id); + } else { + // OpenAI: set voice chips + setCountryVoices(voices); + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name || voices[0].id); + } + } + } + // api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language + }, [providerId]); + + // Open modal — load language list + const openModal = async () => { + setModalOpen(true); + setModalSearch(""); + setModalError(""); + if (languages.length) return; // already loaded + setModalLoading(true); + try { + // Use provider-specific apiEndpoint if available, else default to edge-tts voices API + const url = config.apiEndpoint + ? config.apiEndpoint + : `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`; + const r = await fetch(url); + const d = await r.json(); + if (d.error) { setModalError(d.error); return; } + setLanguages(d.languages || []); + setByLang(d.byLang || {}); + } catch (e) { + setModalError(e.message); + } finally { + setModalLoading(false); + } + }; + + // Click language → close modal → show voices below + const handlePickLanguage = (lang) => { + setModalOpen(false); + setSelectedLang(lang.code); + const voices = byLang[lang.code]?.voices || []; + setCountryVoices(voices); + // Auto-select first voice + if (voices.length) { + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name); + if (config.hasVoiceIdInput) setVoiceId(voices[0].id); + } + }; + + const filteredLanguages = modalSearch + ? languages.filter((c) => + c.name.toLowerCase().includes(modalSearch.toLowerCase()) || + c.code.toLowerCase().includes(modalSearch.toLowerCase()) + ) + : languages; + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + // For ElevenLabs: use voiceId (editable) instead of selectedVoice + const activeVoiceId = config.hasVoiceIdInput ? voiceId : selectedVoice; + const modelFull = config.hasModelSelector && activeVoiceId && selectedModel + ? `${providerAlias}/${selectedModel}/${activeVoiceId}` + : activeVoiceId ? `${providerAlias}/${activeVoiceId}` : ""; + + const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -d '{"model": "${modelFull}", "input": "${input}"}' \\ + ${responseFormat === "json" ? "" : "--output speech.mp3"}`; + + const handleRun = async () => { + if (!input.trim() || !modelFull) return; + setRunning(true); + setError(""); + setAudioUrl(""); + setJsonResponse(null); + const start = Date.now(); + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`; + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ model: modelFull, input: input.trim() }), + }); + setLatency(Date.now() - start); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d?.error?.message || d?.error || `HTTP ${res.status}`); + return; + } + + if (responseFormat === "json") { + const data = await res.json(); + setJsonResponse(data); // Store full JSON response + const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob()); + setAudioUrl(URL.createObjectURL(audioBlob)); + } else { + const blob = await res.blob(); + setAudioUrl(URL.createObjectURL(blob)); + } + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + return ( + <> + +

Example

+ +
+ {/* Endpoint + API Key as read-only text */} + +
+ + {endpoint}/v1/audio/speech + + {tunnelEndpoint && ( + + )} +
+
+ + + {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + + + + {/* Model selector (OpenAI, ElevenLabs) */} + {config.hasModelSelector && config.modelKey && ( + + + + )} + + {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */} + {config.hasBrowseButton && ( + +
+ + +
+
+ )} + + {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */} + {countryVoices.length > 0 && ( + +
+ {countryVoices.map((v) => ( + + ))} +
+
+ )} + + {/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */} + {config.hasVoiceIdInput && ( + +
+
+ { + setVoiceId(e.target.value); + setSelectedVoice(e.target.value); + }} + placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17" + 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" + /> + {voiceId && ( + + )} +
+
+
+ )} + + {/* Google TTS: Language dropdown */} + {config.hasLanguageDropdown && ( + + + + )} + + {/* Input */} + +
+ setInput(e.target.value)} + 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" + /> + {input && ( + + )} +
+
+ + {/* Output Format */} + + + + + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {error &&

{error}

} + + {/* Audio player */} + {audioUrl ? ( +
+
+ + Response {latency && ⚡ {latency}ms} + + + download + Download + +
+
+ ) : ( +

Audio will appear here after running.

+ )} +
+
+ + {/* Country Picker Modal */} + {modalOpen && ( +
setModalOpen(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+

Select Language

+ +
+ + {/* Search */} +
+ setModalSearch(e.target.value)} + placeholder="Search language..." + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ + {/* Language list */} +
+ {modalError &&

{modalError}

} + {modalLoading ? ( +

Loading...

+ ) : ( +
+ {filteredLanguages.map((c) => ( + + ))} + {filteredLanguages.length === 0 && ( +

No languages found.

+ )} +
+ )} +
+
+
+ )} + + ); +} + +// MediaProviderDetailPage export default function MediaProviderDetailPage() { const { kind, id } = useParams(); - const { copied, copy } = useCopyToClipboard(); - const [headerImgError, setHeaderImgError] = useState(false); - const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); if (!kindConfig) return notFound(); @@ -24,8 +732,6 @@ export default function MediaProviderDetailPage() { const kinds = provider.serviceKinds ?? ["llm"]; if (!kinds.includes(kind)) return notFound(); - const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`; - return (
{/* Back */} @@ -40,21 +746,15 @@ export default function MediaProviderDetailPage() { {/* Header */}
-
- {headerImgError ? ( - - {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} - - ) : ( - {provider.name} setHeaderImgError(true)} - /> - )} +
+

{provider.name}

@@ -69,25 +769,29 @@ export default function MediaProviderDetailPage() {
- {/* Endpoint block */} - -

{kindConfig.label} Endpoint

-
- - {kindConfig.endpoint.method} - - {kindConfig.endpoint.path} - -
-
+ {/* Connections */} + {provider.noAuth ? ( + +
+
+ lock_open +
+
+

No authentication required

+

This provider is ready to use.

+
+
+
+ ) : ( + + )} - {/* Connections — reuse shared component */} - + {/* Models - only for non-tts kinds */} + {kind !== "tts" && } - {/* Models — filtered by current kind */} - + {/* Example — per kind */} + {kind === "embedding" && } + {kind === "tts" && }
); } diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js index 6d86b0af..ed7775a5 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js @@ -2,44 +2,91 @@ import { useParams, notFound } from "next/navigation"; import Link from "next/link"; -import { Card } from "@/shared/components"; +import { useEffect, useState } from "react"; +import { Card, Badge } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers"; -import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +function getEffectiveStatus(conn) { + const isCooldown = Object.entries(conn).some( + ([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now() + ); + return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus; +} + +function MediaProviderCard({ provider, kind, connections }) { + const providerInfo = AI_PROVIDERS[provider.id]; + const isNoAuth = !!providerInfo?.noAuth; + + const providerConns = connections.filter((c) => c.provider === provider.id); + const connected = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "active" || s === "success"; }).length; + const error = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "error" || s === "expired" || s === "unavailable"; }).length; + const total = providerConns.length; + const allDisabled = total > 0 && providerConns.every((c) => c.isActive === false); + + const renderStatus = () => { + if (isNoAuth) return Ready; + if (allDisabled) return Disabled; + if (total === 0) return No connections; + return ( + <> + {connected > 0 && {connected} Connected} + {error > 0 && {error} Error} + {connected === 0 && error === 0 && {total} Added} + + ); + }; + + return ( + + +
+
7 ? provider.color : (provider.color ?? "#888") + "15"}` }} + > + +
+
+

{provider.name}

+
+ {renderStatus()} +
+
+
+
+ + ); +} export default function MediaProviderKindPage() { const { kind } = useParams(); - const { copied, copy } = useCopyToClipboard(); + const [connections, setConnections] = useState([]); const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); if (!kindConfig) return notFound(); const providers = getProvidersByKind(kind); - const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`; + + useEffect(() => { + fetch("/api/providers", { cache: "no-store" }) + .then((r) => r.json()) + .then((d) => setConnections(d.connections || [])) + .catch(() => {}); + }, []); return (
- {/* Endpoint block */} - -

Endpoint

-
- - {kindConfig.endpoint.method} - - {kindConfig.endpoint.path} - -
-
- - {/* Provider list */} {providers.length === 0 ? (
No providers support {kindConfig.label} yet. @@ -47,29 +94,12 @@ export default function MediaProviderKindPage() { ) : (
{providers.map((provider) => ( - - -
-
7 ? provider.color : (provider.color ?? "#888") + "15"}` }} - > - -
-
-

{provider.name}

-

{(provider.serviceKinds ?? ["llm"]).join(", ")}

-
-
-
- + ))}
)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 80618edf..b5f1374b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -549,10 +549,11 @@ export default function ProviderDetailPage() { ); } // Combine hardcoded models with Kilo free models (deduplicated) + // Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers const displayModels = [ ...models, ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), - ]; + ].filter((m) => !m.type || m.type === "llm"); // Custom models added by user (stored as aliases: modelId → providerAlias/modelId) const customModels = Object.entries(modelAliases) .filter(([alias, fullModel]) => { @@ -1579,7 +1580,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov }; return ( -
+
{/* Priority arrows */}
diff --git a/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js b/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js index a80f19e3..30a69fb0 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js @@ -104,7 +104,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov }; return ( -
+
*/} - {mediaOpen && ( -
- {MEDIA_PROVIDER_KINDS.map((kind) => ( - - {kind.icon} - {kind.label} - - ))} -
- )} - - {/* System section */}

System

+ + {/* Media Providers accordion */} + + {mediaOpen && ( +
+ {MEDIA_PROVIDER_KINDS.filter((k) => VISIBLE_MEDIA_KINDS.includes(k.id)).map((kind) => ( + + {kind.icon} + {kind.label} + + ))} +
+ )} + {systemItems.map((item) => ( { export function getProvidersByKind(kind) { return Object.values(AI_PROVIDERS).filter((p) => { const kinds = p.serviceKinds ?? ["llm"]; - return kinds.includes(kind); + if (!kinds.includes(kind)) return false; + if (p.hidden) return false; // globally hidden + if (p.hiddenKinds?.includes(kind)) return false; // hidden for specific kind + return true; }); } diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js new file mode 100644 index 00000000..0117febb --- /dev/null +++ b/src/shared/constants/ttsProviders.js @@ -0,0 +1,41 @@ +/** + * TTS Provider Configuration + * Centralized config for TTS provider UI behavior + */ +export const TTS_PROVIDER_CONFIG = { + "google-tts": { + hasLanguageDropdown: true, + hasModelSelector: false, + hasBrowseButton: false, + voiceSource: "hardcoded", // from providerModels + }, + "openai": { + hasLanguageDropdown: false, + hasModelSelector: true, + hasBrowseButton: false, + voiceSource: "hardcoded", // from providerModels + modelKey: "openai-tts-models", + voiceKey: "openai-tts-voices", + }, + "elevenlabs": { + hasLanguageDropdown: false, + hasModelSelector: true, + hasBrowseButton: true, + hasVoiceIdInput: true, // allow manual voice id entry + voiceSource: "api-language", // grouped by language from backend + modelKey: "elevenlabs-tts-models", + apiEndpoint: "/api/media-providers/tts/elevenlabs/voices", + }, + "edge-tts": { + hasLanguageDropdown: false, + hasModelSelector: false, + hasBrowseButton: true, + voiceSource: "api-language", // from API with language picker + }, + "local-device": { + hasLanguageDropdown: false, + hasModelSelector: false, + hasBrowseButton: true, + voiceSource: "api-language", // from API with language picker + }, +}; diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js new file mode 100644 index 00000000..18ac5bee --- /dev/null +++ b/src/sse/handlers/tts.js @@ -0,0 +1,85 @@ +import { + extractApiKey, isValidApiKey, + getProviderCredentials, markAccountUnavailable, +} from "../services/auth.js"; +import { getSettings } from "@/lib/localDb"; +import { getModelInfo } from "../services/model.js"; +import { handleTtsCore } from "open-sse/handlers/ttsCore.js"; +import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; +import * as log from "../utils/logger.js"; + +// Providers that require stored credentials (not noAuth) +const CREDENTIALED_PROVIDERS = new Set(["openai", "elevenlabs"]); + +export async function handleTts(request) { + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const url = new URL(request.url); + const modelStr = body.model; + const responseFormat = url.searchParams.get("response_format") || "mp3"; // mp3 (default) | json + log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}`); + + const settings = await getSettings(); + if (settings.requireApiKey) { + const apiKey = extractApiKey(request); + if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + const valid = await isValidApiKey(apiKey); + if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + + if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + if (!body.input) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); + + const modelInfo = await getModelInfo(modelStr); + if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); + + const { provider, model } = modelInfo; + log.info("ROUTING", `Provider: ${provider}, Voice: ${model}`); + + // noAuth providers — no credential needed + if (!CREDENTIALED_PROVIDERS.has(provider)) { + const result = await handleTtsCore({ provider, model, input: body.input, responseFormat }); + if (result.success) return result.response; + return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "TTS failed"); + } + + // Credentialed providers — fallback loop (same pattern as embeddings) + const excludeConnectionIds = new Set(); + let lastError = null; + let lastStatus = null; + + while (true) { + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + + if (!credentials || credentials.allRateLimited) { + if (credentials?.allRateLimited) { + const msg = lastError || credentials.lastError || "Unavailable"; + const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + return unavailableResponse(status, `[${provider}/${model}] ${msg}`, credentials.retryAfter, credentials.retryAfterHuman); + } + if (excludeConnectionIds.size === 0) return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); + return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + } + + log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); + + const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat }); + + if (result.success) return result.response; + + const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); + if (shouldFallback) { + excludeConnectionIds.add(credentials.connectionId); + lastError = result.error; + lastStatus = result.status; + continue; + } + return result.response || errorResponse(result.status, result.error); + } +}