mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
# v0.4.28 (2026-05-10)
## Features - Add bun:sqlite adapter with automatic runtime detection (Bun/Node) - Add bulk API key import (format: `name|sk-key`, one per line) ## Fixes - Fix add API key for custom providers
This commit is contained in:
@@ -72,6 +72,8 @@ export default function APIPageClient({ machineId }) {
|
||||
const [tsLoading, setTsLoading] = useState(false);
|
||||
const [tsProgress, setTsProgress] = useState("");
|
||||
const [tsStatus, setTsStatus] = useState(null);
|
||||
const [tsAuthUrl, setTsAuthUrl] = useState("");
|
||||
const [tsAuthLabel, setTsAuthLabel] = useState("");
|
||||
const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false
|
||||
const [tsInstalling, setTsInstalling] = useState(false);
|
||||
const [tsInstallLog, setTsInstallLog] = useState([]);
|
||||
@@ -492,12 +494,16 @@ export default function APIPageClient({ machineId }) {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Open auth URL only when actually needed (avoids blank popup flash on success path).
|
||||
// Falls back to status message with clickable link if popup blocker prevents opening.
|
||||
const openAuthUrl = (url) => {
|
||||
const w = window.open(url, "tailscale_auth", "width=600,height=700");
|
||||
if (!w) setTsStatus({ type: "warning", message: `Popup blocked. Open manually: ${url}` });
|
||||
return w;
|
||||
// Show inline login button instead of auto-opening popup (browsers block popups
|
||||
// opened after async work because the user gesture is lost).
|
||||
const requestUserAuth = (url, label) => {
|
||||
setTsAuthUrl(url);
|
||||
setTsAuthLabel(label);
|
||||
};
|
||||
|
||||
const clearUserAuth = () => {
|
||||
setTsAuthUrl("");
|
||||
setTsAuthLabel("");
|
||||
};
|
||||
|
||||
const handleConnectTailscale = async () => {
|
||||
@@ -506,6 +512,7 @@ export default function APIPageClient({ machineId }) {
|
||||
setTsLoading(true);
|
||||
setTsStatus(null);
|
||||
setTsProgress("Connecting...");
|
||||
clearUserAuth();
|
||||
try {
|
||||
const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
||||
const data = await res.json();
|
||||
@@ -519,8 +526,8 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
|
||||
if (data.needsLogin && data.authUrl) {
|
||||
openAuthUrl(data.authUrl);
|
||||
setTsProgress("Waiting for login...");
|
||||
requestUserAuth(data.authUrl, "Open Login Page");
|
||||
setTsProgress("Login required — click \"Open Login Page\" to continue");
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
@@ -528,6 +535,7 @@ export default function APIPageClient({ machineId }) {
|
||||
if (r2.ok) {
|
||||
const check = await r2.json();
|
||||
if (check.loggedIn) {
|
||||
clearUserAuth();
|
||||
setTsProgress("Starting funnel...");
|
||||
const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
||||
const data2 = await res2.json();
|
||||
@@ -546,6 +554,7 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
clearUserAuth();
|
||||
setTsStatus({ type: "error", message: "Login timed out. Please try again." });
|
||||
return;
|
||||
}
|
||||
@@ -562,18 +571,20 @@ export default function APIPageClient({ machineId }) {
|
||||
setTsLoading(false);
|
||||
setTsConnecting(false);
|
||||
setTsProgress("");
|
||||
clearUserAuth();
|
||||
}
|
||||
};
|
||||
|
||||
const pollFunnelEnable = async (enableUrl) => {
|
||||
openAuthUrl(enableUrl);
|
||||
setTsProgress("Enable Funnel in browser, waiting...");
|
||||
requestUserAuth(enableUrl, "Open Funnel Settings");
|
||||
setTsProgress("Click \"Open Funnel Settings\" to enable Funnel...");
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
clearUserAuth();
|
||||
setTsUrl(data.tunnelUrl || "");
|
||||
const ok3 = await pingTsHealth(data.tunnelUrl);
|
||||
setTsEnabled(true);
|
||||
@@ -582,11 +593,13 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
if (data.funnelNotEnabled) continue;
|
||||
if (data.error) {
|
||||
clearUserAuth();
|
||||
setTsStatus({ type: "error", message: data.error });
|
||||
return;
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
clearUserAuth();
|
||||
setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." });
|
||||
};
|
||||
|
||||
@@ -614,8 +627,13 @@ export default function APIPageClient({ machineId }) {
|
||||
const handleOpenTsModal = async () => {
|
||||
setTsStatus(null);
|
||||
setTsInstallLog([]);
|
||||
setShowTsModal(true);
|
||||
await checkTailscaleInstalled();
|
||||
const data = await checkTailscaleInstalled();
|
||||
if (data?.installed) {
|
||||
// Skip modal, connect directly when already installed
|
||||
handleConnectTailscale();
|
||||
} else {
|
||||
setShowTsModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
@@ -857,8 +875,17 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tsProgress || "Connecting..."}
|
||||
</div>
|
||||
{tsAuthUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon="open_in_new"
|
||||
onClick={() => window.open(tsAuthUrl, "tailscale_auth", "width=600,height=700,noopener,noreferrer")}
|
||||
>
|
||||
{tsAuthLabel || "Open"}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setTsLoading(false); setTsConnecting(false); setTsProgress(""); }}
|
||||
onClick={() => { setTsLoading(false); setTsConnecting(false); setTsProgress(""); clearUserAuth(); }}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||
title="Stop"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||
|
||||
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onClose }) {
|
||||
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
|
||||
|
||||
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) {
|
||||
const NONE_PROXY_POOL_VALUE = "__none__";
|
||||
const isOllamaLocal = provider === "ollama-local";
|
||||
const isCookie = authType === "cookie";
|
||||
@@ -34,6 +36,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mode, setMode] = useState("single"); // "single" | "bulk"
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
|
||||
|
||||
const buildProviderSpecificData = () => {
|
||||
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
|
||||
@@ -113,11 +118,70 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
const lines = bulkText.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
setSaving(true);
|
||||
setBulkResult(null);
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split("|");
|
||||
const apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
|
||||
const baseName = parts.length >= 2 ? parts[0].trim() : "Key";
|
||||
const name = `${baseName} ${i + 1}`;
|
||||
try {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey, name, priority: 1, testStatus: "unknown" }),
|
||||
});
|
||||
if (res.ok) success++;
|
||||
else failed++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
setBulkResult({ success, failed });
|
||||
if (success > 0 && onBulkDone) onBulkDone();
|
||||
};
|
||||
|
||||
if (!provider) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Add ${providerName || provider} ${credentialLabel}`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Mode switcher */}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant={mode === "single" ? "primary" : "ghost"} onClick={() => { setMode("single"); setBulkResult(null); }}>Single</Button>
|
||||
<Button size="sm" variant={mode === "bulk" ? "primary" : "ghost"} onClick={() => { setMode("bulk"); setBulkResult(null); }}>Bulk Add</Button>
|
||||
</div>
|
||||
|
||||
{mode === "bulk" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-text-muted">One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</p>
|
||||
<textarea
|
||||
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={BULK_PLACEHOLDER}
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
/>
|
||||
{bulkResult && (
|
||||
<div className={`text-sm font-medium ${bulkResult.failed > 0 ? "text-yellow-400" : "text-green-400"}`}>
|
||||
✓ {bulkResult.success} added{bulkResult.failed > 0 ? `, ✗ ${bulkResult.failed} failed` : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleBulkSubmit} fullWidth disabled={saving || !bulkText.trim()}>
|
||||
{saving ? "Adding..." : "Add All Keys"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "single" && (<>
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
@@ -278,6 +342,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -298,5 +363,6 @@ AddApiKeyModal.propTypes = {
|
||||
})),
|
||||
error: PropTypes.string,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onBulkDone: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -936,7 +936,6 @@ export default function ProviderDetailPage() {
|
||||
setAddConnectionError("");
|
||||
setShowAddApiKeyModal(true);
|
||||
}}
|
||||
disabled={connections.length > 0}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Add API Key
|
||||
@@ -971,11 +970,6 @@ export default function ProviderDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{connections.length > 0 && (
|
||||
<p className="text-sm text-text-muted">
|
||||
Only one connection is allowed per compatible node. Add another node if you need more connections.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1190,6 +1184,7 @@ export default function ProviderDetailPage() {
|
||||
proxyPools={proxyPools}
|
||||
error={addConnectionError}
|
||||
onSave={handleSaveApiKey}
|
||||
onBulkDone={fetchConnections}
|
||||
onClose={() => {
|
||||
setAddConnectionError("");
|
||||
setShowAddApiKeyModal(false);
|
||||
|
||||
@@ -127,12 +127,6 @@ export async function POST(request) {
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingConnections = await getProviderConnections({ provider });
|
||||
if (existingConnections.length > 0) {
|
||||
return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 });
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
prefix: node.prefix,
|
||||
apiType: node.apiType,
|
||||
@@ -144,12 +138,6 @@ export async function POST(request) {
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingConnections = await getProviderConnections({ provider });
|
||||
if (existingConnections.length > 0) {
|
||||
return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 });
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
prefix: node.prefix,
|
||||
baseUrl: node.baseUrl,
|
||||
@@ -160,12 +148,6 @@ export async function POST(request) {
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingConnections = await getProviderConnections({ provider });
|
||||
if (existingConnections.length > 0) {
|
||||
return NextResponse.json({ error: "Only one connection is allowed for this Custom Embedding node" }, { status: 400 });
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
prefix: node.prefix,
|
||||
baseUrl: node.baseUrl,
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function POST() {
|
||||
const result = await enableTailscale();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Tailscale enable error:", error);
|
||||
console.error("Tailscale enable error:", error.message);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user