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 };