mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: add the feature for user save combo
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user