From 8f2e6685a6a7865182ddaf163caa8031d7d7e345 Mon Sep 17 00:00:00 2001 From: dmdfami Date: Sun, 3 May 2026 15:09:13 +0700 Subject: [PATCH] feat(cli-tools): add browser-local endpoint presets (#819) Add reusable EndpointPresetControl for CLI tool Base URL/API key presets, stored in browser localStorage. Wire into Claude, Codex, OpenCode, Droid, OpenClaw, Hermes, and Copilot cards. Allow selecting preset API keys not in dashboard keys list. Thanks @dmdfami for the contribution! Co-authored-by: dmdfami Co-authored-by: Cursor --- .../cli-tools/components/ClaudeToolCard.js | 13 +- .../cli-tools/components/CodexToolCard.js | 12 +- .../cli-tools/components/CopilotToolCard.js | 29 +++- .../cli-tools/components/DroidToolCard.js | 14 +- .../components/EndpointPresetControl.js | 128 ++++++++++++++++++ .../cli-tools/components/HermesToolCard.js | 12 +- .../cli-tools/components/OpenClawToolCard.js | 12 +- .../cli-tools/components/OpenCodeToolCard.js | 12 +- .../dashboard/cli-tools/components/index.js | 2 +- 9 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 65367dbd..701f17d2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -135,6 +136,7 @@ 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); @@ -301,6 +303,13 @@ export default function ClaudeToolCard({ )} + + {/* Base URL */}
Base URL @@ -323,8 +332,9 @@ export default function ClaudeToolCard({
API Key arrow_forward - {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( @@ -393,4 +403,3 @@ export default function ClaudeToolCard({ ); } - diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index c75ed18a..44b3cd76 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -76,6 +77,7 @@ 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); @@ -279,6 +281,13 @@ model = "${effectiveSubagentModel}" ) : null; })()} + + {/* Base URL */}
Base URL @@ -301,8 +310,9 @@ model = "${effectiveSubagentModel}"
API Key arrow_forward - {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js index b223fde1..192723cb 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) { const [status, setStatus] = useState(initialStatus || null); @@ -11,6 +12,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a const [restoring, setRestoring] = useState(false); const [message, setMessage] = useState(null); const [selectedApiKey, setSelectedApiKey] = useState(""); + const [customBaseUrl, setCustomBaseUrl] = useState(""); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); @@ -66,7 +68,11 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a }; const configStatus = getConfigStatus(); - const getEffectiveBaseUrl = () => baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const getEffectiveBaseUrl = () => { + 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(); @@ -200,11 +206,30 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
+ + +
+ + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+ {/* API Key */}
- {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 6698f11a..528de780 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -116,6 +117,7 @@ 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(); @@ -296,6 +298,13 @@ export default function DroidToolCard({
)} + + {/* Base URL */}
Base URL @@ -318,8 +327,9 @@ export default function DroidToolCard({
API Key arrow_forward - {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( @@ -415,4 +425,4 @@ export default function DroidToolCard({ /> ); -} \ No newline at end of file +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js b/src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js new file mode 100644 index 00000000..d374d19a --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +const STORAGE_KEY = "9router.cliToolEndpointPresets"; + +function maskApiKey(apiKey) { + if (!apiKey) return "No API key"; + if (apiKey.length <= 12) return `${apiKey.slice(0, 4)}...`; + return `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`; +} + +function normalizePresets(value) { + if (!Array.isArray(value)) return []; + return value.filter((preset) => preset?.name && preset?.baseUrl && preset?.apiKey); +} + +function readPresets() { + if (typeof window === "undefined") return []; + try { + return normalizePresets(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]")); + } catch { + return []; + } +} + +function writePresets(presets) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizePresets(presets))); +} + +export default function EndpointPresetControl({ + baseUrl, + apiKey, + onBaseUrlChange, + onApiKeyChange, +}) { + const [presets, setPresets] = useState([]); + const [selectedName, setSelectedName] = useState(""); + + useEffect(() => { + setPresets(readPresets()); + }, []); + + const selectedPreset = useMemo( + () => presets.find((preset) => preset.name === selectedName) || null, + [presets, selectedName] + ); + + const handleSelect = (name) => { + setSelectedName(name); + const preset = presets.find((item) => item.name === name); + if (!preset) return; + onBaseUrlChange(preset.baseUrl); + onApiKeyChange(preset.apiKey); + }; + + const handleSave = () => { + const trimmedBaseUrl = (baseUrl || "").trim(); + const trimmedApiKey = (apiKey || "").trim(); + if (!trimmedBaseUrl || !trimmedApiKey) return; + + let defaultName = selectedPreset?.name || trimmedBaseUrl; + try { + defaultName = selectedPreset?.name || new URL(trimmedBaseUrl).host; + } catch { + defaultName = selectedPreset?.name || trimmedBaseUrl; + } + const name = window.prompt("Preset name", defaultName); + if (!name?.trim()) return; + + const nextPreset = { name: name.trim(), baseUrl: trimmedBaseUrl, apiKey: trimmedApiKey }; + const nextPresets = [ + ...presets.filter((preset) => preset.name !== nextPreset.name), + nextPreset, + ].sort((a, b) => a.name.localeCompare(b.name)); + + setPresets(nextPresets); + setSelectedName(nextPreset.name); + writePresets(nextPresets); + }; + + const handleDelete = () => { + if (!selectedPreset) return; + const nextPresets = presets.filter((preset) => preset.name !== selectedPreset.name); + setPresets(nextPresets); + setSelectedName(""); + writePresets(nextPresets); + }; + + return ( +
+ Preset + arrow_forward + + + {selectedPreset && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 3ed3ac79..3371aff3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; const ENDPOINT = "/api/cli-tools/hermes-settings"; @@ -104,6 +105,7 @@ 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); @@ -237,6 +239,13 @@ export default function HermesToolCard({
)} + +
Base URL arrow_forward @@ -257,8 +266,9 @@ export default function HermesToolCard({
API Key arrow_forward - {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index bc786992..61ef8490 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; export default function OpenClawToolCard({ tool, @@ -122,6 +123,7 @@ 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); @@ -287,6 +289,13 @@ export default function OpenClawToolCard({
)} + + {/* Base URL */}
Base URL @@ -309,8 +318,9 @@ export default function OpenClawToolCard({
API Key arrow_forward - {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index d77d2b72..d006599c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import EndpointPresetControl from "./EndpointPresetControl"; export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) { const [status, setStatus] = useState(initialStatus || null); @@ -81,6 +82,7 @@ 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); @@ -266,6 +268,13 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)} + + {/* Base URL */}
Base URL @@ -288,8 +297,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
API Key arrow_forward - {apiKeys.length > 0 ? ( + {apiKeys.length > 0 || selectedApiKey ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 782fcae8..4500800d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -10,4 +10,4 @@ export { default as CopilotToolCard } from "./CopilotToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; - +export { default as EndpointPresetControl } from "./EndpointPresetControl";