| "use client"; | |
| import { useMemo, useState, useCallback } from "react"; | |
| import { Card, Button } from "@/shared/components"; | |
| import { useTranslations } from "next-intl"; | |
| import { copyToClipboard } from "@/shared/utils/clipboard"; | |
| import { | |
| buildCustomCliEnvScript, | |
| buildCustomCliJsonConfig, | |
| normalizeOpenAiBaseUrl, | |
| } from "./customCliConfig"; | |
| import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; | |
| interface ModelOption { | |
| value: string; | |
| label: string; | |
| } | |
| interface CustomCliMappingRow { | |
| id: string; | |
| alias: string; | |
| model: string; | |
| } | |
| export default function CustomCliCard({ | |
| tool, | |
| isExpanded = false, | |
| onToggle = () => {}, | |
| baseUrl, | |
| apiKeys, | |
| availableModels = [], | |
| cloudEnabled = false, | |
| hasActiveProviders = false, | |
| }) { | |
| const t = useTranslations("cliTools"); | |
| const translateOrFallback = useCallback( | |
| (key: string, fallback: string, values?: Record<string, unknown>) => { | |
| try { | |
| const translated = t(key, values); | |
| return translated === key || translated === `cliTools.${key}` ? fallback : translated; | |
| } catch { | |
| return fallback; | |
| } | |
| }, | |
| [t] | |
| ); | |
| const [copiedField, setCopiedField] = useState<string | null>(null); | |
| const [cliName, setCliName] = useState("Custom CLI"); | |
| const [defaultModel, setDefaultModel] = useState(""); | |
| const [selectedApiKeyId, setSelectedApiKeyId] = useState(() => | |
| apiKeys?.length > 0 ? apiKeys[0].id : "" | |
| ); | |
| const [aliasMappings, setAliasMappings] = useState<CustomCliMappingRow[]>([]); | |
| const effectiveSelectedApiKeyId = selectedApiKeyId || apiKeys?.[0]?.id || ""; | |
| const effectiveDefaultModel = defaultModel || availableModels[0]?.value || ""; | |
| const selectedKeyObj = apiKeys?.find((key) => key.id === effectiveSelectedApiKeyId); | |
| const keyToUse = | |
| selectedKeyObj?.key || | |
| (!cloudEnabled | |
| ? "sk_omniroute" | |
| : translateOrFallback("yourApiKeyPlaceholder", "sk-your-omniroute-key")); | |
| const baseUrlWithV1 = normalizeOpenAiBaseUrl(baseUrl || DEFAULT_DISPLAY_BASE_URL); | |
| const chatCompletionsEndpoint = `${baseUrlWithV1}/chat/completions`; | |
| const envScript = useMemo( | |
| () => | |
| buildCustomCliEnvScript({ | |
| cliName, | |
| baseUrl: baseUrlWithV1, | |
| apiKey: keyToUse, | |
| defaultModel: effectiveDefaultModel, | |
| aliasMappings, | |
| }), | |
| [aliasMappings, baseUrlWithV1, cliName, effectiveDefaultModel, keyToUse] | |
| ); | |
| const jsonConfig = useMemo( | |
| () => | |
| buildCustomCliJsonConfig({ | |
| cliName, | |
| baseUrl: baseUrlWithV1, | |
| apiKey: keyToUse, | |
| defaultModel: effectiveDefaultModel, | |
| aliasMappings, | |
| }), | |
| [aliasMappings, baseUrlWithV1, cliName, effectiveDefaultModel, keyToUse] | |
| ); | |
| const handleCopy = async (text: string, field: string) => { | |
| await copyToClipboard(text); | |
| setCopiedField(field); | |
| setTimeout(() => setCopiedField(null), 2000); | |
| }; | |
| const handleAddMapping = () => { | |
| setAliasMappings((prev) => [ | |
| ...prev, | |
| { | |
| id: `mapping_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, | |
| alias: "", | |
| model: effectiveDefaultModel, | |
| }, | |
| ]); | |
| }; | |
| const handleUpdateMapping = (id: string, field: "alias" | "model", value: string) => { | |
| setAliasMappings((prev) => | |
| prev.map((mapping) => (mapping.id === id ? { ...mapping, [field]: value } : mapping)) | |
| ); | |
| }; | |
| const handleRemoveMapping = (id: string) => { | |
| setAliasMappings((prev) => prev.filter((mapping) => mapping.id !== id)); | |
| }; | |
| const codeBlockClass = | |
| "rounded-lg border border-border bg-bg-secondary/70 p-4 text-xs font-mono whitespace-pre-wrap break-all"; | |
| 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 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"> | |
| <span className="material-symbols-outlined text-xl">{tool.icon || "terminal"}</span> | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-medium text-sm">{tool.name}</h3> | |
| <span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"> | |
| <span className="size-1.5 rounded-full bg-emerald-500" /> | |
| {translateOrFallback("custom", "Custom")} | |
| </span> | |
| </div> | |
| <p className="text-xs text-text-muted truncate">{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 space-y-5"> | |
| <div className="flex items-start gap-3 p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg"> | |
| <span className="material-symbols-outlined text-emerald-500 text-lg"> | |
| tips_and_updates | |
| </span> | |
| <div className="text-sm text-emerald-700 dark:text-emerald-300"> | |
| <p className="font-medium"> | |
| {translateOrFallback("customCliBuilderTitle", "OpenAI-compatible CLI builder")} | |
| </p> | |
| <p className="mt-1 text-xs opacity-90"> | |
| {translateOrFallback( | |
| "customCliBuilderDescription", | |
| "Generate env vars and JSON snippets for any CLI or SDK that accepts an OpenAI-compatible base URL, API key, and model ID." | |
| )} | |
| </p> | |
| </div> | |
| </div> | |
| {!hasActiveProviders && ( | |
| <div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"> | |
| <span className="material-symbols-outlined text-yellow-500 text-lg">warning</span> | |
| <div> | |
| <p className="text-sm font-medium text-yellow-700 dark:text-yellow-300"> | |
| {translateOrFallback("noActiveProviders", "No active providers")} | |
| </p> | |
| <p className="text-xs text-yellow-700/80 dark:text-yellow-300/80"> | |
| {translateOrFallback( | |
| "customCliNoModels", | |
| "Connect at least one provider to populate the model selectors." | |
| )} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="block text-sm font-medium mb-2"> | |
| {translateOrFallback("customCliNameLabel", "CLI name")} | |
| </label> | |
| <input | |
| type="text" | |
| value={cliName} | |
| onChange={(e) => setCliName(e.target.value)} | |
| placeholder={translateOrFallback("customCliNamePlaceholder", "e.g. My Team CLI")} | |
| className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium mb-2"> | |
| {translateOrFallback("customCliDefaultModelLabel", "Default model")} | |
| </label> | |
| <select | |
| value={effectiveDefaultModel} | |
| onChange={(e) => setDefaultModel(e.target.value)} | |
| disabled={!hasActiveProviders} | |
| className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-60" | |
| > | |
| <option value=""> | |
| {translateOrFallback("modelPlaceholder", "Select a model")} | |
| </option> | |
| {(availableModels as ModelOption[]).map((model) => ( | |
| <option key={model.value} value={model.value}> | |
| {model.label} | |
| </option> | |
| ))} | |
| </select> | |
| <p className="mt-2 text-xs text-text-muted"> | |
| {translateOrFallback( | |
| "customCliDefaultModelHelp", | |
| "Use any OmniRoute model ID or combo. Most OpenAI-compatible CLIs only need the /v1 base URL plus a model string." | |
| )} | |
| </p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium mb-2"> | |
| {translateOrFallback("apiKey", "API Key")} | |
| </label> | |
| {apiKeys && apiKeys.length > 0 ? ( | |
| <select | |
| value={effectiveSelectedApiKeyId} | |
| onChange={(e) => setSelectedApiKeyId(e.target.value)} | |
| className="w-full 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> | |
| ) : ( | |
| <div className="px-3 py-2 rounded-lg border border-border bg-bg-secondary text-sm text-text-muted"> | |
| {keyToUse} | |
| </div> | |
| )} | |
| <p className="mt-2 text-xs text-text-muted"> | |
| {translateOrFallback( | |
| "customCliKeyHelper", | |
| "For local installs OmniRoute can use sk_omniroute. In cloud mode, pick one of your management API keys." | |
| )} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <div> | |
| <h4 className="text-sm font-medium"> | |
| {translateOrFallback("customCliAliasMappingsLabel", "Alias mappings")} | |
| </h4> | |
| <p className="text-xs text-text-muted mt-1"> | |
| {translateOrFallback( | |
| "customCliAliasMappingsHelp", | |
| "Optional helper aliases for wrapper scripts or config files that want stable shorthand names." | |
| )} | |
| </p> | |
| </div> | |
| <Button variant="outline" size="sm" onClick={handleAddMapping}> | |
| <span className="material-symbols-outlined text-[14px] mr-1">add</span> | |
| {translateOrFallback("customCliAddAlias", "Add alias")} | |
| </Button> | |
| </div> | |
| {aliasMappings.length === 0 ? ( | |
| <div className="rounded-lg border border-dashed border-border p-4 text-xs text-text-muted"> | |
| {translateOrFallback( | |
| "customCliNoMappings", | |
| "No alias mappings yet. Add one if your wrapper or team scripts use stable short names." | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| {aliasMappings.map((mapping) => ( | |
| <div | |
| key={mapping.id} | |
| className="grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)_auto] gap-2" | |
| > | |
| <input | |
| type="text" | |
| value={mapping.alias} | |
| onChange={(e) => handleUpdateMapping(mapping.id, "alias", e.target.value)} | |
| placeholder={translateOrFallback( | |
| "customCliAliasPlaceholder", | |
| "e.g. review" | |
| )} | |
| 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" | |
| /> | |
| <select | |
| value={mapping.model} | |
| onChange={(e) => handleUpdateMapping(mapping.id, "model", e.target.value)} | |
| disabled={!hasActiveProviders} | |
| 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 disabled:opacity-60" | |
| > | |
| <option value=""> | |
| {translateOrFallback("customCliTargetModelLabel", "Target model")} | |
| </option> | |
| {(availableModels as ModelOption[]).map((model) => ( | |
| <option key={model.value} value={model.value}> | |
| {model.label} | |
| </option> | |
| ))} | |
| </select> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleRemoveMapping(mapping.id)} | |
| > | |
| <span className="material-symbols-outlined text-[16px]">delete</span> | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="rounded-lg border border-border/60 bg-black/[0.02] dark:bg-white/[0.02] p-3 text-xs text-text-muted"> | |
| <p className="font-medium text-text-main"> | |
| {translateOrFallback("customCliEndpointHintLabel", "How to wire the endpoint")} | |
| </p> | |
| <p className="mt-1"> | |
| {translateOrFallback( | |
| "customCliEndpointHint", | |
| "Point any OpenAI-compatible client to the OmniRoute /v1 base URL. The raw chat completions endpoint is {endpoint}. Use the JSON block when the tool wants a provider object, or the env script when it reads OPENAI_* variables.", | |
| { endpoint: chatCompletionsEndpoint } | |
| )} | |
| </p> | |
| </div> | |
| <div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <h4 className="text-sm font-medium"> | |
| {translateOrFallback("customCliEnvBlockTitle", "Env / shell snippet")} | |
| </h4> | |
| <Button variant="outline" size="sm" onClick={() => handleCopy(envScript, "env")}> | |
| <span className="material-symbols-outlined text-[14px] mr-1"> | |
| {copiedField === "env" ? "check" : "content_copy"} | |
| </span> | |
| {translateOrFallback("copy", "Copy")} | |
| </Button> | |
| </div> | |
| <pre className={codeBlockClass}>{envScript}</pre> | |
| </div> | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <h4 className="text-sm font-medium"> | |
| {translateOrFallback("customCliJsonBlockTitle", "Provider JSON block")} | |
| </h4> | |
| <Button variant="outline" size="sm" onClick={() => handleCopy(jsonConfig, "json")}> | |
| <span className="material-symbols-outlined text-[14px] mr-1"> | |
| {copiedField === "json" ? "check" : "content_copy"} | |
| </span> | |
| {translateOrFallback("copy", "Copy")} | |
| </Button> | |
| </div> | |
| <pre className={codeBlockClass}>{jsonConfig}</pre> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </Card> | |
| ); | |
| } | |