diff --git a/package.json b/package.json index 81b7ca51..9c562178 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "9router-app", - "version": "0.2.63", + "version": "0.2.66", "description": "9Router web dashboard", "private": true, "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", - "start": "next start" + "start": "next start" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/public/providers/anthropic-m.png b/public/providers/anthropic-m.png index 59807db5..feea1edd 100644 Binary files a/public/providers/anthropic-m.png and b/public/providers/anthropic-m.png differ diff --git a/public/providers/anthropic.png b/public/providers/anthropic.png index a3c374d3..dc58142b 100644 Binary files a/public/providers/anthropic.png and b/public/providers/anthropic.png differ diff --git a/public/providers/antigravity.png b/public/providers/antigravity.png index bb2f256e..6fe0feae 100644 Binary files a/public/providers/antigravity.png and b/public/providers/antigravity.png differ diff --git a/public/providers/claude.png b/public/providers/claude.png index 15b3663a..c223ad46 100644 Binary files a/public/providers/claude.png and b/public/providers/claude.png differ diff --git a/public/providers/cline.png b/public/providers/cline.png index 215efe81..be241899 100644 Binary files a/public/providers/cline.png and b/public/providers/cline.png differ diff --git a/public/providers/codex.png b/public/providers/codex.png index b22f2c2c..41a9dd77 100644 Binary files a/public/providers/codex.png and b/public/providers/codex.png differ diff --git a/public/providers/continue.png b/public/providers/continue.png index 8b60abbe..f54685be 100644 Binary files a/public/providers/continue.png and b/public/providers/continue.png differ diff --git a/public/providers/copilot.png b/public/providers/copilot.png index bedbab1f..9907963e 100644 Binary files a/public/providers/copilot.png and b/public/providers/copilot.png differ diff --git a/public/providers/cursor.png b/public/providers/cursor.png index 234bd669..ec02b070 100644 Binary files a/public/providers/cursor.png and b/public/providers/cursor.png differ diff --git a/public/providers/droid.png b/public/providers/droid.png new file mode 100644 index 00000000..28b8350a Binary files /dev/null and b/public/providers/droid.png differ diff --git a/public/providers/gemini-cli.png b/public/providers/gemini-cli.png index c226db22..5b72946d 100644 Binary files a/public/providers/gemini-cli.png and b/public/providers/gemini-cli.png differ diff --git a/public/providers/gemini.png b/public/providers/gemini.png index f687493c..9df2d301 100644 Binary files a/public/providers/gemini.png and b/public/providers/gemini.png differ diff --git a/public/providers/github.png b/public/providers/github.png index bedbab1f..9907963e 100644 Binary files a/public/providers/github.png and b/public/providers/github.png differ diff --git a/public/providers/glm.png b/public/providers/glm.png index 22956015..cee2b24b 100644 Binary files a/public/providers/glm.png and b/public/providers/glm.png differ diff --git a/public/providers/iflow.png b/public/providers/iflow.png index 9ca74cad..1bddeae6 100644 Binary files a/public/providers/iflow.png and b/public/providers/iflow.png differ diff --git a/public/providers/kimi.png b/public/providers/kimi.png index f060fe43..422b7f96 100644 Binary files a/public/providers/kimi.png and b/public/providers/kimi.png differ diff --git a/public/providers/kiro.png b/public/providers/kiro.png index 86bd8644..166c7c72 100644 Binary files a/public/providers/kiro.png and b/public/providers/kiro.png differ diff --git a/public/providers/minimax-cn.png b/public/providers/minimax-cn.png index ac198eaf..a8b9bf7e 100644 Binary files a/public/providers/minimax-cn.png and b/public/providers/minimax-cn.png differ diff --git a/public/providers/minimax.png b/public/providers/minimax.png index ac198eaf..a8b9bf7e 100644 Binary files a/public/providers/minimax.png and b/public/providers/minimax.png differ diff --git a/public/providers/oai-cc.png b/public/providers/oai-cc.png index 47a08283..56a7a3e6 100644 Binary files a/public/providers/oai-cc.png and b/public/providers/oai-cc.png differ diff --git a/public/providers/oai-r.png b/public/providers/oai-r.png index 9c192c15..0dbd61c7 100644 Binary files a/public/providers/oai-r.png and b/public/providers/oai-r.png differ diff --git a/public/providers/openai.png b/public/providers/openai.png index 9ddf6a0b..d4367af0 100644 Binary files a/public/providers/openai.png and b/public/providers/openai.png differ diff --git a/public/providers/openclaw.png b/public/providers/openclaw.png new file mode 100644 index 00000000..7ef77ac7 Binary files /dev/null and b/public/providers/openclaw.png differ diff --git a/public/providers/openrouter.png b/public/providers/openrouter.png index 76ad989e..0b4802d1 100644 Binary files a/public/providers/openrouter.png and b/public/providers/openrouter.png differ diff --git a/public/providers/qwen.png b/public/providers/qwen.png index b4b7f582..0fc2d1b3 100644 Binary files a/public/providers/qwen.png and b/public/providers/qwen.png differ diff --git a/public/providers/roo.png b/public/providers/roo.png index 8dd0c336..05035775 100644 Binary files a/public/providers/roo.png and b/public/providers/roo.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 2705c8fb..bf099369 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, DefaultToolCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard } from "./components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -156,6 +156,10 @@ export default function CLIToolsPageClient({ machineId }) { ); case "codex": return ; + case "droid": + return ; + case "openclaw": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 17dc929f..b60f2ba7 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -102,7 +102,15 @@ export default function ClaudeToolCard({ } }; - const getEffectiveBaseUrl = () => customBaseUrl || baseUrl; + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; const handleApplySettings = async () => { setApplying(true); @@ -250,15 +258,26 @@ export default function ClaudeToolCard({ {!checkingClaude && claudeStatus?.installed && ( <>
+ {/* Current Base URL */} + {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && ( +
+ Current + arrow_forward + + {claudeStatus.settings.env.ANTHROPIC_BASE_URL} + +
+ )} + {/* Base URL */}
Base URL arrow_forward setCustomBaseUrl(e.target.value)} - placeholder="https://..." + placeholder="https://.../v1" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> {customBaseUrl && customBaseUrl !== baseUrl && ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 4136e6e9..a268086d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -229,6 +229,21 @@ wire_api = "responses" {!checkingCodex && codexStatus?.installed && ( <>
+ {/* Current Base URL */} + {codexStatus?.config && (() => { + const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); + const currentBaseUrl = parsed ? parsed[1] : null; + return currentBaseUrl ? ( +
+ Current + arrow_forward + + {currentBaseUrl} + +
+ ) : null; + })()} + {/* Base URL */}
Base URL diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js new file mode 100644 index 00000000..e2efbc78 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -0,0 +1,332 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function DroidToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [droidStatus, setDroidStatus] = useState(null); + const [checkingDroid, setCheckingDroid] = 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 (!droidStatus?.installed) return null; + const currentConfig = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0"); + if (!currentConfig) return "not_configured"; + const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL); + if (localMatch || cloudMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !droidStatus) { + checkDroidStatus(); + fetchModelAliases(); + } + }, [isExpanded, droidStatus]); + + 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 (droidStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const customModel = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0"); + if (customModel) { + if (customModel.model) setSelectedModel(customModel.model); + if (customModel.apiKey && apiKeys?.some(k => k.key === customModel.apiKey)) { + setSelectedApiKey(customModel.apiKey); + } + } + } + }, [droidStatus, apiKeys]); + + const checkDroidStatus = async () => { + setCheckingDroid(true); + try { + const res = await fetch("/api/cli-tools/droid-settings"); + const data = await res.json(); + setDroidStatus(data); + } catch (error) { + setDroidStatus({ installed: false, error: error.message }); + } finally { + setCheckingDroid(false); + } + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || baseUrl; + 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/droid-settings", { + 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!" }); + checkDroidStatus(); + } 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/droid-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + setSelectedApiKey(""); + checkDroidStatus(); + } 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 settingsContent = { + customModels: [ + { + model: selectedModel || "provider/model-id", + id: "custom:9Router-0", + index: 0, + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + displayName: selectedModel || "provider/model-id", + maxOutputTokens: 131072, + noImageSupport: false, + provider: "openai", + }, + ], + }; + + const platform = typeof navigator !== "undefined" && navigator.platform; + const isWindows = platform?.toLowerCase().includes("win"); + const settingsPath = isWindows + ? "%USERPROFILE%\\.factory\\settings.json" + : "~/.factory/settings.json"; + + return [ + { + filename: settingsPath, + content: JSON.stringify(settingsContent, 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 && ( +
+ {checkingDroid && ( +
+ progress_activity + Checking Factory Droid CLI... +
+ )} + + {!checkingDroid && droidStatus && !droidStatus.installed && ( +
+ warning +
+

Factory Droid CLI not installed

+

Please install Factory Droid CLI to use this feature.

+
+
+ )} + + {!checkingDroid && droidStatus?.installed && ( + <> +
+ {/* Current Base URL */} + {droidStatus?.settings?.customModels?.find(m => m.id === "custom:9Router-0")?.baseUrl && ( +
+ Current + arrow_forward + + {droidStatus.settings.customModels.find(m => m.id === "custom:9Router-0").baseUrl} + +
+ )} + + {/* Base URL */} +
+ Base URL + arrow_forward + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ + {/* API Key */} +
+ API Key + arrow_forward + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ + {/* Model */} +
+ Model + arrow_forward + setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> + + {selectedModel && } +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Factory Droid" + /> + + setShowManualConfigModal(false)} + title="Factory Droid - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js new file mode 100644 index 00000000..2bec5e8b --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -0,0 +1,339 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function OpenClawToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [openclawStatus, setOpenclawStatus] = useState(null); + const [checkingOpenclaw, setCheckingOpenclaw] = 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 (!openclawStatus?.installed) return null; + const currentProvider = openclawStatus.settings?.models?.providers?.["9router"]; + if (!currentProvider) return "not_configured"; + const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && currentProvider.baseUrl?.startsWith(CLOUD_URL); + if (localMatch || cloudMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !openclawStatus) { + checkOpenclawStatus(); + fetchModelAliases(); + } + }, [isExpanded, openclawStatus]); + + 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 (openclawStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const provider = openclawStatus.settings?.models?.providers?.["9router"]; + if (provider) { + const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary; + if (primaryModel) { + const modelId = primaryModel.replace("9router/", ""); + setSelectedModel(modelId); + } + if (provider.apiKey && apiKeys?.some(k => k.key === provider.apiKey)) { + setSelectedApiKey(provider.apiKey); + } + } + } + }, [openclawStatus, apiKeys]); + + const checkOpenclawStatus = async () => { + setCheckingOpenclaw(true); + try { + const res = await fetch("/api/cli-tools/openclaw-settings"); + const data = await res.json(); + setOpenclawStatus(data); + } catch (error) { + setOpenclawStatus({ installed: false, error: error.message }); + } finally { + setCheckingOpenclaw(false); + } + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || baseUrl; + 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/openclaw-settings", { + 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!" }); + checkOpenclawStatus(); + } 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/openclaw-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + setSelectedApiKey(""); + checkOpenclawStatus(); + } 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 settingsContent = { + agents: { + defaults: { + model: { + primary: `9router/${selectedModel || "provider/model-id"}`, + }, + }, + }, + models: { + providers: { + "9router": { + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + api: "openai-completions", + models: [ + { + id: selectedModel || "provider/model-id", + name: (selectedModel || "provider/model-id").split("/").pop(), + }, + ], + }, + }, + }, + }; + + return [ + { + filename: "~/.openclaw/openclaw.json", + content: JSON.stringify(settingsContent, 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 && ( +
+ {checkingOpenclaw && ( +
+ progress_activity + Checking Open Claw CLI... +
+ )} + + {!checkingOpenclaw && openclawStatus && !openclawStatus.installed && ( +
+ warning +
+

Open Claw CLI not installed

+

Please install Open Claw CLI to use this feature.

+
+
+ )} + + {!checkingOpenclaw && openclawStatus?.installed && ( + <> +
+ {/* Current Base URL */} + {openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && ( +
+ Current + arrow_forward + + {openclawStatus.settings.models.providers["9router"].baseUrl} + +
+ )} + + {/* Base URL */} +
+ Base URL + arrow_forward + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ + {/* API Key */} +
+ API Key + arrow_forward + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ + {/* Model */} +
+ Model + arrow_forward + setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> + + {selectedModel && } +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Open Claw" + /> + + setShowManualConfigModal(false)} + title="Open Claw - 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 dd07abcb..63d3492a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -1,4 +1,6 @@ export { default as ClaudeToolCard } from "./ClaudeToolCard"; export { default as CodexToolCard } from "./CodexToolCard"; +export { default as DroidToolCard } from "./DroidToolCard"; +export { default as OpenClawToolCard } from "./OpenClawToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index 3c2a84a6..e64d0944 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -102,6 +102,13 @@ export async function POST(request) { } } + // Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix + if (env.ANTHROPIC_BASE_URL) { + env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1") + ? env.ANTHROPIC_BASE_URL + : `${env.ANTHROPIC_BASE_URL}/v1`; + } + // Merge new env with existing settings const newSettings = { ...currentSettings, diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js index 387f9a5b..18f17eb9 100644 --- a/src/app/api/cli-tools/codex-settings/route.js +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -155,9 +155,11 @@ export async function POST(request) { parsed._root.model_provider = "9router"; // Update or create 9router provider section (no api_key - Codex reads from auth.json) + // Ensure /v1 suffix is added only once + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; parsed._sections["model_providers.9router"] = { name: "9Router", - base_url: `${baseUrl}/v1`, + base_url: normalizedBaseUrl, wire_api: "responses", }; diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js new file mode 100644 index 00000000..12f65545 --- /dev/null +++ b/src/app/api/cli-tools/droid-settings/route.js @@ -0,0 +1,175 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +const getDroidDir = () => path.join(os.homedir(), ".factory"); +const getDroidSettingsPath = () => path.join(getDroidDir(), "settings.json"); + +// Check if droid CLI is installed +const checkDroidInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where droid" : "which droid"; + await execAsync(command); + return true; + } catch { + return false; + } +}; + +// Read current settings.json +const readSettings = async () => { + try { + const settingsPath = getDroidSettingsPath(); + const content = await fs.readFile(settingsPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Check if settings has 9Router customModels +const has9RouterConfig = (settings) => { + if (!settings || !settings.customModels) return false; + return settings.customModels.some(m => m.id === "custom:9Router-0"); +}; + +// GET - Check droid CLI and read current settings +export async function GET() { + try { + const isInstalled = await checkDroidInstalled(); + + if (!isInstalled) { + return NextResponse.json({ + installed: false, + settings: null, + message: "Factory Droid CLI is not installed", + }); + } + + const settings = await readSettings(); + + return NextResponse.json({ + installed: true, + settings, + has9Router: has9RouterConfig(settings), + settingsPath: getDroidSettingsPath(), + }); + } catch (error) { + console.log("Error checking droid settings:", error); + return NextResponse.json({ error: "Failed to check droid settings" }, { status: 500 }); + } +} + +// POST - Update 9Router customModels (merge with existing settings) +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const droidDir = getDroidDir(); + const settingsPath = getDroidSettingsPath(); + + // Ensure directory exists + await fs.mkdir(droidDir, { recursive: true }); + + // Read existing settings or create new + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch { /* No existing settings */ } + + // Ensure customModels array exists + if (!settings.customModels) { + settings.customModels = []; + } + + // Remove existing 9Router config if any + settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0"); + + // Normalize baseUrl to ensure /v1 suffix + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // Add new 9Router config + const customModel = { + model: model, + id: "custom:9Router-0", + index: 0, + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "your_api_key", + displayName: model, + maxOutputTokens: 131072, + noImageSupport: false, + provider: "openai", + }; + + settings.customModels.unshift(customModel); + + // Write settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "Factory Droid settings applied successfully!", + settingsPath, + }); + } catch (error) { + console.log("Error updating droid settings:", error); + return NextResponse.json({ error: "Failed to update droid settings" }, { status: 500 }); + } +} + +// DELETE - Remove 9Router customModels only (keep other settings) +export async function DELETE() { + try { + const settingsPath = getDroidSettingsPath(); + + // Read existing settings + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ + success: true, + message: "No settings file to reset", + }); + } + throw error; + } + + // Remove 9Router customModels + if (settings.customModels) { + settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0"); + + // Remove customModels array if empty + if (settings.customModels.length === 0) { + delete settings.customModels; + } + } + + // Write updated settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "9Router settings removed successfully", + }); + } catch (error) { + console.log("Error resetting droid settings:", error); + return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js new file mode 100644 index 00000000..cd46bcdc --- /dev/null +++ b/src/app/api/cli-tools/openclaw-settings/route.js @@ -0,0 +1,180 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +const getOpenClawDir = () => path.join(os.homedir(), ".openclaw"); +const getOpenClawSettingsPath = () => path.join(getOpenClawDir(), "openclaw.json"); + +// Check if openclaw CLI is installed +const checkOpenClawInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where openclaw" : "which openclaw"; + await execAsync(command); + return true; + } catch { + return false; + } +}; + +// Read current settings.json +const readSettings = async () => { + try { + const settingsPath = getOpenClawSettingsPath(); + const content = await fs.readFile(settingsPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Check if settings has 9Router config +const has9RouterConfig = (settings) => { + if (!settings || !settings.models || !settings.models.providers) return false; + return !!settings.models.providers["9router"]; +}; + +// GET - Check openclaw CLI and read current settings +export async function GET() { + try { + const isInstalled = await checkOpenClawInstalled(); + + if (!isInstalled) { + return NextResponse.json({ + installed: false, + settings: null, + message: "Open Claw CLI is not installed", + }); + } + + const settings = await readSettings(); + + return NextResponse.json({ + installed: true, + settings, + has9Router: has9RouterConfig(settings), + settingsPath: getOpenClawSettingsPath(), + }); + } catch (error) { + console.log("Error checking openclaw settings:", error); + return NextResponse.json({ error: "Failed to check openclaw settings" }, { status: 500 }); + } +} + +// POST - Update 9Router settings (merge with existing settings) +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const openclawDir = getOpenClawDir(); + const settingsPath = getOpenClawSettingsPath(); + + // Ensure directory exists + await fs.mkdir(openclawDir, { recursive: true }); + + // Read existing settings or create new + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch { /* No existing settings */ } + + // Ensure structure exists + if (!settings.agents) settings.agents = {}; + if (!settings.agents.defaults) settings.agents.defaults = {}; + if (!settings.agents.defaults.model) settings.agents.defaults.model = {}; + if (!settings.models) settings.models = {}; + if (!settings.models.providers) settings.models.providers = {}; + + // Normalize baseUrl to ensure /v1 suffix + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // Update agents.defaults.model.primary + settings.agents.defaults.model.primary = `9router/${model}`; + + // Update models.providers.9router + settings.models.providers["9router"] = { + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "your_api_key", + api: "openai-completions", + models: [ + { + id: model, + name: model.split("/").pop() || model, + }, + ], + }; + + // Write settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "Open Claw settings applied successfully!", + settingsPath, + }); + } catch (error) { + console.log("Error updating openclaw settings:", error); + return NextResponse.json({ error: "Failed to update openclaw settings" }, { status: 500 }); + } +} + +// DELETE - Remove 9Router settings only (keep other settings) +export async function DELETE() { + try { + const settingsPath = getOpenClawSettingsPath(); + + // Read existing settings + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ + success: true, + message: "No settings file to reset", + }); + } + throw error; + } + + // Remove 9Router from models.providers + if (settings.models && settings.models.providers) { + delete settings.models.providers["9router"]; + + // Remove providers object if empty + if (Object.keys(settings.models.providers).length === 0) { + delete settings.models.providers; + } + } + + // Reset agents.defaults.model.primary if it uses 9router + if (settings.agents?.defaults?.model?.primary?.startsWith("9router/")) { + delete settings.agents.defaults.model.primary; + } + + // Write updated settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "9Router settings removed successfully", + }); + } catch (error) { + console.log("Error resetting openclaw settings:", error); + return NextResponse.json({ error: "Failed to reset openclaw settings" }, { status: 500 }); + } +} diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js index cba15167..61df88e4 100644 --- a/src/app/api/provider-nodes/route.js +++ b/src/app/api/provider-nodes/route.js @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { createProviderNode, getProviderNodes } from "@/models"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; +import { generateId } from "@/shared/utils"; const OPENAI_COMPATIBLE_DEFAULTS = { baseUrl: "https://api.openai.com/v1", @@ -44,7 +45,7 @@ export async function POST(request) { } const node = await createProviderNode({ - id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`, + id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`, type: "openai-compatible", prefix: prefix.trim(), apiType, @@ -63,7 +64,7 @@ export async function POST(request) { } const node = await createProviderNode({ - id: `${ANTHROPIC_COMPATIBLE_PREFIX}${crypto.randomUUID()}`, + id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`, type: "anthropic-compatible", prefix: prefix.trim(), baseUrl: sanitizedBaseUrl, diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 6c39b0f6..690b2c10 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -147,6 +147,12 @@ export async function getProviderNodeById(id) { */ export async function createProviderNode(data) { const db = await getDb(); + + // Initialize providerNodes if undefined (backward compatibility) + if (!db.data.providerNodes) { + db.data.providerNodes = []; + } + const now = new Date().toISOString(); const node = { @@ -171,6 +177,10 @@ export async function createProviderNode(data) { */ export async function updateProviderNode(id, data) { const db = await getDb(); + if (!db.data.providerNodes) { + db.data.providerNodes = []; + } + const index = db.data.providerNodes.findIndex((node) => node.id === id); if (index === -1) return null; @@ -191,6 +201,10 @@ export async function updateProviderNode(id, data) { */ export async function deleteProviderNode(id) { const db = await getDb(); + if (!db.data.providerNodes) { + db.data.providerNodes = []; + } + const index = db.data.providerNodes.findIndex((node) => node.id === id); if (index === -1) return null; diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 18e5755e..631aed34 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -30,6 +30,22 @@ export const CLI_TOOLS = { description: "OpenAI Codex CLI", configType: "custom", }, + droid: { + id: "droid", + name: "Factory Droid", + image: "/providers/droid.png", + color: "#00D4FF", + description: "Factory Droid AI Assistant", + configType: "custom", + }, + openclaw: { + id: "openclaw", + name: "Open Claw", + image: "/providers/openclaw.png", + color: "#FF6B35", + description: "Open Claw AI Assistant", + configType: "custom", + }, cursor: { id: "cursor", name: "Cursor", diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index d7db9d95..f64461ab 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -2,6 +2,14 @@ export { cn } from "./cn"; export * as api from "./api"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Generate unique ID (UUID v4) + * @returns {string} UUID v4 string + */ +export const generateId = uuidv4; + /** * Extract error code from error message (401, 429, 503...) * @param {string} lastError - Error message