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:
@@ -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:");
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user