Enhance token refresh logic and improve MITM server handling

- Introduced a caching mechanism for in-flight token refresh requests to prevent race conditions and reduce unnecessary API calls.
- Added error handling for unrecoverable refresh errors, ensuring that the application can gracefully handle token reuse and invalidation scenarios.
- Updated the MITM server management to handle port 443 conflicts, allowing users to kill processes occupying the port before starting the server.
- Improved user feedback in the MitmServerCard component regarding port conflicts and admin privileges.
- Refactored the ComboList component to streamline the display of media provider combos.

This update aims to enhance the reliability and user experience of the token management and MITM functionalities.
This commit is contained in:
decolua committed 2026-05-03 22:10:03 +07:00
1 parent b8e3a46add
commit 4ba546afe7
17 files changed
+680 -229

No files matched your search

@@ -7,6 +7,11 @@ import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/component
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
// Kinds that support combos (currently disabled for image/tts — temporarily hidden).
// webSearch/webFetch handled by /web page.
const COMBO_KINDS = new Set([]);
const COMBO_BASE_NAMES = { image: "image-combo", tts: "tts-combo" };
function getEffectiveStatus(conn) {
const isCooldown = Object.entries(conn).some(
([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now()
@@ -70,11 +75,53 @@ function MediaProviderCard({ provider, kind, connections, isCustom }) {
);
}
function ComboList({ combos }) {
if (combos.length === 0) return null;
return (
<div className="flex flex-col gap-2">
{combos.map((combo) => (
<Link key={combo.id} href={`/dashboard/media-providers/combo/${combo.id}`}>
<Card padding="xs" className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors cursor-pointer">
<div className="flex min-w-0 items-center gap-3">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
<code className="text-sm font-mono font-medium flex-1 truncate">{combo.name}</code>
<div className="flex flex-wrap items-center gap-1 sm:shrink-0">
{combo.models.slice(0, 6).map((entry, i) => {
const pid = typeof entry === "string" ? entry.split("/")[0] : "";
const p = AI_PROVIDERS[pid];
return (
<div key={`${entry}-${i}`} title={p?.name || entry} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
size={18}
className="object-contain rounded max-w-[18px] max-h-[18px]"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
</div>
);
})}
{combo.models.length > 6 && (
<span className="text-[10px] text-text-muted ml-1">+{combo.models.length - 6}</span>
)}
</div>
<span className="text-[11px] text-text-muted shrink-0">{combo.models.length}</span>
<span className="material-symbols-outlined text-text-muted text-[16px]">chevron_right</span>
</div>
</Card>
</Link>
))}
</div>
);
}
export default function MediaProviderKindPage() {
const { kind } = useParams();
const router = useRouter();
const [connections, setConnections] = useState([]);
const [customNodes, setCustomNodes] = useState([]);
const [combos, setCombos] = useState([]);
const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false);
// webSearch/webFetch listing pages are merged into /web
@@ -86,6 +133,7 @@ export default function MediaProviderKindPage() {
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
const isEmbedding = kind === "embedding";
const supportsCombo = COMBO_KINDS.has(kind);
useEffect(() => {
if (!kindConfig) return;
@@ -99,11 +147,18 @@ export default function MediaProviderKindPage() {
.then((d) => setCustomNodes((d.nodes || []).filter((n) => n.type === "custom-embedding")))
.catch(() => {});
}
}, [isEmbedding, kindConfig]);
if (supportsCombo) {
fetch("/api/combos", { cache: "no-store" })
.then((r) => r.json())
.then((d) => setCombos(d.combos || []))
.catch(() => {});
}
}, [isEmbedding, supportsCombo, kindConfig]);
if (!kindConfig) return notFound();
const providers = getProvidersByKind(kind);
const kindCombos = combos.filter((c) => c.kind === kind);
// Map custom nodes to MediaProviderCard shape
const customProviders = customNodes.map((n) => ({
@@ -115,16 +170,45 @@ export default function MediaProviderKindPage() {
const allProviders = [...providers, ...customProviders];
const handleCreateCombo = async () => {
const base = COMBO_BASE_NAMES[kind] || `${kind}-combo`;
let name = base;
let i = 1;
const existing = new Set(combos.map((c) => c.name));
while (existing.has(name)) { name = `${base}-${i++}`; }
const res = await fetch("/api/combos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, models: [], kind }),
});
if (res.ok) {
const created = await res.json();
router.push(`/dashboard/media-providers/combo/${created.id}`);
} else {
const err = await res.json();
alert(err.error || "Failed to create combo");
}
};
return (
<div className="flex flex-col gap-6">
{isEmbedding && (
<div className="flex items-center justify-end">
<Button size="sm" icon="add" onClick={() => setShowAddCustomEmbedding(true)}>
Add Custom Embedding
</Button>
{(isEmbedding || supportsCombo) && (
<div className="flex items-center justify-end gap-2">
{supportsCombo && (
<Button size="sm" icon="add" onClick={handleCreateCombo}>Create Combo</Button>
)}
{isEmbedding && (
<Button size="sm" icon="add" onClick={() => setShowAddCustomEmbedding(true)}>
Add Custom Embedding
</Button>
)}
</div>
)}
{supportsCombo && kindCombos.length > 0 && (
<ComboList combos={kindCombos} />
)}
{allProviders.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-xl text-text-muted text-sm">
No providers support <strong>{kindConfig.label}</strong> yet.
@@ -3,78 +3,45 @@
import { useParams, notFound, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, Button, Input, Toggle, Modal } from "@/shared/components";
import { Card, Button, Input, Toggle, ModelSelectModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
import { AI_PROVIDERS, MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
// Parse "providerId/model" or just "providerId" → { providerId, model }
function parseModelEntry(entry) {
if (typeof entry !== "string") return { providerId: "", model: "" };
const idx = entry.indexOf("/");
if (idx < 0) return { providerId: entry, model: "" };
return { providerId: entry.slice(0, idx), model: entry.slice(idx + 1) };
}
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
const KIND_LABELS = {
webSearch: "Web Search",
webFetch: "Web Fetch",
image: "Text to Image",
tts: "Text To Speech",
};
const EXAMPLE_PATHS = {
webSearch: "/v1/search",
webFetch: "/v1/web/fetch",
image: "/v1/images/generations",
tts: "/v1/audio/speech",
};
const EXAMPLE_BODIES = {
webSearch: (comboName) => ({ model: comboName, query: "What is the latest news about AI?", search_type: "web", max_results: 5 }),
webFetch: (comboName) => ({ model: comboName, url: "https://example.com", format: "markdown" }),
webSearch: (n) => ({ model: n, query: "What is the latest news about AI?", search_type: "web", max_results: 5 }),
webFetch: (n) => ({ model: n, url: "https://example.com", format: "markdown" }),
image: (n) => ({ model: n, prompt: "A cute cat playing piano", n: 1, size: "1024x1024" }),
tts: (n) => ({ model: n, input: "Hello, this is a test.", voice: "alloy" }),
};
function ProviderPickerModal({ isOpen, onClose, onPick, kind, currentIds, connections }) {
// Only show providers with at least one usable connection (active/success) or noAuth
const usableIds = new Set(
(connections || [])
.filter((c) => {
if (c.isActive === false) return false;
const s = c.testStatus;
return s === "active" || s === "success" || s === "unavailable";
})
.map((c) => c.provider)
);
const all = kind ? getProvidersByKind(kind) : [];
const providers = all.filter((p) => p.noAuth || usableIds.has(p.id));
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Add ${KIND_LABELS[kind] || ""} Provider`} size="md">
{providers.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
No connected providers available. Add a connection first in the {KIND_LABELS[kind]} section.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 max-h-[400px] overflow-y-auto">
{providers.map((p) => {
const already = currentIds.includes(p.id);
return (
<button
key={p.id}
disabled={already}
onClick={() => { onPick(p.id); onClose(); }}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
already
? "border-border opacity-40 cursor-not-allowed"
: "border-border hover:border-primary/50 hover:bg-primary/5 cursor-pointer"
}`}
>
<ProviderIcon
src={`/providers/${p.id}.png`}
alt={p.name}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p.textIcon || p.id.slice(0, 2).toUpperCase()}
fallbackColor={p.color}
/>
<span className="text-xs font-medium truncate text-left">{p.name}</span>
{already && <span className="text-[9px] text-text-muted ml-auto">added</span>}
</button>
);
})}
</div>
)}
</Modal>
);
// Map combo.kind → listing route to go back to
function getListingHref(kind) {
if (kind === "webSearch" || kind === "webFetch") return "/dashboard/media-providers/web";
return `/dashboard/media-providers/${kind}`;
}
export default function ComboDetailPage() {
@@ -89,19 +56,23 @@ export default function ComboDetailPage() {
const [showPicker, setShowPicker] = useState(false);
const [logs, setLogs] = useState([]);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
const [testResult, setTestResult] = useState(null);
const [testError, setTestError] = useState("");
const [apiKey, setApiKey] = useState("");
const [connections, setConnections] = useState([]);
const [modelAliases, setModelAliases] = useState({});
const fetchAll = async () => {
try {
const [comboRes, settingsRes, logsRes, keysRes, connsRes] = await Promise.all([
const [comboRes, settingsRes, logsRes, keysRes, connsRes, aliasesRes] = await Promise.all([
fetch(`/api/combos/${id}`, { cache: "no-store" }),
fetch("/api/settings", { cache: "no-store" }),
fetch("/api/usage/logs", { cache: "no-store" }),
fetch("/api/keys", { cache: "no-store" }),
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/models/alias", { cache: "no-store" }),
]);
if (aliasesRes.ok) setModelAliases((await aliasesRes.json()).aliases || {});
if (keysRes.ok) {
const k = await keysRes.json();
setApiKey((k.keys || []).find((x) => x.isActive !== false)?.key || "");
@@ -147,8 +118,10 @@ export default function ComboDetailPage() {
if (ok) await fetchAll();
};
const handleAddProvider = async (providerId) => {
const next = [...providers, providerId];
const handleAddModel = async (model) => {
const value = model?.value || model;
if (!value || providers.includes(value)) return;
const next = [...providers, value];
setProviders(next);
await saveCombo({ models: next });
};
@@ -185,42 +158,85 @@ export default function ComboDetailPage() {
const handleDelete = async () => {
if (!confirm(`Delete combo "${combo.name}"?`)) return;
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) router.push("/dashboard/media-providers/web");
if (res.ok) router.push(getListingHref(combo.kind));
};
const handleTest = async () => {
setTesting(true);
setTestResult("");
setTestResult(null);
setTestError("");
if (testResult?.audioUrl) { try { URL.revokeObjectURL(testResult.audioUrl); } catch {} }
if (testResult?.imageUrl?.startsWith("blob:")) { try { URL.revokeObjectURL(testResult.imageUrl); } catch {} }
const start = Date.now();
try {
const path = EXAMPLE_PATHS[combo.kind];
const body = EXAMPLE_BODIES[combo.kind](combo.name);
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`/api${path}`, { method: "POST", headers, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
setTestResult(JSON.stringify(data, null, 2));
const latencyMs = Date.now() - start;
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setTestError(d?.error?.message || d?.error || `HTTP ${res.status}`);
setTestResult({ json: JSON.stringify(d, null, 2), latencyMs });
return;
}
const ctype = res.headers.get("content-type") || "";
// Binary image
if (ctype.startsWith("image/")) {
const blob = await res.blob();
setTestResult({ imageUrl: URL.createObjectURL(blob), latencyMs });
return;
}
// Binary audio
if (ctype.startsWith("audio/") || ctype === "application/octet-stream") {
const blob = await res.blob();
setTestResult({ audioUrl: URL.createObjectURL(blob), latencyMs });
return;
}
// JSON — could be image (data[0].b64_json/url) or generic
const data = await res.json();
const first = data?.data?.[0];
const imageUrl = first?.b64_json
? `data:image/png;base64,${first.b64_json}`
: (first?.url || "");
setTestResult({ json: JSON.stringify(maskB64(data), null, 2), imageUrl, latencyMs });
} catch (e) {
setTestResult(`Error: ${e.message}`);
setTestError(e.message || "Network error");
}
setTesting(false);
};
// Mask large b64_json strings to keep JSON view readable
function maskB64(obj) {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(maskB64);
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
? `<${v.length} chars base64>`
: maskB64(v);
}
return out;
}
if (loading) return <div className="text-text-muted text-sm">Loading...</div>;
if (!combo) return notFound();
const kindLabel = KIND_LABELS[combo.kind] || "Web";
const kindLabel = KIND_LABELS[combo.kind] || MEDIA_PROVIDER_KINDS.find((k) => k.id === combo.kind)?.label || "Combo";
const examplePath = EXAMPLE_PATHS[combo.kind];
const exampleBody = combo.kind ? EXAMPLE_BODIES[combo.kind](combo.name) : null;
const exampleBody = combo.kind && EXAMPLE_BODIES[combo.kind] ? EXAMPLE_BODIES[combo.kind](combo.name) : null;
const curlExample = examplePath
? `curl -X POST http://localhost:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'`
: "";
const backHref = getListingHref(combo.kind);
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<Link href="/dashboard/media-providers/web" className="text-text-muted hover:text-primary">
<Link href={backHref} className="text-text-muted hover:text-primary">
<span className="material-symbols-outlined">arrow_back</span>
</Link>
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
@@ -269,20 +285,24 @@ export default function ComboDetailPage() {
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((pid, idx) => {
const p = AI_PROVIDERS[pid];
{providers.map((entry, idx) => {
const { providerId, model } = parseModelEntry(entry);
const p = AI_PROVIDERS[providerId];
return (
<div key={`${pid}-${idx}`} className="flex items-center gap-3 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<div key={`${entry}-${idx}`} className="flex items-center gap-3 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<span className="text-xs text-text-muted w-5 text-center">{idx + 1}</span>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
src={`/providers/${providerId}.png`}
alt={p?.name || providerId}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackText={p?.textIcon || providerId.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
<span className="text-sm font-medium flex-1 truncate">{p?.name || pid}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{p?.name || providerId}</div>
{model && <code className="text-[10px] text-text-muted font-mono truncate block">{model}</code>}
</div>
<div className="flex items-center gap-0.5">
<button onClick={() => handleMove(idx, -1)} disabled={idx === 0} className={`p-1 rounded ${idx === 0 ? "text-text-muted/20" : "text-text-muted hover:text-primary hover:bg-black/5"}`} title="Move up">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
@@ -302,7 +322,7 @@ export default function ComboDetailPage() {
</Card>
{/* Test Example Card */}
{combo.kind && (
{combo.kind && examplePath && (
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-3">
<h2 className="text-lg font-semibold">Test Example</h2>
@@ -313,10 +333,42 @@ export default function ComboDetailPage() {
<pre className="text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-x-auto whitespace-pre-wrap break-all">
{curlExample}
</pre>
{testError && (
<p className="mt-3 text-xs text-red-500 break-words">{testError}</p>
)}
{testResult && (
<pre className="mt-3 text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[300px]">
{testResult}
</pre>
<div className="mt-3 flex flex-col gap-3">
{testResult.latencyMs != null && (
<span className="text-[11px] text-text-muted">⚡ {testResult.latencyMs}ms</span>
)}
{testResult.imageUrl && (
<div>
<div className="flex items-center justify-end mb-1.5">
<a href={testResult.imageUrl} download="image.png" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[14px]">download</span>
Download
</a>
</div>
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" />
</div>
)}
{testResult.audioUrl && (
<div>
<div className="flex items-center justify-end mb-1.5">
<a href={testResult.audioUrl} download="speech.mp3" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[14px]">download</span>
Download
</a>
</div>
<audio controls src={testResult.audioUrl} className="w-full" />
</div>
)}
{testResult.json && (
<pre className="text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[300px] whitespace-pre-wrap break-all">
{testResult.json}
</pre>
)}
</div>
)}
</Card>
)}
@@ -333,13 +385,14 @@ export default function ComboDetailPage() {
)}
</Card>
<ProviderPickerModal
<ModelSelectModal
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onPick={handleAddProvider}
kind={combo.kind}
currentIds={providers}
connections={connections}
onSelect={handleAddModel}
activeProviders={connections}
modelAliases={modelAliases}
title={`Add ${kindLabel} Model`}
kindFilter={combo.kind}
/>
</div>
);
@@ -70,17 +70,18 @@ function ComboList({ combos }) {
return (
<div className="flex flex-col gap-2">
{combos.map((combo) => (
<Link key={combo.id} href={`/dashboard/media-providers/web/combo/${combo.id}`}>
<Link key={combo.id} href={`/dashboard/media-providers/combo/${combo.id}`}>
<Card padding="xs" className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors cursor-pointer">
<div className="flex min-w-0 items-center gap-3">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
<code className="text-sm font-mono font-medium flex-1 truncate">{combo.name}</code>
{/* Provider icons preview */}
<div className="flex flex-wrap items-center gap-1 sm:shrink-0">
{combo.models.slice(0, 6).map((pid, i) => {
{combo.models.slice(0, 6).map((entry, i) => {
const pid = typeof entry === "string" ? entry.split("/")[0] : "";
const p = AI_PROVIDERS[pid];
return (
<div key={`${pid}-${i}`} title={p?.name || pid} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<div key={`${entry}-${i}`} title={p?.name || entry} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
@@ -180,7 +181,7 @@ export default function WebProvidersPage() {
});
if (res.ok) {
const created = await res.json();
router.push(`/dashboard/media-providers/web/combo/${created.id}`);
router.push(`/dashboard/media-providers/combo/${created.id}`);
} else {
const err = await res.json();
alert(err.error || "Failed to create combo");