mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: add STT support, Gemini TTS, and expand usage tracking
- Speech-to-Text: full pipeline with sttCore handler, /v1/audio/transcriptions endpoint, sttConfig for OpenAI, Gemini, Groq, Deepgram, AssemblyAI, HuggingFace, NVIDIA Parakeet; new 9router-stt skill - Gemini TTS: add gemini provider with 30 prebuilt voices and TTS_PROVIDER_CONFIG - Usage: implement GLM (intl/cn) and MiniMax (intl/cn) quota fetchers; refactor Gemini CLI usage to use retrieveUserQuota with per-model buckets - Disabled models: lowdb-backed disabledModelsDb + /api/models/disabled route - Header search: reusable Zustand store (headerSearchStore) wired into Header - CLI tools: add Claude Cowork tool card and cowork-settings API - Providers: introduce mediaPriority sorting in getProvidersByKind, add Kimi K2.6, reorder hermes, drop qwen STT kind - UI: expand media-providers/[kind]/[id] page (+314), enhance OAuthModal, ModelSelectModal, ProviderTopology, ProxyPools, ProviderLimits - Assets: refresh provider PNGs (alicode, byteplus, cloudflare-ai, nvidia, ollama, vertex, volcengine-ark) and add aws-polly, fal-ai, jina-ai, recraft, runwayml, stability-ai, topaz, black-forest-labs
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { Low } from "lowdb";
|
||||
import { JSONFile } from "lowdb/node";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
const DB_FILE = path.join(DATA_DIR, "disabledModels.json");
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const defaultData = { disabled: {} };
|
||||
|
||||
let dbInstance = null;
|
||||
|
||||
async function getDb() {
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
dbInstance = new Low(adapter, defaultData);
|
||||
try {
|
||||
await dbInstance.read();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
dbInstance.data = { ...defaultData };
|
||||
await dbInstance.write();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!dbInstance.data || typeof dbInstance.data !== "object") dbInstance.data = { ...defaultData };
|
||||
if (!dbInstance.data.disabled) dbInstance.data.disabled = {};
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export async function getDisabledModels() {
|
||||
const db = await getDb();
|
||||
return db.data.disabled || {};
|
||||
}
|
||||
|
||||
export async function getDisabledByProvider(providerAlias) {
|
||||
const all = await getDisabledModels();
|
||||
return all[providerAlias] || [];
|
||||
}
|
||||
|
||||
export async function disableModels(providerAlias, ids) {
|
||||
if (!providerAlias || !Array.isArray(ids)) return;
|
||||
const db = await getDb();
|
||||
const current = new Set(db.data.disabled[providerAlias] || []);
|
||||
ids.forEach((id) => current.add(id));
|
||||
db.data.disabled[providerAlias] = [...current];
|
||||
await db.write();
|
||||
}
|
||||
|
||||
export async function enableModels(providerAlias, ids) {
|
||||
if (!providerAlias) return;
|
||||
const db = await getDb();
|
||||
const current = db.data.disabled[providerAlias] || [];
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
delete db.data.disabled[providerAlias];
|
||||
} else {
|
||||
const removeSet = new Set(ids);
|
||||
const next = current.filter((id) => !removeSet.has(id));
|
||||
if (next.length === 0) delete db.data.disabled[providerAlias];
|
||||
else db.data.disabled[providerAlias] = next;
|
||||
}
|
||||
await db.write();
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import initializeApp from "@/shared/services/initializeApp";
|
||||
|
||||
let initialized = false;
|
||||
// Survive Next.js HMR — module-level flag resets on reload, globalThis persists
|
||||
const g = globalThis.__cloudSyncInit ??= { initialized: false, inProgress: null };
|
||||
|
||||
export async function ensureAppInitialized() {
|
||||
if (!initialized) {
|
||||
if (g.initialized) return true;
|
||||
if (g.inProgress) return g.inProgress;
|
||||
g.inProgress = (async () => {
|
||||
try {
|
||||
await initializeApp();
|
||||
initialized = true;
|
||||
g.initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[ServerInit] Error initializing app:", error);
|
||||
} finally {
|
||||
g.inProgress = null;
|
||||
}
|
||||
}
|
||||
return initialized;
|
||||
return g.initialized;
|
||||
})();
|
||||
return g.inProgress;
|
||||
}
|
||||
|
||||
// Auto-initialize at runtime only, not during next build
|
||||
|
||||
+108
-16
@@ -119,41 +119,133 @@ let codexProxyServer = null;
|
||||
let codexProxyTimeout = null;
|
||||
|
||||
const CODEX_PROXY_TIMEOUT_MS = 300000; // 5 minutes
|
||||
const CODEX_PORT = 1455;
|
||||
|
||||
// Pending exchange sessions keyed by state — used by server-side exchange mode
|
||||
const pendingExchanges = new Map();
|
||||
|
||||
/**
|
||||
* Start a proxy server on Codex fixed port (1455) that redirects callback to the app port.
|
||||
* Returns { success: true } if started, or { success: false } if port is busy.
|
||||
* Register a pending exchange session for server-side mode.
|
||||
* Modal client calls this before opening popup.
|
||||
*/
|
||||
export function registerCodexSession({ state, codeVerifier, redirectUri }) {
|
||||
if (!state || !codeVerifier || !redirectUri) return false;
|
||||
pendingExchanges.set(state, {
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
status: "pending",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session status (modal polls this).
|
||||
*/
|
||||
export function getCodexSessionStatus(state) {
|
||||
return pendingExchanges.get(state) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a session (called after modal consumes status).
|
||||
*/
|
||||
export function clearCodexSession(state) {
|
||||
pendingExchanges.delete(state);
|
||||
}
|
||||
|
||||
function renderCodexResultPage(success, message) {
|
||||
const color = success ? "#22c55e" : "#ef4444";
|
||||
const icon = success ? "✓" : "✗";
|
||||
const title = success ? "Authentication Successful" : "Authentication Failed";
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f5f5f5}.c{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.1)}.i{color:${color};font-size:3rem}h1{margin:1rem 0}p{color:#666}</style>
|
||||
</head><body><div class="c"><div class="i">${icon}</div><h1>${title}</h1><p>${message}</p><p>Closing in <span id="cd">3</span>s...</p>
|
||||
<script>let n=3;const c=document.getElementById("cd");const t=setInterval(()=>{n--;c.textContent=n;if(n<=0){clearInterval(t);window.close();}},1000);</script>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Codex proxy on fixed port 1455.
|
||||
* Mode A (server-side): if any session was registered, proxy auto-exchanges + saves DB.
|
||||
* Mode B (channel fallback): if no session, proxy 302 redirects to app port for legacy channel-based flow.
|
||||
*/
|
||||
export function startCodexProxy(appPort) {
|
||||
return new Promise((resolve) => {
|
||||
// Already running
|
||||
if (codexProxyServer) {
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const CODEX_PORT = 1455;
|
||||
const server = http.createServer((req, res) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
|
||||
if (url.pathname === "/callback" || url.pathname === "/auth/callback") {
|
||||
// Redirect to app port with all query params preserved
|
||||
const redirectUrl = `http://localhost:${appPort}/callback${url.search}`;
|
||||
res.writeHead(302, { Location: redirectUrl });
|
||||
res.end();
|
||||
|
||||
// Auto-close after redirect
|
||||
stopCodexProxy();
|
||||
if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const errorParam = url.searchParams.get("error");
|
||||
const session = state ? pendingExchanges.get(state) : null;
|
||||
|
||||
// Mode A: server-side exchange (session registered)
|
||||
if (session) {
|
||||
try {
|
||||
if (errorParam) {
|
||||
throw new Error(url.searchParams.get("error_description") || errorParam);
|
||||
}
|
||||
if (!code) throw new Error("No authorization code received");
|
||||
|
||||
// Lazy import to avoid circular deps
|
||||
const { exchangeTokens } = await import("../providers.js");
|
||||
const { createProviderConnection } = await import("@/models");
|
||||
|
||||
const tokenData = await exchangeTokens(
|
||||
"codex",
|
||||
code,
|
||||
session.redirectUri,
|
||||
session.codeVerifier,
|
||||
state
|
||||
);
|
||||
const connection = await createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
...tokenData,
|
||||
expiresAt: tokenData.expiresIn
|
||||
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
|
||||
: null,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
session.status = "done";
|
||||
session.connectionId = connection.id;
|
||||
session.email = connection.email;
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderCodexResultPage(true, "You can close this window."));
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err.message;
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderCodexResultPage(false, err.message));
|
||||
} finally {
|
||||
stopCodexProxy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode B: legacy channel fallback — 302 redirect to app /callback
|
||||
const redirectUrl = `http://localhost:${appPort}/callback${url.search}`;
|
||||
res.writeHead(302, { Location: redirectUrl });
|
||||
res.end();
|
||||
stopCodexProxy();
|
||||
});
|
||||
|
||||
server.listen(CODEX_PORT, "127.0.0.1", () => {
|
||||
codexProxyServer = server;
|
||||
// Auto-cleanup after timeout
|
||||
codexProxyTimeout = setTimeout(() => stopCodexProxy(), CODEX_PROXY_TIMEOUT_MS);
|
||||
resolve({ success: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user