Feat : tts

This commit is contained in:
decolua
2026-04-10 10:17:53 +07:00
parent 39545cf4c8
commit 3c96e8d6d1
40 changed files with 1896 additions and 147 deletions

View File

@@ -109,7 +109,7 @@ export async function GET() {
// POST - Update 9Router settings (merge with existing config)
export async function POST(request) {
try {
const { baseUrl, apiKey, model } = await request.json();
const { baseUrl, apiKey, model, subagentModel } = await request.json();
if (!baseUrl || !apiKey || !model) {
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
@@ -141,6 +141,12 @@ export async function POST(request) {
wire_api: "responses",
});
// Add subagent configuration
const effectiveSubagentModel = subagentModel || model;
setNestedSection(parsed, "agents.subagent", {
model: effectiveSubagentModel,
});
// Write merged config
const configContent = stringifyTOML(parsed);
await fs.writeFile(configPath, configContent);
@@ -196,6 +202,9 @@ export async function DELETE() {
// Remove 9router provider section
deleteNestedSection(parsed, "model_providers.9router");
// Remove subagent configuration
deleteNestedSection(parsed, "agents.subagent");
// Write updated config
const configContent = stringifyTOML(parsed);
await fs.writeFile(configPath, configContent);

View File

@@ -77,7 +77,7 @@ export async function GET() {
// POST - Apply 9Router as openai-compatible provider
export async function POST(request) {
try {
const { baseUrl, apiKey, model } = await request.json();
const { baseUrl, apiKey, model, subagentModel } = await request.json();
if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
@@ -97,6 +97,7 @@ export async function POST(request) {
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const keyToUse = apiKey || "sk_9router";
const effectiveSubagentModel = subagentModel || model;
// Merge 9router provider
if (!config.provider) config.provider = {};
@@ -108,12 +109,21 @@ export async function POST(request) {
},
models: {
[model]: { name: model },
[effectiveSubagentModel]: { name: effectiveSubagentModel },
},
};
// Set as active model
config.model = `9router/${model}`;
// Add subagent configuration
if (!config.agent) config.agent = {};
config.agent.explorer = {
description: "Fast explorer subagent for codebase exploration",
mode: "subagent",
model: `9router/${effectiveSubagentModel}`,
};
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
return NextResponse.json({
@@ -149,6 +159,13 @@ export async function DELETE() {
// Reset model if it was pointing to 9router
if (config.model?.startsWith("9router/")) delete config.model;
// Remove subagent configuration
if (config.agent?.explorer?.model?.startsWith("9router/")) {
delete config.agent.explorer;
// Clean up empty agent object
if (Object.keys(config.agent).length === 0) delete config.agent;
}
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
return NextResponse.json({

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { getProviderConnections } from "@/lib/localDb";
import { fetchElevenLabsVoices } from "open-sse/handlers/ttsCore.js";
const langNames = new Intl.DisplayNames(["en"], { type: "language" });
/**
* GET /api/media-providers/tts/elevenlabs/voices[?lang=en]
* Returns { languages, byLang } grouped by language - same format as edge-tts
* Uses direct DB read (no mutex) to avoid blocking on concurrent TTS requests
*/
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const langFilter = searchParams.get("lang");
// Direct DB read - bypass auth mutex used for TTS inference
const connections = await getProviderConnections({ provider: "elevenlabs", isActive: true });
const apiKey = connections[0]?.apiKey;
if (!apiKey) {
return NextResponse.json({ error: "No ElevenLabs connection found" }, { status: 400 });
}
const voices = await fetchElevenLabsVoices(apiKey);
// Group by all supported languages (verified_languages + labels.language)
const byLang = {};
const addToLang = (code, voice) => {
if (!byLang[code]) {
byLang[code] = {
code,
name: (() => { try { return langNames.of(code); } catch { return code; } })(),
voices: [],
};
}
// Avoid duplicate voice in same lang
if (!byLang[code].voices.find((v) => v.id === voice.voice_id)) {
byLang[code].voices.push({
id: voice.voice_id,
name: voice.name,
gender: voice.labels?.gender || "",
lang: code,
// premade voices are free; professional library voices added to account may require paid plan
free_users_allowed: voice.category === "premade" || voice.is_owner === true
});
}
};
for (const v of voices) {
// Add to primary language
const primaryLang = v.labels?.language || "en";
addToLang(primaryLang, v);
// Add to all verified languages
for (const vl of v.verified_languages || []) {
if (vl.language && vl.language !== primaryLang) {
addToLang(vl.language, v);
}
}
}
const languages = Object.values(byLang).sort((a, b) => a.name.localeCompare(b.name));
// If lang filter requested, return only that group's voices
if (langFilter) {
return NextResponse.json({ voices: byLang[langFilter]?.voices || [] });
}
return NextResponse.json({ languages, byLang });
} catch (err) {
return NextResponse.json({ error: err.message || "Failed to fetch voices" }, { status: 502 });
}
}

View File

@@ -0,0 +1,98 @@
import { VOICE_FETCHERS } from "open-sse/handlers/ttsCore.js";
import { NextResponse } from "next/server";
// Map locale code → country name
const LOCALE_NAMES = new Intl.DisplayNames(["en"], { type: "region" });
const LANG_NAMES = new Intl.DisplayNames(["en"], { type: "language" });
function countryName(code) {
try { return LOCALE_NAMES.of(code); } catch { return code; }
}
function langName(code) {
try { return LANG_NAMES.of(code); } catch { return code; }
}
/**
* GET /api/media-providers/tts/voices
* Query:
* ?provider=edge-tts | local-device | elevenlabs (default: edge-tts)
* ?lang=en (optional filter by lang code)
* ?apiKey=xxx (required for elevenlabs)
*/
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider") || "edge-tts";
const langFilter = searchParams.get("lang");
const apiKey = searchParams.get("apiKey");
const fetcher = VOICE_FETCHERS[provider];
if (!fetcher) {
return NextResponse.json({ error: `Provider '${provider}' does not support voice listing` }, { status: 400 });
}
// ElevenLabs requires API key
const raw = provider === "elevenlabs" ? await fetcher(apiKey) : await fetcher();
let voices;
if (provider === "local-device") {
voices = raw.map((v) => ({
id: v.id,
name: v.name,
locale: v.locale.replace("_", "-"),
lang: v.lang,
country: v.country,
countryName: countryName(v.country),
langName: langName(v.lang),
gender: v.gender,
}));
} else if (provider === "elevenlabs") {
voices = raw.map((v) => ({
id: v.voice_id,
name: v.name,
locale: v.labels?.language || "en",
lang: (v.labels?.language || "en").split("-")[0],
country: "",
countryName: "",
langName: langName((v.labels?.language || "en").split("-")[0]),
gender: v.labels?.gender || "",
category: v.category,
}));
} else {
// edge-tts (default)
voices = raw.map((v) => {
const [lang, country] = v.Locale.split("-");
return {
id: v.ShortName,
name: (v.FriendlyName || v.ShortName)
.replace("Microsoft ", "")
.replace(/ Online \(Natural\) - /g, " ("),
locale: v.Locale,
lang,
country: country || "",
countryName: countryName(country || lang),
langName: langName(lang),
gender: v.Gender,
};
});
}
// Apply filter
if (langFilter) voices = voices.filter((v) => v.lang === langFilter);
// Group by language
const byLang = {};
for (const v of voices) {
const key = v.lang;
if (!byLang[key]) byLang[key] = { code: key, name: v.langName, voices: [] };
byLang[key].voices.push(v);
}
// Sorted language list
const languages = Object.values(byLang).sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({ voices, languages, byLang });
} catch (err) {
return NextResponse.json({ error: err.message || "Failed to fetch voices" }, { status: 502 });
}
}

View File

@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { getApiKeys } from "@/lib/localDb";
// POST /api/models/test - Ping a single model via internal completions
// POST /api/models/test - Ping a single model via internal completions or embeddings
export async function POST(request) {
try {
const { model } = await request.json();
const { model, kind } = await request.json();
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
const baseUrl = process.env.BASE_URL ||
@@ -21,6 +21,32 @@ export async function POST(request) {
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const start = Date.now();
// Route to appropriate endpoint based on kind
if (kind === "embedding") {
const res = await fetch(`${baseUrl}/api/v1/embeddings`, {
method: "POST",
headers,
body: JSON.stringify({ model, input: "test" }),
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
let parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
if (!res.ok) {
const detail = parsed?.error?.message || parsed?.error || rawText;
return NextResponse.json({ ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status });
}
const hasEmbedding = Array.isArray(parsed?.data) && parsed.data.length > 0 && Array.isArray(parsed.data[0]?.embedding);
if (!hasEmbedding) {
return NextResponse.json({ ok: false, latencyMs, status: res.status, error: "Provider returned no embedding data" });
}
return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status });
}
// Default: chat completions
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
method: "POST",
headers,

View File

@@ -0,0 +1,16 @@
import { handleTts } from "@/sse/handlers/tts.js";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/** POST /v1/audio/speech - OpenAI-compatible TTS endpoint */
export async function POST(request) {
return await handleTts(request);
}