| "use client";
|
|
|
| import { useState, useEffect } from "react";
|
| import { Card, Button } from "@/shared/components";
|
| import Image from "next/image";
|
| import { useTranslations } from "next-intl";
|
| import { matchesSearch } from "@/shared/utils/turkishText";
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| export default function CopilotToolCard({
|
| tool,
|
| isExpanded = false,
|
| onToggle = () => {},
|
| baseUrl,
|
| apiKeys,
|
| activeProviders = [],
|
| hasActiveProviders = false,
|
| cloudEnabled = false,
|
| batchStatus,
|
| }) {
|
| const t = useTranslations("cliTools");
|
| const [copiedField, setCopiedField] = useState<string | null>(null);
|
| const [selectedModels, setSelectedModels] = useState<Set<string>>(() => {
|
| if (typeof window === "undefined") return new Set<string>();
|
| try {
|
| const saved = localStorage.getItem("omniroute-copilot-selected-models");
|
| return saved ? new Set<string>(JSON.parse(saved)) : new Set<string>();
|
| } catch {
|
| return new Set<string>();
|
| }
|
| });
|
| const [selectedApiKeyId, setSelectedApiKeyId] = useState(() => {
|
| if (typeof window !== "undefined") {
|
| const savedKey = localStorage.getItem("omniroute-cli-key-copilot");
|
| if (savedKey && apiKeys?.some((k: any) => k.id === savedKey)) return savedKey;
|
| }
|
| return apiKeys?.length > 0 ? apiKeys[0].id : "";
|
| });
|
| const [maxInputTokens, setMaxInputTokens] = useState(128000);
|
| const [maxOutputTokens, setMaxOutputTokens] = useState(16000);
|
| const [toolCalling, setToolCalling] = useState(true);
|
| const [vision, setVision] = useState(false);
|
| const [allModels, setAllModels] = useState<Array<{ value: string; label: string }>>([]);
|
| const [modelsLoaded, setModelsLoaded] = useState(false);
|
| const [searchFilter, setSearchFilter] = useState("");
|
|
|
|
|
|
|
| useEffect(() => {
|
| if (!isExpanded || modelsLoaded) return;
|
| let cancelled = false;
|
| fetch("/v1/models")
|
| .then((res) => res.json())
|
| .then((data) => {
|
| if (cancelled) return;
|
| const modelList = (data.data || [])
|
| .filter((m: any) => m && !m.type && !m.parent && m.id)
|
| .map((m: any) => ({
|
| value: m.id,
|
| label: m.id,
|
| }));
|
| setAllModels(modelList);
|
| setModelsLoaded(true);
|
| })
|
| .catch(() => {
|
| if (!cancelled) setModelsLoaded(true);
|
| });
|
| return () => {
|
| cancelled = true;
|
| };
|
| }, [isExpanded, modelsLoaded]);
|
|
|
|
|
| const availableModels = searchFilter
|
| ? allModels.filter((m) => matchesSearch(m.label, searchFilter))
|
| : allModels;
|
|
|
|
|
| useEffect(() => {
|
| if (selectedModels.size > 0) {
|
| localStorage.setItem(
|
| "omniroute-copilot-selected-models",
|
| JSON.stringify([...selectedModels])
|
| );
|
| }
|
| }, [selectedModels]);
|
|
|
| const toggleModel = (modelValue: string) => {
|
| setSelectedModels((prev) => {
|
| const next = new Set(prev);
|
| if (next.has(modelValue)) {
|
| next.delete(modelValue);
|
| } else {
|
| next.add(modelValue);
|
| }
|
| return next;
|
| });
|
| };
|
|
|
| const selectAll = () => {
|
| setSelectedModels(new Set(allModels.map((m) => m.value)));
|
| };
|
|
|
| const deselectAll = () => {
|
| setSelectedModels(new Set());
|
| };
|
|
|
| const getBaseUrlForConfig = () => {
|
| const url = baseUrl;
|
| return `${url}/v1/chat/completions`;
|
| };
|
|
|
|
|
| const generateConfig = () => {
|
| const models = [...selectedModels].map((modelId) => ({
|
| id: modelId,
|
| name: modelId,
|
| url: `${getBaseUrlForConfig()}#models.ai.azure.com`,
|
| toolCalling,
|
| vision,
|
| maxInputTokens,
|
| maxOutputTokens,
|
| }));
|
|
|
| const responseModels = [...selectedModels].map((modelId) => ({
|
| id: modelId,
|
| name: modelId,
|
| url: `${baseUrl}/v1/responses#models.ai.azure.com`,
|
| supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
|
| zeroDataRetentionEnabled: true,
|
| toolCalling,
|
| vision,
|
| maxInputTokens,
|
| maxOutputTokens,
|
| }));
|
|
|
| const config = {
|
| name: "OmniRoute",
|
| vendor: "azure",
|
| apiKey: `\${input:chat.lm.secret.omniroute}`,
|
| models,
|
| };
|
|
|
| const responsesConfig = {
|
| name: "OmniRoute-responses",
|
| vendor: "azure",
|
| apiKey: `\${input:chat.lm.secret.omniroute}`,
|
| models: responseModels,
|
| };
|
|
|
| return [config, responsesConfig].map((entry) => JSON.stringify(entry, null, 2)).join(",\n");
|
| };
|
|
|
| const handleCopy = async (text: string, field: string) => {
|
| await navigator.clipboard.writeText(text);
|
| setCopiedField(field);
|
| setTimeout(() => setCopiedField(null), 2000);
|
| };
|
|
|
| const handleApiKeyChange = (value: string) => {
|
| setSelectedApiKeyId(value);
|
| if (value) localStorage.setItem("omniroute-cli-key-copilot", value);
|
| };
|
|
|
| return (
|
| <Card padding="sm" className="overflow-hidden">
|
| {/* Header */}
|
| <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">
|
| <Image
|
| src={tool.image || "/providers/copilot.png"}
|
| 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";
|
| }}
|
| />
|
| </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-blue-500/10 text-blue-600 dark:text-blue-400">
|
| <span className="size-1.5 rounded-full bg-blue-500" />
|
| {t("guide")}
|
| </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>
|
|
|
| {/* Expanded content */}
|
| {isExpanded && (
|
| <div className="mt-6 pt-6 border-t border-border">
|
| <div className="flex flex-col gap-5">
|
| {/* Info box */}
|
| <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">info</span>
|
| <div className="text-sm text-blue-700 dark:text-blue-300">
|
| <p className="font-medium">{t("copilotConfigGenerator")}</p>
|
| <p className="mt-1 text-xs opacity-80">
|
| Generates the{" "}
|
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
| chatLanguageModels.json
|
| </code>{" "}
|
| block for VS Code GitHub Copilot using the Azure vendor pattern. Select the models
|
| you want, then copy the JSON into your config file.
|
| </p>
|
| </div>
|
| </div>
|
|
|
| {/* Version compatibility warning */}
|
| <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>
|
| <p className="text-xs text-yellow-600 dark:text-yellow-400">
|
| This configuration uses the Azure vendor workaround for custom model lists. Tested
|
| with <strong>VS Code ≥ 1.109</strong> and{" "}
|
| <strong>GitHub Copilot Chat ≥ v0.37</strong>. Future extension updates may change
|
| this behavior.
|
| </p>
|
| </div>
|
|
|
| {/* Step 2: API Key (if cloud enabled) */}
|
| {cloudEnabled && apiKeys?.length > 0 && (
|
| <div>
|
| <div className="flex items-center gap-2 mb-2">
|
| <div
|
| className="size-6 rounded-full flex items-center justify-center text-xs font-semibold text-white"
|
| style={{ backgroundColor: tool.color }}
|
| >
|
| 1
|
| </div>
|
| <span className="font-medium text-sm">{t("copilotApiKey")}</span>
|
| </div>
|
| <select
|
| value={selectedApiKeyId}
|
| onChange={(e) => handleApiKeyChange(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: any) => (
|
| <option key={key.id} value={key.id}>
|
| {key.key}
|
| </option>
|
| ))}
|
| </select>
|
| </div>
|
| )}
|
|
|
| {/* Step 3: Model Selection */}
|
| <div>
|
| <div className="flex items-center justify-between mb-2">
|
| <div className="flex items-center gap-2">
|
| <div
|
| className="size-6 rounded-full flex items-center justify-center text-xs font-semibold text-white"
|
| style={{ backgroundColor: tool.color }}
|
| >
|
| {cloudEnabled && apiKeys?.length > 0 ? "2" : "1"}
|
| </div>
|
| <span className="font-medium text-sm">
|
| Select Models ({selectedModels.size}/{availableModels.length})
|
| </span>
|
| </div>
|
| <div className="flex gap-2">
|
| <button
|
| onClick={selectAll}
|
| className="px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
|
| >
|
| Select All
|
| </button>
|
| <button
|
| onClick={deselectAll}
|
| className="px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
|
| >
|
| Clear
|
| </button>
|
| </div>
|
| </div>
|
|
|
| {/* Search filter */}
|
| <div className="mb-2">
|
| <input
|
| type="text"
|
| value={searchFilter}
|
| onChange={(e) => setSearchFilter(e.target.value)}
|
| placeholder={t("copilotFilterModelsPlaceholder")}
|
| className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| />
|
| </div>
|
|
|
| {!modelsLoaded && allModels.length === 0 ? (
|
| <div className="flex items-center gap-2 p-3 text-text-muted text-sm">
|
| <span className="material-symbols-outlined animate-spin text-base">
|
| progress_activity
|
| </span>
|
| <span>Loading models...</span>
|
| </div>
|
| ) : availableModels.length === 0 && allModels.length === 0 ? (
|
| <div className="flex items-center gap-2 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>
|
| <p className="text-sm text-yellow-600 dark:text-yellow-400">
|
| {t("noActiveProviders")}
|
| </p>
|
| </div>
|
| ) : (
|
| <div className="max-h-64 overflow-y-auto rounded-lg border border-border bg-bg-secondary">
|
| {availableModels.map((model) => (
|
| <label
|
| key={model.value}
|
| className="flex items-center gap-3 px-3 py-2 hover:bg-bg-tertiary cursor-pointer border-b border-border last:border-0 transition-colors"
|
| >
|
| <input
|
| type="checkbox"
|
| checked={selectedModels.has(model.value)}
|
| onChange={() => toggleModel(model.value)}
|
| className="rounded border-border text-primary accent-[#1F6FEB]"
|
| />
|
| <span className="text-sm font-mono truncate">{model.label}</span>
|
| </label>
|
| ))}
|
| </div>
|
| )}
|
| </div>
|
|
|
| {/* Step 4: Advanced options (collapsible) */}
|
| <details className="group">
|
| <summary className="flex items-center gap-2 cursor-pointer text-sm text-text-muted hover:text-text-main transition-colors">
|
| <span className="material-symbols-outlined text-base group-open:rotate-90 transition-transform">
|
| chevron_right
|
| </span>
|
| Advanced Options
|
| </summary>
|
| <div className="mt-3 grid grid-cols-2 gap-3 pl-6">
|
| <div>
|
| <label className="text-xs text-text-muted block mb-1">
|
| {t("copilotMaxInputTokens")}
|
| </label>
|
| <input
|
| type="number"
|
| value={maxInputTokens}
|
| onChange={(e) => setMaxInputTokens(Number(e.target.value) || 128000)}
|
| className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| />
|
| </div>
|
| <div>
|
| <label className="text-xs text-text-muted block mb-1">
|
| {t("copilotMaxOutputTokens")}
|
| </label>
|
| <input
|
| type="number"
|
| value={maxOutputTokens}
|
| onChange={(e) => setMaxOutputTokens(Number(e.target.value) || 16000)}
|
| className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| />
|
| </div>
|
| <label className="flex items-center gap-2 cursor-pointer">
|
| <input
|
| type="checkbox"
|
| checked={toolCalling}
|
| onChange={(e) => setToolCalling(e.target.checked)}
|
| className="rounded border-border accent-[#1F6FEB]"
|
| />
|
| <span className="text-sm">{t("copilotToolCalling")}</span>
|
| </label>
|
| <label className="flex items-center gap-2 cursor-pointer">
|
| <input
|
| type="checkbox"
|
| checked={vision}
|
| onChange={(e) => setVision(e.target.checked)}
|
| className="rounded border-border accent-[#1F6FEB]"
|
| />
|
| <span className="text-sm">Vision</span>
|
| </label>
|
| </div>
|
| </details>
|
|
|
| {/* Step 5: Generated config */}
|
| {selectedModels.size > 0 && (
|
| <div>
|
| <div className="flex items-center justify-between mb-2">
|
| <div className="flex items-center gap-2">
|
| <div
|
| className="size-6 rounded-full flex items-center justify-center text-xs font-semibold text-white"
|
| style={{ backgroundColor: tool.color }}
|
| >
|
| {cloudEnabled && apiKeys?.length > 0 ? "3" : "2"}
|
| </div>
|
| <span className="font-medium text-sm">
|
| Copy Config ({selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""}
|
| )
|
| </span>
|
| </div>
|
| <Button
|
| variant="primary"
|
| size="sm"
|
| onClick={() => handleCopy(generateConfig(), "config")}
|
| >
|
| <span className="material-symbols-outlined text-[14px] mr-1">
|
| {copiedField === "config" ? "check" : "content_copy"}
|
| </span>
|
| {copiedField === "config" ? t("copied") : t("copyConfig")}
|
| </Button>
|
| </div>
|
| <pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto max-h-80">
|
| <code className="text-xs font-mono whitespace-pre text-text-main">
|
| {generateConfig()}
|
| </code>
|
| </pre>
|
|
|
| {/* Usage instructions */}
|
| <div className="mt-3 p-3 bg-bg-secondary rounded-lg border border-border">
|
| <p className="text-xs text-text-muted">
|
| <span className="font-medium text-text-main">{t("copilotPasteInto")} </span>
|
| <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
| ~/.config/Code/User/chatLanguageModels.json
|
| </code>
|
| </p>
|
| <p className="text-xs text-text-muted mt-1">
|
| Then reload VS Code and set the API key in the input prompt.
|
| </p>
|
| </div>
|
| </div>
|
| )}
|
| </div>
|
| </div>
|
| )}
|
| </Card>
|
| );
|
| }
|
|
|