diff --git a/CHANGELOG.md b/CHANGELOG.md index 41736ac..bb26464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# v0.4.41 (2026-05-14) + +## Features +- Add jcode CLI tool integration with auto-configuration (#1047) +- Redesign CLI Tools dashboard: grid layout (1/2/3 cols) + dedicated detail page per tool +- Add drag-and-drop reordering for combo models (#1108) +- Add Today period option to Usage & Analytics (#1063) +- Add DeepSeek V4 Pro effort aliases (#950) + +## Fixes +- fix(autostart): work on nvm + npm 9/10, actually register with launchctl (#1104, fixes #1082) +- Fix Ollama usage not tracked/shown in UI (#1102) +- fix(opencode): preserve DeepSeek reasoning content (#1099, fixes #1093) + +## Improvements +- Sync DeepSeek TUI card style with other CLI tools (badges, layout, manual config modal) +- Add official logos for Amp CLI, jcode, Qwen Code (replace generic icons) +- Resize deepseek-tui icon 1024→128 with padding for visual consistency + +# v0.4.39 (2026-05-14) + +## Fixes +- fix(docker): restore `/app/server.js` (v0.4.38 regression) + # v0.4.38 (2026-05-13) ## Features diff --git a/cli/package.json b/cli/package.json index 0ec8bda..d18c26b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.4.39", + "version": "0.4.41", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/package.json b/package.json index 159a390..450ff7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.39", + "version": "0.4.41", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/providers/amp.png b/public/providers/amp.png new file mode 100644 index 0000000..6bacd05 Binary files /dev/null and b/public/providers/amp.png differ diff --git a/public/providers/deepseek-tui.png b/public/providers/deepseek-tui.png index 21e6614..fe56671 100644 Binary files a/public/providers/deepseek-tui.png and b/public/providers/deepseek-tui.png differ diff --git a/public/providers/jcode.png b/public/providers/jcode.png new file mode 100644 index 0000000..27e75a9 Binary files /dev/null and b/public/providers/jcode.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 0148372..e8a5245 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -1,130 +1,38 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { Card, CardSkeleton } from "@/shared/components"; -import { CLI_TOOLS } from "@/shared/constants/cliTools"; -import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, MitmLinkCard } from "./components"; -import { MITM_TOOLS } from "@/shared/constants/cliTools"; - -const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +import { useState, useEffect } from "react"; +import { CardSkeleton } from "@/shared/components"; +import { CLI_TOOLS, MITM_TOOLS } from "@/shared/constants/cliTools"; +import { MitmLinkCard } from "./components"; +import ToolSummaryCard from "./components/ToolSummaryCard"; const ALL_STATUSES_URL = "/api/cli-tools/all-statuses"; export default function CLIToolsPageClient({ machineId }) { - const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); - const [expandedTool, setExpandedTool] = useState(null); - const [modelMappings, setModelMappings] = useState({}); - const [cloudEnabled, setCloudEnabled] = useState(false); - const [tunnelEnabled, setTunnelEnabled] = useState(false); - const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); - const [tailscaleEnabled, setTailscaleEnabled] = useState(false); - const [tailscaleUrl, setTailscaleUrl] = useState(""); - const [apiKeys, setApiKeys] = useState([]); const [toolStatuses, setToolStatuses] = useState({}); - const fetchAllStatuses = async () => { - try { - const res = await fetch(ALL_STATUSES_URL); - if (res.ok) setToolStatuses(await res.json()); - } catch (error) { - console.log("Error fetching tool statuses:", error); - } - }; - - const loadCloudSettings = async () => { - try { - const [settingsRes, tunnelRes] = await Promise.all([ - fetch("/api/settings"), - fetch("/api/tunnel/status"), - ]); - if (settingsRes.ok) { - const data = await settingsRes.json(); - setCloudEnabled(data.cloudEnabled || false); - } - if (tunnelRes.ok) { - const data = await tunnelRes.json(); - setTunnelEnabled(!!(data.tunnel?.enabled || data.tunnel?.settingsEnabled)); - setTunnelPublicUrl(data.tunnel?.publicUrl || ""); - setTailscaleEnabled(!!(data.tailscale?.enabled || data.tailscale?.settingsEnabled)); - setTailscaleUrl(data.tailscale?.tunnelUrl || ""); - } - } catch (error) { - console.log("Error loading settings:", error); - } - }; - - const fetchApiKeys = async () => { - try { - const res = await fetch("/api/keys"); - if (res.ok) { - const data = await res.json(); - setApiKeys(data.keys || []); - } - } catch (error) { - console.log("Error fetching API keys:", error); - } - }; - - const fetchConnections = async () => { - try { - const res = await fetch("/api/providers"); - const data = await res.json(); - if (res.ok) { - setConnections(data.connections || []); - } - } catch (error) { - console.log("Error fetching connections:", error); - } finally { - setLoading(false); - } - }; - useEffect(() => { - fetchConnections(); - loadCloudSettings(); - fetchApiKeys(); - fetchAllStatuses(); + let mounted = true; + (async () => { + try { + const res = await fetch(ALL_STATUSES_URL); + if (res.ok && mounted) setToolStatuses(await res.json()); + } catch (error) { + console.log("Error fetching tool statuses:", error); + } finally { + if (mounted) setLoading(false); + } + })(); + return () => { mounted = false; }; }, []); - const getActiveProviders = () => connections.filter(c => c.isActive !== false); - - const getAllAvailableModels = () => { - const activeProviders = getActiveProviders(); - const models = []; - const seenModels = new Set(); - activeProviders.forEach(conn => { - const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; - const providerModels = getModelsByProviderId(conn.provider); - providerModels.forEach(m => { - const modelValue = `${alias}/${m.id}`; - if (!seenModels.has(modelValue)) { - seenModels.add(modelValue); - models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id }); - } - }); - }); - return models; - }; - - const handleModelMappingChange = useCallback((toolId, modelAlias, targetModel) => { - setModelMappings(prev => { - if (prev[toolId]?.[modelAlias] === targetModel) return prev; - return { ...prev, [toolId]: { ...prev[toolId], [modelAlias]: targetModel } }; - }); - }, []); - - const getBaseUrl = () => { - if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; - if (cloudEnabled && CLOUD_URL) return CLOUD_URL; - if (typeof window !== "undefined") return window.location.origin; - return "http://localhost:20128"; - }; - if (loading) { return ( -
+
+ + + @@ -132,95 +40,26 @@ export default function CLIToolsPageClient({ machineId }) { ); } - const availableModels = getAllAvailableModels(); - const hasActiveProviders = availableModels.length > 0; - - const renderToolCard = (toolId, tool) => { - const commonProps = { - tool, - isExpanded: expandedTool === toolId, - onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId), - baseUrl: getBaseUrl(), - apiKeys, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, - }; - - switch (toolId) { - case "claude": - return ( - handleModelMappingChange(toolId, alias, target)} - hasActiveProviders={hasActiveProviders} - cloudEnabled={cloudEnabled} - initialStatus={toolStatuses.claude} - /> - ); - case "codex": - return ; - case "opencode": - return ; - case "cowork": - return ( - - ); - case "droid": - return ; - case "openclaw": - return ; - case "hermes": - return ; - case "copilot": - return ; - case "cline": - return ; - case "kilo": - return ; - case "deepseek-tui": - return ; - default: - return ; - } - }; - const regularTools = Object.entries(CLI_TOOLS); const mitmTools = Object.entries(MITM_TOOLS); return (
-
-

CLI Tools

-

Configure local coding tools to use your 9Router providers.

+
+ {regularTools.map(([toolId, tool]) => ( + + ))}
-
- {regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))} -
-
+
security

