Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useState, useEffect, useRef } from "react"; | |
| import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; | |
| import ProviderIcon from "@/shared/components/ProviderIcon"; | |
| import CliStatusBadge from "./CliStatusBadge"; | |
| import { useTranslations } from "next-intl"; | |
| import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; | |
| const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; | |
| export default function ClineToolCard({ | |
| tool, | |
| isExpanded = false, | |
| onToggle = () => {}, | |
| baseUrl, | |
| hasActiveProviders, | |
| apiKeys, | |
| activeProviders, | |
| cloudEnabled, | |
| batchStatus, | |
| lastConfiguredAt, | |
| }) { | |
| const t = useTranslations("cliTools"); | |
| const [clineStatus, setClineStatus] = useState(null); | |
| const [checkingCline, setCheckingCline] = useState(false); | |
| const [applying, setApplying] = useState(false); | |
| const [restoring, setRestoring] = useState(false); | |
| const [message, setMessage] = useState(null); | |
| const [selectedApiKeyId, setSelectedApiKeyId] = useState(""); | |
| const [selectedModel, setSelectedModel] = useState(""); | |
| const [modalOpen, setModalOpen] = useState(false); | |
| const [modelAliases, setModelAliases] = useState({}); | |
| const [showManualConfigModal, setShowManualConfigModal] = useState(false); | |
| const [customBaseUrl, setCustomBaseUrl] = useState(""); | |
| const hasInitializedModel = useRef(false); | |
| // Backups state | |
| const [backups, setBackups] = useState([]); | |
| const [showBackups, setShowBackups] = useState(false); | |
| const [restoringBackup, setRestoringBackup] = useState(null); | |
| const cliReady = !!(clineStatus?.installed && clineStatus?.runnable); | |
| const getConfigStatus = () => { | |
| if (!cliReady) return null; | |
| if (!clineStatus.hasOmniRoute) return "not_configured"; | |
| const baseUrlVal = clineStatus.settings?.openAiBaseUrl || ""; | |
| const localMatch = baseUrlVal.includes("localhost") || baseUrlVal.includes("127.0.0.1"); | |
| const cloudMatch = cloudEnabled && CLOUD_URL && baseUrlVal.startsWith(CLOUD_URL); | |
| if (localMatch || cloudMatch) return "configured"; | |
| return "other"; | |
| }; | |
| const configStatus = getConfigStatus(); | |
| // Use batch status as fallback when card hasn't been expanded yet | |
| const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null; | |
| // (#523) Store the key *id* (not the masked string) so the backend can | |
| // resolve the real secret from DB before writing to config files. | |
| useEffect(() => { | |
| if (apiKeys?.length > 0 && !selectedApiKeyId) { | |
| setSelectedApiKeyId(apiKeys[0].id); | |
| } | |
| }, [apiKeys, selectedApiKeyId]); | |
| useEffect(() => { | |
| if (isExpanded && !clineStatus) { | |
| checkClineStatus(); | |
| fetchModelAliases(); | |
| fetchBackups(); | |
| } | |
| }, [isExpanded, clineStatus]); | |
| useEffect(() => { | |
| if (clineStatus?.settings && !hasInitializedModel.current) { | |
| const currentModel = clineStatus.settings.openAiModelId; | |
| if (currentModel) { | |
| setSelectedModel(currentModel); | |
| hasInitializedModel.current = true; | |
| } | |
| } | |
| }, [clineStatus]); | |
| const fetchModelAliases = async () => { | |
| try { | |
| const res = await fetch("/api/models/alias"); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setModelAliases(data.aliases || {}); | |
| } | |
| } catch { | |
| /* ignore */ | |
| } | |
| }; | |
| const fetchBackups = async () => { | |
| try { | |
| const res = await fetch("/api/cli-tools/backups?tool=cline"); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setBackups(data.backups || []); | |
| } | |
| } catch { | |
| /* ignore */ | |
| } | |
| }; | |
| const handleRestoreBackup = async (backupId) => { | |
| setRestoringBackup(backupId); | |
| try { | |
| const res = await fetch("/api/cli-tools/backups", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ tool: "cline", backupId }), | |
| }); | |
| if (res.ok) { | |
| setMessage({ type: "success", text: t("backupRestoredReloading") }); | |
| await checkClineStatus(); | |
| await fetchBackups(); | |
| } else { | |
| const data = await res.json(); | |
| setMessage({ | |
| type: "error", | |
| text: | |
| (typeof data.error === "string" ? data.error : data.error?.message) || | |
| t("failedRestoreBackup"), | |
| }); | |
| } | |
| } catch (e) { | |
| setMessage({ type: "error", text: e.message }); | |
| } finally { | |
| setRestoringBackup(null); | |
| } | |
| }; | |
| const checkClineStatus = async () => { | |
| setCheckingCline(true); | |
| try { | |
| const res = await fetch("/api/cli-tools/cline-settings"); | |
| const data = await res.json(); | |
| setClineStatus(data); | |
| } catch (error) { | |
| setClineStatus({ error: error.message }); | |
| } finally { | |
| setCheckingCline(false); | |
| } | |
| }; | |
| const getEffectiveBaseUrl = () => { | |
| if (customBaseUrl) return customBaseUrl; | |
| return baseUrl || DEFAULT_DISPLAY_BASE_URL; | |
| }; | |
| const handleApply = async () => { | |
| setApplying(true); | |
| setMessage(null); | |
| try { | |
| const effectiveBaseUrl = getEffectiveBaseUrl(); | |
| const normalizedBaseUrl = effectiveBaseUrl.endsWith("/v1") | |
| ? effectiveBaseUrl | |
| : `${effectiveBaseUrl}/v1`; | |
| // (#523) Prefer keyId lookup so the backend writes the real key to disk. | |
| const selectedKeyId = selectedApiKeyId?.trim() || null; | |
| const res = await fetch("/api/cli-tools/cline-settings", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| baseUrl: normalizedBaseUrl, | |
| apiKey: !cloudEnabled ? "sk_omniroute" : null, | |
| keyId: selectedKeyId, | |
| model: selectedModel, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| setMessage({ type: "success", text: data.message || t("applied") }); | |
| await checkClineStatus(); | |
| await fetchBackups(); | |
| } else { | |
| setMessage({ | |
| type: "error", | |
| text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failed"), | |
| }); | |
| } | |
| } catch (error) { | |
| setMessage({ type: "error", text: error.message }); | |
| } finally { | |
| setApplying(false); | |
| } | |
| }; | |
| const handleReset = async () => { | |
| setRestoring(true); | |
| setMessage(null); | |
| try { | |
| const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| setMessage({ type: "success", text: data.message || t("resetDone") }); | |
| setSelectedModel(""); | |
| hasInitializedModel.current = false; | |
| await checkClineStatus(); | |
| await fetchBackups(); | |
| } else { | |
| setMessage({ | |
| type: "error", | |
| text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failed"), | |
| }); | |
| } | |
| } catch (error) { | |
| setMessage({ type: "error", text: error.message }); | |
| } finally { | |
| setRestoring(false); | |
| } | |
| }; | |
| const handleSelectModel = (model) => { | |
| setSelectedModel(model.value); | |
| setModalOpen(false); | |
| }; | |
| const handleManualConfig = (config) => { | |
| if (config.model) setSelectedModel(config.model); | |
| // (#523) Match apiKey string to key id if possible | |
| if (config.apiKey && apiKeys?.length > 0) { | |
| const prefix = config.apiKey.slice(0, 8); | |
| const suffix = config.apiKey.slice(-4); | |
| const matchedKey = apiKeys.find( | |
| (k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix) | |
| ); | |
| if (matchedKey) setSelectedApiKeyId(matchedKey.id); | |
| } | |
| if (config.baseUrl) setCustomBaseUrl(config.baseUrl); | |
| setShowManualConfigModal(false); | |
| }; | |
| return ( | |
| <Card padding="sm" className="overflow-hidden"> | |
| <div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}> | |
| <div className="flex items-center gap-3"> | |
| <div className="size-8 rounded-lg flex items-center justify-center shrink-0"> | |
| <ProviderIcon providerId={tool.id || "cline"} size={32} type="color" /> | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-medium text-sm">{tool.name}</h3> | |
| <CliStatusBadge | |
| effectiveConfigStatus={effectiveConfigStatus} | |
| batchStatus={batchStatus} | |
| lastConfiguredAt={lastConfiguredAt} | |
| /> | |
| </div> | |
| <p className="text-xs text-text-muted truncate">{t("toolDescriptions.cline")}</p> | |
| </div> | |
| </div> | |
| <span | |
| className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`} | |
| > | |
| expand_more | |
| </span> | |
| </div> | |
| {isExpanded && ( | |
| <div className="mt-6 pt-6 border-t border-border"> | |
| {checkingCline && ( | |
| <div className="flex items-center gap-2 text-text-muted text-sm"> | |
| <span className="material-symbols-outlined animate-spin text-base"> | |
| progress_activity | |
| </span> | |
| <span>{t("checkingCli", { tool: "Cline" })}</span> | |
| </div> | |
| )} | |
| {clineStatus && !checkingCline && ( | |
| <div className="flex flex-col gap-4"> | |
| {/* Runtime status */} | |
| <div className="flex items-start gap-3 p-3 rounded-lg border bg-bg-secondary/50 border-border"> | |
| <span | |
| className={`material-symbols-outlined text-lg ${cliReady ? "text-green-500" : "text-yellow-500"}`} | |
| > | |
| {cliReady ? "check_circle" : "warning"} | |
| </span> | |
| <div className="flex flex-col gap-1"> | |
| <p className="text-sm font-medium"> | |
| {cliReady | |
| ? t("cliDetectedReady", { tool: "Cline" }) | |
| : clineStatus.installed | |
| ? t("cliNotRunnable", { tool: "Cline" }) | |
| : t("cliNotDetected", { tool: "Cline" })} | |
| </p> | |
| {clineStatus.commandPath && ( | |
| <p className="text-xs text-text-muted"> | |
| {t("binary")}:{" "} | |
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10"> | |
| {clineStatus.commandPath} | |
| </code> | |
| </p> | |
| )} | |
| {clineStatus.globalStatePath && ( | |
| <p className="text-xs text-text-muted"> | |
| {t("configPathShort")}:{" "} | |
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10"> | |
| {clineStatus.globalStatePath} | |
| </code> | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| {cliReady && ( | |
| <> | |
| {/* Current config info */} | |
| {configStatus === "configured" && ( | |
| <div className="flex items-start gap-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg"> | |
| <span className="material-symbols-outlined text-green-500 text-lg"> | |
| check_circle | |
| </span> | |
| <div className="flex flex-col gap-1"> | |
| <p className="text-sm text-green-700 dark:text-green-300"> | |
| {t("omnirouteConfiguredOpenAiCompatible")} | |
| </p> | |
| <p className="text-xs text-text-muted"> | |
| {t("provider")}: <strong>openai</strong> • {t("model")}:{" "} | |
| <strong>{clineStatus.settings?.openAiModelId || "—"}</strong> | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Model selection */} | |
| <div className="flex flex-col gap-2"> | |
| <label className="text-sm text-text-muted">{t("model")}</label> | |
| <div className="flex items-center gap-2"> | |
| <input | |
| type="text" | |
| value={selectedModel} | |
| onChange={(e) => setSelectedModel(e.target.value)} | |
| placeholder={t("providerModelPlaceholder")} | |
| className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" | |
| /> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setModalOpen(true)} | |
| disabled={!hasActiveProviders} | |
| > | |
| {t("select")} | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setShowManualConfigModal(true)} | |
| > | |
| <span className="material-symbols-outlined text-[16px]">edit</span> | |
| </Button> | |
| </div> | |
| </div> | |
| {/* API Key selection */} | |
| <div className="flex flex-col gap-2"> | |
| <label className="text-sm text-text-muted">{t("apiKey")}</label> | |
| {apiKeys && apiKeys.length > 0 ? ( | |
| <select | |
| value={selectedApiKeyId} | |
| onChange={(e) => setSelectedApiKeyId(e.target.value)} | |
| className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" | |
| > | |
| {apiKeys.map((key) => ( | |
| <option key={key.id} value={key.id}> | |
| {key.key} | |
| </option> | |
| ))} | |
| </select> | |
| ) : ( | |
| <p className="text-sm text-text-muted"> | |
| {cloudEnabled ? t("noApiKeysAvailable") : t("usingDefaultOmniroute")} | |
| </p> | |
| )} | |
| </div> | |
| {/* Action buttons */} | |
| <div className="flex items-center gap-2 pt-2"> | |
| <Button | |
| variant="primary" | |
| size="sm" | |
| onClick={handleApply} | |
| disabled={!selectedModel} | |
| loading={applying} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1">save</span> | |
| {configStatus === "configured" ? t("updateConfig") : t("applyConfig")} | |
| </Button> | |
| {configStatus === "configured" && ( | |
| <Button variant="outline" size="sm" onClick={handleReset} loading={restoring}> | |
| <span className="material-symbols-outlined text-[14px] mr-1"> | |
| restart_alt | |
| </span> | |
| {t("reset")} | |
| </Button> | |
| )} | |
| </div> | |
| {/* Message */} | |
| {message && ( | |
| <div | |
| className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`} | |
| > | |
| <span className="material-symbols-outlined text-[16px]"> | |
| {message.type === "success" ? "check_circle" : "error"} | |
| </span> | |
| <span>{message.text}</span> | |
| </div> | |
| )} | |
| {/* Backups section */} | |
| <div className="border-t border-border pt-3 mt-1"> | |
| <button | |
| onClick={() => setShowBackups(!showBackups)} | |
| className="flex items-center gap-2 text-sm text-text-muted hover:text-text transition-colors" | |
| > | |
| <span | |
| className={`material-symbols-outlined text-[16px] transition-transform ${showBackups ? "rotate-90" : ""}`} | |
| > | |
| chevron_right | |
| </span> | |
| <span className="material-symbols-outlined text-[16px]">backup</span> | |
| {t("backups")} {backups.length > 0 && `(${backups.length})`} | |
| </button> | |
| {showBackups && backups.length > 0 && ( | |
| <div className="mt-2 flex flex-col gap-1.5 pl-6"> | |
| {backups.map((b) => ( | |
| <div | |
| key={b.id} | |
| className="flex items-center justify-between gap-2 p-2 rounded bg-bg-secondary/50 text-xs" | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">{b.originalFile}</span> | |
| <span className="text-text-muted"> | |
| {new Date(b.createdAt).toLocaleString()} | |
| </span> | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleRestoreBackup(b.id)} | |
| loading={restoringBackup === b.id} | |
| > | |
| {t("restore")} | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {showBackups && backups.length === 0 && ( | |
| <p className="mt-2 pl-6 text-xs text-text-muted">{t("noBackupsAvailable")}</p> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <ModelSelectModal | |
| isOpen={modalOpen} | |
| onClose={() => setModalOpen(false)} | |
| onSelect={handleSelectModel} | |
| selectedModel={selectedModel} | |
| activeProviders={activeProviders} | |
| title={t("selectModelForTool", { tool: "Cline" })} | |
| /> | |
| {showManualConfigModal && ( | |
| <ManualConfigModal | |
| isOpen={showManualConfigModal} | |
| onClose={() => setShowManualConfigModal(false)} | |
| title={t("clineManualConfiguration")} | |
| {...({ | |
| onApply: handleManualConfig, | |
| currentConfig: { | |
| model: selectedModel, | |
| apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "", | |
| baseUrl: customBaseUrl || baseUrl, | |
| }, | |
| } as any)} | |
| /> | |
| )} | |
| </Card> | |
| ); | |
| } | |