From a68ada1c8368cadcfaf02ddefea1e751938e2225 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 27 Aug 2026 18:54:44 +0700 Subject: [PATCH] feat(cli-tools): share endpoint presets across every tool card Each card kept its own copy of the localStorage preset logic inside BaseUrlSelect, so an endpoint saved on one card was invisible to the others until a reload, and a URL typed into the custom field was forgotten the moment the card collapsed. Move the store into cliEndpointPresets.js and publish a change event so open cards resync live. Applying settings now remembers the endpoint unless it matches a built-in option, and each card passes its configured URL as currentUrl so BaseUrlSelect can preselect the matching preset instead of always falling back to 127.0.0.1. Deleting a preset falls back to the first real option rather than clearing the field. Co-Authored-By: Claude Fable 5 --- .../cli-tools/components/BaseUrlSelect.js | 81 ++++++++++--------- .../cli-tools/components/ClaudeToolCard.js | 6 ++ .../cli-tools/components/ClineToolCard.js | 6 ++ .../cli-tools/components/CodexToolCard.js | 17 ++-- .../cli-tools/components/CopilotToolCard.js | 6 ++ .../cli-tools/components/CoworkToolCard.js | 6 ++ .../components/DeepSeekTuiToolCard.js | 6 ++ .../cli-tools/components/DroidToolCard.js | 6 ++ .../cli-tools/components/GrokBuildToolCard.js | 6 +- .../cli-tools/components/HermesToolCard.js | 6 ++ .../cli-tools/components/JcodeToolCard.js | 6 ++ .../cli-tools/components/KiloToolCard.js | 3 + .../cli-tools/components/OpenClawToolCard.js | 6 ++ .../cli-tools/components/OpenCodeToolCard.js | 6 ++ .../components/cliEndpointPresets.js | 71 ++++++++++++++++ 15 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index 58482a9b..52ca8b09 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { UPDATER_CONFIG } from "@/shared/constants/config"; +import { readPresets, upsertPreset, deletePreset, subscribePresets, stripSlash } from "./cliEndpointPresets"; -const STORAGE_KEY = "9router.cliToolEndpointPresets"; const CUSTOM_VALUE = "__custom__"; const SAVE_VALUE = "__save__"; @@ -13,22 +13,6 @@ const ensureV1 = (url) => { return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; }; -const readSavedPresets = () => { - if (typeof window === "undefined") return []; - try { - const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); - if (!Array.isArray(raw)) return []; - return raw.filter((p) => p?.name && p?.baseUrl); - } catch { - return []; - } -}; - -const writeSavedPresets = (presets) => { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); -}; - const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => { const opts = []; const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); @@ -66,14 +50,34 @@ export default function BaseUrlSelect({ cloudEnabled = false, cloudUrl = "", withV1 = true, + currentUrl = "", }) { const [savedPresets, setSavedPresets] = useState([]); + const [presetsLoaded, setPresetsLoaded] = useState(false); const [mode, setMode] = useState(""); const [customInput, setCustomInput] = useState(""); const initializedRef = useRef(false); + const customInputRef = useRef(""); useEffect(() => { - setSavedPresets(readSavedPresets()); + const sync = () => { + const presets = readPresets(); + setSavedPresets(presets); + // A preset saved elsewhere (e.g. on Apply) takes over the custom slot + setMode((prev) => { + if (prev !== CUSTOM_VALUE) return prev; + const typed = stripSlash(customInputRef.current); + if (!typed) return prev; + const match = presets.find((p) => { + const saved = stripSlash(p.baseUrl); + return saved === typed || saved === ensureV1(typed); + }); + return match ? `saved:${match.name}` : prev; + }); + }; + sync(); + setPresetsLoaded(true); + return subscribePresets(sync); }, []); const options = useMemo( @@ -81,19 +85,23 @@ export default function BaseUrlSelect({ [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] ); - // Always default to first option (127.0.0.1) on mount, ignore persisted value + // Prefer a saved preset matching the currently configured URL, else first option useEffect(() => { if (initializedRef.current) return; - if (options.length === 0) return; + if (!presetsLoaded || options.length === 0) return; initializedRef.current = true; - const first = options.find((o) => o.value !== CUSTOM_VALUE); - if (first) { - setMode(first.value); - onChange(first.url); + const current = stripSlash(currentUrl); + const matched = current + ? options.find((o) => o.saved && stripSlash(o.url) === current) + : null; + const target = matched || options.find((o) => o.value !== CUSTOM_VALUE); + if (target) { + setMode(target.value); + onChange(target.url); } else { setMode(CUSTOM_VALUE); } - }, [options, onChange]); + }, [presetsLoaded, options, onChange, currentUrl]); const handleSelect = (e) => { const next = e.target.value; @@ -103,11 +111,8 @@ export default function BaseUrlSelect({ let defaultName = trimmed; try { defaultName = new URL(trimmed).host; } catch {} const name = window.prompt("Save endpoint as:", defaultName); - if (!name?.trim()) return; - const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }] - .sort((a, b) => a.name.localeCompare(b.name)); - setSavedPresets(updated); - writeSavedPresets(updated); + const saved = name?.trim() ? upsertPreset(trimmed, name.trim()) : null; + if (saved) setMode(`saved:${saved}`); return; } setMode(next); @@ -122,19 +127,23 @@ export default function BaseUrlSelect({ const handleCustomInput = (e) => { const v = e.target.value; + customInputRef.current = v; setCustomInput(v); onChange(v); }; const handleDeleteSaved = () => { if (!mode.startsWith("saved:")) return; - const name = mode.slice(6); - const updated = savedPresets.filter((p) => p.name !== name); - setSavedPresets(updated); - writeSavedPresets(updated); - setMode(CUSTOM_VALUE); + deletePreset(mode.slice(6)); setCustomInput(""); - onChange(""); + const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode); + if (fallback) { + setMode(fallback.value); + onChange(fallback.url); + } else { + setMode(CUSTOM_VALUE); + onChange(""); + } }; const isSaved = mode.startsWith("saved:"); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 7ee5ad2e..912114d6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -53,6 +54,8 @@ export default function ClaudeToolCard({ const [maxContextTokens, setMaxContextTokens] = useState(""); const hasInitializedModels = useRef(false); + const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || ""; + const getConfigStatus = () => { if (!claudeStatus?.installed) return null; const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; @@ -189,6 +192,8 @@ export default function ClaudeToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled })); } else { @@ -334,6 +339,7 @@ export default function ClaudeToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js index a88f79e6..b1deb02f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -50,6 +51,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api } }; + const currentBaseUrl = status?.settings?.openAiBaseUrl || ""; + const getConfigStatus = () => { if (!status?.installed) return null; if (!status.has9Router) return "not_configured"; @@ -94,6 +97,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -226,6 +231,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index f5aa88d7..6fed52de 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -6,6 +6,7 @@ import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; +import { rememberEndpoint } from "./cliEndpointPresets"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -62,12 +63,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api } }, [codexStatus]); + const getCurrentBaseUrl = () => { + const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/); + return parsed ? parsed[1] : ""; + }; + + const currentBaseUrl = getCurrentBaseUrl(); + const getConfigStatus = () => { if (!codexStatus?.installed) return null; if (!codexStatus.config) return "not_configured"; - const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); - const currentUrl = parsed ? parsed[1] : ""; - return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; + return matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -114,6 +120,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkCodexStatus(); } else { @@ -273,13 +281,12 @@ default_subagent_model = "${effectiveSubagentModel}" tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> {/* Current configured */} {codexStatus?.config && (() => { - const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); - const currentBaseUrl = parsed ? parsed[1] : null; return currentBaseUrl ? (
Current diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js index 90f96f94..43242938 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -77,6 +78,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a } }; + const currentBaseUrl = status?.currentUrl || ""; + const getConfigStatus = () => { if (!status) return null; if (!status.has9Router) return "not_configured"; @@ -123,6 +126,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." }); checkStatus(); } else { @@ -230,6 +235,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js index 2d1fb820..367ef5db 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; const ENDPOINT = "/api/cli-tools/cowork-settings"; @@ -110,6 +111,8 @@ export default function CoworkToolCard({ const getEffectiveBaseUrl = () => ensureV1(customBaseUrl); + const currentBaseUrl = status?.cowork?.baseUrl || ""; + const getConfigStatus = () => { if (!status?.installed) return null; const url = status?.cowork?.baseUrl; @@ -148,6 +151,8 @@ export default function CoworkToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." }); checkStatus(); } else { @@ -306,6 +311,7 @@ export default function CoworkToolCard({ tailscaleUrl={tailscaleUrl} cloudEnabled={cloudEnabled} cloudUrl={cloudUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js index ad84a4b7..db051f6b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -37,6 +38,8 @@ export default function DeepSeekTuiToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = deepseekStatus?.settings?.["providers.openai"]?.base_url || ""; + const getConfigStatus = () => { if (!deepseekStatus?.installed) return null; const openaiSection = deepseekStatus.settings?.["providers.openai"]; @@ -128,6 +131,8 @@ export default function DeepSeekTuiToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -263,6 +268,7 @@ model = "${selectedModel || "provider/model-id"}" tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index adc2a7ae..d6068f11 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -39,6 +40,8 @@ export default function DroidToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = droidStatus?.settings?.customModels?.find((m) => m.id?.startsWith("custom:9Router"))?.baseUrl || ""; + const getConfigStatus = () => { if (!droidStatus?.installed) return null; // Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...) @@ -154,6 +157,8 @@ export default function DroidToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkDroidStatus(); } else { @@ -299,6 +304,7 @@ export default function DroidToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js index f1ea3c22..fde57a38 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js @@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp import { useModelCaps } from "@/shared/hooks/useModelCaps"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -96,6 +97,7 @@ export default function GrokBuildToolCard({ const hasFetchedStatus = useRef(Boolean(initialStatus)); const configuredModel = grokStatus?.settings?.model; + const currentBaseUrl = configuredModel?.base_url || ""; const configStatus = !grokStatus?.installed ? null : !configuredModel?.base_url @@ -184,6 +186,8 @@ export default function GrokBuildToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Main and subagent models applied successfully!" }); checkStatus(); } else { @@ -310,7 +314,7 @@ export default function GrokBuildToolCard({
Select Endpoint arrow_forward - +
{configuredModel?.base_url && ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 9ef6cddf..bf5c25d1 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -37,6 +38,8 @@ export default function HermesToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = hermesStatus?.settings?.model?.base_url || ""; + const getConfigStatus = () => { if (!hermesStatus?.installed) return null; const cfg = hermesStatus.settings?.model; @@ -128,6 +131,8 @@ export default function HermesToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -242,6 +247,7 @@ export default function HermesToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js index c4544fa3..c616980c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -35,6 +36,8 @@ export default function JcodeToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = jcodeStatus?.config?.providers?.["9router"]?.base_url || ""; + const getConfigStatus = () => { if (!jcodeStatus?.installed) return null; if (!jcodeStatus?.has9Router) return "not_configured"; @@ -140,6 +143,8 @@ export default function JcodeToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkJcodeStatus(); } else { @@ -295,6 +300,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`; tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js index be348595..87f14f54 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -88,6 +89,8 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index b646a6eb..8e5cb8c2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -37,6 +38,8 @@ export default function OpenClawToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl || ""; + const getConfigStatus = () => { if (!openclawStatus?.installed) return null; const currentProvider = openclawStatus.settings?.models?.providers?.["9router"]; @@ -146,6 +149,8 @@ export default function OpenClawToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkOpenclawStatus(); } else { @@ -291,6 +296,7 @@ export default function OpenClawToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index c99edc98..5c800bc2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -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 { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -94,6 +95,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, } }; + const currentBaseUrl = status?.config?.provider?.["9router"]?.options?.baseURL || ""; + const getConfigStatus = () => { if (!status?.installed) return null; if (!status.config) return "not_configured"; @@ -145,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -297,6 +302,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js new file mode 100644 index 00000000..53cd0d43 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js @@ -0,0 +1,71 @@ +import { UPDATER_CONFIG } from "@/shared/constants/config"; + +// Browser-local endpoint presets shared by every CLI tool card +const STORAGE_KEY = "9router.cliToolEndpointPresets"; +const CHANGE_EVENT = "9router:endpoint-presets-changed"; + +const stripSlash = (url) => (url || "").replace(/\/+$/, ""); + +export function readPresets() { + if (typeof window === "undefined") return []; + try { + const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); + if (!Array.isArray(raw)) return []; + return raw.filter((p) => p?.name && p?.baseUrl); + } catch { + return []; + } +} + +function writePresets(presets) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); +} + +export function subscribePresets(handler) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, handler); + return () => window.removeEventListener(CHANGE_EVENT, handler); +} + +function defaultNameFor(url) { + try { return new URL(url).host; } catch { return url; } +} + +// Adds or replaces a preset; returns the stored name, or null when skipped +export function upsertPreset(baseUrl, name) { + const url = stripSlash(baseUrl); + if (!url) return null; + + const presets = readPresets(); + const existing = presets.find((p) => stripSlash(p.baseUrl) === url); + if (existing && !name) return existing.name; + + const finalName = (name || defaultNameFor(url)).trim(); + if (!finalName) return null; + + const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }] + .sort((a, b) => a.name.localeCompare(b.name)); + writePresets(next); + return finalName; +} + +// Save an applied endpoint unless it exactly matches a built-in dropdown option +export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) { + const url = stripSlash(baseUrl); + if (!url) return null; + + const builtIns = [`http://127.0.0.1:${UPDATER_CONFIG.appPort}`, tunnelPublicUrl, tailscaleUrl, cloudUrl] + .filter(Boolean) + .flatMap((u) => [stripSlash(u), `${stripSlash(u)}/v1`]); + if (builtIns.includes(url)) return null; + + return upsertPreset(url); +} + +export function deletePreset(name) { + writePresets(readPresets().filter((p) => p.name !== name)); +} + +export { stripSlash };