MITM Tools

- {mitmTools.map(([toolId, tool]) => ( - - ))} +
+ {mitmTools.map(([toolId, tool]) => ( + + ))} +
); diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js new file mode 100644 index 0000000..ef76822 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -0,0 +1,160 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { CardSkeleton } from "@/shared/components"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { + ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, + HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, + CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, + JcodeToolCard, +} from "../components"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function ToolDetailClient({ toolId, machineId }) { + const tool = CLI_TOOLS[toolId]; + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [modelMappings, setModelMappings] = useState({}); + const [cloudEnabled, setCloudEnabled] = useState(false); + const [tunnelEnabled, setTunnelEnabled] = useState(false); + const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); + const [tailscaleEnabled, setTailscaleEnabled] = useState(false); + const [tailscaleUrl, setTailscaleUrl] = useState(""); + const [apiKeys, setApiKeys] = useState([]); + + useEffect(() => { + let mounted = true; + (async () => { + try { + const [provRes, settingsRes, tunnelRes, keysRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/settings"), + fetch("/api/tunnel/status"), + fetch("/api/keys"), + ]); + if (!mounted) return; + if (provRes.ok) { + const data = await provRes.json(); + setConnections(data.connections || []); + } + if (settingsRes.ok) { + const data = await settingsRes.json(); + setCloudEnabled(data.cloudEnabled || false); + } + if (tunnelRes.ok) { + const data = await tunnelRes.json(); + setTunnelEnabled(!!(data.tunnel?.enabled || data.tunnel?.settingsEnabled)); + setTunnelPublicUrl(data.tunnel?.publicUrl || ""); + setTailscaleEnabled(!!(data.tailscale?.enabled || data.tailscale?.settingsEnabled)); + setTailscaleUrl(data.tailscale?.tunnelUrl || ""); + } + if (keysRes.ok) { + const data = await keysRes.json(); + setApiKeys(data.keys || []); + } + } catch (error) { + console.log("Error loading tool data:", error); + } finally { + if (mounted) setLoading(false); + } + })(); + return () => { mounted = false; }; + }, []); + + const getActiveProviders = () => connections.filter(c => c.isActive !== false); + + const getAllAvailableModels = () => { + const activeProviders = getActiveProviders(); + const models = []; + const seenModels = new Set(); + activeProviders.forEach(conn => { + const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; + const providerModels = getModelsByProviderId(conn.provider); + providerModels.forEach(m => { + const modelValue = `${alias}/${m.id}`; + if (!seenModels.has(modelValue)) { + seenModels.add(modelValue); + models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id }); + } + }); + }); + return models; + }; + + const handleModelMappingChange = useCallback((tId, alias, target) => { + setModelMappings(prev => { + if (prev[tId]?.[alias] === target) return prev; + return { ...prev, [tId]: { ...prev[tId], [alias]: target } }; + }); + }, []); + + const getBaseUrl = () => { + if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; + if (cloudEnabled && CLOUD_URL) return CLOUD_URL; + if (typeof window !== "undefined") return window.location.origin; + return "http://localhost:20128"; + }; + + const renderToolCard = () => { + const availableModels = getAllAvailableModels(); + const hasActiveProviders = availableModels.length > 0; + const commonProps = { + tool, + isExpanded: true, + onToggle: () => {}, + baseUrl: getBaseUrl(), + apiKeys, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, + }; + + switch (toolId) { + case "claude": + return handleModelMappingChange(toolId, a, t)} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />; + case "codex": + return ; + case "opencode": + return ; + case "cowork": + return ; + case "droid": + return ; + case "openclaw": + return ; + case "hermes": + return ; + case "copilot": + return ; + case "cline": + return ; + case "kilo": + return ; + case "deepseek-tui": + return ; + case "jcode": + return ; + default: + return ; + } + }; + + return ( +
+ + arrow_back + Back to CLI Tools + +
+

{tool.name}

+

{tool.description}

+
+ {loading ? : renderToolCard()} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js new file mode 100644 index 0000000..1f904d4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js @@ -0,0 +1,11 @@ +import { notFound } from "next/navigation"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { getMachineId } from "@/shared/utils/machine"; +import ToolDetailClient from "./ToolDetailClient"; + +export default async function ToolDetailPage({ params }) { + const { toolId } = await params; + if (!CLI_TOOLS[toolId]) notFound(); + const machineId = await getMachineId(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js index b342bfd..426ca74 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal } from "@/shared/components"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; @@ -10,378 +10,329 @@ import { matchKnownEndpoint } from "./cliEndpointMatch"; const ENDPOINT = "/api/cli-tools/deepseek-tui-settings"; export default function DeepSeekTuiToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - hasActiveProviders, - apiKeys, - activeProviders, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, }) { - const [deepseekStatus, setDeepseekStatus] = 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 [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); + const [deepseekStatus, setDeepseekStatus] = 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 [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); - const getConfigStatus = () => { - if (!deepseekStatus?.installed) return null; - const cfg = deepseekStatus.settings; - if (!cfg) return "not_configured"; - const openaiSection = cfg["providers.openai"]; - if (!openaiSection?.base_url) return "not_configured"; - if (matchKnownEndpoint(openaiSection.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; - return "other"; - }; + const getConfigStatus = () => { + if (!deepseekStatus?.installed) return null; + const openaiSection = deepseekStatus.settings?.["providers.openai"]; + if (!openaiSection?.base_url) return "not_configured"; + if (matchKnownEndpoint(openaiSection.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; + return "other"; + }; - const configStatus = getConfigStatus(); + const configStatus = getConfigStatus(); - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); - useEffect(() => { - if (initialStatus) setDeepseekStatus(initialStatus); - }, [initialStatus]); + useEffect(() => { + if (initialStatus) setDeepseekStatus(initialStatus); + }, [initialStatus]); - useEffect(() => { - if (isExpanded && !deepseekStatus) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); + useEffect(() => { + if (isExpanded && !deepseekStatus) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); - 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 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); + } + }; - useEffect(() => { - if (deepseekStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const cfg = deepseekStatus.settings; - const openaiSection = cfg?.["providers.openai"]; - if (openaiSection?.model) setSelectedModel(openaiSection.model); - } - }, [deepseekStatus]); + useEffect(() => { + if (deepseekStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const openaiSection = deepseekStatus.settings?.["providers.openai"]; + if (openaiSection?.model) setSelectedModel(openaiSection.model); + } + }, [deepseekStatus]); - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch(ENDPOINT); - const data = await res.json(); - setDeepseekStatus(data); - } catch (error) { - setDeepseekStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const data = await res.json(); + setDeepseekStatus(data); + } catch (error) { + setDeepseekStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; - const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); + const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); - const getLocalBaseUrl = () => { - if (typeof window !== "undefined") { - return normalizeLocalhost(window.location.origin); - } - return "http://127.0.0.1:20128"; - }; + const getLocalBaseUrl = () => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:20128"; + }; - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); - const res = await fetch(ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - model: selectedModel, - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "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 res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "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(ENDPOINT, { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset to defaults!" }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; - const handleSelectModel = (model) => { - setSelectedModel(model.value); - setModalOpen(false); - }; + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; - const renderIcon = () => { - if (tool.image) { - return ( - {tool.name} { e.target.style.display = "none"; }} - /> - ); - } - if (tool.icon) { - return {tool.icon}; - } - return ( - {tool.name} { e.target.style.display = "none"; }} - /> - ); - }; + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); - const renderStatusBadge = () => { - if (!deepseekStatus?.installed) { - return ( - - close - Not Installed - - ); - } - if (configStatus === "configured") { - return ( - - check_circle - Configured - - ); - } - if (configStatus === "other") { - return ( - - settings - Other Config - - ); - } - return ( - - info - Not Configured - - ); - }; + const tomlContent = `[providers.openai] +base_url = "${getEffectiveBaseUrl()}" +api_key = "${keyToUse}" +model = "${selectedModel || "provider/model-id"}" +`; - return ( - -
-
-
- {renderIcon()} -
-
-
-

{tool.name}

- {renderStatusBadge()} -
-

{tool.description}

-
-
- expand_more + return [ + { filename: "~/.deepseek/config.toml", content: tomlContent }, + ]; + }; + + 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 && ( -
- {/* Notes */} - {tool.notes && tool.notes.length > 0 && ( -
- {tool.notes.map((note, index) => { - const isWarning = note.type === "warning"; - const isError = note.type === "error"; - 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"; + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking DeepSeek TUI... +
+ )} - 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 ( -
- {icon} -

{note.text}

-
- ); - })} -
- )} - - {/* Install check */} - {!deepseekStatus?.installed && ( -
-

DeepSeek TUI is not detected on your system.

-
-

Install via npm:

- npm install -g deepseek-tui -
- -
- )} - - {/* Config section */} - {deepseekStatus?.installed && ( -
- {/* Config path */} -
- folder - {deepseekStatus.configPath} -
- - {/* Base URL */} -
- - -
- - {/* API Key */} -
- - -
- - {/* Model */} -
- -
- setSelectedModel(e.target.value)} - placeholder="ollama/gpt-oss:120b" - className="w-full sm:w-auto 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 && ( -
- {message.text} -
- )} - - {/* Actions */} -
- - -
-
- )} + {!checking && deepseekStatus && !deepseekStatus.installed && ( +
+
+
+ warning +
+

DeepSeek TUI not detected locally

+

Install via npm:

+ npm install -g deepseek-tui +

Manual configuration is still available if 9router is deployed on a remote server.

+
- )} +
+ +
+
+
+ )} - setModalOpen(false)} - onSelect={handleSelectModel} - selectedModel={selectedModel} - activeProviders={activeProviders} - title="Select Model" - /> - - ); -} \ No newline at end of file + {!checking && deepseekStatus?.installed && ( + <> +
+ {tool.notes && tool.notes.length > 0 && ( +
+ {tool.notes.map((note, idx) => ( +
+ + {note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"} + + {note.text} +
+ ))} +
+ )} + +
+ Select Endpoint + arrow_forward + +
+ + {deepseekStatus?.settings?.["providers.openai"]?.base_url && ( +
+ Current + arrow_forward + + {deepseekStatus.settings["providers.openai"].base_url} + +
+ )} + +
+ API Key + arrow_forward + +
+ +
+ Default Model + arrow_forward +
+ setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> + {selectedModel && } +
+ +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for DeepSeek TUI" + /> + + setShowManualConfigModal(false)} + title="DeepSeek TUI - Manual Configuration" + configs={getManualConfigs()} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js new file mode 100644 index 0000000..487def9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js @@ -0,0 +1,380 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; +import BaseUrlSelect from "./BaseUrlSelect"; +import ApiKeySelect from "./ApiKeySelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; + +export default function JcodeToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, +}) { + const [jcodeStatus, setJcodeStatus] = useState(initialStatus || null); + const [checkingJcode, setCheckingJcode] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + + const getConfigStatus = () => { + if (!jcodeStatus?.installed) return null; + if (!jcodeStatus?.has9Router) return "not_configured"; + const currentProvider = jcodeStatus.config?.providers?.["9router"]; + if (!currentProvider) return "not_configured"; + return matchKnownEndpoint(currentProvider.base_url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setJcodeStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !jcodeStatus) { + checkJcodeStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + 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); + } + }; + + useEffect(() => { + if (jcodeStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const provider = jcodeStatus.config?.providers?.["9router"]; + if (provider) { + if (provider.default_model) { + setSelectedModel(provider.default_model); + } + // Try to match API key from env file + const envApiKey = jcodeStatus.envApiKey; + if (envApiKey && apiKeys?.some(k => k.key === envApiKey)) { + setSelectedApiKey(envApiKey); + } + } + } + }, [jcodeStatus, apiKeys]); + + const checkJcodeStatus = async () => { + setCheckingJcode(true); + try { + const res = await fetch("/api/cli-tools/jcode-settings"); + const data = await res.json(); + setJcodeStatus(data); + } catch (error) { + setJcodeStatus({ installed: false, error: error.message }); + } finally { + setCheckingJcode(false); + } + }; + + const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = () => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:20128"; + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApplySettings = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch("/api/cli-tools/jcode-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + models: selectedModel ? [selectedModel] : [], + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkJcodeStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleResetSettings = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/jcode-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + setSelectedApiKey(""); + checkJcodeStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + const configToml = `[providers.9router] +type = "openai-compatible" +base_url = "${getEffectiveBaseUrl()}" +auth = "bearer" +api_key_env = "JCODE_9ROUTER_API_KEY" +env_file = "provider-9router.env" +default_model = "${selectedModel || "cc/claude-opus-4-7"}" +requires_api_key = true + +[[providers.9router.models]] +id = "${selectedModel || "cc/claude-opus-4-7"}"`; + + const envContent = `JCODE_9ROUTER_API_KEY="${keyToUse}"`; + + return [ + { + filename: "~/.jcode/config.toml", + content: configToml, + }, + { + filename: "~/.config/jcode/provider-9router.env", + content: envContent, + }, + ]; + }; + + 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 && ( +
+ {checkingJcode && ( +
+ progress_activity + Checking jcode CLI... +
+ )} + + {!checkingJcode && jcodeStatus && !jcodeStatus.installed && ( +
+
+
+ warning +
+

jcode CLI not detected locally

+

Install jcode to enable automatic configuration:

+ + curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash + +

Manual configuration is still available if 9router is deployed on a remote server.

+
+
+
+ +
+
+
+ )} + + {!checkingJcode && jcodeStatus?.installed && ( + <> +
+ {/* Info notes */} + {tool.notes && tool.notes.length > 0 && ( +
+ {tool.notes.map((note, idx) => ( +
+ + {note.type === "info" ? "info" : note.type === "warning" ? "warning" : "help"} + + {note.text} +
+ ))} +
+ )} + + {/* Endpoint (selector) */} +
+ Select Endpoint + arrow_forward + +
+ + {/* Current configured */} + {jcodeStatus?.config?.providers?.["9router"]?.base_url && ( +
+ Current + arrow_forward + + {jcodeStatus.config.providers["9router"].base_url} + +
+ )} + + {/* API Key */} +
+ API Key + arrow_forward + +
+ + {/* Default Model */} +
+ Default Model + arrow_forward +
+ setSelectedModel(e.target.value)} placeholder="cc/claude-opus-4-7" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> + {selectedModel && } +
+ +
+ + {/* Usage hint */} +
+

Usage:

+ jcode --provider-profile 9router + jcode --provider-profile 9router --model {selectedModel || "cc/claude-opus-4-7"} +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for jcode" + /> + + setShowManualConfigModal(false)} + title="jcode - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js new file mode 100644 index 0000000..32463d9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { Card } from "@/shared/components"; + +// Derive simple connected/configured/not-installed status from API payload +function getStatus(status) { + if (!status) return { label: "Unknown", cls: "bg-gray-500/10 text-gray-500" }; + if (!status.installed) return { label: "Not installed", cls: "bg-red-500/10 text-red-600 dark:text-red-400" }; + if (status.has9Router) return { label: "Connected", cls: "bg-green-500/10 text-green-600 dark:text-green-400" }; + return { label: "Not configured", cls: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" }; +} + +export default function ToolSummaryCard({ toolId, tool, status }) { + const s = getStatus(status); + return ( + + +
+
+
+ {tool.image ? ( + {tool.name} { e.target.style.display = "none"; }} /> + ) : tool.icon ? ( + {tool.icon} + ) : null} +
+
+

{tool.name}

+ {s.label} +
+ chevron_right +
+

{tool.description}

+
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index a9e78e3..aeca870 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -11,6 +11,7 @@ export { default as CopilotToolCard } from "./CopilotToolCard"; export { default as ClineToolCard } from "./ClineToolCard"; export { default as KiloToolCard } from "./KiloToolCard"; export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; +export { default as JcodeToolCard } from "./JcodeToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index 14eb010..4d5174f 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -12,6 +12,7 @@ import { GET as copilotGet } from "../copilot-settings/route"; import { GET as clineGet } from "../cline-settings/route"; import { GET as kiloGet } from "../kilo-settings/route"; import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; +import { GET as jcodeGet } from "../jcode-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -25,6 +26,7 @@ const STATUS_GETTERS = { cline: clineGet, kilo: kiloGet, "deepseek-tui": deepseekTuiGet, + jcode: jcodeGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/jcode-settings/route.js b/src/app/api/cli-tools/jcode-settings/route.js new file mode 100644 index 0000000..9e05a16 --- /dev/null +++ b/src/app/api/cli-tools/jcode-settings/route.js @@ -0,0 +1,216 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { parseTOML, stringifyTOML } from "confbox"; + +const execAsync = promisify(exec); + +const getJcodeConfigDir = () => path.join(os.homedir(), ".jcode"); +const getConfigPath = () => path.join(getJcodeConfigDir(), "config.toml"); + +const getProviderEnvPath = () => { + const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configDir, "jcode", "provider-9router.env"); +}; + +const checkJcodeInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where jcode" : "which jcode"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getJcodeConfigDir()); + return true; + } catch { + return false; + } + } +}; + +const readConfig = async () => { + try { + const configPath = getConfigPath(); + const content = await fs.readFile(configPath, "utf-8"); + return parseTOML(content); + } catch (error) { + return { providers: {} }; + } +}; + +const has9RouterConfig = (config) => { + if (!config || !config.providers) return false; + + const providers = config.providers; + + if (providers["9router"]) return true; + + for (const [name, provider] of Object.entries(providers)) { + if (provider.base_url && provider.base_url.includes("localhost:20128")) { + return true; + } + } + + return false; +}; + +const writeConfig = async (config) => { + const configPath = getConfigPath(); + const content = stringifyTOML(config); + await fs.writeFile(configPath, content, "utf-8"); +}; + +const readProviderEnv = async () => { + try { + const envPath = getProviderEnvPath(); + const content = await fs.readFile(envPath, "utf-8"); + const env = {}; + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const eqIndex = trimmed.indexOf("="); + if (eqIndex > 0) { + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + env[key] = value; + } + } + + return env; + } catch { + return {}; + } +}; + +const writeProviderEnv = async (env) => { + const envPath = getProviderEnvPath(); + let content = "# jcode provider environment variables\n"; + + for (const [key, value] of Object.entries(env)) { + content += `${key}="${value}"\n`; + } + + await fs.writeFile(envPath, content, "utf-8"); +}; + +export async function GET() { + const isInstalled = await checkJcodeInstalled(); + + if (!isInstalled) { + return NextResponse.json({ + installed: false, + message: "jcode not installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", + }); + } + + const config = await readConfig(); + const has9Router = has9RouterConfig(config); + + return NextResponse.json({ + installed: true, + config, + has9Router, + configPath: getConfigPath(), + }); +} + +export async function POST(request) { + try { + const { baseUrl, apiKey, models } = await request.json(); + + if (!baseUrl || !apiKey) { + return NextResponse.json( + { error: "baseUrl and apiKey are required" }, + { status: 400 } + ); + } + + const normalizedBaseUrl = baseUrl.endsWith("/v1") + ? baseUrl + : `${baseUrl}/v1`; + + let config = await readConfig(); + + if (!config.providers) { + config.providers = {}; + } + + config.providers["9router"] = { + type: "openai-compatible", + base_url: normalizedBaseUrl, + auth: "bearer", + api_key_env: "JCODE_9ROUTER_API_KEY", + env_file: "provider-9router.env", + default_model: models && models.length > 0 ? models[0] : "cc/claude-opus-4-7", + requires_api_key: true, + }; + + const configDir = getJcodeConfigDir(); + await fs.mkdir(configDir, { recursive: true }); + + await writeConfig(config); + + const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + const jcodeConfigDir = path.join(xdgConfigDir, "jcode"); + await fs.mkdir(jcodeConfigDir, { recursive: true }); + + const env = await readProviderEnv(); + env.JCODE_9ROUTER_API_KEY = apiKey; + await writeProviderEnv(env); + + return NextResponse.json({ + success: true, + message: "jcode configured successfully. Use: jcode --provider-profile 9router", + configPath: getConfigPath(), + }); + } catch (error) { + console.error("Error configuring jcode:", error); + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } +} + +export async function DELETE() { + try { + const config = await readConfig(); + + if (!config.providers) { + return NextResponse.json({ success: true, message: "No configuration to remove" }); + } + + delete config.providers["9router"]; + + await writeConfig(config); + + const env = await readProviderEnv(); + delete env.JCODE_9ROUTER_API_KEY; + await writeProviderEnv(env); + + return NextResponse.json({ + success: true, + message: "9router configuration removed from jcode", + }); + } catch (error) { + console.error("Error removing jcode configuration:", error); + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 6a92d42..9306715 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -71,7 +71,7 @@ export const CLI_TOOLS = { claude: { id: "claude", name: "Claude Code", - icon: "terminal", + image: "/providers/claude.png", color: "#D97757", description: "Anthropic Claude Code CLI", configType: "env", @@ -217,7 +217,7 @@ export const CLI_TOOLS = { amp: { id: "amp", name: "Amp CLI", - icon: "terminal", + image: "/providers/amp.png", color: "#F97316", description: "Sourcegraph Amp coding assistant CLI", docsUrl: "/docs?section=cli-tools&tool=amp", @@ -248,7 +248,7 @@ amp --model "{{model}}" qwen: { id: "qwen", name: "Qwen Code", - icon: "psychology", + image: "/providers/qwen.png", color: "#10B981", description: "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router", docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/", @@ -314,6 +314,35 @@ amp --model "{{model}}" { type: "warning", text: "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml" }, ], }, + jcode: { + id: "jcode", + name: "jcode", + image: "/providers/jcode.png", + color: "#FF6B35", + description: "High-performance Rust-based coding agent harness", + configType: "custom", + docsUrl: "https://github.com/1jehuang/jcode", + notes: [ + { + type: "info", + text: "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot)." + }, + { + type: "info", + text: "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer." + }, + { + type: "warning", + text: "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash" + }, + ], + defaultModels: [ + { id: "claude-opus-4-7", name: "Claude Opus 4.7", alias: "opus", defaultValue: "cc/claude-opus-4-7" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "sonnet", defaultValue: "cc/claude-sonnet-4-6" }, + { id: "gpt-5.5", name: "GPT 5.5", alias: "gpt5", defaultValue: "cx/gpt-5.5" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli",