Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useEffect, useRef, useState, useCallback } from "react"; | |
| import { Card, Button, ModelSelectModal } from "@/shared/components"; | |
| import Image from "next/image"; | |
| import { useTranslations } from "next-intl"; | |
| import { copyToClipboard } from "@/shared/utils/clipboard"; | |
| import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig"; | |
| import { useTheme } from "@/shared/hooks/useTheme"; | |
| import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; | |
| import ProviderIcon from "@/shared/components/ProviderIcon"; | |
| export default function DefaultToolCard({ | |
| toolId, | |
| tool, | |
| isExpanded = false, | |
| onToggle = () => {}, | |
| baseUrl, | |
| apiKeys, | |
| activeProviders = [], | |
| cloudEnabled = false, | |
| batchStatus, | |
| }) { | |
| const t = useTranslations("cliTools"); | |
| const translateOrFallback = useCallback( | |
| (key, fallback, values = undefined) => { | |
| try { | |
| return t(key, values); | |
| } catch { | |
| return fallback; | |
| } | |
| }, | |
| [t] | |
| ); | |
| const [copiedField, setCopiedField] = useState(null); | |
| const [showModelModal, setShowModelModal] = useState(false); | |
| const [modelValue, setModelValue] = useState(""); | |
| const [modelValues, setModelValues] = useState<string[]>([]); | |
| const [runtimeStatus, setRuntimeStatus] = useState(null); | |
| const [message, setMessage] = useState(null); | |
| const [saving, setSaving] = useState(false); | |
| const runtimeFetchStartedRef = useRef(false); | |
| const { isDark } = useTheme(); | |
| // (#523) Initialize state with key *id* instead of masked key string | |
| const [selectedApiKeyId, setSelectedApiKeyId] = useState(() => | |
| apiKeys?.length > 0 ? apiKeys[0].id : "" | |
| ); | |
| const isMultiModelTool = tool.modelSelectionMode === "multiple"; | |
| const usesOpenCodePreview = tool.previewConfigMode === "opencode"; | |
| const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId); | |
| const resolveApiKeyValue = useCallback( | |
| () => selectedKeyObj?.rawKey || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder")), | |
| [cloudEnabled, selectedKeyObj?.rawKey, t] | |
| ); | |
| const getSelectedModelEntries = useCallback(() => { | |
| const selectedValues = isMultiModelTool | |
| ? modelValues.length > 0 | |
| ? modelValues | |
| : modelValue | |
| ? [modelValue] | |
| : [] | |
| : modelValue | |
| ? [modelValue] | |
| : []; | |
| const availableModels = Array.isArray(activeProviders) | |
| ? activeProviders.flatMap((provider) => provider?.models || []) | |
| : []; | |
| const modelMap = new Map( | |
| availableModels.filter((model) => model?.value).map((model) => [model.value, model]) | |
| ); | |
| return selectedValues.map((value) => { | |
| const matched = modelMap.get(value); | |
| return { | |
| value, | |
| label: matched?.name || matched?.label || value, | |
| }; | |
| }); | |
| }, [activeProviders, isMultiModelTool, modelValue, modelValues]); | |
| const getSelectedModelLabels = useCallback( | |
| () => getSelectedModelEntries().map((entry) => entry.label), | |
| [getSelectedModelEntries] | |
| ); | |
| const getSelectedModelLabelMap = useCallback( | |
| () => Object.fromEntries(getSelectedModelEntries().map((entry) => [entry.value, entry.label])), | |
| [getSelectedModelEntries] | |
| ); | |
| const normalizedBaseUrl = baseUrl || DEFAULT_DISPLAY_BASE_URL; | |
| const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1") | |
| ? normalizedBaseUrl | |
| : `${normalizedBaseUrl}/v1`; | |
| // Persist and restore model selection per tool via localStorage | |
| useEffect(() => { | |
| const savedModel = localStorage.getItem(`omniroute-cli-model-${toolId}`); | |
| if (savedModel) { | |
| if (isMultiModelTool) { | |
| try { | |
| const parsed = JSON.parse(savedModel); | |
| if (Array.isArray(parsed)) { | |
| const normalized = parsed.map((value) => String(value || "").trim()).filter(Boolean); | |
| setModelValues(normalized); | |
| setModelValue(normalized[0] || ""); | |
| } else { | |
| setModelValue(savedModel); | |
| setModelValues([savedModel]); | |
| } | |
| } catch { | |
| setModelValue(savedModel); | |
| setModelValues([savedModel]); | |
| } | |
| } else { | |
| setModelValue(savedModel); | |
| } | |
| } | |
| const savedKey = localStorage.getItem(`omniroute-cli-key-${toolId}`); | |
| // (#523) localStorage may contain a masked key string from before the fix — | |
| // match by prefix/suffix against known keys to find the id. | |
| if (savedKey && apiKeys?.length > 0) { | |
| const prefix = savedKey.slice(0, 8); | |
| const suffix = savedKey.slice(-4); | |
| const matchedKey = apiKeys.find( | |
| (k) => | |
| (k.rawKey && k.rawKey.startsWith(prefix) && k.rawKey.endsWith(suffix)) || | |
| (k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)) | |
| ); | |
| if (matchedKey) setSelectedApiKeyId(matchedKey.id); | |
| } | |
| }, [toolId, apiKeys, isMultiModelTool]); | |
| const handleModelChange = useCallback( | |
| (value) => { | |
| setModelValue(value); | |
| if (value) { | |
| localStorage.setItem(`omniroute-cli-model-${toolId}`, value); | |
| } else { | |
| localStorage.removeItem(`omniroute-cli-model-${toolId}`); | |
| } | |
| }, | |
| [toolId] | |
| ); | |
| const handleModelValuesChange = useCallback( | |
| (values) => { | |
| const normalized = Array.isArray(values) | |
| ? [...new Set(values.map((value) => String(value || "").trim()).filter(Boolean))] | |
| : []; | |
| setModelValues(normalized); | |
| setModelValue(normalized[0] || ""); | |
| if (normalized.length > 0) { | |
| localStorage.setItem(`omniroute-cli-model-${toolId}`, JSON.stringify(normalized)); | |
| } else { | |
| localStorage.removeItem(`omniroute-cli-model-${toolId}`); | |
| } | |
| }, | |
| [toolId] | |
| ); | |
| const handleApiKeyChange = useCallback( | |
| (value) => { | |
| setSelectedApiKeyId(value); | |
| if (value) { | |
| // (#523) Store the key id in localStorage for persistence | |
| localStorage.setItem(`omniroute-cli-key-${toolId}`, value); | |
| } | |
| }, | |
| [toolId] | |
| ); | |
| useEffect(() => { | |
| if (!isExpanded || runtimeStatus || runtimeFetchStartedRef.current) return; | |
| runtimeFetchStartedRef.current = true; | |
| fetch(`/api/cli-tools/runtime/${toolId}`) | |
| .then((res) => res.json()) | |
| .then((data) => setRuntimeStatus(data)) | |
| .catch((error) => setRuntimeStatus({ error: error?.message || t("runtimeCheckFailed") })); | |
| }, [isExpanded, runtimeStatus, t, toolId]); | |
| const replaceVars = useCallback( | |
| (text) => { | |
| const keyToUse = resolveApiKeyValue(); | |
| return text | |
| .replace(/\{\{baseUrl\}\}/g, baseUrlWithV1) | |
| .replace(/\{\{apiKey\}\}/g, keyToUse) | |
| .replace(/\{\{model\}\}/g, getSelectedModelLabels()[0] || t("modelPlaceholder")); | |
| }, | |
| [baseUrl, getSelectedModelLabels, resolveApiKeyValue, t] | |
| ); | |
| const handleCopy = async (text, field) => { | |
| await copyToClipboard(replaceVars(text)); | |
| setCopiedField(field); | |
| setTimeout(() => setCopiedField(null), 2000); | |
| }; | |
| const getSelectedModels = useCallback(() => { | |
| if (!isMultiModelTool) return modelValue ? [modelValue] : []; | |
| return modelValues.length > 0 ? modelValues : modelValue ? [modelValue] : []; | |
| }, [isMultiModelTool, modelValue, modelValues]); | |
| const getRenderedCodeBlock = useCallback(() => { | |
| if (!tool.codeBlock?.code) return ""; | |
| if (!usesOpenCodePreview) return replaceVars(tool.codeBlock.code); | |
| const keyToUse = resolveApiKeyValue(); | |
| return JSON.stringify( | |
| buildOpenCodeConfigDocument({ | |
| baseUrl: baseUrlWithV1, | |
| apiKey: keyToUse, | |
| models: getSelectedModels(), | |
| model: getSelectedModels()[0], | |
| modelLabels: getSelectedModelLabelMap(), | |
| }), | |
| null, | |
| 2 | |
| ); | |
| }, [ | |
| baseUrl, | |
| getSelectedModels, | |
| getSelectedModelLabelMap, | |
| replaceVars, | |
| resolveApiKeyValue, | |
| tool.codeBlock?.code, | |
| usesOpenCodePreview, | |
| ]); | |
| const handleSelectModel = (model) => { | |
| if (!isMultiModelTool) { | |
| handleModelChange(model.value); | |
| return; | |
| } | |
| if (!model) { | |
| handleModelValuesChange([]); | |
| return; | |
| } | |
| if (modelValues.includes(model.value)) { | |
| handleModelValuesChange(modelValues.filter((value) => value !== model.value)); | |
| return; | |
| } | |
| handleModelValuesChange([...modelValues, model.value]); | |
| }; | |
| const hasActiveProviders = activeProviders.length > 0; | |
| const checkingRuntime = isExpanded && runtimeStatus === null; | |
| // Save config to file (for tools that support it, like Continue) | |
| const handleSaveConfig = async () => { | |
| setSaving(true); | |
| setMessage(null); | |
| try { | |
| // (#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/guide-settings/${toolId}`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| baseUrl: baseUrlWithV1, | |
| apiKey: !cloudEnabled ? "sk_omniroute" : null, | |
| keyId: selectedKeyId, | |
| model: modelValue, | |
| models: isMultiModelTool ? getSelectedModels() : undefined, | |
| modelLabels: getSelectedModelLabelMap(), | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| setMessage({ type: "success", text: data.message || t("configurationSaved") }); | |
| } else { | |
| setMessage({ | |
| type: "error", | |
| text: | |
| (typeof data.error === "string" ? data.error : data.error?.message) || | |
| t("failedToSave"), | |
| }); | |
| } | |
| } catch (error) { | |
| setMessage({ type: "error", text: error.message }); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| // Check if this tool supports direct config file write | |
| const supportsDirectSave = ["continue", "opencode", "qwen"].includes(toolId); | |
| const renderApiKeySelector = () => { | |
| return ( | |
| <div className="mt-2 flex items-center gap-2"> | |
| {apiKeys && apiKeys.length > 0 ? ( | |
| <> | |
| <select | |
| value={selectedApiKeyId} | |
| onChange={(e) => handleApiKeyChange(e.target.value)} | |
| 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" | |
| > | |
| {apiKeys.map((key) => ( | |
| <option key={key.id} value={key.id}> | |
| {key.key} | |
| </option> | |
| ))} | |
| </select> | |
| <button | |
| onClick={() => { | |
| handleCopy(resolveApiKeyValue(), "apiKey"); | |
| }} | |
| className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors" | |
| > | |
| <span className="material-symbols-outlined text-lg"> | |
| {copiedField === "apiKey" ? "check" : "content_copy"} | |
| </span> | |
| </button> | |
| </> | |
| ) : ( | |
| <span className="text-sm text-text-muted"> | |
| {cloudEnabled ? t("noApiKeysCreateOne") : "sk_omniroute"} | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const renderModelSelector = () => { | |
| const displayValue = isMultiModelTool | |
| ? getSelectedModelLabels().join(", ") | |
| : getSelectedModelLabels()[0] || ""; | |
| return ( | |
| <div className="mt-2 flex items-center gap-2"> | |
| <input | |
| type="text" | |
| value={displayValue} | |
| onChange={(e) => | |
| isMultiModelTool | |
| ? handleModelValuesChange( | |
| e.target.value | |
| .split(",") | |
| .map((value) => value.trim()) | |
| .filter(Boolean) | |
| ) | |
| : handleModelChange(e.target.value) | |
| } | |
| placeholder={t("modelPlaceholder")} | |
| 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 | |
| onClick={() => setShowModelModal(true)} | |
| disabled={!hasActiveProviders} | |
| className={`shrink-0 px-3 py-2 rounded-lg border text-sm transition-colors ${ | |
| hasActiveProviders | |
| ? "bg-bg-secondary border-border text-text-main hover:border-primary cursor-pointer" | |
| : "opacity-50 cursor-not-allowed border-border" | |
| }`} | |
| > | |
| {t("selectModel")} | |
| </button> | |
| {displayValue && ( | |
| <> | |
| <button | |
| onClick={() => handleCopy(displayValue, "model")} | |
| className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors" | |
| > | |
| <span className="material-symbols-outlined text-lg"> | |
| {copiedField === "model" ? "check" : "content_copy"} | |
| </span> | |
| </button> | |
| <button | |
| onClick={() => | |
| isMultiModelTool ? handleModelValuesChange([]) : handleModelChange("") | |
| } | |
| className="p-2 text-text-muted hover:text-red-500 rounded transition-colors" | |
| title={t("clear")} | |
| > | |
| <span className="material-symbols-outlined text-lg">close</span> | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const renderNotes = () => { | |
| if (!tool.notes || tool.notes.length === 0) return null; | |
| return ( | |
| <div className="flex flex-col gap-2 mb-4"> | |
| {tool.notes.map((note, index) => { | |
| if (note.type === "cloudCheck" && cloudEnabled) return null; | |
| const isWarning = note.type === "warning"; | |
| const isError = note.type === "cloudCheck" && !cloudEnabled; | |
| let bgClass = "bg-blue-500/10 border-blue-500/30"; | |
| let textClass = "text-blue-600 dark:text-blue-400"; | |
| let iconClass = "text-blue-500"; | |
| let icon = "info"; | |
| if (isWarning) { | |
| bgClass = "bg-yellow-500/10 border-yellow-500/30"; | |
| textClass = "text-yellow-600 dark:text-yellow-400"; | |
| iconClass = "text-yellow-500"; | |
| icon = "warning"; | |
| } else if (isError) { | |
| bgClass = "bg-red-500/10 border-red-500/30"; | |
| textClass = "text-red-600 dark:text-red-400"; | |
| iconClass = "text-red-500"; | |
| icon = "error"; | |
| } | |
| return ( | |
| <div key={index} className={`flex items-start gap-3 p-3 rounded-lg border ${bgClass}`}> | |
| <span className={`material-symbols-outlined text-lg ${iconClass}`}>{icon}</span> | |
| <p className={`text-sm ${textClass}`}> | |
| {translateOrFallback(`guides.${toolId}.notes.${index}`, note.text)} | |
| </p> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| }; | |
| const canShowGuide = () => { | |
| if (tool.requiresCloud && !cloudEnabled) return false; | |
| return true; | |
| }; | |
| const renderGuideSteps = () => { | |
| if (!tool.guideSteps) return <p className="text-text-muted text-sm">{t("comingSoon")}</p>; | |
| return ( | |
| <div className="flex flex-col gap-4"> | |
| {checkingRuntime && ( | |
| <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("checkingRuntime")}</span> | |
| </div> | |
| )} | |
| {!checkingRuntime && runtimeStatus && !runtimeStatus.error && ( | |
| <div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg"> | |
| <span className="material-symbols-outlined text-blue-500 text-lg"> | |
| {runtimeStatus.reason === "not_required" | |
| ? "info" | |
| : runtimeStatus.installed && runtimeStatus.runnable | |
| ? "check_circle" | |
| : "warning"} | |
| </span> | |
| <div className="flex flex-col gap-1"> | |
| <p className="text-sm text-blue-700 dark:text-blue-300"> | |
| {runtimeStatus.reason === "not_required" | |
| ? t("guideOnlyIntegration") | |
| : runtimeStatus.installed && runtimeStatus.runnable | |
| ? t("cliRuntimeDetected") | |
| : runtimeStatus.installed | |
| ? t("cliFoundNotRunnable", { | |
| reason: runtimeStatus.reason ? `: ${runtimeStatus.reason}` : "", | |
| }) | |
| : t("cliRuntimeNotDetected")} | |
| </p> | |
| {runtimeStatus.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"> | |
| {runtimeStatus.commandPath} | |
| </code> | |
| </p> | |
| )} | |
| {runtimeStatus.configPath && ( | |
| <p className="text-xs text-text-muted"> | |
| {t("configPath")}:{" "} | |
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10"> | |
| {runtimeStatus.configPath} | |
| </code> | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {!checkingRuntime && runtimeStatus?.error && ( | |
| <div className="flex items-start gap-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> | |
| <span className="material-symbols-outlined text-red-500 text-lg">error</span> | |
| <p className="text-sm text-red-600 dark:text-red-400"> | |
| {t("failedCheckRuntimeStatus")} | |
| </p> | |
| </div> | |
| )} | |
| {renderNotes()} | |
| {canShowGuide() && | |
| tool.guideSteps.map((item) => ( | |
| <div key={item.step} className="flex items-start gap-4"> | |
| <div | |
| className="size-8 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold text-white" | |
| style={{ backgroundColor: tool.color }} | |
| > | |
| {item.step} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <p className="font-medium text-text"> | |
| {translateOrFallback(`guides.${toolId}.steps.${item.step}.title`, item.title)} | |
| </p> | |
| {item.desc && ( | |
| <p className="text-sm text-text-muted mt-0.5"> | |
| {translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc, { | |
| baseUrl: baseUrlWithV1, | |
| })} | |
| </p> | |
| )} | |
| {item.type === "apiKeySelector" && renderApiKeySelector()} | |
| {item.type === "modelSelector" && renderModelSelector()} | |
| {item.value && ( | |
| <div className="mt-2 flex items-center gap-2"> | |
| <code className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm font-mono border border-border truncate"> | |
| {replaceVars(item.value)} | |
| </code> | |
| {item.copyable && ( | |
| <button | |
| onClick={() => handleCopy(item.value, `${item.step}-${item.title}`)} | |
| className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors" | |
| > | |
| <span className="material-symbols-outlined text-lg"> | |
| {copiedField === `${item.step}-${item.title}` ? "check" : "content_copy"} | |
| </span> | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| {canShowGuide() && tool.codeBlock && ( | |
| <div className="mt-2"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-xs text-text-muted uppercase tracking-wide"> | |
| {tool.codeBlock.language} | |
| </span> | |
| <button | |
| onClick={() => handleCopy(getRenderedCodeBlock(), "codeblock")} | |
| className="flex items-center gap-1 px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors" | |
| > | |
| <span className="material-symbols-outlined text-sm"> | |
| {copiedField === "codeblock" ? "check" : "content_copy"} | |
| </span> | |
| {copiedField === "codeblock" ? t("copied") : t("copy")} | |
| </button> | |
| </div> | |
| <pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto"> | |
| <code className="text-sm font-mono whitespace-pre">{getRenderedCodeBlock()}</code> | |
| </pre> | |
| </div> | |
| )} | |
| {/* Save / Action buttons */} | |
| {canShowGuide() && ( | |
| <div className="mt-2"> | |
| {message && ( | |
| <div | |
| className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs mb-2 ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`} | |
| > | |
| <span className="material-symbols-outlined text-[14px]"> | |
| {message.type === "success" ? "check_circle" : "error"} | |
| </span> | |
| <span>{message.text}</span> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-2"> | |
| {supportsDirectSave && ( | |
| <Button | |
| variant="primary" | |
| size="sm" | |
| onClick={handleSaveConfig} | |
| disabled={isMultiModelTool ? getSelectedModels().length === 0 : !modelValue} | |
| loading={saving} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1">save</span> | |
| {t("saveConfig")} | |
| </Button> | |
| )} | |
| {tool.codeBlock && ( | |
| <Button | |
| variant={supportsDirectSave ? "outline" : "primary"} | |
| size="sm" | |
| onClick={() => handleCopy(getRenderedCodeBlock(), "codeblock")} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1"> | |
| {copiedField === "codeblock" ? "check" : "content_copy"} | |
| </span> | |
| {copiedField === "codeblock" ? t("copied") : t("copyConfig")} | |
| </Button> | |
| )} | |
| {(isMultiModelTool ? getSelectedModels().length > 0 : !!modelValue) && ( | |
| <span className="text-xs text-text-muted flex items-center gap-1"> | |
| <span className="material-symbols-outlined text-[14px] text-green-500"> | |
| check_circle | |
| </span> | |
| {t("selectionSaved")} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const renderIcon = () => { | |
| if (tool.image) { | |
| return ( | |
| <Image | |
| src={tool.image} | |
| alt={tool.name} | |
| width={32} | |
| height={32} | |
| className="size-8 object-contain rounded-lg" | |
| sizes="32px" | |
| onError={(e) => { | |
| (e.currentTarget as HTMLElement).style.display = "none"; | |
| }} | |
| /> | |
| ); | |
| } | |
| if (tool.imageLight || tool.imageDark) { | |
| const themedSrc = isDark | |
| ? tool.imageDark || tool.imageLight | |
| : tool.imageLight || tool.imageDark; | |
| return ( | |
| <Image | |
| src={themedSrc} | |
| alt={tool.name} | |
| width={32} | |
| height={32} | |
| className="size-8 object-contain rounded-lg" | |
| sizes="32px" | |
| onError={(e) => { | |
| (e.currentTarget as HTMLElement).style.display = "none"; | |
| }} | |
| /> | |
| ); | |
| } | |
| if (tool.icon) { | |
| return ( | |
| <span className="material-symbols-outlined text-xl" style={{ color: tool.color }}> | |
| {tool.icon} | |
| </span> | |
| ); | |
| } | |
| return <ProviderIcon providerId={toolId} size={32} type="color" />; | |
| }; | |
| 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"> | |
| {renderIcon()} | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-medium text-sm">{tool.name}</h3> | |
| {(() => { | |
| // Use runtime status if available (after expanding), otherwise use batch status | |
| const rs = runtimeStatus; | |
| const bs = batchStatus; | |
| const isGuide = rs?.reason === "not_required" || tool.configType === "guide"; | |
| const isDetected = rs ? rs.installed && rs.runnable : bs?.installed && bs?.runnable; | |
| const isInstalled = rs ? rs.installed : bs?.installed; | |
| if (isGuide) { | |
| return ( | |
| <span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400"> | |
| <span className="size-1.5 rounded-full bg-blue-500" /> | |
| {t("guide")} | |
| </span> | |
| ); | |
| } | |
| if (isDetected) { | |
| return ( | |
| <span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400"> | |
| <span className="size-1.5 rounded-full bg-green-500" /> | |
| {t("detected")} | |
| </span> | |
| ); | |
| } | |
| if (isInstalled === false && (rs || bs)) { | |
| return ( | |
| <span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"> | |
| <span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" /> | |
| {t("notInstalled")} | |
| </span> | |
| ); | |
| } | |
| if (isInstalled && !isDetected && (rs || bs)) { | |
| return ( | |
| <span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"> | |
| <span className="size-1.5 rounded-full bg-yellow-500" /> | |
| {t("notReady")} | |
| </span> | |
| ); | |
| } | |
| return null; | |
| })()} | |
| </div> | |
| <p className="text-xs text-text-muted truncate"> | |
| {translateOrFallback(`toolDescriptions.${toolId}`, tool.description)} | |
| </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">{renderGuideSteps()}</div>} | |
| <ModelSelectModal | |
| isOpen={showModelModal} | |
| onClose={() => setShowModelModal(false)} | |
| onSelect={handleSelectModel} | |
| selectedModel={modelValue} | |
| selectedModels={isMultiModelTool ? getSelectedModels() : []} | |
| activeProviders={activeProviders} | |
| title={t("selectModel")} | |
| multiSelect={isMultiModelTool} | |
| showCombos={!tool.hideComboModels} | |
| /> | |
| </Card> | |
| ); | |
| } | |