feat: add the feature for user save combo

This commit is contained in:
2026-07-16 16:52:37 +07:00
parent e3cf2e7708
commit 448a8aedc7
20 changed files with 1215 additions and 114 deletions
@@ -1,12 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useState, useEffect } from "react";
import Link from "next/link";
import { CardSkeleton } from "@/shared/components";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { resolveCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint";
import { ConfigGeneratorCard, DefaultToolCard } from "../components";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
const CONFIGURED_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
export default function ToolDetailClient({ toolId, machineId }) {
const tool = CLI_TOOLS[toolId];
@@ -19,17 +21,19 @@ export default function ToolDetailClient({ toolId, machineId }) {
const [tailscaleUrl, setTailscaleUrl] = useState("");
const [apiKeys, setApiKeys] = useState([]);
const [availableModels, setAvailableModels] = useState([]);
const [initialConfig, setInitialConfig] = useState(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const [provRes, settingsRes, tunnelRes, keysRes, modelsRes] = await Promise.all([
const [provRes, settingsRes, tunnelRes, keysRes, modelsRes, configRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/settings"),
fetch("/api/tunnel/status"),
fetch("/api/keys"),
fetch("/api/models/connected", { cache: "no-store" }),
fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { cache: "no-store" }),
]);
if (!mounted) return;
if (provRes.ok) {
@@ -55,6 +59,10 @@ export default function ToolDetailClient({ toolId, machineId }) {
const data = await modelsRes.json();
setAvailableModels((data.models || []).filter((model) => !model.disabled));
}
if (configRes.ok) {
const data = await configRes.json();
setInitialConfig(data.config || null);
}
} catch (error) {
console.log("Error loading tool data:", error);
} finally {
@@ -62,15 +70,34 @@ export default function ToolDetailClient({ toolId, machineId }) {
}
})();
return () => { mounted = false; };
}, []);
}, [toolId]);
const saveConfig = useCallback(async (config) => {
const response = await fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "Failed to save configuration");
setInitialConfig(data.config);
return data.config;
}, [toolId]);
const getActiveProviders = () => connections.filter(c => c.isActive !== false);
const getBaseUrl = () => {
if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl;
if (cloudEnabled && CLOUD_URL) return CLOUD_URL;
if (typeof window !== "undefined") return window.location.origin;
return "http://localhost:20128";
return resolveCliToolBaseUrl({
appUrl: typeof window !== "undefined" ? window.location.origin : "",
configuredBaseUrl: CONFIGURED_BASE_URL,
requiresExternalUrl: tool?.requiresExternalUrl === true,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
cloudEnabled,
cloudUrl: CLOUD_URL,
});
};
const renderToolCard = () => {
@@ -86,6 +113,8 @@ export default function ToolDetailClient({ toolId, machineId }) {
activeProviders: getActiveProviders(),
availableModels,
cloudEnabled,
initialConfig,
onSaveConfig: saveConfig,
};
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
@@ -1,65 +1,57 @@
"use client";
import { useState } from "react";
const CUSTOM_VALUE = "__custom__";
const UNSET_VALUE = "__unset__";
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) {
const isCustom = !apiKeys.some((k) => k.key === value) && value !== "";
const [mode, setMode] = useState(() => {
if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE;
if (apiKeys.some((k) => k.key === value)) return value;
return CUSTOM_VALUE;
});
const [customInput, setCustomInput] = useState(isCustom ? value : "");
export default function ApiKeySelect({ value, onChange, apiKeys = [], className = "", mode = "managed", onModeChange }) {
const matchingKey = apiKeys.find((key) => key.key === value);
const selectedMode = mode === "custom" ? CUSTOM_VALUE : (matchingKey?.key || UNSET_VALUE);
const handleSelect = (e) => {
const next = e.target.value;
setMode(next);
if (next === UNSET_VALUE) return;
if (next === CUSTOM_VALUE) {
setCustomInput("");
onModeChange?.("custom");
onChange("");
} else {
onModeChange?.("managed");
onChange(next);
}
};
const handleCustomInput = (e) => {
const v = e.target.value;
setCustomInput(v);
onModeChange?.("custom");
onChange(v);
};
const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE;
if (noKeys && mode !== CUSTOM_VALUE) {
return (
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
);
}
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
<select
value={mode}
value={selectedMode}
onChange={handleSelect}
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
>
{selectedMode === UNSET_VALUE && <option value={UNSET_VALUE}>No managed API key selected</option>}
{apiKeys.map((k) => (
<option key={k.id} value={k.key}>{k.key}</option>
))}
<option value={CUSTOM_VALUE}>Custom...</option>
</select>
{mode === CUSTOM_VALUE && (
<input
type="text"
value={customInput}
onChange={handleCustomInput}
placeholder="sk-..."
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{selectedMode === CUSTOM_VALUE && (
<>
<input
type="password"
value={mode === "custom" ? value : ""}
onChange={handleCustomInput}
placeholder="sk-..."
autoComplete="off"
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{mode === "custom" && !value && (
<span className="text-[11px] font-normal text-amber-600 dark:text-amber-400">Custom keys are not saved. Enter it again to generate the configuration.</span>
)}
</>
)}
</div>
);
@@ -1,18 +1,13 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { APP_CONFIG } from "@/shared/constants/config";
import { ensureCliToolV1Endpoint, isLocalCliToolUrl } from "@/shared/utils/cliToolEndpoint";
const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save__";
const ensureV1 = (url) => {
const trimmed = (url || "").replace(/\/+$/, "");
if (!trimmed) return "";
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
};
const readSavedPresets = () => {
if (typeof window === "undefined") return [];
try {
@@ -29,12 +24,15 @@ const writeSavedPresets = (presets) => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
};
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const buildOptions = ({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
if (!requiresExternalUrl) {
const localUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
opts.push({ value: "local", label: localUrl, url: localUrl });
const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, ""));
const runtimeUrl = wrap(appUrl);
if (runtimeUrl && (!requiresExternalUrl || !isLocalCliToolUrl(runtimeUrl))) {
opts.push({ value: isLocalCliToolUrl(runtimeUrl) ? "local" : "deployment", label: runtimeUrl, url: runtimeUrl });
} else if (!requiresExternalUrl) {
const fallbackLocalUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
opts.push({ value: "local", label: fallbackLocalUrl, url: fallbackLocalUrl });
}
if (tunnelEnabled && tunnelPublicUrl) {
const u = wrap(tunnelPublicUrl);
@@ -58,6 +56,7 @@ const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tai
export default function BaseUrlSelect({
value,
onChange,
appUrl = "",
requiresExternalUrl = false,
tunnelEnabled = false,
tunnelPublicUrl = "",
@@ -69,31 +68,24 @@ export default function BaseUrlSelect({
}) {
const [savedPresets, setSavedPresets] = useState([]);
const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false);
useEffect(() => {
setSavedPresets(readSavedPresets());
queueMicrotask(() => setSavedPresets(readSavedPresets()));
}, []);
const options = useMemo(
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
() => buildOptions({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
[appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
);
// Always default to first option (127.0.0.1) on mount, ignore persisted value
useEffect(() => {
if (initializedRef.current) return;
if (options.length === 0) return;
initializedRef.current = true;
const first = options.find((o) => o.value !== CUSTOM_VALUE);
if (first) {
setMode(first.value);
onChange(first.url);
} else {
setMode(CUSTOM_VALUE);
}
}, [options, onChange]);
const effectiveMode = useMemo(() => {
if (mode) return mode;
const normalizedValue = (value || "").replace(/\/+$/, "");
const matchingOption = options.find((option) => option.value !== CUSTOM_VALUE && option.url.replace(/\/+$/, "") === normalizedValue);
if (matchingOption) return matchingOption.value;
if (value) return CUSTOM_VALUE;
return options.find((option) => option.value !== CUSTOM_VALUE)?.value || CUSTOM_VALUE;
}, [mode, options, value]);
const handleSelect = (e) => {
const next = e.target.value;
@@ -112,7 +104,6 @@ export default function BaseUrlSelect({
}
setMode(next);
if (next === CUSTOM_VALUE) {
setCustomInput("");
onChange("");
return;
}
@@ -121,31 +112,28 @@ export default function BaseUrlSelect({
};
const handleCustomInput = (e) => {
const v = e.target.value;
setCustomInput(v);
onChange(v);
onChange(e.target.value);
};
const handleDeleteSaved = () => {
if (!mode.startsWith("saved:")) return;
const name = mode.slice(6);
if (!effectiveMode.startsWith("saved:")) return;
const name = effectiveMode.slice(6);
const updated = savedPresets.filter((p) => p.name !== name);
setSavedPresets(updated);
writeSavedPresets(updated);
setMode(CUSTOM_VALUE);
setCustomInput("");
onChange("");
};
const isSaved = mode.startsWith("saved:");
const isCustom = mode === CUSTOM_VALUE;
const canSave = isCustom && (customInput || "").trim().length > 0;
const isSaved = effectiveMode.startsWith("saved:");
const isCustom = effectiveMode === CUSTOM_VALUE;
const canSave = isCustom && (value || "").trim().length > 0;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<select
value={mode}
value={effectiveMode}
onChange={handleSelect}
className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
>
@@ -163,7 +151,7 @@ export default function BaseUrlSelect({
{isCustom && (
<input
type="text"
value={customInput}
value={value || ""}
onChange={handleCustomInput}
placeholder={withV1 ? "https://example.com/v1" : "https://example.com"}
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
import { DEFAULT_MODEL_TOKEN_LIMITS, getInputTokenOptions, getOutputTokenOptions } from "@/shared/constants/copilotModelTokens.js";
import { resolveInitialCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
@@ -171,23 +172,36 @@ export default function ConfigGeneratorCard({
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
initialConfig,
onSaveConfig,
}) {
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
const [selectedModels, setSelectedModels] = useState([]);
const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" });
const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" });
const restoredApiKey = initialConfig?.apiKeyId
? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || ""
: "";
const [selectedApiKey, setSelectedApiKey] = useState(() => (
initialConfig?.apiKeyMode === "custom"
? ""
: initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || ""
));
const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed");
const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []);
const [claudeModels, setClaudeModels] = useState(() => initialConfig?.claudeModels || { sonnet: "", opus: "", haiku: "" });
const [claudeThinking, setClaudeThinking] = useState(() => initialConfig?.claudeThinking || { sonnet: "", opus: "", haiku: "" });
const [claudeModelSlot, setClaudeModelSlot] = useState("");
const [codexModel, setCodexModel] = useState("");
const [codexThinking, setCodexThinking] = useState("");
const [opencodeModels, setOpencodeModels] = useState([]);
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState("");
const [coworkThinking, setCoworkThinking] = useState({});
const [copilotTokens, setCopilotTokens] = useState({});
const [copilotThinking, setCopilotThinking] = useState({});
const [codexModel, setCodexModel] = useState(() => initialConfig?.codexModel || "");
const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || "");
const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []);
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || "");
const [coworkThinking, setCoworkThinking] = useState(() => initialConfig?.coworkThinking || {});
const [copilotTokens, setCopilotTokens] = useState(() => initialConfig?.copilotTokens || {});
const [copilotThinking, setCopilotThinking] = useState(() => initialConfig?.copilotThinking || {});
const connectedModels = availableModels;
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl));
const [modelModalOpen, setModelModalOpen] = useState(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [saveStatus, setSaveStatus] = useState("idle");
const [saveError, setSaveError] = useState("");
const initializedSaveState = useRef(false);
const effectiveBaseUrl = customBaseUrl || baseUrl;
const apiKey = toolId === "copilot"
@@ -198,6 +212,43 @@ export default function ConfigGeneratorCard({
[toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels]
);
const buildPersistableConfig = () => {
const config = { baseUrl: effectiveBaseUrl };
if (toolId !== "copilot") {
config.apiKeyMode = apiKeyMode;
config.apiKeyId = apiKeyMode === "managed"
? apiKeys.find((key) => key.key === selectedApiKey)?.id || null
: null;
}
if (toolId === "claude") Object.assign(config, { claudeModels, claudeThinking });
if (toolId === "codex") Object.assign(config, { codexModel, codexThinking });
if (toolId === "opencode") Object.assign(config, { opencodeModels, opencodeDefaultModel });
if (toolId === "cowork") Object.assign(config, { selectedModels, coworkThinking });
if (toolId === "copilot") Object.assign(config, { selectedModels, copilotThinking, copilotTokens });
return config;
};
const handleSave = async () => {
setSaveStatus("saving");
setSaveError("");
try {
await onSaveConfig(buildPersistableConfig());
setSaveStatus("saved");
} catch (error) {
setSaveStatus("error");
setSaveError(error.message || "Failed to save configuration");
}
};
useEffect(() => {
if (!initializedSaveState.current) {
initializedSaveState.current = true;
return;
}
setSaveStatus((current) => current === "saving" ? current : "dirty");
setSaveError("");
}, [effectiveBaseUrl, apiKeyMode, selectedApiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking]);
const getThinkingLevelsForModel = (fullModel) => {
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
if (!connectedModel?.provider?.id || !connectedModel.model) return null;
@@ -307,8 +358,9 @@ export default function ConfigGeneratorCard({
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
Endpoint
<BaseUrlSelect
value={customBaseUrl || baseUrl}
value={customBaseUrl}
onChange={setCustomBaseUrl}
appUrl={baseUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
@@ -320,7 +372,7 @@ export default function ConfigGeneratorCard({
{toolId !== "copilot" && (
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
API key
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} mode={apiKeyMode} onModeChange={setApiKeyMode} />
</label>
)}
{toolId === "claude" ? (
@@ -558,10 +610,20 @@ export default function ConfigGeneratorCard({
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
</div>
)}
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto sm:self-start">
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
Show configuration file
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={handleSave} disabled={saveStatus === "saving"} variant="secondary" className="w-full sm:w-auto">
<span className="material-symbols-outlined mr-1 text-[16px]">{saveStatus === "saving" ? "progress_activity" : "save"}</span>
{saveStatus === "saving" ? "Saving..." : "Save"}
</Button>
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
Show configuration file
</Button>
{saveStatus === "saved" && <span className="text-xs text-green-600 dark:text-green-400">Configuration saved.</span>}
{saveStatus === "dirty" && <span className="text-xs text-text-muted">Unsaved changes</span>}
{saveStatus === "error" && <span className="text-xs text-red-600 dark:text-red-400">{saveError}</span>}
</div>
{apiKeyMode === "custom" && toolId !== "copilot" && <p className="text-xs text-amber-600 dark:text-amber-400">The custom API key is never saved and must be entered again after reload.</p>}
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
</div>
@@ -1,22 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { Card, ModelSelectModal } from "@/shared/components";
import { useEffect, useRef, useState } from "react";
import { Button, Card, ModelSelectModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Image from "next/image";
import ApiKeySelect from "./ApiKeySelect";
export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false }) {
export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false, initialConfig, onSaveConfig }) {
const [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false);
const [modelValue, setModelValue] = useState("");
const [selectedModels, setSelectedModels] = useState([]);
const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []);
const [isExpanded, setIsExpanded] = useState(true);
// Initialize state directly with computed value - no need for useEffect
const [selectedApiKey, setSelectedApiKey] = useState(() =>
apiKeys?.length > 0 ? apiKeys[0].key : ""
);
const restoredApiKey = initialConfig?.apiKeyId
? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || ""
: "";
const [selectedApiKey, setSelectedApiKey] = useState(() => (
initialConfig?.apiKeyMode === "custom"
? ""
: initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || ""
));
const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed");
const [saveStatus, setSaveStatus] = useState("idle");
const [saveError, setSaveError] = useState("");
const initializedSaveState = useRef(false);
useEffect(() => {
if (!initializedSaveState.current) {
initializedSaveState.current = true;
return;
}
setSaveStatus((current) => current === "saving" ? current : "dirty");
setSaveError("");
}, [apiKeyMode, selectedApiKey, selectedModels]);
const replaceVars = (text) => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
@@ -57,11 +73,29 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
setSelectedModels((current) => current.filter((value) => value !== model.value));
};
const handleSave = async () => {
setSaveStatus("saving");
setSaveError("");
try {
await onSaveConfig({
selectedModels,
apiKeyMode,
apiKeyId: apiKeyMode === "managed"
? apiKeys.find((key) => key.key === selectedApiKey)?.id || null
: null,
});
setSaveStatus("saved");
} catch (error) {
setSaveStatus("error");
setSaveError(error.message || "Failed to save configuration");
}
};
const hasActiveProviders = activeProviders.length > 0;
const renderApiKeySelector = () => (
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" />
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" mode={apiKeyMode} onModeChange={setApiKeyMode} />
</div>
);
@@ -249,6 +283,20 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
</pre>
</div>
)}
{canShowGuide() && (
<div className="flex flex-col gap-2 border-t border-border pt-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={handleSave} disabled={saveStatus === "saving"}>
<span className="material-symbols-outlined mr-1 text-[16px]">{saveStatus === "saving" ? "progress_activity" : "save"}</span>
{saveStatus === "saving" ? "Saving..." : "Save"}
</Button>
{saveStatus === "saved" && <span className="text-xs text-green-600 dark:text-green-400">Configuration saved.</span>}
{saveStatus === "dirty" && <span className="text-xs text-text-muted">Unsaved changes</span>}
{saveStatus === "error" && <span className="text-xs text-red-600 dark:text-red-400">{saveError}</span>}
</div>
{apiKeyMode === "custom" && <p className="text-xs text-amber-600 dark:text-amber-400">The custom API key is never saved and must be entered again after reload.</p>}
</div>
)}
</div>
);
};