From 38ded5c62f50b945e577cd5116c5de9f4bf56a95 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 3 Mar 2026 11:04:56 +0700 Subject: [PATCH] feat(cli-tools): add OpenCode tool integration to CLI Tools page --- package.json | 2 +- .../dashboard/cli-tools/CLIToolsPageClient.js | 5 +- .../cli-tools/components/CopilotToolCard.js | 295 ++++++++++++++++++ .../dashboard/cli-tools/components/index.js | 1 + .../api/cli-tools/copilot-settings/route.js | 148 +++++++++ src/shared/constants/cliTools.js | 42 +-- 6 files changed, 457 insertions(+), 36 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js create mode 100644 src/app/api/cli-tools/copilot-settings/route.js diff --git a/package.json b/package.json index 1ba5cee..b5b0ff0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.27", + "version": "0.3.28", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 51e11c3..3b30c71 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { PROVIDER_MODELS, getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard, OpenCodeToolCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard, OpenCodeToolCard, CopilotToolCard } from "./components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -12,6 +12,7 @@ const STATUS_ENDPOINTS = { claude: "/api/cli-tools/claude-settings", codex: "/api/cli-tools/codex-settings", opencode: "/api/cli-tools/opencode-settings", + copilot: "/api/cli-tools/copilot-settings", droid: "/api/cli-tools/droid-settings", openclaw: "/api/cli-tools/openclaw-settings", antigravity: "/api/cli-tools/antigravity-mitm", @@ -202,6 +203,8 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "opencode": return ; + case "copilot": + return ; case "droid": return ; case "openclaw": diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js new file mode 100644 index 0000000..8e8f0d3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -0,0 +1,295 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) { + const [status, setStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + + // Model list management + const [modelInput, setModelInput] = useState(""); + const [modelList, setModelList] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !status) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + // Pre-fill model list from existing config + useEffect(() => { + if (status?.config && Array.isArray(status.config) && modelList.length === 0) { + const entry = status.config.find((e) => e.name === "9Router"); + if (entry?.models?.length > 0) { + setModelList(entry.models.map((m) => m.id)); + } + } + }, [status]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + const getConfigStatus = () => { + if (!status) return null; + if (!status.has9Router) return "not_configured"; + const url = status.currentUrl || ""; + return url.includes("localhost") || url.includes("127.0.0.1") || url.includes(baseUrl) + ? "configured" : "other"; + }; + + const configStatus = getConfigStatus(); + const getEffectiveBaseUrl = () => baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + const addModel = () => { + const val = modelInput.trim(); + if (!val || modelList.includes(val)) return; + setModelList((prev) => [...prev, val]); + setModelInput(""); + }; + + const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id)); + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch("/api/cli-tools/copilot-settings"); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ error: error.message }); + } finally { + setChecking(false); + } + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : selectedApiKey); + + const res = await fetch("/api/cli-tools/copilot-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models: modelList }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Settings applied successfully!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } 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/copilot-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setModelList([]); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + const effectiveBaseUrl = getEffectiveBaseUrl(); + + return [{ + filename: "~/Library/Application Support/Code/User/chatLanguageModels.json", + content: JSON.stringify([{ + name: "9Router", + vendor: "azure", + apiKey: keyToUse, + models: modelList.map((id) => ({ + id, name: id, + url: `${effectiveBaseUrl}/chat/completions#models.ai.azure.com`, + toolCalling: true, vision: false, + maxInputTokens: 128000, maxOutputTokens: 16000, + })), + }], null, 2), + }]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Copilot config... +
+ )} + + {!checking && ( + <> + {/* Info */} +
+ info +
+

Writes to chatLanguageModels.json

+

Reload VS Code after applying for changes to take effect.

+
+
+ +
+ {/* API Key */} +
+ + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ + {/* Model input + Add */} +
+ + + {/* Model list */} + {modelList.length > 0 && ( +
+ {modelList.map((id) => ( +
+ {id} + +
+ ))} +
+ )} + +
+ setModelInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addModel()} + placeholder="provider/model-id" + 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" + /> + + +
+
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={(model) => { setModelInput(model.value); setModalOpen(false); }} + selectedModel={modelInput} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for GitHub Copilot" + /> + + setShowManualConfigModal(false)} + title="GitHub Copilot - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 078970f..06c29dc 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -5,4 +5,5 @@ export { default as OpenClawToolCard } from "./OpenClawToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; export { default as AntigravityToolCard } from "./AntigravityToolCard"; export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; +export { default as CopilotToolCard } from "./CopilotToolCard"; diff --git a/src/app/api/cli-tools/copilot-settings/route.js b/src/app/api/cli-tools/copilot-settings/route.js new file mode 100644 index 0000000..449c6e2 --- /dev/null +++ b/src/app/api/cli-tools/copilot-settings/route.js @@ -0,0 +1,148 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +// Resolve chatLanguageModels.json path per OS +const getConfigPath = () => { + const home = os.homedir(); + const platform = os.platform(); + if (platform === "win32") { + return path.join(process.env.APPDATA || home, "Code", "User", "chatLanguageModels.json"); + } + if (platform === "darwin") { + return path.join(home, "Library", "Application Support", "Code", "User", "chatLanguageModels.json"); + } + return path.join(home, ".config", "Code", "User", "chatLanguageModels.json"); +}; + +const readConfig = async () => { + try { + const content = await fs.readFile(getConfigPath(), "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +const has9RouterConfig = (config) => { + if (!Array.isArray(config)) return false; + return config.some((entry) => entry.name === "9Router"); +}; + +const get9RouterEntry = (config) => { + if (!Array.isArray(config)) return null; + return config.find((entry) => entry.name === "9Router") || null; +}; + +// GET - Read current copilot config +export async function GET() { + try { + const config = await readConfig(); + const entry = get9RouterEntry(config); + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(config), + configPath: getConfigPath(), + currentModel: entry?.models?.[0]?.id || null, + currentUrl: entry?.models?.[0]?.url || null, + }); + } catch (error) { + console.log("Error checking copilot settings:", error); + return NextResponse.json({ error: "Failed to check copilot settings" }, { status: 500 }); + } +} + +// POST - Apply 9Router config to chatLanguageModels.json +export async function POST(request) { + try { + const { baseUrl, apiKey, models } = await request.json(); + + if (!baseUrl || !models?.length) { + return NextResponse.json({ error: "baseUrl and models are required" }, { status: 400 }); + } + + const configPath = getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + // Read existing config array + let config = []; + try { + const existing = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(existing); + config = Array.isArray(parsed) ? parsed : []; + } catch { /* No existing config */ } + + const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`; + const keyToUse = apiKey || "sk_9router"; + + const newEntry = { + name: "9Router", + vendor: "azure", + apiKey: keyToUse, + models: models.map((id) => ({ + id, + name: id, + url: endpointUrl, + toolCalling: true, + vision: false, + maxInputTokens: 128000, + maxOutputTokens: 16000, + })), + }; + + // Replace existing 9Router entry or append + const idx = config.findIndex((e) => e.name === "9Router"); + if (idx >= 0) { + config[idx] = newEntry; + } else { + config.push(newEntry); + } + + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + + return NextResponse.json({ + success: true, + message: "Copilot settings applied! Reload VS Code to take effect.", + configPath, + }); + } catch (error) { + console.log("Error updating copilot settings:", error); + return NextResponse.json({ error: "Failed to update copilot settings" }, { status: 500 }); + } +} + +// DELETE - Remove 9Router entry from chatLanguageModels.json +export async function DELETE() { + try { + const configPath = getConfigPath(); + + let config = []; + try { + const existing = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(existing); + config = Array.isArray(parsed) ? parsed : []; + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + throw error; + } + + config = config.filter((e) => e.name !== "9Router"); + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + + return NextResponse.json({ + success: true, + message: "9Router removed from Copilot config", + }); + } catch (error) { + console.log("Error resetting copilot settings:", error); + return NextResponse.json({ error: "Failed to reset copilot settings" }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index ae8a8d4..e1e2579 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -122,40 +122,14 @@ export const CLI_TOOLS = { { step: 5, title: "Select Model", type: "modelSelector" }, ], }, - copilot: { - id: "copilot", - name: "GitHub Copilot", - image: "/providers/copilot.png", - color: "#1F6FEB", - description: "GitHub Copilot Chat — VS Code Extension", - configType: "guide", - guideSteps: [ - { step: 1, title: "Open VS Code Settings", desc: "Open Command Palette → \"Open User Settings (JSON)\"" }, - { step: 2, title: "Add config to chatLanguageModels.json", desc: "Add an entry using the Azure vendor pattern:" }, - { step: 3, title: "Base URL (endpoint)", value: "{{baseUrl}}/chat/completions#models.ai.azure.com", copyable: true }, - { step: 4, title: "API Key", type: "apiKeySelector" }, - { step: 5, title: "Select Model", type: "modelSelector" }, - ], - codeBlock: { - language: "json", - code: `{ - "name": "9Router", - "vendor": "azure", - "apiKey": "{{apiKey}}", - "models": [ - { - "id": "{{model}}", - "name": "{{model}}", - "url": "{{baseUrl}}/chat/completions#models.ai.azure.com", - "toolCalling": true, - "vision": false, - "maxInputTokens": 128000, - "maxOutputTokens": 16000 - } - ] -}`, - }, - }, + // copilot: { + // id: "copilot", + // name: "GitHub Copilot", + // image: "/providers/copilot.png", + // color: "#1F6FEB", + // description: "GitHub Copilot Chat — VS Code Extension", + // configType: "custom", + // }, roo: { id: "roo", name: "Roo",