Fix MITM on window

This commit is contained in:
decolua
2026-02-28 10:04:57 +07:00
parent 49a56612bf
commit 833069caac
22 changed files with 650 additions and 199 deletions

View File

@@ -240,7 +240,23 @@ export default function AntigravityToolCard({
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{/* Start/Stop Button - always on top */}
{/* Status indicators */}
<div className="flex items-center gap-3">
{[
{ label: "DNS", ok: status?.dnsConfigured },
{ label: "Cert", ok: status?.certExists },
{ label: "Server", ok: status?.running },
].map(({ label, ok }) => (
<div key={label} className="flex items-center gap-1">
<span className={`material-symbols-outlined text-[14px] ${ok ? "text-green-500" : "text-text-muted"}`}>
{ok ? "check_circle" : "radio_button_unchecked"}
</span>
<span className={`text-xs ${ok ? "text-green-500" : "text-text-muted"}`}>{label}</span>
</div>
))}
</div>
{/* Start/Stop Button */}
<div className="flex items-center gap-2">
{isRunning ? (
<button

View File

@@ -26,6 +26,7 @@ export default function ProviderDetailPage() {
const [headerImgError, setHeaderImgError] = useState(false);
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(false);
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const { copied, copy } = useCopyToClipboard();
const providerInfo = providerNode
@@ -307,9 +308,21 @@ export default function ProviderDetailPage() {
/>
);
}
if (models.length === 0) {
return <p className="text-sm text-text-muted">No models configured</p>;
}
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
const customModels = Object.entries(modelAliases)
.filter(([alias, fullModel]) => {
const prefix = `${providerStorageAlias}/`;
if (!fullModel.startsWith(prefix)) return false;
const modelId = fullModel.slice(prefix.length);
// Only show if not already in hardcoded list
return !models.some((m) => m.id === modelId) && alias === modelId;
})
.map(([alias, fullModel]) => ({
id: fullModel.slice(`${providerStorageAlias}/`.length),
alias,
fullModel,
}));
return (
<div className="flex flex-wrap gap-3">
{models.map((model) => {
@@ -332,6 +345,30 @@ export default function ProviderDetailPage() {
/>
);
})}
{/* 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)}
isCustom
/>
))}
{/* Add model button — inline, same style as model chips */}
<button
onClick={() => setShowAddCustomModel(true)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-dashed border-black/15 dark:border-white/15 text-xs text-text-muted hover:text-primary hover:border-primary/40 transition-colors"
>
<span className="material-symbols-outlined text-sm">add</span>
Add Model
</button>
</div>
);
};
@@ -526,7 +563,8 @@ export default function ProviderDetailPage() {
<Button
size="sm"
variant="secondary"
icon={testingModels ? "progress_activity" : "science"}
icon="science"
loading={testingModels}
onClick={handleTestModels}
disabled={testingModels}
>
@@ -584,11 +622,23 @@ export default function ProviderDetailPage() {
isAnthropic={isAnthropicCompatible}
/>
)}
{!isCompatible && !providerInfo?.passthroughModels && (
<AddCustomModelModal
isOpen={showAddCustomModel}
providerAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
onSave={async (modelId) => {
await handleSetAlias(modelId, modelId, providerStorageAlias);
setShowAddCustomModel(false);
}}
onClose={() => setShowAddCustomModel(false)}
/>
)}
</div>
);
}
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, onDeleteAlias }) {
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
@@ -602,7 +652,7 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
: undefined;
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
<div className={`group flex items-center gap-2 px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
<span
className="material-symbols-outlined text-base"
style={iconColor ? { color: iconColor } : undefined}
@@ -619,6 +669,15 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
{isCustom && (
<button
onClick={onDeleteAlias}
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
title="Remove custom model"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
)}
</div>
);
}
@@ -632,6 +691,8 @@ ModelRow.propTypes = {
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
testStatus: PropTypes.oneOf(["ok", "error"]),
isCustom: PropTypes.bool,
onDeleteAlias: PropTypes.func,
};
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
@@ -1553,3 +1614,115 @@ EditCompatibleNodeModal.propTypes = {
isAnthropic: PropTypes.bool,
};
function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) {
const [modelId, setModelId] = useState("");
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
const [testError, setTestError] = useState("");
const [saving, setSaving] = useState(false);
// Reset state when modal opens
useEffect(() => {
if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); }
}, [isOpen]);
const handleTest = async () => {
if (!modelId.trim()) 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()}` }),
});
const data = await res.json();
setTestStatus(data.ok ? "ok" : "error");
setTestError(data.error || "");
} catch (err) {
setTestStatus("error");
setTestError(err.message);
}
};
const handleSave = async () => {
if (!modelId.trim() || saving) return;
setSaving(true);
try {
await onSave(modelId.trim());
} finally {
setSaving(false);
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") handleTest();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Add Custom Model">
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium mb-1.5 block">Model ID</label>
<div className="flex gap-2">
<input
type="text"
value={modelId}
onChange={(e) => { setModelId(e.target.value); setTestStatus(null); setTestError(""); }}
onKeyDown={handleKeyDown}
placeholder="e.g. claude-opus-4-5"
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
autoFocus
/>
<Button
variant="secondary"
icon="science"
loading={testStatus === "testing"}
onClick={handleTest}
disabled={!modelId.trim() || testStatus === "testing"}
>
{testStatus === "testing" ? "Testing..." : "Test"}
</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>
</p>
</div>
{/* Test result */}
{testStatus === "ok" && (
<div className="flex items-center gap-2 text-sm text-green-600">
<span className="material-symbols-outlined text-base">check_circle</span>
Model is reachable
</div>
)}
{testStatus === "error" && (
<div className="flex items-start gap-2 text-sm text-red-500">
<span className="material-symbols-outlined text-base shrink-0">cancel</span>
<span>{testError || "Model not reachable"}</span>
</div>
)}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">Cancel</Button>
<Button
onClick={handleSave}
fullWidth
size="sm"
disabled={!modelId.trim() || saving}
>
{saving ? "Adding..." : "Add Model"}
</Button>
</div>
</div>
</Modal>
);
}
AddCustomModelModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
providerAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired,
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -222,7 +222,7 @@ export default function ProvidersPage() {
title="Test all OAuth connections"
aria-label="Test all OAuth connections"
>
<span className="material-symbols-outlined text-[14px]">
<span className={`material-symbols-outlined text-[14px]${testingMode === "oauth" ? " animate-spin" : ""}`}>
{testingMode === "oauth" ? "sync" : "play_arrow"}
</span>
{testingMode === "oauth" ? "Testing..." : "Test All"}
@@ -260,7 +260,7 @@ export default function ProvidersPage() {
title="Test all Free connections"
aria-label="Test all Free provider connections"
>
<span className="material-symbols-outlined text-[14px]">
<span className={`material-symbols-outlined text-[14px]${testingMode === "free" ? " animate-spin" : ""}`}>
{testingMode === "free" ? "sync" : "play_arrow"}
</span>
{testingMode === "free" ? "Testing..." : "Test All"}
@@ -297,7 +297,7 @@ export default function ProvidersPage() {
title="Test all API Key connections"
aria-label="Test all API Key connections"
>
<span className="material-symbols-outlined text-[14px]">
<span className={`material-symbols-outlined text-[14px]${testingMode === "apikey" ? " animate-spin" : ""}`}>
{testingMode === "apikey" ? "sync" : "play_arrow"}
</span>
{testingMode === "apikey" ? "Testing..." : "Test All"}
@@ -335,7 +335,7 @@ export default function ProvidersPage() {
}`}
title="Test all Compatible connections"
>
<span className="material-symbols-outlined text-[14px]">
<span className={`material-symbols-outlined text-[14px]${testingMode === "compatible" ? " animate-spin" : ""}`}>
{testingMode === "compatible" ? "sync" : "play_arrow"}
</span>
{testingMode === "compatible" ? "Testing..." : "Test All"}

View File

@@ -144,7 +144,7 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
const error = !active && errorSet.has(p.provider?.toLowerCase());
const nodeId = `provider-${p.provider}`;
const data = {
label: config.name || p.name || p.provider,
label: (config.name !== p.provider ? config.name : null) || p.name || p.provider,
color: config.color || "#6b7280",
imageUrl: getProviderImageUrl(p.provider),
textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(),

View File

@@ -21,7 +21,7 @@ const checkClaudeInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where claude" : "command -v claude";
await execAsync(command);
await execAsync(command, { windowsHide: true });
return true;
} catch {
return false;

View File

@@ -76,7 +76,7 @@ const checkCodexInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where codex" : "command -v codex";
await execAsync(command);
await execAsync(command, { windowsHide: true });
return true;
} catch {
return false;

View File

@@ -17,7 +17,7 @@ const checkDroidInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where droid" : "command -v droid";
await execAsync(command);
await execAsync(command, { windowsHide: true });
return true;
} catch {
return false;

View File

@@ -17,7 +17,7 @@ const checkOpenClawInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where openclaw" : "command -v openclaw";
await execAsync(command);
await execAsync(command, { windowsHide: true });
return true;
} catch {
return false;

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { getApiKeys } from "@/lib/localDb";
// POST /api/models/test - Ping a single model via internal completions
export async function POST(request) {
try {
const { model } = await request.json();
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
// Get an active internal API key for auth (if requireApiKey is enabled)
let apiKey = null;
try {
const keys = await getApiKeys();
apiKey = keys.find((k) => k.isActive !== false)?.key || null;
} catch {}
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const start = Date.now();
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model,
max_tokens: 1,
stream: false,
messages: [{ role: "user", content: "hi" }],
}),
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
// 200 = ok; 400 = bad request but auth passed (model reachable)
const ok = res.status === 200 || res.status === 400;
let error = null;
if (!ok) {
const text = await res.text().catch(() => "");
error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`;
}
return NextResponse.json({ ok, latencyMs, error });
} catch (err) {
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getProviderConnections, createProviderConnection, getProviderNodeById } from "@/models";
import { getProviderConnections, createProviderConnection, getProviderNodeById, getProviderNodes } from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
@@ -7,15 +7,31 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha
export async function GET() {
try {
const connections = await getProviderConnections();
// Build nodeNameMap for compatible providers (id → name)
let nodeNameMap = {};
try {
const nodes = await getProviderNodes();
for (const node of nodes) {
if (node.id && node.name) nodeNameMap[node.id] = node.name;
}
} catch {}
// Hide sensitive fields
const safeConnections = connections.map(c => ({
...c,
apiKey: undefined,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
}));
// Hide sensitive fields, enrich name for compatible providers
const safeConnections = connections.map(c => {
const isCompatible = isOpenAICompatibleProvider(c.provider) || isAnthropicCompatibleProvider(c.provider);
const name = isCompatible
? (nodeNameMap[c.provider] || c.providerSpecificData?.nodeName || c.provider)
: c.name;
return {
...c,
name,
apiKey: undefined,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
});
return NextResponse.json({ connections: safeConnections });
} catch (error) {