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 { 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:");

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
</div>
{/* Current configured */}
{codexStatus?.config && (() => {
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentBaseUrl = parsed ? parsed[1] : null;
return currentBaseUrl ? (
<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>

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
</div>

View File

@@ -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({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect value={customBaseUrl || 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>
{configuredModel?.base_url && (

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
</div>

View File

@@ -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 {

View File

@@ -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}
/>
</div>

View File

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