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 <noreply@anthropic.com>
This commit is contained in:
decolua
2026-08-27 18:54:44 +07:00
parent c4af43faa3
commit a68ada1c83
15 changed files with 196 additions and 42 deletions

View File

@@ -2,8 +2,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { UPDATER_CONFIG } from "@/shared/constants/config"; 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 CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save__"; const SAVE_VALUE = "__save__";
@@ -13,22 +13,6 @@ const ensureV1 = (url) => {
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; 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 buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const opts = []; const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
@@ -66,14 +50,34 @@ export default function BaseUrlSelect({
cloudEnabled = false, cloudEnabled = false,
cloudUrl = "", cloudUrl = "",
withV1 = true, withV1 = true,
currentUrl = "",
}) { }) {
const [savedPresets, setSavedPresets] = useState([]); const [savedPresets, setSavedPresets] = useState([]);
const [presetsLoaded, setPresetsLoaded] = useState(false);
const [mode, setMode] = useState(""); const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState(""); const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false); const initializedRef = useRef(false);
const customInputRef = useRef("");
useEffect(() => { 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( const options = useMemo(
@@ -81,19 +85,23 @@ export default function BaseUrlSelect({
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] [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(() => { useEffect(() => {
if (initializedRef.current) return; if (initializedRef.current) return;
if (options.length === 0) return; if (!presetsLoaded || options.length === 0) return;
initializedRef.current = true; initializedRef.current = true;
const first = options.find((o) => o.value !== CUSTOM_VALUE); const current = stripSlash(currentUrl);
if (first) { const matched = current
setMode(first.value); ? options.find((o) => o.saved && stripSlash(o.url) === current)
onChange(first.url); : null;
const target = matched || options.find((o) => o.value !== CUSTOM_VALUE);
if (target) {
setMode(target.value);
onChange(target.url);
} else { } else {
setMode(CUSTOM_VALUE); setMode(CUSTOM_VALUE);
} }
}, [options, onChange]); }, [presetsLoaded, options, onChange, currentUrl]);
const handleSelect = (e) => { const handleSelect = (e) => {
const next = e.target.value; const next = e.target.value;
@@ -103,11 +111,8 @@ export default function BaseUrlSelect({
let defaultName = trimmed; let defaultName = trimmed;
try { defaultName = new URL(trimmed).host; } catch {} try { defaultName = new URL(trimmed).host; } catch {}
const name = window.prompt("Save endpoint as:", defaultName); const name = window.prompt("Save endpoint as:", defaultName);
if (!name?.trim()) return; const saved = name?.trim() ? upsertPreset(trimmed, name.trim()) : null;
const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }] if (saved) setMode(`saved:${saved}`);
.sort((a, b) => a.name.localeCompare(b.name));
setSavedPresets(updated);
writeSavedPresets(updated);
return; return;
} }
setMode(next); setMode(next);
@@ -122,19 +127,23 @@ export default function BaseUrlSelect({
const handleCustomInput = (e) => { const handleCustomInput = (e) => {
const v = e.target.value; const v = e.target.value;
customInputRef.current = v;
setCustomInput(v); setCustomInput(v);
onChange(v); onChange(v);
}; };
const handleDeleteSaved = () => { const handleDeleteSaved = () => {
if (!mode.startsWith("saved:")) return; if (!mode.startsWith("saved:")) return;
const name = mode.slice(6); deletePreset(mode.slice(6));
const updated = savedPresets.filter((p) => p.name !== name);
setSavedPresets(updated);
writeSavedPresets(updated);
setMode(CUSTOM_VALUE);
setCustomInput(""); 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:"); const isSaved = mode.startsWith("saved:");

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -53,6 +54,8 @@ export default function ClaudeToolCard({
const [maxContextTokens, setMaxContextTokens] = useState(""); const [maxContextTokens, setMaxContextTokens] = useState("");
const hasInitializedModels = useRef(false); const hasInitializedModels = useRef(false);
const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!claudeStatus?.installed) return null; if (!claudeStatus?.installed) return null;
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
@@ -189,6 +192,8 @@ export default function ClaudeToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled })); setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled }));
} else { } else {
@@ -334,6 +339,7 @@ export default function ClaudeToolCard({
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -50,6 +51,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
} }
}; };
const currentBaseUrl = status?.settings?.openAiBaseUrl || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!status?.installed) return null; if (!status?.installed) return null;
if (!status.has9Router) return "not_configured"; if (!status.has9Router) return "not_configured";
@@ -94,6 +97,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus(); checkStatus();
} else { } else {
@@ -226,6 +231,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -6,6 +6,7 @@ import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
import { rememberEndpoint } from "./cliEndpointPresets";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null); const [codexStatus, setCodexStatus] = useState(initialStatus || null);
@@ -62,12 +63,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
} }
}, [codexStatus]); }, [codexStatus]);
const getCurrentBaseUrl = () => {
const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/);
return parsed ? parsed[1] : "";
};
const currentBaseUrl = getCurrentBaseUrl();
const getConfigStatus = () => { const getConfigStatus = () => {
if (!codexStatus?.installed) return null; if (!codexStatus?.installed) return null;
if (!codexStatus.config) return "not_configured"; if (!codexStatus.config) return "not_configured";
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); return matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
const currentUrl = parsed ? parsed[1] : "";
return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
}; };
const configStatus = getConfigStatus(); const configStatus = getConfigStatus();
@@ -114,6 +120,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkCodexStatus(); checkCodexStatus();
} else { } else {
@@ -273,13 +281,12 @@ default_subagent_model = "${effectiveSubagentModel}"
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>
{/* Current configured */} {/* Current configured */}
{codexStatus?.config && (() => { {codexStatus?.config && (() => {
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentBaseUrl = parsed ? parsed[1] : null;
return currentBaseUrl ? ( return currentBaseUrl ? (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2"> <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="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -77,6 +78,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
} }
}; };
const currentBaseUrl = status?.currentUrl || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!status) return null; if (!status) return null;
if (!status.has9Router) return "not_configured"; if (!status.has9Router) return "not_configured";
@@ -123,6 +126,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { 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." }); setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." });
checkStatus(); checkStatus();
} else { } else {
@@ -230,6 +235,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components"; import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
const ENDPOINT = "/api/cli-tools/cowork-settings"; const ENDPOINT = "/api/cli-tools/cowork-settings";
@@ -110,6 +111,8 @@ export default function CoworkToolCard({
const getEffectiveBaseUrl = () => ensureV1(customBaseUrl); const getEffectiveBaseUrl = () => ensureV1(customBaseUrl);
const currentBaseUrl = status?.cowork?.baseUrl || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!status?.installed) return null; if (!status?.installed) return null;
const url = status?.cowork?.baseUrl; const url = status?.cowork?.baseUrl;
@@ -148,6 +151,8 @@ export default function CoworkToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { 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." }); setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." });
checkStatus(); checkStatus();
} else { } else {
@@ -306,6 +311,7 @@ export default function CoworkToolCard({
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled} cloudEnabled={cloudEnabled}
cloudUrl={cloudUrl} cloudUrl={cloudUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function DeepSeekTuiToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false); const hasInitializedModel = useRef(false);
const currentBaseUrl = deepseekStatus?.settings?.["providers.openai"]?.base_url || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!deepseekStatus?.installed) return null; if (!deepseekStatus?.installed) return null;
const openaiSection = deepseekStatus.settings?.["providers.openai"]; const openaiSection = deepseekStatus.settings?.["providers.openai"];
@@ -128,6 +131,8 @@ export default function DeepSeekTuiToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus(); checkStatus();
} else { } else {
@@ -263,6 +268,7 @@ model = "${selectedModel || "provider/model-id"}"
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -39,6 +40,8 @@ export default function DroidToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false); const hasInitializedModel = useRef(false);
const currentBaseUrl = droidStatus?.settings?.customModels?.find((m) => m.id?.startsWith("custom:9Router"))?.baseUrl || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!droidStatus?.installed) return null; if (!droidStatus?.installed) return null;
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...) // 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(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkDroidStatus(); checkDroidStatus();
} else { } else {
@@ -299,6 +304,7 @@ export default function DroidToolCard({
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { useModelCaps } from "@/shared/hooks/useModelCaps";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -96,6 +97,7 @@ export default function GrokBuildToolCard({
const hasFetchedStatus = useRef(Boolean(initialStatus)); const hasFetchedStatus = useRef(Boolean(initialStatus));
const configuredModel = grokStatus?.settings?.model; const configuredModel = grokStatus?.settings?.model;
const currentBaseUrl = configuredModel?.base_url || "";
const configStatus = !grokStatus?.installed const configStatus = !grokStatus?.installed
? null ? null
: !configuredModel?.base_url : !configuredModel?.base_url
@@ -184,6 +186,8 @@ export default function GrokBuildToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { 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!" }); setMessage({ type: "success", text: "Main and subagent models applied successfully!" });
checkStatus(); checkStatus();
} else { } else {
@@ -310,7 +314,7 @@ export default function GrokBuildToolCard({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm: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="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> <span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect value={customBaseUrl || getEffectiveBaseUrl()} onChange={setCustomBaseUrl} requiresExternalUrl={tool.requiresExternalUrl} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} /> <BaseUrlSelect value={customBaseUrl || getEffectiveBaseUrl()} onChange={setCustomBaseUrl} requiresExternalUrl={tool.requiresExternalUrl} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} currentUrl={currentBaseUrl} />
</div> </div>
{configuredModel?.base_url && ( {configuredModel?.base_url && (

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function HermesToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false); const hasInitializedModel = useRef(false);
const currentBaseUrl = hermesStatus?.settings?.model?.base_url || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!hermesStatus?.installed) return null; if (!hermesStatus?.installed) return null;
const cfg = hermesStatus.settings?.model; const cfg = hermesStatus.settings?.model;
@@ -128,6 +131,8 @@ export default function HermesToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus(); checkStatus();
} else { } else {
@@ -242,6 +247,7 @@ export default function HermesToolCard({
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -35,6 +36,8 @@ export default function JcodeToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false); const hasInitializedModel = useRef(false);
const currentBaseUrl = jcodeStatus?.config?.providers?.["9router"]?.base_url || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!jcodeStatus?.installed) return null; if (!jcodeStatus?.installed) return null;
if (!jcodeStatus?.has9Router) return "not_configured"; if (!jcodeStatus?.has9Router) return "not_configured";
@@ -140,6 +143,8 @@ export default function JcodeToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkJcodeStatus(); checkJcodeStatus();
} else { } else {
@@ -295,6 +300,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -88,6 +89,8 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus(); checkStatus();
} else { } else {

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function OpenClawToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false); const hasInitializedModel = useRef(false);
const currentBaseUrl = openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl || "";
const getConfigStatus = () => { const getConfigStatus = () => {
if (!openclawStatus?.installed) return null; if (!openclawStatus?.installed) return null;
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"]; const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
@@ -146,6 +149,8 @@ export default function OpenClawToolCard({
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkOpenclawStatus(); checkOpenclawStatus();
} else { } else {
@@ -291,6 +296,7 @@ export default function OpenClawToolCard({
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; 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 = () => { const getConfigStatus = () => {
if (!status?.installed) return null; if (!status?.installed) return null;
if (!status.config) return "not_configured"; if (!status.config) return "not_configured";
@@ -145,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus(); checkStatus();
} else { } else {
@@ -297,6 +302,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl} tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/> />
</div> </div>

View File

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