mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat: GitHub Copilot config generator for CLI Tools (#142)
Adds a Copilot configuration generator to the CLI Tools dashboard page. Users can select models and generate the chatLanguageModels.json config block for VS Code GitHub Copilot with the Azure vendor pattern. Features: - Bulk model selection from /v1/models (includes combos, custom, aliased) - Search/filter for large model lists - Configurable maxInputTokens, maxOutputTokens, toolCalling, vision - One-click copy to clipboard - Persistent model selection via localStorage - Version compatibility warning (VS Code >= 1.109, Copilot >= v0.37) Feedback from @alpgul applied: - Use /v1/models instead of /api/models/alias (includes combo definitions) - Use window.location.origin for URL (no port duplication in Docker) Also: added electron/dist-electron/ to .gitignore (build artifact)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,3 +106,4 @@ app.__qa_backup/
|
||||
# Electron (subproject dependency lock)
|
||||
electron/package-lock.json
|
||||
icon.iconset/
|
||||
electron/dist-electron/
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
KiloToolCard,
|
||||
DefaultToolCard,
|
||||
AntigravityToolCard,
|
||||
CopilotToolCard,
|
||||
} from "./components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -263,6 +264,16 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "copilot":
|
||||
return (
|
||||
<CopilotToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DefaultToolCard
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* GitHub Copilot Configuration Generator
|
||||
*
|
||||
* Generates the chatLanguageModels.json block for VS Code GitHub Copilot
|
||||
* using the Azure vendor pattern as required by Copilot's architecture.
|
||||
*
|
||||
* Feature request: https://github.com/diegosouzapw/OmniRoute/issues/142
|
||||
*/
|
||||
export default function CopilotToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
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 [selectedApiKey, setSelectedApiKey] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedKey = localStorage.getItem("omniroute-cli-key-copilot");
|
||||
if (savedKey && apiKeys?.some((k: any) => k.key === savedKey)) return savedKey;
|
||||
}
|
||||
return apiKeys?.length > 0 ? apiKeys[0].key : "";
|
||||
});
|
||||
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("");
|
||||
|
||||
// Fetch ALL models dynamically from /v1/models (includes combos, custom, aliased)
|
||||
// Per @alpgul feedback: /api/models/alias doesn't include combo definitions
|
||||
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.id) // Only models with valid IDs
|
||||
.map((m: any) => ({
|
||||
value: m.id,
|
||||
label: m.id,
|
||||
}));
|
||||
setAllModels(modelList);
|
||||
setModelsLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setModelsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isExpanded, modelsLoaded]);
|
||||
|
||||
// Filter models by search
|
||||
const availableModels = searchFilter
|
||||
? allModels.filter((m) => m.label.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
: allModels;
|
||||
|
||||
// Persist selection
|
||||
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 = () => {
|
||||
// Use window.location.origin directly — works correctly in Docker/reverse-proxy
|
||||
// Per @alpgul feedback: don't use baseUrl prop (has port duplication issues)
|
||||
const origin =
|
||||
typeof window !== "undefined" ? window.location.origin : "http://localhost:20128";
|
||||
return `${origin}/v1/chat/completions`;
|
||||
};
|
||||
|
||||
// Generate the Copilot chatLanguageModels.json config
|
||||
const generateConfig = () => {
|
||||
const models = [...selectedModels].map((modelId) => ({
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
url: `${getBaseUrlForConfig()}#models.ai.azure.com`,
|
||||
toolCalling,
|
||||
vision,
|
||||
maxInputTokens,
|
||||
maxOutputTokens,
|
||||
}));
|
||||
|
||||
const config = {
|
||||
name: "OmniRoute",
|
||||
vendor: "azure",
|
||||
apiKey: `\${input:chat.lm.secret.omniroute}`,
|
||||
models,
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string, field: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setSelectedApiKey(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">GitHub Copilot Config Generator</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">API Key</span>
|
||||
</div>
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
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.key}>
|
||||
{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="Filter models..."
|
||||
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">Max Input Tokens</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">Max Output Tokens</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">Tool Calling</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">Paste into: </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>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { default as ClineToolCard } from "./ClineToolCard";
|
||||
export { default as KiloToolCard } from "./KiloToolCard";
|
||||
export { default as DefaultToolCard } from "./DefaultToolCard";
|
||||
export { default as AntigravityToolCard } from "./AntigravityToolCard";
|
||||
export { default as CopilotToolCard } from "./CopilotToolCard";
|
||||
|
||||
@@ -164,6 +164,14 @@ export const CLI_TOOLS = {
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" },
|
||||
],
|
||||
},
|
||||
copilot: {
|
||||
id: "copilot",
|
||||
name: "GitHub Copilot",
|
||||
image: "/providers/copilot.png",
|
||||
color: "#1F6FEB",
|
||||
description: "GitHub Copilot Chat — VS Code Extension",
|
||||
configType: "custom",
|
||||
},
|
||||
// HIDDEN: gemini-cli
|
||||
// "gemini-cli": {
|
||||
// id: "gemini-cli",
|
||||
|
||||
Reference in New Issue
Block a user