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:
@@ -41,6 +41,10 @@ export default function ProxyPoolsPage() {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [testingId, setTestingId] = useState(null);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [healthChecking, setHealthChecking] = useState(false);
|
||||
const [healthProgress, setHealthProgress] = useState({ current: 0, total: 0 });
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchProxyPools = useCallback(async () => {
|
||||
@@ -162,6 +166,136 @@ export default function ProxyPoolsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (pool) => {
|
||||
const next = !pool.isActive;
|
||||
setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: next } : p));
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${pool.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
|
||||
notify.error("Failed to update active state");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error toggling active:", error);
|
||||
setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = proxyPools.length > 0 && selectedIds.length === proxyPools.length;
|
||||
const toggleSelect = (id) => setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
|
||||
const toggleSelectAll = () => setSelectedIds(allSelected ? [] : proxyPools.map((p) => p.id));
|
||||
const clearSelection = () => setSelectedIds([]);
|
||||
|
||||
const bulkSetActive = async (isActive) => {
|
||||
const targets = selectedIds.length > 0 ? selectedIds : proxyPools.map((p) => p.id);
|
||||
if (targets.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
let ok = 0; let failed = 0;
|
||||
for (const id of targets) {
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
if (res.ok) ok += 1; else failed += 1;
|
||||
} catch { failed += 1; }
|
||||
}
|
||||
await fetchProxyPools();
|
||||
notify.success(`${isActive ? "Activated" : "Deactivated"} ${ok}${failed ? `, failed ${failed}` : ""}`);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (!confirm(`Delete ${selectedIds.length} proxy pool(s)?`)) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
let ok = 0; let blocked = 0; let failed = 0;
|
||||
for (const id of selectedIds) {
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${id}`, { method: "DELETE" });
|
||||
if (res.ok) ok += 1;
|
||||
else if (res.status === 409) blocked += 1;
|
||||
else failed += 1;
|
||||
} catch { failed += 1; }
|
||||
}
|
||||
await fetchProxyPools();
|
||||
clearSelection();
|
||||
notify.success(`Deleted ${ok}${blocked ? `, ${blocked} bound` : ""}${failed ? `, ${failed} failed` : ""}`);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHealthCheck = async () => {
|
||||
const targets = selectedIds.length > 0
|
||||
? proxyPools.filter((p) => selectedIds.includes(p.id))
|
||||
: proxyPools;
|
||||
if (targets.length === 0) return;
|
||||
setHealthChecking(true);
|
||||
setHealthProgress({ current: 0, total: targets.length });
|
||||
let alive = 0; const deadIds = [];
|
||||
let done = 0;
|
||||
const CONCURRENCY = 10;
|
||||
const queue = [...targets];
|
||||
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const pool = queue.shift();
|
||||
if (!pool) break;
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${pool.id}/test`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) alive += 1; else deadIds.push(pool.id);
|
||||
} catch {
|
||||
deadIds.push(pool.id);
|
||||
} finally {
|
||||
done += 1;
|
||||
setHealthProgress({ current: done, total: targets.length });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
|
||||
await fetchProxyPools();
|
||||
setHealthChecking(false);
|
||||
setHealthProgress({ current: 0, total: 0 });
|
||||
|
||||
if (deadIds.length > 0 && confirm(`Alive: ${alive}, Dead: ${deadIds.length}.\n\nDisable ${deadIds.length} dead proxies?`)) {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
for (const id of deadIds) {
|
||||
try {
|
||||
await fetch(`/api/proxy-pools/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
await fetchProxyPools();
|
||||
notify.success(`Disabled ${deadIds.length} dead proxies`);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
} else {
|
||||
notify.success(`Health check done. Alive: ${alive}, Dead: ${deadIds.length}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup selectedIds when pools change
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => prev.filter((id) => proxyPools.some((p) => p.id === id)));
|
||||
}, [proxyPools]);
|
||||
|
||||
const openBatchImportModal = () => {
|
||||
setBatchImportText("");
|
||||
setShowBatchImportModal(true);
|
||||
@@ -354,13 +488,57 @@ export default function ProxyPoolsPage() {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="default">Total: {proxyPools.length}</Badge>
|
||||
<Badge variant="success">Active: {activeCount}</Badge>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{proxyPools.length > 0 && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleSelectAll}
|
||||
className="size-4 rounded border-black/20 dark:border-white/20"
|
||||
/>
|
||||
{allSelected ? "Unselect all" : "Select all"}
|
||||
</label>
|
||||
)}
|
||||
<Badge variant="default">Total: {proxyPools.length}</Badge>
|
||||
<Badge variant="success">Active: {activeCount}</Badge>
|
||||
</div>
|
||||
|
||||
{(selectedIds.length > 0 || healthChecking) && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">checklist</span>
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : "All pools"}
|
||||
</span>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
icon={healthChecking ? "progress_activity" : "health_and_safety"}
|
||||
onClick={handleHealthCheck}
|
||||
disabled={healthChecking || bulkBusy || proxyPools.length === 0}
|
||||
>
|
||||
{healthChecking ? `Checking ${healthProgress.current}/${healthProgress.total}` : "Health Check"}
|
||||
</Button>
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" icon="toggle_on" onClick={() => bulkSetActive(true)} disabled={bulkBusy || healthChecking}>
|
||||
Activate
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="toggle_off" onClick={() => bulkSetActive(false)} disabled={bulkBusy || healthChecking}>
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="delete" onClick={bulkDelete} disabled={bulkBusy || healthChecking}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearSelection} disabled={bulkBusy || healthChecking}>
|
||||
Clear
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyPools.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-text-main font-medium mb-1">No proxy pool entries yet</p>
|
||||
@@ -372,8 +550,15 @@ export default function ProxyPoolsPage() {
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-black/[0.04] dark:divide-white/[0.05]">
|
||||
{proxyPools.map((pool) => (
|
||||
<div key={pool.id} className="group flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div key={pool.id} className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(pool.id)}
|
||||
onChange={() => toggleSelect(pool.id)}
|
||||
className="mt-1 size-4 shrink-0 rounded border-black/20 dark:border-white/20"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="min-w-0 max-w-full truncate text-sm font-medium sm:max-w-[18rem]">{pool.name}</p>
|
||||
<Badge variant={getStatusVariant(pool.testStatus)} size="sm" dot>
|
||||
@@ -397,9 +582,16 @@ export default function ProxyPoolsPage() {
|
||||
Last tested: {formatDateTime(pool.lastTestedAt)}
|
||||
{pool.lastError ? ` · ${pool.lastError}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={pool.isActive === true}
|
||||
onChange={() => handleToggleActive(pool)}
|
||||
title={pool.isActive ? "Disable" : "Enable"}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleTest(pool.id)}
|
||||
className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
|
||||
|
||||
Reference in New Issue
Block a user