Merge branch 'master' into gitea/new_feature

This commit is contained in:
2026-07-30 23:14:05 +07:00
252 changed files with 20302 additions and 4337 deletions

View File

@@ -166,11 +166,13 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
</div>
</Modal>
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel} onDeselect={handleDeselectModel}
activeProviders={activeProviders} modelAliases={modelAliases}
title="Add Model to Combo" kindFilter={kindFilter}
addedModelValues={models} closeOnSelect={false} />
{showModelSelect && (
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel} onDeselect={handleDeselectModel}
activeProviders={activeProviders} modelAliases={modelAliases}
title="Add Model to Combo" kindFilter={kindFilter}
addedModelValues={models} closeOnSelect={false} />
)}
</>
);
}

View File

@@ -106,6 +106,8 @@ function DonateChannelCard({ channel }) {
src={qr}
alt={`${label} QR`}
className="w-full max-w-[180px] aspect-square object-contain rounded-lg bg-white p-1"
loading="lazy"
decoding="async"
/>
)}
</>

View File

@@ -12,6 +12,7 @@ import DonateModal from "@/shared/components/DonateModal";
import { useHeaderSearchStore } from "@/store/headerSearchStore";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
import { translate } from "@/i18n/runtime";
const getPageInfo = (pathname) => {
@@ -30,7 +31,7 @@ const getPageInfo = (pathname) => {
breadcrumbs: [
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
{ label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` },
{ label: provider?.name || providerId, image: `/providers/${providerId}.png` },
{ label: provider?.name || providerId, image: getProviderIconSrc(providerId) },
],
};
}
@@ -62,7 +63,7 @@ const getPageInfo = (pathname) => {
{ label: "Providers", href: "/dashboard/providers" },
{
label: providerInfo.name,
image: `/providers/${providerInfo.id}.png`,
image: getProviderIconSrc(providerInfo.id),
},
],
};

View File

@@ -39,6 +39,7 @@ const getLocaleInfo = (locale) => {
"tl": { name: "Tagalog", flag: "🇵🇭" },
"id": { name: "Indonesia", flag: "🇮🇩" },
"th": { name: "ไทย", flag: "🇹🇭" },
"km": { name: "ខ្មែរ", flag: "🇰🇭" },
"hi": { name: "हिन्दी", flag: "🇮🇳" },
"bn": { name: "বাংলা", flag: "🇧🇩" },
"ur": { name: "اردو", flag: "🇵🇰" },
@@ -63,9 +64,9 @@ export default function LanguageSwitcher({ className = "", isOpen: controlledOpe
const isControlled = typeof controlledOpen === "boolean";
const isOpen = isControlled ? controlledOpen : internalOpen;
const setIsOpen = (value) => {
const setIsOpen = (value, nextLocale = locale) => {
if (isControlled) {
if (!value && onClose) onClose(locale);
if (!value && onClose) onClose(nextLocale);
} else {
setInternalOpen(value);
}
@@ -92,7 +93,6 @@ export default function LanguageSwitcher({ className = "", isOpen: controlledOpe
if (nextLocale === locale || isPending) return;
setIsPending(true);
setIsOpen(false);
try {
await fetch("/api/locale", {
method: "POST",
@@ -103,6 +103,7 @@ export default function LanguageSwitcher({ className = "", isOpen: controlledOpe
// Reload translations without full page reload
await reloadTranslations();
setLocale(nextLocale);
setIsOpen(false, nextLocale);
} catch (err) {
console.error("Failed to set locale:", err);
} finally {

View File

@@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
{s.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} />
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
) : (
<div className="size-7 rounded bg-surface shrink-0" />
)}

View File

@@ -48,6 +48,48 @@ export default function ModelSelectModal({
const [providerNodes, setProviderNodes] = useState([]);
const [customModels, setCustomModels] = useState([]);
const [disabledModels, setDisabledModels] = useState({});
const [cursorModels, setCursorModels] = useState([]);
// Cursor exposes the usable catalog per account. Keep the static catalog only
// as a fallback, since it quickly becomes stale and different accounts can
// have different model entitlements.
const cursorConnectionIds = useMemo(
() => activeProviders
.filter((provider) => provider.provider === "cursor" && provider.id)
.map((provider) => provider.id),
[activeProviders],
);
useEffect(() => {
if (!isOpen || cursorConnectionIds.length === 0) {
setCursorModels([]);
return undefined;
}
let cancelled = false;
Promise.all(cursorConnectionIds.map(async (connectionId) => {
const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" });
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data.models) ? data.models : [];
}))
.then((modelLists) => {
if (cancelled) return;
const seen = new Set();
setCursorModels(modelLists.flat().filter((model) => {
if (!model?.id || seen.has(model.id)) return false;
seen.add(model.id);
return true;
}));
})
.catch((error) => {
// Do not hide the static fallback when the account catalog is unavailable.
console.warn("Unable to load Cursor models for selector:", error);
if (!cancelled) setCursorModels([]);
});
return () => { cancelled = true; };
}, [isOpen, cursorConnectionIds]);
const fetchCombos = async () => {
try {
@@ -280,7 +322,9 @@ export default function ModelSelectModal({
hasModels: mergedModels.length > 0,
};
} else {
const hardcodedModels = getModelsByProviderId(providerId);
const hardcodedModels = providerId === "cursor" && cursorModels.length > 0
? cursorModels
: getModelsByProviderId(providerId);
const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
// Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider
@@ -349,7 +393,7 @@ export default function ModelSelectModal({
});
return groups;
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]);
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]);
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
const filteredCombos = useMemo(() => {

View File

@@ -5,6 +5,31 @@ import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
// Providers using the dynamic-port local callback proxy.
// Browser OAuth: popup → auto callback → auto exchange → poll-status.
const PROXY_OAUTH_PROVIDERS = new Set(["trae", "windsurf", "zed"]);
// Providers offering a paste-token fallback (import-token flow).
// UX warns if the IDE (which issues the token) is not installed.
const PASTE_TOKEN_PROVIDERS = {
trae: {
label: "Cloud-IDE-JWT",
instructions:
"Sign in at trae.ai (or solo.trae.ai), open DevTools → Network, copy the Cloud-IDE-JWT token from any request's Authorization header (~14-day lifetime).",
placeholder: "Paste Cloud-IDE-JWT here...",
ideName: "Trae",
ideOptional: true, // token can be grabbed from DevTools without the IDE
},
windsurf: {
label: "Windsurf API key",
instructions:
"In the Windsurf/VS Code IDE, run the \"Windsurf: Provide Auth Token\" command, then copy the displayed sk-ws-... key.",
placeholder: "Paste sk-ws-... key here...",
ideName: "Windsurf",
ideOptional: false,
},
};
/**
* OAuth Modal Component
* - Localhost: Auto callback via popup message
@@ -18,6 +43,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const [isDeviceCode, setIsDeviceCode] = useState(false);
const [deviceData, setDeviceData] = useState(null);
const [polling, setPolling] = useState(false);
// trae/windsurf: choose between browser OAuth (proxy) and paste-token (import)
const [authMode, setAuthMode] = useState("browser"); // "browser" | "paste-token"
const [pasteToken, setPasteToken] = useState("");
const [ideStatus, setIdeStatus] = useState(null);
const popupRef = useRef(null);
const pollingAbortRef = useRef(false);
const openedRef = useRef(false);
@@ -150,20 +179,60 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setPolling(false);
}, [provider, onSuccess]);
// Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange.
const startProxyFlow = useCallback(async (providerId) => {
// 1. Start the local callback server (returns a dynamic port + callback URL).
const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`);
const startData = await startRes.json();
if (!startRes.ok || !startData.success || !startData.callbackUrl) {
throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`);
}
// 2. Build the authorize URL with redirect_uri = proxy callback URL.
const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl);
const authRes = await fetch(authorizeUrl);
const authData = await authRes.json();
if (!authRes.ok) throw new Error(authData.error);
// 3. Register the session so the proxy can match the incoming callback.
// Zed also passes code_verifier (encodes the RSA private key for decrypt);
// sent via POST body so the private key never lands in URL/query logs.
const regBody = { state: authData.state };
if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier;
await fetch(`/api/oauth/${providerId}/register-session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(regBody),
});
// 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status.
setAuthData({ ...authData, proxyProvider: providerId });
setStep("waiting");
popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700");
if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste
}, []);
// Start OAuth flow
const startOAuthFlow = useCallback(async () => {
if (!provider) return;
try {
setError(null);
// Trae/Windsurf: proxy OAuth (browser mode) — handled by dedicated flow.
// Paste-token mode is handled by handleManualSubmit (no /authorize call).
if (PROXY_OAUTH_PROVIDERS.has(provider) && authMode === "browser") {
await startProxyFlow(provider);
return;
}
// Device code flow providers (must match oauth providers with flowType: "device_code")
const deviceCodeProviders = [
"github",
"qwen",
"kiro",
"kimi",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"codebuddy-intl",
"qoder",
"grok-cli",
];
@@ -206,6 +275,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
_qoderMachineId: data._qoderMachineId,
_qoderVerifier: data.codeVerifier,
}
: (provider === "kimi" || provider === "kimi-coding")
? { _kimiDeviceId: data._kimiDeviceId }
: null;
startPolling(
data.device_code,
@@ -326,7 +397,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig]);
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]);
// Reset state and start OAuth when modal opens
useEffect(() => {
@@ -340,7 +411,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
setAuthMode("browser");
setPasteToken("");
setIdeStatus(null);
pollingAbortRef.current = false;
// Best-effort IDE detection for paste-token providers (Trae/Windsurf)
if (PASTE_TOKEN_PROVIDERS[provider]) {
fetch(`/api/oauth/${provider}/ide-status`)
.then((r) => r.json())
.then((data) => setIdeStatus(data))
.catch(() => setIdeStatus({ installed: false, path: null }));
}
startOAuthFlow();
} else if (!isOpen) {
// Abort polling and cleanup proxy when modal closes
@@ -350,13 +431,26 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
}
}, [isOpen, provider, startOAuthFlow]);
// Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB)
// Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port):
// poll status until the proxy auto-exchanges and saves the connection.
useEffect(() => {
const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null;
const pollProvider = authData?.codexServerSide
? "codex"
: authData?.xaiServerSide
? "xai"
: authData?.proxyProvider
? authData.proxyProvider
: null;
if (!pollProvider || !authData?.state) return;
if (callbackProcessedRef.current) return;
let cancelled = false;
@@ -484,8 +578,38 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
try {
setError(null);
// Paste-token mode (Trae/Windsurf): token goes straight to /exchange
if (authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider]) {
const token = pasteToken.trim();
if (!token) throw new Error("Missing token");
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: token }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
return;
}
const input = callbackUrl.trim();
// Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL
if (PROXY_OAUTH_PROVIDERS.has(provider) && input) {
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: input, state: authData?.state }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
return;
}
// Detect raw JWT access token (starts with eyJ) — skip URL parsing
if (input.startsWith("eyJ") && input.includes(".")) {
await exchangeTokens(input, null);
@@ -535,6 +659,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
onClose();
}, [onClose, provider]);
@@ -553,8 +683,82 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return (
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{/* Waiting + Manual Input combined (non-device-code) */}
{(step === "waiting" || step === "input") && !isDeviceCode && (
{/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */}
{PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && (
<>
<div className="flex gap-2">
<button
type="button"
onClick={() => { setAuthMode("browser"); setError(null); setStep("waiting"); startOAuthFlow(); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "browser" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🌐 Sign in with browser
</button>
<button
type="button"
onClick={() => { setAuthMode("paste-token"); setError(null); setStep("input"); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "paste-token" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🔑 Paste token
</button>
</div>
{authMode === "browser" && (
<>
{step === "waiting" && (
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-base text-primary animate-spin">progress_activity</span>
<span className="text-sm">Waiting for browser authorization</span>
</div>
)}
{step === "input" && (
<div className="space-y-3">
<p className="text-sm text-text-muted">
Popup was blocked. After authorizing in the browser, paste the full callback URL here:
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="http://127.0.0.1:.../callback?..."
className="font-mono text-xs"
/>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>Connect</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
)}
</>
)}
{authMode === "paste-token" && (
<div className="space-y-3">
{ideStatus && !ideStatus.installed && (
<div className={`px-3 py-2 rounded-lg text-sm ${PASTE_TOKEN_PROVIDERS[provider].ideOptional ? "bg-blue-500/10 text-blue-700 dark:text-blue-300" : "bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"}`}>
{PASTE_TOKEN_PROVIDERS[provider].ideName} IDE not detected.
{PASTE_TOKEN_PROVIDERS[provider].ideOptional
? " You can still grab the token from DevTools."
: ` Install ${PASTE_TOKEN_PROVIDERS[provider].ideName} IDE to get the token, or use "Sign in with browser".`}
</div>
)}
<p className="text-sm text-text-muted">{PASTE_TOKEN_PROVIDERS[provider].instructions}</p>
<Input
value={pasteToken}
onChange={(e) => setPasteToken(e.target.value)}
placeholder={PASTE_TOKEN_PROVIDERS[provider].placeholder}
className="font-mono text-xs"
/>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!pasteToken}>Connect</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
)}
</>
)}
{/* Waiting + Manual Input combined (non-device-code, non-proxy) */}
{(step === "waiting" || step === "input") && !isDeviceCode && !PROXY_OAUTH_PROVIDERS.has(provider) && (
<>
{/* Option A: Auto via popup */}
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">

View File

@@ -2,18 +2,29 @@
import { useState } from "react";
import PropTypes from "prop-types";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
function resolveSrc(src, providerId) {
if (providerId) return getProviderIconSrc(providerId);
if (!src) return null;
const m = String(src).match(/^\/providers\/([^/]+)\.png$/i);
if (m) return getProviderIconSrc(m[1]);
return src;
}
export default function ProviderIcon({
src,
providerId,
alt,
size = 32,
className = "",
fallbackText = "?",
fallbackColor,
}) {
const effectiveSrc = resolveSrc(src, providerId);
const [errored, setErrored] = useState(false);
if (!src || errored) {
if (!effectiveSrc || errored) {
return (
<span
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
@@ -31,18 +42,26 @@ export default function ProviderIcon({
return (
<img
src={src}
src={effectiveSrc}
alt={alt}
width={size}
height={size}
className={className}
onError={() => setErrored(true)}
loading="lazy"
decoding="async"
onError={() => {
const m = effectiveSrc.match(/^\/providers\/([^/]+)\.png$/i);
if (m) markProviderIconMissing(m[1]);
if (providerId) markProviderIconMissing(providerId);
setErrored(true);
}}
/>
);
}
ProviderIcon.propTypes = {
src: PropTypes.string,
providerId: PropTypes.string,
alt: PropTypes.string,
size: PropTypes.number,
className: PropTypes.string,

View File

@@ -302,9 +302,26 @@ export default function Sidebar({ onClose }) {
<span className="material-symbols-outlined text-[18px] group-hover:text-primary transition-colors">
computer
</span>
<span className="text-[13px] font-medium">Remote</span>
<span className="text-[13px] font-medium">9Remote</span>
</button>
{/* 9English */}
<a
href="https://9english.net/"
target="_blank"
rel="noreferrer"
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group w-full",
"text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
<span className="material-symbols-outlined text-[18px] group-hover:text-primary transition-colors">
translate
</span>
<span className="text-[13px] font-medium">9English</span>
</a>
{/* Settings */}
<Link
href="/dashboard/profile"

View File

@@ -8,9 +8,12 @@ export const MITM_TOOLS = {
description: "Google Antigravity IDE with MITM",
configType: "mitm",
mitmDomain: "daily-cloudcode-pa.googleapis.com",
modelAliases: ["gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
defaultModels: [
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low" },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", alias: "gemini-3.6-flash-low" },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low", mandatory: true },
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)", alias: "gemini-3-flash-agent" },
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)", alias: "gemini-3.5-flash-extra-low" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", alias: "gemini-3.1-pro-low" },
@@ -105,7 +108,7 @@ export const CLI_TOOLS = {
settingsFile: "~/.claude/settings.json",
defaultModels: [
{ id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" },
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" },
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-5" },
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" },
{ id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
],
@@ -357,7 +360,7 @@ amp --model "{{model}}"
},
],
defaultModels: [
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", alias: "opus", defaultValue: "cc/claude-opus-4-7" },
{ id: "claude-opus-5", name: "Claude Opus 5", alias: "opus", defaultValue: "cc/claude-opus-5" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "sonnet", defaultValue: "cc/claude-sonnet-4-6" },
{ id: "gpt-5.5", name: "GPT 5.5", alias: "gpt5", defaultValue: "cx/gpt-5.5" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" },
@@ -387,6 +390,32 @@ amp --model "{{model}}"
},
],
},
devin: {
id: "devin",
name: "Devin CLI",
image: "/providers/devin-cli.png",
color: "#6366F1",
description: "Cognition Devin CLI — local binary called by the Devin CLI provider via ACP/stdio",
configType: "guide",
installUrl: "https://cli.devin.ai",
notes: [
{ type: "info", text: "This is a local dependency, not a routed CLI. The Devin CLI provider spawns `devin acp --agent-type summarizer` and relays its output." },
{ type: "warning", text: "Install the Devin CLI and run `devin auth login` — without it, the provider returns a spawn error on first request." },
],
guideSteps: [
{ step: 1, title: "Install Devin CLI", desc: "Install via the official installer at cli.devin.ai.", docsUrl: "https://cli.devin.ai" },
{ step: 2, title: "Authenticate", desc: "Log in once so the binary stores its own credentials." },
{ step: 3, title: "Use the provider", desc: "Pick any Devin CLI model under the Providers tab — no API key field needed." },
],
codeBlock: {
language: "bash",
code: `# Install Devin CLI (see https://cli.devin.ai for options)
devin auth login
# Verify detection (optional)
devin --version`,
},
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",

View File

@@ -22,6 +22,7 @@ export const LOCALE_FLAGS = {
"tl": "🇵🇭",
"id": "🇮🇩",
"th": "🇹🇭",
"km": "🇰🇭",
"hi": "🇮🇳",
"bn": "🇧🇩",
"ur": "🇵🇰",

View File

@@ -1,44 +1,80 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
// Module cache: one /api/models fetch shared by every useModelCaps instance.
let cache = null; // { byFull, byId } | null
let inflight = null;
function buildMaps(models) {
const byFull = {};
const byId = {};
for (const m of models || []) {
if (!m.caps) continue;
if (m.fullModel) byFull[m.fullModel] = m.caps;
if (m.routedModel) byFull[m.routedModel] = m.caps;
if (m.model) byId[m.model] = m.caps;
}
return { byFull, byId };
}
function loadModelCaps() {
if (cache) return Promise.resolve(cache);
if (inflight) return inflight;
inflight = fetch("/api/models")
.then(async (res) => {
if (!res.ok) throw new Error(`models ${res.status}`);
const data = await res.json();
cache = buildMaps(data.models);
return cache;
})
.catch(() => {
// Keep null so a later mount can retry
return { byFull: {}, byId: {} };
})
.finally(() => { inflight = null; });
return inflight;
}
// Resolve caps from a "provider/model" string or a bare model id.
function resolveCaps(byFull, byId, key) {
if (!key) return null;
if (byFull[key]) return byFull[key];
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
if (byId[bare]) return byId[bare];
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
const c = getCapabilitiesForModel(provider, bare);
return {
vision: c.vision,
search: c.search,
reasoning: c.reasoning,
contextWindow: c.contextWindow,
maxOutput: c.maxOutput,
};
}
export function useModelCaps() {
const [byFull, setByFull] = useState({});
const [byId, setById] = useState({});
const [byFull, setByFull] = useState(() => cache?.byFull || {});
const [byId, setById] = useState(() => cache?.byId || {});
useEffect(() => {
if (cache) {
setByFull(cache.byFull);
setById(cache.byId);
return;
}
let alive = true;
(async () => {
try {
const res = await fetch("/api/models");
if (!res.ok) return;
const data = await res.json();
const full = {};
const id = {};
for (const m of data.models || []) {
if (!m.caps) continue;
if (m.fullModel) full[m.fullModel] = m.caps;
if (m.model) id[m.model] = m.caps;
}
if (alive) { setByFull(full); setById(id); }
} catch { /* ignore */ }
})();
loadModelCaps().then((maps) => {
if (alive) { setByFull(maps.byFull); setById(maps.byId); }
});
return () => { alive = false; };
}, []);
// Resolve caps from a "provider/model" string or a bare model id.
const getCaps = (key) => {
if (!key) return null;
if (byFull[key]) return byFull[key];
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
if (byId[bare]) return byId[bare];
// Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
const c = getCapabilitiesForModel(provider, bare);
return { vision: c.vision, search: c.search, reasoning: c.reasoning };
};
const getCaps = useCallback(
(key) => resolveCaps(byFull, byId, key),
[byFull, byId],
);
return { getCaps };
}

View File

@@ -1,6 +1,7 @@
// Shared Utils - Export all
export { cn } from "./cn";
export * as api from "./api";
export { getProviderIconSrc, markProviderIconMissing, resolveProviderIconId } from "./providerIcon";
import { v4 as uuidv4 } from "uuid";

View File

@@ -0,0 +1,40 @@
// Provider icon paths under /public/providers.
// Alias related brands; session-cache 404s so one miss never spams again.
const ICON_ALIASES = {
"perplexity-agent": "perplexity",
"gitlab-duo": "gitlab",
"vercel-ai-gateway": "vercel",
};
// Runtime only — first 404 remembers id for the whole session
const failedIds = new Set();
function normalizeId(providerId) {
if (!providerId || typeof providerId !== "string") return "";
return providerId.trim().toLowerCase();
}
/** Resolve icon file id (after alias). Empty if previously failed this session. */
export function resolveProviderIconId(providerId) {
const id = normalizeId(providerId);
if (!id) return "";
if (failedIds.has(id)) return "";
const aliased = ICON_ALIASES[id] || id;
if (failedIds.has(aliased)) return "";
return aliased;
}
/** `/providers/{id}.png` or null when previously failed. */
export function getProviderIconSrc(providerId) {
const id = resolveProviderIconId(providerId);
return id ? `/providers/${id}.png` : null;
}
/** Call from img onError so later mounts skip the request. */
export function markProviderIconMissing(providerId) {
const id = normalizeId(providerId);
if (id) failedIds.add(id);
const aliased = ICON_ALIASES[id];
if (aliased) failedIds.add(aliased);
}