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:
decolua committed 2026-05-05 10:32:59 +07:00
1 parent bfb7d42164
commit d4bc42e1f5
67 files changed
+2930 -234

No files matched your search

@@ -15,15 +15,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); }
}, [isOpen]);
// Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider)
const stripAlias = (id) => {
const prefix = `${providerAlias}/`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
};
const handleTest = async () => {
if (!modelId.trim()) return;
const cleanId = stripAlias(modelId.trim());
if (!cleanId) return;
setTestStatus("testing");
setTestError("");
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerAlias}/${modelId.trim()}` }),
body: JSON.stringify({ model: `${providerAlias}/${cleanId}` }),
});
const data = await res.json();
setTestStatus(data.ok ? "ok" : "error");
@@ -35,10 +42,11 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
};
const handleSave = async () => {
if (!modelId.trim() || saving) return;
const cleanId = stripAlias(modelId.trim());
if (!cleanId || saving) return;
setSaving(true);
try {
await onSave(modelId.trim());
await onSave(cleanId);
} finally {
setSaving(false);
}
@@ -74,7 +82,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
</Button>
</div>
<p className="text-xs text-text-muted mt-1">
Sent to provider as: <code className="font-mono bg-sidebar px-1 rounded">{modelId.trim() || "model-id"}</code>
Sent to provider as: <code className="font-mono bg-sidebar px-1 rounded">{stripAlias(modelId.trim()) || "model-id"}</code>
</p>
</div>
@@ -1,6 +1,6 @@
import PropTypes from "prop-types";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting }) {
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable }) {
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
@@ -55,7 +55,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
</span>
</div>
{isCustom && (
{isCustom ? (
<button
onClick={onDeleteAlias}
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
@@ -63,7 +63,15 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
)}
) : onDisable ? (
<button
onClick={onDisable}
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
title="Disable this model"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
) : null}
</div>
</div>
);
@@ -83,4 +91,5 @@ ModelRow.propTypes = {
onDeleteAlias: PropTypes.func,
onTest: PropTypes.func,
isTesting: PropTypes.bool,
onDisable: PropTypes.func,
};
@@ -46,6 +46,7 @@ export default function ProviderDetailPage() {
const [thinkingMode, setThinkingMode] = useState("auto");
const [suggestedModels, setSuggestedModels] = useState([]);
const [kiloFreeModels, setKiloFreeModels] = useState([]);
const [disabledModelIds, setDisabledModelIds] = useState([]);
const { copied, copy } = useCopyToClipboard();
const providerInfo = providerNode
@@ -74,6 +75,62 @@ export default function ProviderDetailPage() {
? (providerNode?.prefix || providerId)
: providerAlias;
const fetchDisabledModels = useCallback(async () => {
try {
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" });
const data = await res.json();
if (res.ok) setDisabledModelIds(data.ids || []);
} catch (error) {
console.log("Error fetching disabled models:", error);
}
}, [providerStorageAlias]);
const handleDisableModel = async (modelId) => {
try {
const res = await fetch("/api/models/disabled", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }),
});
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error disabling model:", error);
}
};
const handleEnableModel = async (modelId) => {
try {
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" });
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error enabling model:", error);
}
};
const handleDisableAll = async (ids) => {
if (!ids.length) return;
if (!confirm(`Disable all ${ids.length} model(s)?`)) return;
try {
const res = await fetch("/api/models/disabled", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerAlias: providerStorageAlias, ids }),
});
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error disabling all models:", error);
}
};
const handleEnableAll = async () => {
try {
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { method: "DELETE" });
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error enabling all models:", error);
}
};
// Define callbacks BEFORE the useEffect that uses them
const fetchAliases = useCallback(async () => {
try {
@@ -237,7 +294,8 @@ export default function ProviderDetailPage() {
useEffect(() => {
fetchConnections();
fetchAliases();
}, [fetchConnections, fetchAliases]);
fetchDisabledModels();
}, [fetchConnections, fetchAliases, fetchDisabledModels]);
// Fetch suggested models from provider's public API (if configured)
useEffect(() => {
@@ -587,10 +645,13 @@ 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 = [
const allModels = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => !m.type || m.type === "llm");
const disabledSet = new Set(disabledModelIds);
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
const customModels = Object.entries(modelAliases)
.filter(([alias, fullModel]) => {
@@ -610,6 +671,25 @@ export default function ProviderDetailPage() {
return (
<div className="flex flex-wrap gap-3">
{/* Custom models first */}
{customModels.map((model) => (
<ModelRow
key={model.id}
model={{ id: model.id }}
fullModel={`${providerDisplayAlias}/${model.id}`}
alias={model.alias}
copied={copied}
onCopy={copy}
onSetAlias={() => {}}
onDeleteAlias={() => handleDeleteAlias(model.alias)}
testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelId === model.id}
isCustom
isFree={false}
/>
))}
{displayModels.map((model) => {
const fullModel = `${providerStorageAlias}/${model.id}`;
const oldFormatModel = `${providerId}/${model.id}`;
@@ -630,33 +710,15 @@ export default function ProviderDetailPage() {
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelId === model.id}
isFree={model.isFree}
onDisable={() => handleDisableModel(model.id)}
/>
);
})}
{/* Custom models inline */}
{customModels.map((model) => (
<ModelRow
key={model.id}
model={{ id: model.id }}
fullModel={`${providerDisplayAlias}/${model.id}`}
alias={model.alias}
copied={copied}
onCopy={copy}
onSetAlias={() => {}}
onDeleteAlias={() => handleDeleteAlias(model.alias)}
testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelId === model.id}
isCustom
isFree={false}
/>
))}
{/* Add model button — inline, same style as model chips */}
<button
onClick={() => setShowAddCustomModel(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-black/15 px-3 py-2 text-xs text-text-muted transition-colors hover:border-primary/40 hover:text-primary sm:w-auto"
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2 text-xs text-primary transition-colors hover:border-primary hover:bg-primary/5 sm:w-auto"
>
<span className="material-symbols-outlined text-sm">add</span>
Add Model
@@ -692,6 +754,26 @@ export default function ProviderDetailPage() {
</div>
);
})()}
{/* Disabled models — restorable */}
{disabledDisplayModels.length > 0 && (
<div className="w-full mt-2">
<p className="text-xs text-text-muted mb-2">Disabled models ({disabledDisplayModels.length}):</p>
<div className="flex flex-wrap gap-2">
{disabledDisplayModels.map((m) => (
<button
key={m.id}
onClick={() => handleEnableModel(m.id)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-dashed border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
title="Restore model"
>
<span className="material-symbols-outlined text-[13px]">add</span>
{m.id}
</button>
))}
</div>
</div>
)}
</div>
);
};
@@ -969,6 +1051,27 @@ export default function ProviderDetailPage() {
<h2 className="text-lg font-semibold">
{"Available Models"}
</h2>
{!isCompatible && (() => {
const allIds = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => !m.type || m.type === "llm").map((m) => m.id);
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
return (
<div className="flex gap-2">
{disabledModelIds.length > 0 && (
<Button size="sm" variant="secondary" icon="restart_alt" onClick={handleEnableAll}>
Active All
</Button>
)}
{activeIds.length > 0 && (
<Button size="sm" variant="secondary" icon="block" onClick={() => handleDisableAll(activeIds)}>
Disable All
</Button>
)}
</div>
);
})()}
</div>
{!!modelsTestError && (
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>