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:
parent
bfb7d42164
commit
d4bc42e1f5
67 changed files with 2930 additions and 234 deletions
|
|
@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||
import { Card, CardSkeleton } from "@/shared/components";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, MitmLinkCard } from "./components";
|
||||
import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, MitmLinkCard } from "./components";
|
||||
import { MITM_TOOLS } from "@/shared/constants/cliTools";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
|
@ -17,6 +17,7 @@ const STATUS_ENDPOINTS = {
|
|||
droid: "/api/cli-tools/droid-settings",
|
||||
openclaw: "/api/cli-tools/openclaw-settings",
|
||||
hermes: "/api/cli-tools/hermes-settings",
|
||||
cowork: "/api/cli-tools/cowork-settings",
|
||||
};
|
||||
|
||||
export default function CLIToolsPageClient({ machineId }) {
|
||||
|
|
@ -27,6 +28,8 @@ export default function CLIToolsPageClient({ machineId }) {
|
|||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [tunnelEnabled, setTunnelEnabled] = useState(false);
|
||||
const [tunnelPublicUrl, setTunnelPublicUrl] = useState("");
|
||||
const [tailscaleEnabled, setTailscaleEnabled] = useState(false);
|
||||
const [tailscaleUrl, setTailscaleUrl] = useState("");
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [toolStatuses, setToolStatuses] = useState({});
|
||||
|
||||
|
|
@ -68,8 +71,10 @@ export default function CLIToolsPageClient({ machineId }) {
|
|||
}
|
||||
if (tunnelRes.ok) {
|
||||
const data = await tunnelRes.json();
|
||||
setTunnelEnabled(data.enabled || false);
|
||||
setTunnelPublicUrl(data.publicUrl || "");
|
||||
setTunnelEnabled(!!(data.tunnel?.enabled || data.tunnel?.settingsEnabled));
|
||||
setTunnelPublicUrl(data.tunnel?.publicUrl || "");
|
||||
setTailscaleEnabled(!!(data.tailscale?.enabled || data.tailscale?.settingsEnabled));
|
||||
setTailscaleUrl(data.tailscale?.tunnelUrl || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading settings:", error);
|
||||
|
|
@ -176,6 +181,22 @@ export default function CLIToolsPageClient({ machineId }) {
|
|||
return <CodexToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.codex} />;
|
||||
case "opencode":
|
||||
return <OpenCodeToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.opencode} />;
|
||||
case "cowork":
|
||||
return (
|
||||
<CoworkToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
cloudUrl={CLOUD_URL}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
initialStatus={toolStatuses.cowork}
|
||||
/>
|
||||
);
|
||||
case "droid":
|
||||
return <DroidToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.droid} />;
|
||||
case "openclaw":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/cowork-settings";
|
||||
|
||||
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
|
||||
|
||||
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
|
||||
const ensureV1 = (url) => {
|
||||
const trimmed = (url || "").replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||
};
|
||||
|
||||
export default function CoworkToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
hasActiveProviders,
|
||||
cloudEnabled,
|
||||
cloudUrl,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
initialStatus,
|
||||
}) {
|
||||
const [status, setStatus] = useState(initialStatus || null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [endpointMode, setEndpointMode] = useState("custom");
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
|
||||
const endpointOptions = useMemo(() => {
|
||||
const opts = [];
|
||||
if (tunnelEnabled && tunnelPublicUrl) {
|
||||
opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: ensureV1(tunnelPublicUrl) });
|
||||
}
|
||||
if (tailscaleEnabled && tailscaleUrl) {
|
||||
opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: ensureV1(tailscaleUrl) });
|
||||
}
|
||||
if (cloudEnabled && cloudUrl) {
|
||||
opts.push({ value: "cloud", label: `Cloud - ${cloudUrl}`, url: ensureV1(cloudUrl) });
|
||||
}
|
||||
opts.push({ value: "custom", label: "Custom URL (VPS / public host)", url: "" });
|
||||
return opts;
|
||||
}, [tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.cowork?.models?.length) {
|
||||
setSelectedModels(status.cowork.models);
|
||||
}
|
||||
if (status?.cowork?.baseUrl && !customBaseUrl) {
|
||||
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
|
||||
setEndpointMode("custom");
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Auto-pick first available preset when expand if user has not set anything
|
||||
useEffect(() => {
|
||||
if (!customBaseUrl && endpointOptions[0]?.url) {
|
||||
setEndpointMode(endpointOptions[0].value);
|
||||
setCustomBaseUrl(stripV1(endpointOptions[0].url));
|
||||
}
|
||||
}, [endpointOptions]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT);
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
setStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => ensureV1(customBaseUrl);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!status?.installed) return null;
|
||||
const url = status?.cowork?.baseUrl;
|
||||
if (!url) return "not_configured";
|
||||
if (isLocalhostUrl(url)) return "invalid";
|
||||
return status.has9Router ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const handleEndpointModeChange = (value) => {
|
||||
setEndpointMode(value);
|
||||
const opt = endpointOptions.find((o) => o.value === value);
|
||||
if (opt?.url) {
|
||||
setCustomBaseUrl(stripV1(opt.url));
|
||||
} else {
|
||||
setCustomBaseUrl("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setMessage(null);
|
||||
const effectiveUrl = getEffectiveBaseUrl();
|
||||
|
||||
if (isLocalhostUrl(effectiveUrl)) {
|
||||
setMessage({ type: "error", text: "Localhost is not allowed. Enable Tunnel/Tailscale or use VPS." });
|
||||
return;
|
||||
}
|
||||
if (selectedModels.length === 0) {
|
||||
setMessage({ type: "error", text: "Please select at least one model" });
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(true);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: effectiveUrl,
|
||||
apiKey: keyToUse,
|
||||
models: selectedModels,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." });
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully" });
|
||||
setSelectedModels([]);
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
|
||||
const cfg = {
|
||||
inferenceProvider: "gateway",
|
||||
inferenceGatewayBaseUrl: getEffectiveBaseUrl() || "https://your-public-host/v1",
|
||||
inferenceGatewayApiKey: keyToUse,
|
||||
inferenceModels: modelsToShow.map((name) => ({ name })),
|
||||
};
|
||||
|
||||
return [{
|
||||
filename: "~/Library/Application Support/Claude-3p/configLibrary/<appliedId>.json",
|
||||
content: JSON.stringify(cfg, null, 2),
|
||||
}];
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="xs" className="overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
|
||||
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
|
||||
{configStatus === "invalid" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full">Localhost (invalid)</span>}
|
||||
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-xs text-blue-700 dark:text-blue-300">
|
||||
<span className="material-symbols-outlined text-[16px] mt-0.5">info</span>
|
||||
<span>Claude Cowork runs in a sandboxed VM and <b>cannot reach localhost</b>. Use Tunnel, Tailscale, or VPS public URL.</span>
|
||||
</div>
|
||||
|
||||
{checking && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Claude Cowork...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status && !status.installed && (
|
||||
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude Desktop (Cowork mode) not detected</p>
|
||||
<p className="text-sm text-text-muted">Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-9">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
|
||||
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
|
||||
Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{status?.cowork?.baseUrl && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{status.cowork.baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Endpoint Mode</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<select
|
||||
value={endpointMode}
|
||||
onChange={(e) => handleEndpointModeChange(e.target.value)}
|
||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||
>
|
||||
{endpointOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Base URL</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<input
|
||||
type="text"
|
||||
value={getEffectiveBaseUrl()}
|
||||
onChange={(e) => setCustomBaseUrl(stripV1(e.target.value))}
|
||||
placeholder="https://your-host.com/v1"
|
||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
{apiKeys.length > 0 || selectedApiKey ? (
|
||||
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
|
||||
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
|
||||
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{selectedModels.length === 0 ? (
|
||||
<span className="text-xs text-text-muted">No models selected</span>
|
||||
) : (
|
||||
selectedModels.map((m) => (
|
||||
<span key={m} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
|
||||
{m}
|
||||
<button onClick={() => setSelectedModels((prev) => prev.filter((x) => x !== m))} className="ml-0.5 hover:text-red-500">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
|
||||
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
}
|
||||
setModalOpen(false);
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Add Model for Claude Cowork"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Claude Cowork - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export { default as HermesToolCard } from "./HermesToolCard";
|
|||
export { default as DefaultToolCard } from "./DefaultToolCard";
|
||||
export { default as AntigravityToolCard } from "./AntigravityToolCard";
|
||||
export { default as OpenCodeToolCard } from "./OpenCodeToolCard";
|
||||
export { default as CoworkToolCard } from "./CoworkToolCard";
|
||||
export { default as CopilotToolCard } from "./CopilotToolCard";
|
||||
export { default as MitmServerCard } from "./MitmServerCard";
|
||||
export { default as MitmToolCard } from "./MitmToolCard";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/Co
|
|||
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
|
||||
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
|
||||
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
|
||||
import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
|
||||
|
||||
// Shared row layout — defined outside components to avoid re-mount on re-render
|
||||
function Row({ label, children }) {
|
||||
|
|
@ -92,13 +93,6 @@ const KIND_EXAMPLE_CONFIG = {
|
|||
extraBody: { prompt: "Describe this image in detail" },
|
||||
defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`,
|
||||
},
|
||||
stt: {
|
||||
inputLabel: "Audio URL",
|
||||
inputPlaceholder: "https://example.com/audio.mp3",
|
||||
defaultInput: "",
|
||||
bodyKey: "url",
|
||||
defaultResponse: `{\n "text": "Hello world...",\n "model": "..."\n}`,
|
||||
},
|
||||
video: {
|
||||
inputLabel: "Prompt",
|
||||
inputPlaceholder: "A serene lake at sunset",
|
||||
|
|
@ -394,6 +388,8 @@ function TtsExampleCard({ providerId }) {
|
|||
const [modalSearch, setModalSearch] = useState("");
|
||||
const [modalError, setModalError] = useState("");
|
||||
const [byLang, setByLang] = useState({});
|
||||
// Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
|
||||
const [languageHint, setLanguageHint] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndpoint(window.location.origin);
|
||||
|
|
@ -514,10 +510,15 @@ function TtsExampleCard({ providerId }) {
|
|||
return "";
|
||||
})();
|
||||
|
||||
const ttsBody = (() => {
|
||||
const b = { model: modelFull, input };
|
||||
if (config.hasLanguageHint && languageHint) b.language = languageHint;
|
||||
return b;
|
||||
})();
|
||||
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}"}' \\
|
||||
-d '${JSON.stringify(ttsBody)}' \\
|
||||
${responseFormat === "json" ? "" : "--output speech.mp3"}`;
|
||||
|
||||
const handleRun = async () => {
|
||||
|
|
@ -534,7 +535,7 @@ function TtsExampleCard({ providerId }) {
|
|||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ model: modelFull, input: input.trim() }),
|
||||
body: JSON.stringify({ ...ttsBody, input: input.trim() }),
|
||||
});
|
||||
setLatency(Date.now() - start);
|
||||
if (!res.ok) {
|
||||
|
|
@ -608,6 +609,22 @@ function TtsExampleCard({ providerId }) {
|
|||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
|
||||
{config.hasLanguageHint && (
|
||||
<Row label="Language">
|
||||
<select
|
||||
value={languageHint}
|
||||
onChange={(e) => setLanguageHint(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Auto-detect</option>
|
||||
{GOOGLE_TTS_LANGUAGES.map((l) => (
|
||||
<option key={l.id} value={l.name}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
|
||||
{config.hasBrowseButton && (
|
||||
<Row label="Language">
|
||||
|
|
@ -886,7 +903,7 @@ function GenericExampleCard({ providerId, kind }) {
|
|||
// Get models for this kind (e.g., type="image")
|
||||
const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind);
|
||||
// Kinds that need a model identifier in the request (image/video/music)
|
||||
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "stt", "imageToText"]);
|
||||
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
|
||||
const needsModel = KIND_NEEDS_MODEL.has(kind);
|
||||
const allowManualModel = needsModel && kindModels.length === 0;
|
||||
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
||||
|
|
@ -1344,6 +1361,288 @@ function GenericExampleCard({ providerId, kind }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── STT Example Card ────────────────────────────────────────────────────────
|
||||
function SttExampleCard({ providerId }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const builtinSttModels = getModelsByProviderId(providerId).filter((m) => m.type === "stt");
|
||||
const [customSttModels, setCustomSttModels] = useState([]);
|
||||
const sttModels = [...builtinSttModels, ...customSttModels];
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? "");
|
||||
const selectedModelObj = sttModels.find((m) => m.id === selectedModel);
|
||||
const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : [];
|
||||
|
||||
const [audioFile, setAudioFile] = useState(null);
|
||||
const [language, setLanguage] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [responseFormat, setResponseFormat] = useState("json");
|
||||
const [temperature, setTemperature] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [result, setResult] = useState(null);
|
||||
const [latency, setLatency] = 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 loadCustom = () => {
|
||||
fetch("/api/models/custom", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const list = (d.models || []).filter((m) => m.type === "stt" && m.providerAlias === providerAlias);
|
||||
setCustomSttModels(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
loadCustom();
|
||||
window.addEventListener("focus", loadCustom);
|
||||
window.addEventListener("customModelChanged", loadCustom);
|
||||
return () => {
|
||||
window.removeEventListener("focus", loadCustom);
|
||||
window.removeEventListener("customModelChanged", loadCustom);
|
||||
};
|
||||
}, [providerAlias]);
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||
|
||||
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
-F "file=@${audioFile?.name || "audio.mp3"}" \\
|
||||
-F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!audioFile || !modelFull) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", audioFile);
|
||||
fd.append("model", modelFull);
|
||||
if (allowedParams.includes("language") && language) fd.append("language", language);
|
||||
if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat);
|
||||
if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature);
|
||||
if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt);
|
||||
|
||||
const headers = {};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd });
|
||||
setLatency(Date.now() - start);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
const data = ct.includes("application/json") ? await res.json() : await res.text();
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model */}
|
||||
{sttModels.length > 0 ? (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{sttModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Model">
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="Enter model id"
|
||||
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"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}/v1/audio/transcriptions
|
||||
</span>
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* API Key */}
|
||||
<Row label="API Key">
|
||||
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Audio file */}
|
||||
<Row label="Audio File">
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/mp4,.m4a,.mp3,.wav,.ogg,.flac,.webm,.opus"
|
||||
onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer"
|
||||
/>
|
||||
{audioFile && (
|
||||
<span className="text-xs text-text-muted font-mono">
|
||||
{audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Language (if model supports) */}
|
||||
{allowedParams.includes("language") && (
|
||||
<Row label="Language">
|
||||
<input
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="e.g. en, vi, ja (auto-detect if empty)"
|
||||
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"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Prompt (if model supports) */}
|
||||
{allowedParams.includes("prompt") && (
|
||||
<Row label="Prompt">
|
||||
<input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="optional context to improve accuracy"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Temperature (if model supports) */}
|
||||
{allowedParams.includes("temperature") && (
|
||||
<Row label="Temperature">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(e.target.value)}
|
||||
placeholder="0 - 1 (default 0)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Response format (if model supports) */}
|
||||
{allowedParams.includes("response_format") && (
|
||||
<Row label="Response Format">
|
||||
<select
|
||||
value={responseFormat}
|
||||
onChange={(e) => setResponseFormat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="json">json</option>
|
||||
<option value="text">text</option>
|
||||
<option value="srt">srt</option>
|
||||
<option value="verbose_json">verbose_json</option>
|
||||
<option value="vtt">vtt</option>
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !audioFile || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Transcribing..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Response */}
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultStr)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{resultStr}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// MediaProviderDetailPage
|
||||
export default function MediaProviderDetailPage() {
|
||||
const { kind, id } = useParams();
|
||||
|
|
@ -1502,11 +1801,12 @@ export default function MediaProviderDetailPage() {
|
|||
)}
|
||||
|
||||
{/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, searchViaChat */}
|
||||
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.embeddingConfig || provider.searchViaChat) && (
|
||||
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.searchViaChat) && (
|
||||
<ProviderInfoCard
|
||||
config={
|
||||
kind === "webFetch" ? provider.fetchConfig
|
||||
: kind === "tts" ? provider.ttsConfig
|
||||
: kind === "stt" ? provider.sttConfig
|
||||
: kind === "embedding" ? provider.embeddingConfig
|
||||
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, pricingUrl: provider.searchViaChat?.pricingUrl, freeTier: provider.searchViaChat?.freeTier }
|
||||
}
|
||||
|
|
@ -1520,6 +1820,7 @@ export default function MediaProviderDetailPage() {
|
|||
<EmbeddingExampleCard providerId={id} customAlias={customNode?.prefix} />
|
||||
)}
|
||||
{kind === "tts" && <TtsExampleCard providerId={id} />}
|
||||
{kind === "stt" && !isCustom && <SttExampleCard providerId={id} />}
|
||||
{!isCustom && KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
|
||||
|
||||
{isCustom && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -165,7 +165,10 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias, id: modelId, type: effectiveType }),
|
||||
});
|
||||
if (res.ok) await fetchData();
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
window.dispatchEvent(new CustomEvent("customModelChanged"));
|
||||
}
|
||||
} catch (e) { console.log("add custom model error:", e); }
|
||||
};
|
||||
|
||||
|
|
@ -173,7 +176,10 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
|||
try {
|
||||
const params = new URLSearchParams({ providerAlias, id: modelId, type: effectiveType });
|
||||
const res = await fetch(`/api/models/custom?${params}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchData();
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
window.dispatchEvent(new CustomEvent("customModelChanged"));
|
||||
}
|
||||
} catch (e) { console.log("delete custom model error:", e); }
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
||||
|
||||
function getStatusDisplay(connected, error, errorCode) {
|
||||
|
|
@ -103,6 +104,18 @@ export default function ProvidersPage() {
|
|||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
const searchQuery = useHeaderSearchStore((s) => s.query);
|
||||
const registerSearch = useHeaderSearchStore((s) => s.register);
|
||||
const unregisterSearch = useHeaderSearchStore((s) => s.unregister);
|
||||
|
||||
useEffect(() => {
|
||||
registerSearch("Search providers...");
|
||||
return () => unregisterSearch();
|
||||
}, [registerSearch, unregisterSearch]);
|
||||
|
||||
const matchSearch = (name) =>
|
||||
!searchQuery.trim() ||
|
||||
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
@ -224,7 +237,8 @@ export default function ProvidersPage() {
|
|||
color: "#10A37F",
|
||||
textIcon: "OC",
|
||||
apiType: node.apiType,
|
||||
}));
|
||||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
|
||||
const anthropicCompatibleProviders = providerNodes
|
||||
.filter((node) => node.type === "anthropic-compatible")
|
||||
|
|
@ -233,7 +247,22 @@ export default function ProvidersPage() {
|
|||
name: node.name || "Anthropic Compatible",
|
||||
color: "#D97757",
|
||||
textIcon: "AC",
|
||||
}));
|
||||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
|
||||
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) =>
|
||||
matchSearch(info.name),
|
||||
);
|
||||
const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) =>
|
||||
matchSearch(info.name),
|
||||
);
|
||||
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
|
||||
([, info]) => matchSearch(info.name),
|
||||
);
|
||||
const apikeyEntries = Object.entries(APIKEY_PROVIDERS).filter(
|
||||
([, info]) =>
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -244,9 +273,27 @@ export default function ProvidersPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const hasAnyResult =
|
||||
oauthEntries.length > 0 ||
|
||||
freeEntries.length > 0 ||
|
||||
freeTierEntries.length > 0 ||
|
||||
apikeyEntries.length > 0 ||
|
||||
compatibleProviders.length > 0 ||
|
||||
anthropicCompatibleProviders.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
||||
{!hasAnyResult && (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-xl">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-text-muted text-sm">No providers match your search</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
{oauthEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
||||
|
|
@ -275,7 +322,7 @@ export default function ProvidersPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
||||
{oauthEntries.map(([key, info]) => (
|
||||
<ProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
|
|
@ -287,8 +334,10 @@ export default function ProvidersPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free Tier Providers */}
|
||||
{(freeEntries.length > 0 || freeTierEntries.length > 0) && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
||||
|
|
@ -314,7 +363,7 @@ export default function ProvidersPage() {
|
|||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Object.entries(FREE_PROVIDERS).map(([key, info]) => (
|
||||
{freeEntries.map(([key, info]) => (
|
||||
<ProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
|
|
@ -324,7 +373,7 @@ export default function ProvidersPage() {
|
|||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(FREE_TIER_PROVIDERS).map(([key, info]) => (
|
||||
{freeTierEntries.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
|
|
@ -336,8 +385,10 @@ export default function ProvidersPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Providers — fixed list */}
|
||||
{apikeyEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
||||
|
|
@ -363,20 +414,19 @@ export default function ProvidersPage() {
|
|||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Object.entries(APIKEY_PROVIDERS)
|
||||
.filter(([, info]) => (info.serviceKinds ?? ["llm"]).includes("llm"))
|
||||
.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
{apikeyEntries.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web Cookie Providers — use browser subscription cookie instead of API key */}
|
||||
{/* <div className="flex flex-col gap-4">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import Toggle from "@/shared/components/Toggle";
|
|||
import { parseQuotaData, calculatePercentage } from "./utils";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { EditConnectionModal } from "@/shared/components";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { USAGE_SUPPORTED_PROVIDERS, USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
// Connection is eligible for the quota page when it uses OAuth or is an apikey provider whitelisted for quota
|
||||
const isUsageEligible = (conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || USAGE_APIKEY_PROVIDERS.includes(conn.provider));
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
||||
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
|
||||
|
|
@ -239,16 +244,11 @@ export default function ProviderLimits() {
|
|||
try {
|
||||
const conns = await fetchConnections();
|
||||
|
||||
// Filter only supported OAuth providers
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
conn.authType === "oauth",
|
||||
);
|
||||
// Filter eligible connections (OAuth + whitelisted apikey)
|
||||
const eligibleConnections = conns.filter(isUsageEligible);
|
||||
|
||||
// Fetch quota for supported OAuth connections only
|
||||
await Promise.all(
|
||||
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
||||
eligibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
||||
);
|
||||
|
||||
setLastUpdated(new Date());
|
||||
|
|
@ -266,21 +266,17 @@ export default function ProviderLimits() {
|
|||
const conns = await fetchConnections();
|
||||
setConnectionsLoading(false);
|
||||
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
conn.authType === "oauth",
|
||||
);
|
||||
const eligibleConnections = conns.filter(isUsageEligible);
|
||||
|
||||
// Mark all as loading before fetching
|
||||
const loadingState = {};
|
||||
oauthConnections.forEach((conn) => {
|
||||
eligibleConnections.forEach((conn) => {
|
||||
loadingState[conn.id] = true;
|
||||
});
|
||||
setLoading(loadingState);
|
||||
|
||||
await Promise.all(
|
||||
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
||||
eligibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
||||
);
|
||||
setLastUpdated(new Date());
|
||||
};
|
||||
|
|
@ -354,12 +350,8 @@ export default function ProviderLimits() {
|
|||
};
|
||||
}, [autoRefresh, refreshAll]);
|
||||
|
||||
// Filter only supported providers
|
||||
const filteredConnections = connections.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
conn.authType === "oauth",
|
||||
);
|
||||
// Filter eligible connections (OAuth + whitelisted apikey)
|
||||
const filteredConnections = connections.filter(isUsageEligible);
|
||||
|
||||
const providerFilteredConnections = filteredConnections.filter(
|
||||
(conn) => providerFilter === "all" || conn.provider === providerFilter,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
ReactFlow,
|
||||
|
|
@ -10,6 +10,10 @@ import {
|
|||
import "@xyflow/react/dist/style.css";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
// Force-stop FE animation if a provider stays active longer than this
|
||||
const FE_ACTIVE_TIMEOUT_MS = 60000;
|
||||
const FE_ACTIVE_TICK_MS = 1000;
|
||||
|
||||
function getProviderConfig(providerId) {
|
||||
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
|
||||
}
|
||||
|
|
@ -199,13 +203,44 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
|
|||
const lastKey = lastProvider?.toLowerCase() || "";
|
||||
const errorKey = errorProvider?.toLowerCase() || "";
|
||||
|
||||
const activeSet = useMemo(() => new Set(activeKey ? activeKey.split(",") : []), [activeKey]);
|
||||
const rawActiveSet = useMemo(() => new Set(activeKey ? activeKey.split(",") : []), [activeKey]);
|
||||
const lastSet = useMemo(() => new Set(lastKey ? [lastKey] : []), [lastKey]);
|
||||
const errorSet = useMemo(() => new Set(errorKey ? [errorKey] : []), [errorKey]);
|
||||
|
||||
// Track firstSeen per active provider; drop provider if running too long (BE stuck)
|
||||
const firstSeenRef = useRef({});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const seen = firstSeenRef.current;
|
||||
const now = Date.now();
|
||||
for (const p of rawActiveSet) {
|
||||
if (!seen[p]) seen[p] = now;
|
||||
}
|
||||
for (const p of Object.keys(seen)) {
|
||||
if (!rawActiveSet.has(p)) delete seen[p];
|
||||
}
|
||||
}, [rawActiveSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawActiveSet.size === 0) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), FE_ACTIVE_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [rawActiveSet]);
|
||||
|
||||
const activeSet = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const filtered = new Set();
|
||||
for (const p of rawActiveSet) {
|
||||
const ts = firstSeenRef.current[p];
|
||||
if (!ts || now - ts < FE_ACTIVE_TIMEOUT_MS) filtered.add(p);
|
||||
}
|
||||
return filtered;
|
||||
}, [rawActiveSet, tick]);
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
() => buildLayout(providers, activeSet, lastSet, errorSet),
|
||||
[providers, activeKey, lastKey, errorKey]
|
||||
[providers, activeSet, lastKey, errorKey]
|
||||
);
|
||||
|
||||
// Stable key — only remount when provider list changes
|
||||
|
|
|
|||
246
src/app/api/cli-tools/cowork-settings/route.js
Normal file
246
src/app/api/cli-tools/cowork-settings/route.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
|
||||
const PROVIDER = "gateway";
|
||||
|
||||
// Candidate user-data roots — Cowork can run from either Claude-3p (3p mode) or Claude (1p mode w/ cowork features)
|
||||
const getCandidateRoots = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
const base = path.join(os.homedir(), "Library", "Application Support");
|
||||
return [path.join(base, "Claude-3p"), path.join(base, "Claude")];
|
||||
}
|
||||
if (os.platform() === "win32") {
|
||||
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return [
|
||||
path.join(localApp, "Claude-3p"),
|
||||
path.join(roaming, "Claude-3p"),
|
||||
path.join(localApp, "Claude"),
|
||||
path.join(roaming, "Claude"),
|
||||
];
|
||||
}
|
||||
return [
|
||||
path.join(os.homedir(), ".config", "Claude-3p"),
|
||||
path.join(os.homedir(), ".config", "Claude"),
|
||||
];
|
||||
};
|
||||
|
||||
// Claude.app/exe install paths — fallback detect when no user-data folder yet
|
||||
const getAppInstallPaths = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
return ["/Applications/Claude.app", path.join(os.homedir(), "Applications", "Claude.app")];
|
||||
}
|
||||
if (os.platform() === "win32") {
|
||||
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const programFiles = process.env["ProgramFiles"] || "C:\\Program Files";
|
||||
return [
|
||||
path.join(localApp, "AnthropicClaude"),
|
||||
path.join(programFiles, "Claude"),
|
||||
path.join(programFiles, "AnthropicClaude"),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// For READ: prefer existing configLibrary (any root). For WRITE: always Claude-3p (first candidate).
|
||||
const resolveAppRootForRead = async () => {
|
||||
const candidates = getCandidateRoots();
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
await fs.access(path.join(dir, "configLibrary"));
|
||||
return dir;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return candidates[0];
|
||||
};
|
||||
|
||||
const getWriteRoot = () => getCandidateRoots()[0]; // always Claude-3p
|
||||
|
||||
const getConfigDir = async () => path.join(await resolveAppRootForRead(), "configLibrary");
|
||||
const getWriteConfigDir = () => path.join(getWriteRoot(), "configLibrary");
|
||||
const getMetaPath = async () => path.join(await getConfigDir(), "_meta.json");
|
||||
const getWriteMetaPath = () => path.join(getWriteConfigDir(), "_meta.json");
|
||||
|
||||
// Locate Claude (1p) folder for claude_desktop_config.json bootstrap
|
||||
const get1pRoot = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
return path.join(os.homedir(), "Library", "Application Support", "Claude");
|
||||
}
|
||||
if (os.platform() === "win32") {
|
||||
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(roaming, "Claude"); // 1p uses roaming on Win
|
||||
}
|
||||
return path.join(os.homedir(), ".config", "Claude");
|
||||
};
|
||||
|
||||
// Set deploymentMode="3p" in Claude/claude_desktop_config.json (preserve existing keys)
|
||||
const bootstrapDeploymentMode = async () => {
|
||||
const cfgPath = path.join(get1pRoot(), "claude_desktop_config.json");
|
||||
let cfg = {};
|
||||
try {
|
||||
const content = await fs.readFile(cfgPath, "utf-8");
|
||||
cfg = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (cfg.deploymentMode === "3p") return false; // no change
|
||||
cfg.deploymentMode = "3p";
|
||||
await fs.mkdir(get1pRoot(), { recursive: true });
|
||||
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
|
||||
return true;
|
||||
};
|
||||
|
||||
// Cowork is available if either (a) any user-data root exists or (b) Claude app is installed
|
||||
const checkInstalled = async () => {
|
||||
for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) {
|
||||
try {
|
||||
await fs.access(dir);
|
||||
return true;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
|
||||
|
||||
const readJson = async (filePath) => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure meta exists in Claude-3p/configLibrary (write target). If meta already exists in Claude/ (1p), copy appliedId.
|
||||
const ensureMeta = async () => {
|
||||
const writeMetaPath = getWriteMetaPath();
|
||||
let meta = await readJson(writeMetaPath);
|
||||
if (!meta || !meta.appliedId) {
|
||||
// Try to inherit from any existing root
|
||||
const existingRead = await readJson(await getMetaPath());
|
||||
if (existingRead?.appliedId) {
|
||||
meta = existingRead;
|
||||
} else {
|
||||
const newId = crypto.randomUUID();
|
||||
meta = { appliedId: newId, entries: [{ id: newId, name: "Default" }] };
|
||||
}
|
||||
await fs.mkdir(getWriteConfigDir(), { recursive: true });
|
||||
await fs.writeFile(writeMetaPath, JSON.stringify(meta, null, 2));
|
||||
}
|
||||
return meta;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
config: null,
|
||||
message: "Claude Desktop (Cowork mode) not detected",
|
||||
});
|
||||
}
|
||||
|
||||
const meta = await readJson(await getMetaPath());
|
||||
const appliedId = meta?.appliedId || null;
|
||||
const configDir = await getConfigDir();
|
||||
const configPath = appliedId ? path.join(configDir, `${appliedId}.json`) : null;
|
||||
const config = configPath ? await readJson(configPath) : null;
|
||||
|
||||
const baseUrl = config?.inferenceGatewayBaseUrl || null;
|
||||
const models = Array.isArray(config?.inferenceModels)
|
||||
? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config,
|
||||
has9Router,
|
||||
configPath,
|
||||
cowork: {
|
||||
appliedId,
|
||||
baseUrl,
|
||||
models,
|
||||
provider: config?.inferenceProvider || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error reading cowork settings:", error);
|
||||
return NextResponse.json({ error: "Failed to read cowork settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, models } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isLocalhostUrl(baseUrl)) {
|
||||
return NextResponse.json({
|
||||
error: "Claude Cowork sandbox cannot reach localhost. Enable Tunnel/Cloud Endpoint or use Tailscale/VPS.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : [];
|
||||
if (modelsArray.length === 0) {
|
||||
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const bootstrapped = await bootstrapDeploymentMode();
|
||||
const meta = await ensureMeta();
|
||||
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
|
||||
|
||||
const newConfig = {
|
||||
inferenceProvider: PROVIDER,
|
||||
inferenceGatewayBaseUrl: baseUrl,
|
||||
inferenceGatewayApiKey: apiKey,
|
||||
inferenceModels: modelsArray.map((name) => ({ name })),
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bootstrapped,
|
||||
message: bootstrapped
|
||||
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
|
||||
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
||||
configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error applying cowork settings:", error);
|
||||
return NextResponse.json({ error: "Failed to apply cowork settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const meta = await readJson(await getMetaPath());
|
||||
if (!meta?.appliedId) {
|
||||
return NextResponse.json({ success: true, message: "No active config to reset" });
|
||||
}
|
||||
const configPath = path.join(await getConfigDir(), `${meta.appliedId}.json`);
|
||||
try {
|
||||
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting cowork settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset cowork settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export async function GET(request) {
|
|||
|
||||
// ElevenLabs requires API key
|
||||
const raw = provider === "elevenlabs" ? await fetcher(apiKey) : await fetcher();
|
||||
const useElevenShape = provider === "elevenlabs" || provider === "gemini";
|
||||
let voices;
|
||||
|
||||
if (provider === "local-device") {
|
||||
|
|
@ -46,7 +47,7 @@ export async function GET(request) {
|
|||
langName: langName(v.lang),
|
||||
gender: v.gender,
|
||||
}));
|
||||
} else if (provider === "elevenlabs") {
|
||||
} else if (useElevenShape) {
|
||||
voices = raw.map((v) => ({
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
|
|
|
|||
50
src/app/api/models/disabled/route.js
Normal file
50
src/app/api/models/disabled/route.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getDisabledModels, disableModels, enableModels } from "@/lib/disabledModelsDb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/models/disabled?providerAlias=xxx
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerAlias = searchParams.get("providerAlias");
|
||||
const all = await getDisabledModels();
|
||||
if (providerAlias) return NextResponse.json({ ids: all[providerAlias] || [] });
|
||||
return NextResponse.json({ disabled: all });
|
||||
} catch (error) {
|
||||
console.log("Error fetching disabled models:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch disabled models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/models/disabled body: { providerAlias, ids: [...] }
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { providerAlias, ids } = await request.json();
|
||||
if (!providerAlias || !Array.isArray(ids)) {
|
||||
return NextResponse.json({ error: "providerAlias and ids[] required" }, { status: 400 });
|
||||
}
|
||||
await disableModels(providerAlias, ids);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.log("Error disabling models:", error);
|
||||
return NextResponse.json({ error: "Failed to disable models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/models/disabled?providerAlias=xxx[&id=yyy]
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerAlias = searchParams.get("providerAlias");
|
||||
const id = searchParams.get("id");
|
||||
if (!providerAlias) {
|
||||
return NextResponse.json({ error: "providerAlias required" }, { status: 400 });
|
||||
}
|
||||
await enableModels(providerAlias, id ? [id] : []);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.log("Error enabling models:", error);
|
||||
return NextResponse.json({ error: "Failed to enable models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,29 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias } from "@/models";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { AI_MODELS } from "@/shared/constants/config";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
|
||||
// GET /api/models - Get models with aliases
|
||||
export async function GET() {
|
||||
try {
|
||||
const modelAliases = await getModelAliases();
|
||||
|
||||
const models = AI_MODELS.map((m) => {
|
||||
const fullModel = `${m.provider}/${m.model}`;
|
||||
return {
|
||||
...m,
|
||||
fullModel,
|
||||
alias: modelAliases[fullModel] || m.model,
|
||||
};
|
||||
});
|
||||
const disabled = await getDisabledModels();
|
||||
|
||||
const models = AI_MODELS
|
||||
.filter((m) => {
|
||||
const alias = getProviderAlias(m.provider) || m.provider;
|
||||
const list = disabled[alias] || disabled[m.provider] || [];
|
||||
return !list.includes(m.model);
|
||||
})
|
||||
.map((m) => {
|
||||
const fullModel = `${m.provider}/${m.model}`;
|
||||
return {
|
||||
...m,
|
||||
fullModel,
|
||||
alias: modelAliases[fullModel] || m.model,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import {
|
|||
pollForToken
|
||||
} from "@/lib/oauth/providers";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { startCodexProxy, stopCodexProxy } from "@/lib/oauth/utils/server";
|
||||
import {
|
||||
startCodexProxy,
|
||||
stopCodexProxy,
|
||||
registerCodexSession,
|
||||
getCodexSessionStatus,
|
||||
clearCodexSession,
|
||||
} from "@/lib/oauth/utils/server";
|
||||
|
||||
/**
|
||||
* Dynamic OAuth API Route
|
||||
|
|
@ -39,8 +45,34 @@ export async function GET(request, { params }) {
|
|||
if (!appPort) {
|
||||
return NextResponse.json({ error: "Missing app_port" }, { status: 400 });
|
||||
}
|
||||
// Optional server-side mode params: register session for auto-exchange
|
||||
const state = searchParams.get("state");
|
||||
const codeVerifier = searchParams.get("code_verifier");
|
||||
const redirectUri = searchParams.get("redirect_uri");
|
||||
const result = await startCodexProxy(Number(appPort));
|
||||
return NextResponse.json(result);
|
||||
let serverSide = false;
|
||||
if (result.success && state && codeVerifier && redirectUri) {
|
||||
serverSide = registerCodexSession({ state, codeVerifier, redirectUri });
|
||||
}
|
||||
return NextResponse.json({ ...result, serverSide });
|
||||
}
|
||||
|
||||
if (action === "poll-status") {
|
||||
if (provider !== "codex") {
|
||||
return NextResponse.json({ error: "Poll only supported for codex" }, { status: 400 });
|
||||
}
|
||||
const state = searchParams.get("state");
|
||||
if (!state) {
|
||||
return NextResponse.json({ error: "Missing state" }, { status: 400 });
|
||||
}
|
||||
const session = getCodexSessionStatus(state);
|
||||
if (!session) return NextResponse.json({ status: "unknown" });
|
||||
if (session.status === "done" || session.status === "error") {
|
||||
const payload = { ...session };
|
||||
clearCodexSession(state);
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
return NextResponse.json({ status: session.status });
|
||||
}
|
||||
|
||||
if (action === "stop-proxy") {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export async function POST(request) {
|
|||
if (!provider || !isValidProvider) {
|
||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||
}
|
||||
if (!apiKey) {
|
||||
if (!apiKey && provider !== "ollama-local") {
|
||||
return NextResponse.json({ error: `${isWebCookieProvider ? "Cookie value" : "API Key"} is required` }, { status: 400 });
|
||||
}
|
||||
if (!name) {
|
||||
|
|
@ -185,7 +185,7 @@ export async function POST(request) {
|
|||
provider,
|
||||
authType: isWebCookieProvider ? "cookie" : "apikey",
|
||||
name,
|
||||
apiKey,
|
||||
apiKey: apiKey || "",
|
||||
priority: priority || 1,
|
||||
globalPriority: globalPriority || null,
|
||||
defaultModel: defaultModel || null,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ async function probeMediaProvider(provider, apiKey) {
|
|||
const kinds = p.serviceKinds || ["llm"];
|
||||
const isMediaOnly = kinds.every((k) => MEDIA_KINDS.has(k));
|
||||
if (!isMediaOnly) return null;
|
||||
const cfg = p.ttsConfig || p.embeddingConfig || p.imageConfig || p.videoConfig || p.musicConfig;
|
||||
const cfg = p.ttsConfig || p.sttConfig || p.embeddingConfig || p.imageConfig || p.videoConfig || p.musicConfig;
|
||||
// No probe config → best-effort accept (validate at usage time)
|
||||
if (!cfg) return true;
|
||||
if (p.noAuth || cfg.authType === "none") return true;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getProviderConnectionById, updateProviderConnection } from "@/lib/local
|
|||
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||
import { getExecutor } from "open-sse/executors/index.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
// Detect auth-expired messages returned by usage providers instead of throwing
|
||||
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
||||
|
|
@ -113,9 +114,14 @@ export async function GET(request, { params }) {
|
|||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only OAuth connections have usage APIs
|
||||
if (connection.authType !== "oauth") {
|
||||
return Response.json({ message: "Usage not available for API key connections" });
|
||||
// Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/...)
|
||||
const isOAuth = connection.authType === "oauth";
|
||||
const isApikeyEligible =
|
||||
connection.authType === "apikey" &&
|
||||
USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||
|
||||
if (!isOAuth && !isApikeyEligible) {
|
||||
return Response.json({ message: "Usage not available for this connection" });
|
||||
}
|
||||
|
||||
// Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure
|
||||
|
|
@ -128,23 +134,25 @@ export async function GET(request, { params }) {
|
|||
strictProxy: false,
|
||||
};
|
||||
|
||||
// Refresh credentials if needed using executor
|
||||
try {
|
||||
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||
connection = result.connection;
|
||||
} catch (refreshError) {
|
||||
console.error("[Usage API] Credential refresh failed:", refreshError);
|
||||
return Response.json({
|
||||
error: `Credential refresh failed: ${refreshError.message}`
|
||||
}, { status: 401 });
|
||||
// Refresh credentials only for OAuth connections (apikey has no token refresh)
|
||||
if (isOAuth) {
|
||||
try {
|
||||
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||
connection = result.connection;
|
||||
} catch (refreshError) {
|
||||
console.error("[Usage API] Credential refresh failed:", refreshError);
|
||||
return Response.json({
|
||||
error: `Credential refresh failed: ${refreshError.message}`
|
||||
}, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch usage from provider API
|
||||
let usage = await getUsageForProvider(connection, proxyOptions);
|
||||
|
||||
// If provider returned an auth-expired message instead of throwing,
|
||||
// force-refresh token and retry once
|
||||
if (isAuthExpiredMessage(usage) && connection.refreshToken) {
|
||||
// force-refresh token and retry once (OAuth only)
|
||||
if (isOAuth && isAuthExpiredMessage(usage) && connection.refreshToken) {
|
||||
try {
|
||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||
connection = retryResult.connection;
|
||||
|
|
|
|||
19
src/app/api/v1/audio/transcriptions/route.js
Normal file
19
src/app/api/v1/audio/transcriptions/route.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { handleStt } from "@/sse/handlers/stt.js";
|
||||
|
||||
// Allow large audio uploads — 5min for processing large files
|
||||
export const maxDuration = 300;
|
||||
|
||||
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/transcriptions - OpenAI Whisper compatible STT */
|
||||
export async function POST(request) {
|
||||
return await handleStt(request);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue