mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
# v0.4.29 (2026-05-10)
## Features - Add Cline & Kilo Code tool cards - Tailscale TUN mode for stable Funnel TLS - Sort APIKEY providers by usage, collapse to top 20 ## Improvements - Local Material Symbols font (no Google Fonts) - Docker base: Bun → Node 22-alpine - MITM reads aliases from JSON cache (no native sqlite) - Stream stall timeout (2 min) in open-sse ## Fixes - Fal.ai key test: use stable models endpoint
This commit is contained in:
@@ -222,7 +222,7 @@ export default function BasicChatPageClient() {
|
||||
if (connections.length === 0) {
|
||||
if (!cancelled) {
|
||||
setProviderGroups([]);
|
||||
setLoadError("Chưa có provider nào được connect.");
|
||||
setLoadError("No providers connected yet.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -293,12 +293,12 @@ export default function BasicChatPageClient() {
|
||||
if (!cancelled) {
|
||||
setProviderGroups(normalized);
|
||||
if (normalized.length === 0) {
|
||||
setLoadError("Đã có provider connect nhưng chưa lấy được model nào.");
|
||||
setLoadError("Providers connected but no models available.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoadError(textValue(error?.message) || "Không thể tải danh sách provider/model.");
|
||||
setLoadError(textValue(error?.message) || "Failed to load providers/models.");
|
||||
setProviderGroups([]);
|
||||
}
|
||||
} finally {
|
||||
@@ -713,7 +713,7 @@ export default function BasicChatPageClient() {
|
||||
messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: message.content || `Error: ${errorText}`, status: "error" } : message)),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
setLoadError(errorText || "Không thể gửi tin nhắn.");
|
||||
setLoadError(errorText || "Failed to send message.");
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
@@ -756,7 +756,7 @@ export default function BasicChatPageClient() {
|
||||
<div className="absolute left-0 top-[calc(100%+10px)] z-30 w-[min(520px,calc(100vw-2rem))] overflow-hidden rounded-[20px] border border-white/10 bg-[#262626] shadow-2xl shadow-black/50">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-white/45">Models</p>
|
||||
<p className="text-sm text-white/75">Chỉ lấy từ provider đã connect</p>
|
||||
<p className="text-sm text-white/75">Only from connected providers</p>
|
||||
</div>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-2 custom-scrollbar">
|
||||
{providerGroups.map((group) => (
|
||||
@@ -815,7 +815,7 @@ export default function BasicChatPageClient() {
|
||||
<div className="max-h-[48vh] space-y-2 overflow-y-auto p-1 custom-scrollbar">
|
||||
{sessionItems.length === 0 ? (
|
||||
<div className="rounded-[16px] border border-dashed border-white/10 bg-white/5 p-4 text-sm text-white/55">
|
||||
Chưa có cuộc trò chuyện nào.
|
||||
No conversations yet.
|
||||
</div>
|
||||
) : sessionItems.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
|
||||
@@ -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, CoworkToolCard, CopilotToolCard, MitmLinkCard } from "./components";
|
||||
import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, MitmLinkCard } from "./components";
|
||||
import { MITM_TOOLS } from "@/shared/constants/cliTools";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
@@ -190,6 +190,10 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
return <HermesToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.hermes} />;
|
||||
case "copilot":
|
||||
return <CopilotToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.copilot} />;
|
||||
case "cline":
|
||||
return <ClineToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.cline} />;
|
||||
case "kilo":
|
||||
return <KiloToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.kilo} />;
|
||||
default:
|
||||
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
|
||||
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) {
|
||||
const isCustom = !apiKeys.some((k) => k.key === value) && value !== "";
|
||||
const [mode, setMode] = useState(() => {
|
||||
if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE;
|
||||
if (apiKeys.some((k) => k.key === value)) return value;
|
||||
return CUSTOM_VALUE;
|
||||
});
|
||||
const [customInput, setCustomInput] = useState(isCustom ? value : "");
|
||||
|
||||
const handleSelect = (e) => {
|
||||
const next = e.target.value;
|
||||
setMode(next);
|
||||
if (next === CUSTOM_VALUE) {
|
||||
setCustomInput("");
|
||||
onChange("");
|
||||
} else {
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomInput = (e) => {
|
||||
const v = e.target.value;
|
||||
setCustomInput(v);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE;
|
||||
|
||||
if (noKeys && mode !== CUSTOM_VALUE) {
|
||||
return (
|
||||
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={handleSelect}
|
||||
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"
|
||||
>
|
||||
{apiKeys.map((k) => (
|
||||
<option key={k.id} value={k.key}>{k.key}</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>Custom...</option>
|
||||
</select>
|
||||
{mode === CUSTOM_VALUE && (
|
||||
<input
|
||||
type="text"
|
||||
value={customInput}
|
||||
onChange={handleCustomInput}
|
||||
placeholder="sk-..."
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
@@ -138,7 +139,6 @@ export default function ClaudeToolCard({
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
@@ -324,16 +324,7 @@ export default function ClaudeToolCard({
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Model Mappings */}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
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 [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
|
||||
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?.settings?.openAiModelId) setSelectedModel(status.settings.openAiModelId);
|
||||
}, [status]);
|
||||
|
||||
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 getConfigStatus = () => {
|
||||
if (!status?.installed) return null;
|
||||
if (!status.has9Router) return "not_configured";
|
||||
const url = status.settings?.openAiBaseUrl || "";
|
||||
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || `${baseUrl}/v1`;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/cline-settings");
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
setStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||
|
||||
const res = await fetch("/api/cli-tools/cline-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
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("/api/cli-tools/cline-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} 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 effectiveUrl = getEffectiveBaseUrl();
|
||||
const baseWithoutV1 = effectiveUrl.endsWith("/v1") ? effectiveUrl.slice(0, -3) : effectiveUrl;
|
||||
|
||||
return [
|
||||
{
|
||||
filename: "~/.cline/data/globalState.json",
|
||||
content: JSON.stringify({
|
||||
actModeApiProvider: "openai",
|
||||
planModeApiProvider: "openai",
|
||||
openAiBaseUrl: baseWithoutV1,
|
||||
openAiModelId: selectedModel || "provider/model-id",
|
||||
planModeOpenAiModelId: selectedModel || "provider/model-id",
|
||||
}, null, 2),
|
||||
},
|
||||
{
|
||||
filename: "~/.cline/data/secrets.json",
|
||||
content: JSON.stringify({ openAiApiKey: keyToUse }, 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="/providers/cline.png" 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 === "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">
|
||||
{checking && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Cline...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status && !status.installed && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Cline not detected locally</p>
|
||||
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 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>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
|
||||
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
|
||||
{showInstallGuide ? "Hide" : "How to Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showInstallGuide && (
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<h4 className="font-medium mb-3">Installation Guide</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-text-muted">Install Cline VS Code extension or CLI from <a className="text-primary underline" href="https://docs.cline.bot/" target="_blank" rel="noreferrer">docs.cline.bot</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status?.settings?.openAiBaseUrl && (
|
||||
<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.settings.openAiBaseUrl}
|
||||
</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">API Key</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</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">Model</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<div className="relative w-full min-w-0">
|
||||
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
|
||||
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||
</div>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
|
||||
</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="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
|
||||
<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) => { setSelectedModel(model.value); setModalOpen(false); }}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Cline"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Cline - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
@@ -79,7 +80,6 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const checkCodexStatus = async () => {
|
||||
setCheckingCodex(true);
|
||||
@@ -302,16 +302,7 @@ model = "${effectiveSubagentModel}"
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
@@ -72,7 +73,6 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const removeModel = (id) => setSelectedModels((prev) => prev.filter((m) => m !== id));
|
||||
|
||||
@@ -218,16 +218,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/cowork-settings";
|
||||
|
||||
@@ -95,7 +96,6 @@ export default function CoworkToolCard({
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const handleApply = async () => {
|
||||
setMessage(null);
|
||||
@@ -285,16 +285,7 @@ export default function CoworkToolCard({
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { Card, ModelSelectModal } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import Image from "next/image";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
|
||||
export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) {
|
||||
const [copiedField, setCopiedField] = useState(null);
|
||||
@@ -46,37 +47,11 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
|
||||
|
||||
const hasActiveProviders = activeProviders.length > 0;
|
||||
|
||||
const renderApiKeySelector = () => {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
{apiKeys && apiKeys.length > 0 ? (
|
||||
<>
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="w-full sm:w-auto flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.key}>{key.key}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleCopy(selectedApiKey, "apiKey")}
|
||||
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{copiedField === "apiKey" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderApiKeySelector = () => (
|
||||
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderModelSelector = () => {
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
@@ -118,7 +119,6 @@ export default function DroidToolCard({
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const addModel = () => {
|
||||
const val = modelInput.trim();
|
||||
@@ -318,16 +318,7 @@ export default function DroidToolCard({
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/hermes-settings";
|
||||
@@ -108,7 +109,6 @@ export default function HermesToolCard({
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
@@ -259,16 +259,7 @@ export default function HermesToolCard({
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
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 [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
|
||||
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]);
|
||||
|
||||
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 getConfigStatus = () => {
|
||||
if (!status?.installed) return null;
|
||||
return status.has9Router ? "configured" : "not_configured";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || `${baseUrl}/v1`;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/kilo-settings");
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
setStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||
|
||||
const res = await fetch("/api/cli-tools/kilo-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
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("/api/cli-tools/kilo-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} 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>");
|
||||
|
||||
return [{
|
||||
filename: "~/.local/share/kilo/auth.json",
|
||||
content: JSON.stringify({
|
||||
"openai-compatible": {
|
||||
type: "api-key",
|
||||
apiKey: keyToUse,
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
model: selectedModel || "provider/model-id",
|
||||
},
|
||||
}, 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="/providers/kilocode.png" 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>}
|
||||
</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">
|
||||
{checking && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Kilo Code...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status && !status.installed && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Kilo Code not detected locally</p>
|
||||
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 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>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
|
||||
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
|
||||
{showInstallGuide ? "Hide" : "How to Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showInstallGuide && (
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<h4 className="font-medium mb-3">Installation Guide</h4>
|
||||
<p className="text-sm text-text-muted">Install Kilo Code from <a className="text-primary underline" href="https://kilocode.ai" target="_blank" rel="noreferrer">kilocode.ai</a> or VS Code extension marketplace.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</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>
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</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">Model</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<div className="relative w-full min-w-0">
|
||||
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
|
||||
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||
</div>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
|
||||
</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="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
|
||||
<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) => { setSelectedModel(model.value); setModalOpen(false); }}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Kilo Code"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Kilo Code - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -306,11 +306,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
|
||||
<div className="flex flex-col gap-1 text-xs text-text-muted">
|
||||
<p>Port 443 đang bị process khác chiếm:</p>
|
||||
<p>Port 443 is currently used by another process:</p>
|
||||
<p className="font-mono text-text-main" data-i18n-skip="true">
|
||||
{port443Conflict.owner.name} (PID {port443Conflict.owner.pid})
|
||||
</p>
|
||||
<p>Kill process này để chạy MITM Server?</p>
|
||||
<p>Kill this process to start MITM Server?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function OpenClawToolCard({
|
||||
@@ -125,7 +126,6 @@ export default function OpenClawToolCard({
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
@@ -310,16 +310,7 @@ export default function OpenClawToolCard({
|
||||
<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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
@@ -83,7 +84,6 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
@@ -289,16 +289,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, 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">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>
|
||||
)}
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 ClineToolCard } from "./ClineToolCard";
|
||||
export { default as KiloToolCard } from "./KiloToolCard";
|
||||
export { default as MitmServerCard } from "./MitmServerCard";
|
||||
export { default as MitmToolCard } from "./MitmToolCard";
|
||||
export { default as MitmLinkCard } from "./MitmLinkCard";
|
||||
|
||||
@@ -466,6 +466,8 @@ export default function APIPageClient({ machineId }) {
|
||||
} else if (event === "done") {
|
||||
setTsInstalled(true);
|
||||
setTsInstalling(false);
|
||||
setShowTsModal(false);
|
||||
handleConnectTailscale();
|
||||
return;
|
||||
} else if (event === "error") {
|
||||
setTsStatus({ type: "error", message: data.error || "Install failed" });
|
||||
@@ -628,8 +630,7 @@ export default function APIPageClient({ machineId }) {
|
||||
setTsStatus(null);
|
||||
setTsInstallLog([]);
|
||||
const data = await checkTailscaleInstalled();
|
||||
if (data?.installed) {
|
||||
// Skip modal, connect directly when already installed
|
||||
if (data?.installed && data?.hasCachedPassword) {
|
||||
handleConnectTailscale();
|
||||
} else {
|
||||
setShowTsModal(true);
|
||||
|
||||
@@ -94,10 +94,13 @@ function getConnectionErrorTag(connection) {
|
||||
return "ERR";
|
||||
}
|
||||
|
||||
const APIKEY_INITIAL_VISIBLE = 20;
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAllApikey, setShowAllApikey] = useState(false);
|
||||
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] =
|
||||
useState(false);
|
||||
@@ -117,6 +120,13 @@ export default function ProvidersPage() {
|
||||
!searchQuery.trim() ||
|
||||
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
||||
|
||||
const sortByConnections = (entries, authType) =>
|
||||
[...entries].sort(
|
||||
(a, b) =>
|
||||
getProviderStats(b[0], authType).total -
|
||||
getProviderStats(a[0], authType).total,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -259,10 +269,19 @@ export default function ProvidersPage() {
|
||||
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),
|
||||
const apikeyEntries = sortByConnections(
|
||||
Object.entries(APIKEY_PROVIDERS).filter(
|
||||
([, info]) =>
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
||||
),
|
||||
"apikey",
|
||||
);
|
||||
const isApikeySearching = !!searchQuery.trim();
|
||||
const visibleApikeyEntries =
|
||||
isApikeySearching || showAllApikey
|
||||
? apikeyEntries
|
||||
: apikeyEntries.slice(0, APIKEY_INITIAL_VISIBLE);
|
||||
const hiddenApikeyCount = apikeyEntries.length - APIKEY_INITIAL_VISIBLE;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -466,7 +485,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">
|
||||
{apikeyEntries.map(([key, info]) => (
|
||||
{visibleApikeyEntries.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
@@ -477,6 +496,15 @@ export default function ProvidersPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isApikeySearching && !showAllApikey && hiddenApikeyCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAllApikey(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2.5 text-sm font-medium text-primary transition-colors hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">expand_more</span>
|
||||
Show all {apikeyEntries.length} providers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { GET as openclawGet } from "../openclaw-settings/route";
|
||||
import { GET as hermesGet } from "../hermes-settings/route";
|
||||
import { GET as coworkGet } from "../cowork-settings/route";
|
||||
import { GET as copilotGet } from "../copilot-settings/route";
|
||||
import { GET as clineGet } from "../cline-settings/route";
|
||||
import { GET as kiloGet } from "../kilo-settings/route";
|
||||
|
||||
const STATUS_GETTERS = {
|
||||
claude: claudeGet,
|
||||
@@ -19,6 +21,8 @@ const STATUS_GETTERS = {
|
||||
hermes: hermesGet,
|
||||
cowork: coworkGet,
|
||||
copilot: copilotGet,
|
||||
cline: clineGet,
|
||||
kilo: kiloGet,
|
||||
};
|
||||
|
||||
// Batch endpoint: gather all CLI tool statuses in one round-trip
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMitmAlias, setMitmAliasAll } from "@/models";
|
||||
import { getMitmStatus } from "@/mitm/manager";
|
||||
import { writeAliasForTool } from "@/lib/mitmAliasCache";
|
||||
|
||||
// GET - Get MITM aliases for a tool
|
||||
export async function GET(request) {
|
||||
@@ -43,6 +44,7 @@ export async function PUT(request) {
|
||||
}
|
||||
|
||||
await setMitmAliasAll(tool, filtered);
|
||||
writeAliasForTool(tool, filtered);
|
||||
return NextResponse.json({ success: true, aliases: filtered });
|
||||
} catch (error) {
|
||||
console.log("Error saving MITM aliases:", error.message);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getDataDir = () => path.join(os.homedir(), ".cline", "data");
|
||||
const getGlobalStatePath = () => path.join(getDataDir(), "globalState.json");
|
||||
const getSecretsPath = () => path.join(getDataDir(), "secrets.json");
|
||||
|
||||
const checkInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where cline" : "which cline";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getGlobalStatePath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (globalState) => {
|
||||
if (!globalState) return false;
|
||||
const isOpenAi =
|
||||
globalState.actModeApiProvider === "openai" || globalState.planModeApiProvider === "openai";
|
||||
const baseUrl = globalState.openAiBaseUrl || "";
|
||||
return isOpenAi && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router"));
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, settings: null, message: "Cline CLI is not installed" });
|
||||
}
|
||||
const globalState = await readJson(getGlobalStatePath());
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: {
|
||||
actModeApiProvider: globalState?.actModeApiProvider,
|
||||
planModeApiProvider: globalState?.planModeApiProvider,
|
||||
openAiBaseUrl: globalState?.openAiBaseUrl,
|
||||
openAiModelId: globalState?.openAiModelId,
|
||||
},
|
||||
has9Router: has9RouterConfig(globalState),
|
||||
globalStatePath: getGlobalStatePath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking cline settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check cline settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(getDataDir(), { recursive: true });
|
||||
|
||||
// Cline expects base WITHOUT /v1
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
|
||||
|
||||
const globalState = (await readJson(getGlobalStatePath())) || {};
|
||||
globalState.actModeApiProvider = "openai";
|
||||
globalState.planModeApiProvider = "openai";
|
||||
globalState.openAiBaseUrl = normalizedBaseUrl;
|
||||
globalState.openAiModelId = model;
|
||||
globalState.planModeOpenAiModelId = model;
|
||||
await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2));
|
||||
|
||||
const secrets = (await readJson(getSecretsPath())) || {};
|
||||
secrets.openAiApiKey = apiKey;
|
||||
await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2));
|
||||
|
||||
return NextResponse.json({ success: true, message: "Cline settings applied successfully!", globalStatePath: getGlobalStatePath() });
|
||||
} catch (error) {
|
||||
console.log("Error updating cline settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update cline settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const globalState = await readJson(getGlobalStatePath());
|
||||
if (!globalState) {
|
||||
return NextResponse.json({ success: true, message: "No settings file to reset" });
|
||||
}
|
||||
|
||||
if (globalState.actModeApiProvider === "openai") {
|
||||
delete globalState.openAiBaseUrl;
|
||||
delete globalState.openAiModelId;
|
||||
delete globalState.planModeOpenAiModelId;
|
||||
globalState.actModeApiProvider = "cline";
|
||||
globalState.planModeApiProvider = "cline";
|
||||
}
|
||||
await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2));
|
||||
|
||||
const secrets = (await readJson(getSecretsPath())) || {};
|
||||
delete secrets.openAiApiKey;
|
||||
await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2));
|
||||
|
||||
return NextResponse.json({ success: true, message: "9Router settings removed from Cline" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting cline settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset cline settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getDataDir = () => path.join(os.homedir(), ".local", "share", "kilo");
|
||||
const getAuthPath = () => path.join(getDataDir(), "auth.json");
|
||||
const getVscodeSettingsPath = () => path.join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
const checkInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where kilo" : "which kilo";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getAuthPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (auth) => {
|
||||
if (!auth) return false;
|
||||
const entry = auth["openai-compatible"] || auth["9router"];
|
||||
if (!entry) return false;
|
||||
const baseUrl = entry.baseUrl || entry.baseURL || "";
|
||||
return baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router");
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, settings: null, message: "Kilo Code CLI is not installed" });
|
||||
}
|
||||
const auth = await readJson(getAuthPath());
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: { auth: auth ? Object.keys(auth) : [] },
|
||||
has9Router: has9RouterConfig(auth),
|
||||
authPath: getAuthPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking kilo settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check kilo settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(getDataDir(), { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
|
||||
const auth = (await readJson(getAuthPath())) || {};
|
||||
auth["openai-compatible"] = {
|
||||
type: "api-key",
|
||||
apiKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
model,
|
||||
};
|
||||
await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2));
|
||||
|
||||
// Best-effort: update VS Code extension settings
|
||||
try {
|
||||
const vscode = (await readJson(getVscodeSettingsPath())) || {};
|
||||
vscode["kilocode.customProvider"] = { name: "9Router", baseURL: normalizedBaseUrl, apiKey };
|
||||
vscode["kilocode.defaultModel"] = model;
|
||||
await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2));
|
||||
} catch { /* VS Code settings not writable */ }
|
||||
|
||||
return NextResponse.json({ success: true, message: "Kilo Code settings applied successfully!", authPath: getAuthPath() });
|
||||
} catch (error) {
|
||||
console.log("Error updating kilo settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update kilo settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const auth = await readJson(getAuthPath());
|
||||
if (!auth) {
|
||||
return NextResponse.json({ success: true, message: "No settings file to reset" });
|
||||
}
|
||||
delete auth["openai-compatible"];
|
||||
delete auth["9router"];
|
||||
await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2));
|
||||
|
||||
try {
|
||||
const vscode = await readJson(getVscodeSettingsPath());
|
||||
if (vscode) {
|
||||
delete vscode["kilocode.customProvider"];
|
||||
delete vscode["kilocode.defaultModel"];
|
||||
await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return NextResponse.json({ success: true, message: "9Router settings removed from Kilo Code" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting kilo settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset kilo settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions or embeddings
|
||||
export async function POST(request) {
|
||||
@@ -7,8 +8,7 @@ export async function POST(request) {
|
||||
const { model, kind } = await request.json();
|
||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||
|
||||
const baseUrl = process.env.BASE_URL ||
|
||||
(() => { const u = new URL(request.url); return `${u.protocol}//${u.host}`; })();
|
||||
const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`;
|
||||
|
||||
// Get an active internal API key for auth (if requireApiKey is enabled)
|
||||
let apiKey = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, getApiKeys } from "@/lib/localDb";
|
||||
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
/**
|
||||
* Get an active API key to pass through auth when requireApiKey is enabled.
|
||||
@@ -64,10 +65,12 @@ export async function POST(request, { params }) {
|
||||
|
||||
let models = getProviderModels(alias);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`;
|
||||
|
||||
// Compatible providers: fetch live model list
|
||||
if (isCompatible && models.length === 0) {
|
||||
try {
|
||||
const modelsRes = await fetch(`${getBaseUrl(request)}/api/providers/${id}/models`);
|
||||
const modelsRes = await fetch(`${baseUrl}/api/providers/${id}/models`);
|
||||
if (modelsRes.ok) {
|
||||
const data = await modelsRes.json();
|
||||
models = (data.models || []).map((m) => ({ id: m.id || m.name, name: m.name || m.id }));
|
||||
@@ -79,7 +82,6 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl(request);
|
||||
const apiKey = await getInternalApiKey();
|
||||
|
||||
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
|
||||
@@ -104,8 +106,3 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(request) {
|
||||
const url = new URL(request.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
}
|
||||
|
||||
@@ -551,6 +551,11 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||
const res = await fetchWithConnectionProxy("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "fal-ai": {
|
||||
const res = await fetchWithConnectionProxy("https://api.fal.ai/v1/models?limit=1", { headers: { Authorization: `Key ${connection.apiKey}` } }, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "chutes": {
|
||||
const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale";
|
||||
import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
||||
@@ -41,7 +42,8 @@ export async function GET() {
|
||||
installed ? isDaemonRunning() : Promise.resolve(false),
|
||||
]);
|
||||
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning });
|
||||
const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword());
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, hasCachedPassword });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Hide icon ligature text until font is ready */
|
||||
.material-symbols-outlined { visibility: hidden; }
|
||||
.fonts-loaded .material-symbols-outlined { visibility: visible; }
|
||||
|
||||
/* ============================================================
|
||||
9Router palette — adopted from 9remote_private/web
|
||||
Brand orange (dark) / soft coral (light), neutral warm bases
|
||||
|
||||
+2
-15
@@ -1,4 +1,5 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import "material-symbols/outlined.css";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
||||
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
||||
@@ -30,25 +31,11 @@ export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
{/* Non-blocking icon font: preload + inject stylesheet via script */}
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var l=document.createElement('link');l.rel='stylesheet';l.href='https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap';document.head.appendChild(l);})();`,
|
||||
__html: `if(document.fonts&&document.fonts.ready){document.fonts.ready.then(function(){document.documentElement.classList.add('fonts-loaded')})}else{document.documentElement.classList.add('fonts-loaded')}`,
|
||||
}}
|
||||
/>
|
||||
<noscript>
|
||||
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user