mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
- Cowork: ComboFormModal
- BaseUrlSelect: add cloud endpoint option, custom URL local state, always default to first option; new cliEndpointMatch helper; CLI tool cards refactor - API: new /v1/audio/voices and /v1/models/info; /v1/models filters disabled models, drop unused timestamp - initializeApp: guard tunnel/tailscale auto-resume to once-per-process - geminiHelper: ensureObjectType for schemas with properties but no type - skills: minor SKILL.md tweaks (chat/embeddings/image/stt/tts/web-*)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
@@ -13,8 +13,6 @@ const ensureV1 = (url) => {
|
||||
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||
};
|
||||
|
||||
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
|
||||
|
||||
const readSavedPresets = () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
@@ -31,21 +29,27 @@ const writeSavedPresets = (presets) => {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
};
|
||||
|
||||
// Build endpoint options ordered by priority
|
||||
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }) => {
|
||||
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
|
||||
const opts = [];
|
||||
const wrap = (url) => (withV1 ? ensureV1(url) : url.replace(/\/+$/, ""));
|
||||
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
|
||||
if (!requiresExternalUrl) {
|
||||
opts.push({ value: "local", label: `Localhost (127.0.0.1)`, url: wrap(`http://127.0.0.1:${APP_CONFIG.appPort}`) });
|
||||
const localUrl = wrap(`http://127.0.0.1:${UPDATER_CONFIG.appPort}`);
|
||||
opts.push({ value: "local", label: localUrl, url: localUrl });
|
||||
}
|
||||
if (tunnelEnabled && tunnelPublicUrl) {
|
||||
opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: wrap(tunnelPublicUrl) });
|
||||
const u = wrap(tunnelPublicUrl);
|
||||
opts.push({ value: "tunnel", label: u, url: u });
|
||||
}
|
||||
if (tailscaleEnabled && tailscaleUrl) {
|
||||
opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: wrap(tailscaleUrl) });
|
||||
const u = wrap(tailscaleUrl);
|
||||
opts.push({ value: "tailscale", label: u, url: u });
|
||||
}
|
||||
if (cloudEnabled && cloudUrl) {
|
||||
const u = wrap(cloudUrl);
|
||||
opts.push({ value: "cloud", label: u, url: u });
|
||||
}
|
||||
savedPresets.forEach((p) => {
|
||||
opts.push({ value: `saved:${p.name}`, label: `★ ${p.name} - ${p.baseUrl}`, url: p.baseUrl, saved: true });
|
||||
opts.push({ value: `saved:${p.name}`, label: p.baseUrl, url: p.baseUrl, saved: true });
|
||||
});
|
||||
opts.push({ value: CUSTOM_VALUE, label: "Custom URL...", url: "" });
|
||||
return opts;
|
||||
@@ -59,32 +63,37 @@ export default function BaseUrlSelect({
|
||||
tunnelPublicUrl = "",
|
||||
tailscaleEnabled = false,
|
||||
tailscaleUrl = "",
|
||||
cloudEnabled = false,
|
||||
cloudUrl = "",
|
||||
withV1 = true,
|
||||
}) {
|
||||
const [savedPresets, setSavedPresets] = useState([]);
|
||||
const [mode, setMode] = useState("");
|
||||
const [customInput, setCustomInput] = useState("");
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSavedPresets(readSavedPresets());
|
||||
}, []);
|
||||
|
||||
const options = useMemo(
|
||||
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }),
|
||||
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1]
|
||||
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
|
||||
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
|
||||
);
|
||||
|
||||
// Auto-detect mode based on current value matching an option
|
||||
// Always default to first option (127.0.0.1) on mount, ignore persisted value
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
if (options[0] && options[0].value !== CUSTOM_VALUE) {
|
||||
setMode(options[0].value);
|
||||
onChange(options[0].url);
|
||||
}
|
||||
return;
|
||||
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);
|
||||
}
|
||||
const match = options.find((o) => o.url && o.url === value);
|
||||
setMode(match ? match.value : CUSTOM_VALUE);
|
||||
}, [value, options]);
|
||||
}, [options, onChange]);
|
||||
|
||||
const handleSelect = (e) => {
|
||||
const next = e.target.value;
|
||||
@@ -95,14 +104,15 @@ export default function BaseUrlSelect({
|
||||
try { defaultName = new URL(trimmed).host; } catch {}
|
||||
const name = window.prompt("Save endpoint as:", defaultName);
|
||||
if (!name?.trim()) return;
|
||||
const next = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
|
||||
const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setSavedPresets(next);
|
||||
writeSavedPresets(next);
|
||||
setSavedPresets(updated);
|
||||
writeSavedPresets(updated);
|
||||
return;
|
||||
}
|
||||
setMode(next);
|
||||
if (next === CUSTOM_VALUE) {
|
||||
setCustomInput("");
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
@@ -110,18 +120,26 @@ export default function BaseUrlSelect({
|
||||
if (opt) onChange(opt.url);
|
||||
};
|
||||
|
||||
const handleCustomInput = (e) => {
|
||||
const v = e.target.value;
|
||||
setCustomInput(v);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
const handleDeleteSaved = () => {
|
||||
if (!mode.startsWith("saved:")) return;
|
||||
const name = mode.slice(6);
|
||||
const next = savedPresets.filter((p) => p.name !== name);
|
||||
setSavedPresets(next);
|
||||
writeSavedPresets(next);
|
||||
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 && (value || "").trim().length > 0;
|
||||
const canSave = isCustom && (customInput || "").trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -145,8 +163,8 @@ export default function BaseUrlSelect({
|
||||
{isCustom && (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={customInput}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -43,10 +44,7 @@ export default function ClaudeToolCard({
|
||||
if (!claudeStatus?.installed) return null;
|
||||
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
|
||||
if (!currentUrl) return "not_configured";
|
||||
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
|
||||
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
|
||||
const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl);
|
||||
if (localMatch || cloudMatch || tunnelMatch) return "configured";
|
||||
if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
@@ -296,20 +294,9 @@ export default function ClaudeToolCard({
|
||||
{!checkingClaude && claudeStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Current Base URL */}
|
||||
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{claudeStatus.settings.env.ANTHROPIC_BASE_URL}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{/* Endpoint (selector) */}
|
||||
<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">Base URL</span>
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
@@ -322,6 +309,17 @@ export default function ClaudeToolCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current configured */}
|
||||
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{claudeStatus.settings.env.ANTHROPIC_BASE_URL}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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">API Key</span>
|
||||
|
||||
@@ -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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
|
||||
@@ -64,8 +65,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
||||
const getConfigStatus = () => {
|
||||
if (!codexStatus?.installed) return null;
|
||||
if (!codexStatus.config) return "not_configured";
|
||||
const hasBaseUrl = codexStatus.config.includes(baseUrl) || codexStatus.config.includes("localhost") || codexStatus.config.includes("127.0.0.1");
|
||||
return hasBaseUrl ? "configured" : "other";
|
||||
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
const currentUrl = parsed ? parsed[1] : "";
|
||||
return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
@@ -266,7 +268,22 @@ model = "${effectiveSubagentModel}"
|
||||
{!checkingCodex && codexStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Current Base URL */}
|
||||
{/* Endpoint (selector) */}
|
||||
<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 || getDisplayUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current configured */}
|
||||
{codexStatus?.config && (() => {
|
||||
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
const currentBaseUrl = parsed ? parsed[1] : null;
|
||||
@@ -281,21 +298,6 @@ model = "${effectiveSubagentModel}"
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Base URL */}
|
||||
<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">Base URL</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<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">API Key</span>
|
||||
|
||||
@@ -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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
const [status, setStatus] = useState(initialStatus || null);
|
||||
@@ -63,8 +64,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
if (!status) return null;
|
||||
if (!status.has9Router) return "not_configured";
|
||||
const url = status.currentUrl || "";
|
||||
return url.includes("localhost") || url.includes("127.0.0.1") || url.includes(baseUrl)
|
||||
? "configured" : "other";
|
||||
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
@@ -207,7 +207,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-text-muted">Base URL</label>
|
||||
<label className="text-xs font-medium text-text-muted">Select Endpoint</label>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ManualConfigModal, ComboFormModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/cowork-settings";
|
||||
|
||||
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
|
||||
|
||||
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
|
||||
const ensureV1 = (url) => {
|
||||
const trimmed = (url || "").replace(/\/+$/, "");
|
||||
@@ -38,26 +37,11 @@ export default function CoworkToolCard({
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [endpointMode, setEndpointMode] = useState("custom");
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
|
||||
const endpointOptions = useMemo(() => {
|
||||
const opts = [];
|
||||
if (tunnelEnabled && tunnelPublicUrl) {
|
||||
opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: ensureV1(tunnelPublicUrl) });
|
||||
}
|
||||
if (tailscaleEnabled && tailscaleUrl) {
|
||||
opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: ensureV1(tailscaleUrl) });
|
||||
}
|
||||
if (cloudEnabled && cloudUrl) {
|
||||
opts.push({ value: "cloud", label: `Cloud - ${cloudUrl}`, url: ensureV1(cloudUrl) });
|
||||
}
|
||||
opts.push({ value: "custom", label: "Custom URL (VPS / public host)", url: "" });
|
||||
return opts;
|
||||
}, [tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl]);
|
||||
const [selectedPlugins, setSelectedPlugins] = useState([]);
|
||||
const [pluginsExpanded, setPluginsExpanded] = useState(false);
|
||||
const [comboModalOpen, setComboModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
@@ -70,11 +54,7 @@ export default function CoworkToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
if (isExpanded && !status) checkStatus();
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -83,28 +63,12 @@ export default function CoworkToolCard({
|
||||
}
|
||||
if (status?.cowork?.baseUrl && !customBaseUrl) {
|
||||
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
|
||||
setEndpointMode("custom");
|
||||
}
|
||||
if (Array.isArray(status?.cowork?.selectedPlugins)) {
|
||||
setSelectedPlugins(status.cowork.selectedPlugins);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Auto-pick first available preset when expand if user has not set anything
|
||||
useEffect(() => {
|
||||
if (!customBaseUrl && endpointOptions[0]?.url) {
|
||||
setEndpointMode(endpointOptions[0].value);
|
||||
setCustomBaseUrl(stripV1(endpointOptions[0].url));
|
||||
}
|
||||
}, [endpointOptions]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
@@ -124,31 +88,16 @@ export default function CoworkToolCard({
|
||||
if (!status?.installed) return null;
|
||||
const url = status?.cowork?.baseUrl;
|
||||
if (!url) return "not_configured";
|
||||
if (isLocalhostUrl(url)) return "invalid";
|
||||
return status.has9Router ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
||||
|
||||
const handleEndpointModeChange = (value) => {
|
||||
setEndpointMode(value);
|
||||
const opt = endpointOptions.find((o) => o.value === value);
|
||||
if (opt?.url) {
|
||||
setCustomBaseUrl(stripV1(opt.url));
|
||||
} else {
|
||||
setCustomBaseUrl("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setMessage(null);
|
||||
const effectiveUrl = getEffectiveBaseUrl();
|
||||
|
||||
if (isLocalhostUrl(effectiveUrl)) {
|
||||
setMessage({ type: "error", text: "Localhost is not allowed. Enable Tunnel/Tailscale or use VPS." });
|
||||
return;
|
||||
}
|
||||
if (selectedModels.length === 0) {
|
||||
setMessage({ type: "error", text: "Please select at least one model" });
|
||||
return;
|
||||
@@ -167,6 +116,7 @@ export default function CoworkToolCard({
|
||||
baseUrl: effectiveUrl,
|
||||
apiKey: keyToUse,
|
||||
models: selectedModels,
|
||||
plugins: selectedPlugins,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -183,6 +133,29 @@ export default function CoworkToolCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCombo = async ({ name, models }) => {
|
||||
try {
|
||||
const res = await fetch("/api/combos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, models }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
setMessage({ type: "error", text: err.error || "Failed to create combo" });
|
||||
return;
|
||||
}
|
||||
// Add combo name into selected models for Cowork
|
||||
if (!selectedModels.includes(name)) {
|
||||
setSelectedModels([...selectedModels, name]);
|
||||
}
|
||||
setComboModalOpen(false);
|
||||
setMessage({ type: "success", text: `Combo "${name}" created and added.` });
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
@@ -234,7 +207,6 @@ export default function CoworkToolCard({
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
|
||||
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
|
||||
{configStatus === "invalid" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full">Localhost (invalid)</span>}
|
||||
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
@@ -245,11 +217,6 @@ export default function CoworkToolCard({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-xs text-blue-700 dark:text-blue-300">
|
||||
<span className="material-symbols-outlined text-[16px] mt-0.5">info</span>
|
||||
<span>Claude Cowork runs in a sandboxed VM and <b>cannot reach localhost</b>. Use Tunnel, Tailscale, or VPS public URL.</span>
|
||||
</div>
|
||||
|
||||
{checking && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
@@ -278,6 +245,21 @@ export default function CoworkToolCard({
|
||||
{!checking && status?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={getEffectiveBaseUrl()}
|
||||
onChange={(url) => setCustomBaseUrl(stripV1(url))}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
cloudEnabled={cloudEnabled}
|
||||
cloudUrl={cloudUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status?.cowork?.baseUrl && (
|
||||
<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>
|
||||
@@ -288,32 +270,6 @@ export default function CoworkToolCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">Endpoint Mode</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<select
|
||||
value={endpointMode}
|
||||
onChange={(e) => handleEndpointModeChange(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{endpointOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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">Base URL</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<input
|
||||
type="text"
|
||||
value={getEffectiveBaseUrl()}
|
||||
onChange={(e) => setCustomBaseUrl(stripV1(e.target.value))}
|
||||
placeholder="https://your-host.com/v1"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">API Key</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
@@ -347,9 +303,55 @@ export default function CoworkToolCard({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
|
||||
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Add Combo (claude-)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{false && (<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Connectors</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">{selectedPlugins.length} of {(status?.availablePlugins || []).length} selected</span>
|
||||
<button onClick={() => setPluginsExpanded(!pluginsExpanded)} className="text-xs text-primary hover:underline">
|
||||
{pluginsExpanded ? "Hide" : "Show"} all
|
||||
</button>
|
||||
</div>
|
||||
{pluginsExpanded && (
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto px-2 py-2 bg-surface rounded border border-border">
|
||||
{(status?.availablePlugins || []).map((p) => {
|
||||
const checked = selectedPlugins.includes(p.name);
|
||||
return (
|
||||
<label key={p.name} className="flex items-start gap-2 text-xs cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 px-1 py-0.5 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => setSelectedPlugins((prev) => checked ? prev.filter((n) => n !== p.name) : [...prev, p.name])}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
{p.description && <div className="text-text-muted text-[10px] truncate">{p.description}</div>}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!pluginsExpanded && selectedPlugins.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{selectedPlugins.map((name) => (
|
||||
<span key={name} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
|
||||
{name}
|
||||
<button onClick={() => setSelectedPlugins((prev) => prev.filter((x) => x !== name))} className="ml-0.5 hover:text-red-500">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
@@ -370,32 +372,28 @@ export default function CoworkToolCard({
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
}
|
||||
setModalOpen(false);
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Add Model for Claude Cowork"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Claude Cowork - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
|
||||
<ComboFormModal
|
||||
isOpen={comboModalOpen}
|
||||
combo={null}
|
||||
onClose={() => setComboModalOpen(false)}
|
||||
onSave={handleCreateCombo}
|
||||
activeProviders={activeProviders}
|
||||
forcePrefix="claude-"
|
||||
title="Create Cowork Combo"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -42,11 +43,7 @@ export default function DroidToolCard({
|
||||
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
|
||||
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"));
|
||||
if (!currentConfig) return "not_configured";
|
||||
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
|
||||
const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL);
|
||||
const tunnelMatch = baseUrl && currentConfig.baseUrl?.startsWith(baseUrl);
|
||||
if (localMatch || cloudMatch || tunnelMatch) return "configured";
|
||||
return "other";
|
||||
return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
@@ -291,20 +288,9 @@ export default function DroidToolCard({
|
||||
{!checkingDroid && droidStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Current Base URL */}
|
||||
{droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{/* Endpoint (selector) */}
|
||||
<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">Base URL</span>
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
@@ -317,6 +303,17 @@ export default function DroidToolCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current configured */}
|
||||
{droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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">API Key</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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/hermes-settings";
|
||||
|
||||
@@ -39,9 +40,7 @@ export default function HermesToolCard({
|
||||
if (!hermesStatus?.installed) return null;
|
||||
const cfg = hermesStatus.settings?.model;
|
||||
if (!cfg?.base_url) return "not_configured";
|
||||
const localMatch = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(cfg.base_url);
|
||||
const tunnelMatch = baseUrl && cfg.base_url.startsWith(baseUrl);
|
||||
if (localMatch || tunnelMatch) return "configured";
|
||||
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
@@ -233,18 +232,8 @@ export default function HermesToolCard({
|
||||
{!checking && hermesStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{hermesStatus?.settings?.model?.base_url && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{hermesStatus.settings.model.base_url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">Base URL</span>
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||
@@ -257,6 +246,16 @@ export default function HermesToolCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hermesStatus?.settings?.model?.base_url && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{hermesStatus.settings.model.base_url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">API Key</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function OpenClawToolCard({
|
||||
tool,
|
||||
@@ -39,10 +40,7 @@ export default function OpenClawToolCard({
|
||||
if (!openclawStatus?.installed) return null;
|
||||
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
|
||||
if (!currentProvider) return "not_configured";
|
||||
const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1") || currentProvider.baseUrl?.includes("0.0.0.0");
|
||||
const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl);
|
||||
if (localMatch || tunnelMatch) return "configured";
|
||||
return "other";
|
||||
return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
@@ -282,20 +280,9 @@ export default function OpenClawToolCard({
|
||||
{!checkingOpenclaw && openclawStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Current Base URL */}
|
||||
{openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{openclawStatus.settings.models.providers["9router"].baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{/* Endpoint (selector) */}
|
||||
<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">Base URL</span>
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
@@ -308,6 +295,17 @@ export default function OpenClawToolCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current configured */}
|
||||
{openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{openclawStatus.settings.models.providers["9router"].baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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">API Key</span>
|
||||
|
||||
@@ -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 { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
const [status, setStatus] = useState(initialStatus || null);
|
||||
@@ -69,9 +70,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
const getConfigStatus = () => {
|
||||
if (!status?.installed) return null;
|
||||
if (!status.config) return "not_configured";
|
||||
if (!status.has9Router) return "not_configured";
|
||||
const url = status.config?.provider?.["9router"]?.options?.baseURL || "";
|
||||
const isLocal = url.includes("localhost") || url.includes("127.0.0.1");
|
||||
return status.has9Router && (isLocal || url.includes(baseUrl)) ? "configured" : status.has9Router ? "other" : "not_configured";
|
||||
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
@@ -258,19 +259,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Current base URL */}
|
||||
{status?.config?.provider?.["9router"]?.options?.baseURL && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{status.config.provider["9router"].options.baseURL}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{/* Endpoint (selector) */}
|
||||
<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">Base URL</span>
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
@@ -283,6 +274,17 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current configured */}
|
||||
{status?.config?.provider?.["9router"]?.options?.baseURL && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{status.config.provider["9router"].options.baseURL}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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">API Key</span>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Match a configured CLI base URL against all known endpoints (local/tunnel/tailscale/cloud)
|
||||
const stripTrailingSlash = (s) => (s || "").replace(/\/+$/, "");
|
||||
|
||||
export function matchKnownEndpoint(currentUrl, opts = {}) {
|
||||
if (!currentUrl) return false;
|
||||
const url = stripTrailingSlash(currentUrl);
|
||||
const { tunnelPublicUrl, tailscaleUrl, cloudUrl } = opts;
|
||||
if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(url)) return true;
|
||||
if (tunnelPublicUrl && url.startsWith(stripTrailingSlash(tunnelPublicUrl))) return true;
|
||||
if (tailscaleUrl && url.startsWith(stripTrailingSlash(tailscaleUrl))) return true;
|
||||
if (cloudUrl && url.startsWith(stripTrailingSlash(cloudUrl))) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user