diff --git a/CHANGELOG.md b/CHANGELOG.md index f66dfbf..51ca928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# v0.4.29 (2026-05-10) + +## Features +- Add Cline & Kilo Code tool cards +- Tailscale TUN mode for stable Funnel TLS +- Sort APIKEY providers by usage, collapse to top 20 + +## Improvements +- Local Material Symbols font (no Google Fonts) +- Docker base: Bun → Node 22-alpine +- MITM reads aliases from JSON cache (no native sqlite) +- Stream stall timeout (3 min) in open-sse + +## Fixes +- Fal.ai key test: use stable models endpoint + # v0.4.28 (2026-05-10) ## Features @@ -300,121 +316,4 @@ # v0.3.89 (2026-04-13) ## Improvements -- Improved dashboard access control by blocking tunnel/Tailscale access when disabled - -# v0.3.87 (2026-04-13) - -## Fixes -- Fix codex cache session id - -# v0.3.86 (2026-04-13) - -## Features -- Add provider models and thinking configurations for enhanced chat handling -- Add Vercel relay support to proxy functionality -- Add Vercel deploy endpoint for proxy pools management - -## Improvements -- Enhance proxy functionality with new relay capabilities -- Streamline GitHub Actions Docker publish workflow -- Update Docker configuration and package management - -## Fixes -- Remove obsolete 9remote installation/management APIs - -# v0.3.83 (2026-04-08) - -## Fixes -- Fix OpenRouter custom models not showing after being added - -# Unreleased - -## Features -- Added API key visibility toggle (eye icon) to Endpoint dashboard page for improved UX and security. - -# v0.2.66 (2026-02-06) - -## Features -- Added Cursor provider end-to-end support, including OAuth import flow and translator/executor integration (`137f315`, `0a026c7`). -- Enhanced auth/settings flow with `requireLogin` control and `hasPassword` state handling in dashboard/login APIs (`249fc28`). -- Improved usage/quota UX with richer provider limit cards, new quota table, and clearer reset/countdown display (`32aefe5`). -- Added model support for custom providers in UI/combos/model selection (`a7a52be`). -- Expanded model/provider catalog: - - Codex updates: GPT-5.3 support, translation fixes, thinking levels (`127475d`) - - Added Claude Opus 4.6 model (`e8aa3e2`) - - Added MiniMax Coding (CN) provider (`7c609d7`) - - Added iFlow Kimi K2.5 model (`9e357a7`) - - Updated CLI tools with Droid/OpenClaw cards and base URL visibility improvements (`a2122e3`) -- Added auto-validation for provider API keys when saving settings (`b275dfd`). -- Added Docker/runtime deployment docs and architecture documentation updates (`5e4a15b`). - -## Fixes -- Improved local-network compatibility by allowing auth cookie flow over HTTP deployments (`0a394d0`). -- Improved Antigravity quota/stream handling and Droid CLI compatibility behavior (`3c65e0c`, `c612741`, `8c6e3b8`). -- Fixed GitHub Copilot model mapping/selection issues (`95fd950`). -- Hardened local DB behavior with corrupt JSON recovery and schema-shape migration safeguards (`e6ef852`). -- Fixed logout/login edge cases: - - Prevent unintended auto-login after logout (`49df3dc`) - - Avoid infinite loading on failed `/api/settings` responses (`01c9410`) - -# v0.2.56 (2026-02-04) - -## Features -- Added Anthropic-compatible provider support across providers API/UI flow (`da5bdef`). -- Added provider icons to dashboard provider pages/lists (`60bd686`, `8ceb8f2`). -- Enhanced usage tracking pipeline across response handlers/streams with buffered accounting improvements (`a33924b`, `df0e1d6`, `7881db8`). - -## Fixes -- Fixed usage conversion and related provider limits presentation issues (`e6e44ac`). - -# v0.2.52 (2026-02-02) - -## Features -- Implemented Codex Cursor compatibility and Next.js 16 proxy migration updates (`e9b0a73`, `7b864a9`, `1c6dd6d`). -- Added OpenAI-compatible provider nodes with CRUD/validation/test coverage in API and UI (`0a28f9f`). -- Added token expiration and key-validity checks in provider test flow (`686585d`). -- Added Kiro token refresh support in shared token refresh service (`f2ca6f0`). -- Added non-streaming response translation support for multiple formats (`63f2da8`). -- Updated Kiro OAuth wiring and auth-related UI assets/components (`31cc79a`). - -## Fixes -- Fixed cloud translation/request compatibility path (`c7219d0`). -- Fixed Kiro auth modal/flow issues (`85b7bb9`). -- Included Antigravity stability fixes in translator/executor flow (`2393771`, `8c37b39`). - -# v0.2.43 (2026-01-27) - -## Fixes -- Fixed CLI tools model selection behavior (`a015266`). -- Fixed Kiro translator request handling (`d3dd868`). - -# v0.2.36 (2026-01-19) - -## Features -- Added the Usage dashboard page and related usage stats components (`3804357`). -- Integrated outbound proxy support in Open SSE fetch pipeline (`0943387`). -- Improved OpenAI compatibility and build stability across endpoint/profile/providers flows (`d9b8e48`). - -## Fixes -- Fixed combo fallback behavior (`e6ca119`). -- Resolved SonarQube findings, Next.js image warnings, and build/lint cleanups (`7058b06`, `0848dd5`). - -# v0.2.31 (2026-01-18) - -## Fixes -- Fixed Kiro token refresh and executor behavior (`6b22b1f`, `1d481c2`). -- Fixed Kiro request translation handling (`eff52f7`, `da15660`). - -# v0.2.27 (2026-01-15) - -## Features -- Added Kiro provider support with OAuth flow (`26b61e5`). - -## Fixes -- Fixed Codex provider behavior (`26b61e5`). - -# v0.2.21 (2026-01-12) - -## Changes -- README updates. -- Antigravity bug fixes. +- Improved dashboard access control by blocking tunnel/Tailscale access when disabled \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 83496e4..8e282a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # syntax=docker/dockerfile:1.7 -ARG BUN_IMAGE=oven/bun:1.3.2-alpine -FROM ${BUN_IMAGE} AS base +ARG NODE_IMAGE=node:22-alpine +FROM ${NODE_IMAGE} AS base WORKDIR /app FROM base AS builder -RUN apk --no-cache upgrade && apk --no-cache add nodejs npm python3 make g++ linux-headers +RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers COPY package.json ./ RUN --mount=type=cache,target=/root/.npm \ @@ -15,7 +15,7 @@ COPY . ./ ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build -FROM ${BUN_IMAGE} AS runner +FROM ${NODE_IMAGE} AS runner WORKDIR /app LABEL org.opencontainers.image.title="9router" @@ -35,16 +35,16 @@ COPY --from=builder /app/src/mitm ./src/mitm # Standalone node_modules may omit deps only required by the MITM child process. COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge -RUN mkdir -p /app/data && chown -R bun:bun /app && \ - mkdir -p /app/data-home && chown bun:bun /app/data-home && \ +RUN mkdir -p /app/data && chown -R node:node /app && \ + mkdir -p /app/data-home && chown node:node /app/data-home && \ ln -sf /app/data-home /root/.9router 2>/dev/null || true # Fix permissions at runtime (handles mounted volumes) RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ - printf '#!/bin/sh\nchown -R bun:bun /app/data /app/data-home 2>/dev/null\nexec su-exec bun "$@"\n' > /entrypoint.sh && \ + printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \ chmod +x /entrypoint.sh EXPOSE 20128 ENTRYPOINT ["/entrypoint.sh"] -CMD ["bun", "server.js"] +CMD ["node", "server.js"] diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 369ab2d..a82cff3 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -31,6 +31,9 @@ export const MEMORY_CONFIG = { proxyDispatchersMaxSize: 20, }; +// Stream stall timeout: abort if no chunk received within this duration +export const STREAM_STALL_TIMEOUT_MS = 3 * 60 * 1000; + // Default token limits export const DEFAULT_MAX_TOKENS = 64000; export const DEFAULT_MIN_TOKENS = 32000; diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 10ac653..7922746 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -1,4 +1,5 @@ // Stream handler with disconnect detection - shared for all providers +import { STREAM_STALL_TIMEOUT_MS } from "../config/runtimeConfig.js"; // Get HH:MM:SS timestamp function getTimeString() { @@ -98,7 +99,19 @@ export function createDisconnectAwareStream(transformStream, streamController) { } try { - const { done, value } = await reader.read(); + // Race between chunk arrival and stall timeout + let stallTimer; + const stallPromise = new Promise((_, reject) => { + stallTimer = setTimeout(() => reject(new Error("stream stall timeout")), STREAM_STALL_TIMEOUT_MS); + }); + + let done, value; + try { + ({ done, value } = await Promise.race([reader.read(), stallPromise])); + } finally { + clearTimeout(stallTimer); + } + if (done) { streamController.handleComplete(); controller.close(); @@ -107,7 +120,6 @@ export function createDisconnectAwareStream(transformStream, streamController) { controller.enqueue(value); } catch (error) { streamController.handleError(error); - // Cleanup reader/writer to avoid orphaned streams reader.cancel().catch(() => {}); writer.abort().catch(() => {}); controller.error(error); diff --git a/package.json b/package.json index 8e47141..70f46c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.28", + "version": "0.4.29", "description": "9Router web dashboard", "private": true, "scripts": { @@ -21,6 +21,7 @@ "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", + "material-symbols": "^0.44.6", "monaco-editor": "^0.55.1", "next": "^16.1.6", "node-forge": "^1.3.3", diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js index fb1fc4c..2d6b3ef 100644 --- a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -222,7 +222,7 @@ export default function BasicChatPageClient() { if (connections.length === 0) { if (!cancelled) { setProviderGroups([]); - setLoadError("Chưa có provider nào được connect."); + setLoadError("No providers connected yet."); } return; } @@ -293,12 +293,12 @@ export default function BasicChatPageClient() { if (!cancelled) { setProviderGroups(normalized); if (normalized.length === 0) { - setLoadError("Đã có provider connect nhưng chưa lấy được model nào."); + setLoadError("Providers connected but no models available."); } } } catch (error) { if (!cancelled) { - setLoadError(textValue(error?.message) || "Không thể tải danh sách provider/model."); + setLoadError(textValue(error?.message) || "Failed to load providers/models."); setProviderGroups([]); } } finally { @@ -713,7 +713,7 @@ export default function BasicChatPageClient() { messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: message.content || `Error: ${errorText}`, status: "error" } : message)), updatedAt: new Date().toISOString(), })); - setLoadError(errorText || "Không thể gửi tin nhắn."); + setLoadError(errorText || "Failed to send message."); } } finally { setIsSending(false); @@ -756,7 +756,7 @@ export default function BasicChatPageClient() {

Models

-

Chỉ lấy từ provider đã connect

+

Only from connected providers

{providerGroups.map((group) => ( @@ -815,7 +815,7 @@ export default function BasicChatPageClient() {
{sessionItems.length === 0 ? (
- Chưa có cuộc trò chuyện nào. + No conversations yet.
) : sessionItems.map((session) => { const isActive = session.id === activeSessionId; diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 3d2ef59..1ec3382 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 { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, MitmLinkCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, MitmLinkCard } from "./components"; import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -190,6 +190,10 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "copilot": return ; + case "cline": + return ; + case "kilo": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js new file mode 100644 index 0000000..e0f24f3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; + +const CUSTOM_VALUE = "__custom__"; + +export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) { + const isCustom = !apiKeys.some((k) => k.key === value) && value !== ""; + const [mode, setMode] = useState(() => { + if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE; + if (apiKeys.some((k) => k.key === value)) return value; + return CUSTOM_VALUE; + }); + const [customInput, setCustomInput] = useState(isCustom ? value : ""); + + const handleSelect = (e) => { + const next = e.target.value; + setMode(next); + if (next === CUSTOM_VALUE) { + setCustomInput(""); + onChange(""); + } else { + onChange(next); + } + }; + + const handleCustomInput = (e) => { + const v = e.target.value; + setCustomInput(v); + onChange(v); + }; + + const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE; + + if (noKeys && mode !== CUSTOM_VALUE) { + return ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + ); + } + + return ( +
+ + {mode === CUSTOM_VALUE && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index a25767c..c276fcb 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -138,7 +139,6 @@ export default function ClaudeToolCard({ const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; }; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const handleApplySettings = async () => { setApplying(true); @@ -324,16 +324,7 @@ export default function ClaudeToolCard({
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
{/* Model Mappings */} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js new file mode 100644 index 0000000..41fd0b1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js @@ -0,0 +1,301 @@ +"use client"; + +import { useState, useEffect } 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 ClineToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { + 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 [showInstallGuide, setShowInstallGuide] = useState(false); + 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(""); + + 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]); + + useEffect(() => { + if (status?.settings?.openAiModelId) setSelectedModel(status.settings.openAiModelId); + }, [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?.installed) return null; + if (!status.has9Router) return "not_configured"; + const url = status.settings?.openAiBaseUrl || ""; + return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; + }; + + const configStatus = getConfigStatus(); + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || `${baseUrl}/v1`; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch("/api/cli-tools/cline-settings"); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ installed: false, 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/cline-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!" }); + 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/cline-settings", { 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 getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + const effectiveUrl = getEffectiveBaseUrl(); + const baseWithoutV1 = effectiveUrl.endsWith("/v1") ? effectiveUrl.slice(0, -3) : effectiveUrl; + + return [ + { + filename: "~/.cline/data/globalState.json", + content: JSON.stringify({ + actModeApiProvider: "openai", + planModeApiProvider: "openai", + openAiBaseUrl: baseWithoutV1, + openAiModelId: selectedModel || "provider/model-id", + planModeOpenAiModelId: selectedModel || "provider/model-id", + }, null, 2), + }, + { + filename: "~/.cline/data/secrets.json", + content: JSON.stringify({ openAiApiKey: keyToUse }, 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 Cline... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

Cline not detected locally

+

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

+
+
+
+ + +
+
+ {showInstallGuide && ( +
+

Installation Guide

+
+

Install Cline VS Code extension or CLI from docs.cline.bot.

+
+
+ )} +
+ )} + + {!checking && status?.installed && ( + <> +
+
+ Select Endpoint + arrow_forward + +
+ + {status?.settings?.openAiBaseUrl && ( +
+ Current + arrow_forward + + {status.settings.openAiBaseUrl} + +
+ )} + +
+ API Key + arrow_forward + +
+ +
+ 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={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Cline" + /> + + setShowManualConfigModal(false)} + title="Cline - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index f53b369..12ef389 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } 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 CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { @@ -79,7 +80,6 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api }; const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const checkCodexStatus = async () => { setCheckingCodex(true); @@ -302,16 +302,7 @@ model = "${effectiveSubagentModel}"
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
{/* Model */} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js index a7773f2..3c9e973 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } 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 CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { @@ -72,7 +73,6 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a }; const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const removeModel = (id) => setSelectedModels((prev) => prev.filter((m) => m !== id)); @@ -218,16 +218,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
{/* Models */} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js index 61168a1..db4e251 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import ApiKeySelect from "./ApiKeySelect"; const ENDPOINT = "/api/cli-tools/cowork-settings"; @@ -95,7 +96,6 @@ export default function CoworkToolCard({ }; const configStatus = getConfigStatus(); - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const handleApply = async () => { setMessage(null); @@ -285,16 +285,7 @@ export default function CoworkToolCard({
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index 8595d42..28d47d2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -4,6 +4,7 @@ import { useState } from "react"; import { Card, ModelSelectModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Image from "next/image"; +import ApiKeySelect from "./ApiKeySelect"; export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) { const [copiedField, setCopiedField] = useState(null); @@ -46,37 +47,11 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba const hasActiveProviders = activeProviders.length > 0; - const renderApiKeySelector = () => { - return ( -
- {apiKeys && apiKeys.length > 0 ? ( - <> - - - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"} - - )} -
- ); - }; + const renderApiKeySelector = () => ( +
+ +
+ ); const renderModelSelector = () => { return ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index b45b3b2..7e8e2ea 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -4,6 +4,7 @@ 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"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -118,7 +119,6 @@ export default function DroidToolCard({ const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; }; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const addModel = () => { const val = modelInput.trim(); @@ -318,16 +318,7 @@ export default function DroidToolCard({
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
{/* Models */} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index df9c115..806be6b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -4,6 +4,7 @@ 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"; const ENDPOINT = "/api/cli-tools/hermes-settings"; @@ -108,7 +109,6 @@ export default function HermesToolCard({ const url = customBaseUrl || getLocalBaseUrl(); return url.endsWith("/v1") ? url : `${url}/v1`; }; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const handleApply = async () => { setApplying(true); @@ -259,16 +259,7 @@ export default function HermesToolCard({
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js new file mode 100644 index 0000000..2a8a074 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect } 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 KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { + 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 [showInstallGuide, setShowInstallGuide] = useState(false); + 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(""); + + 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]); + + 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?.installed) return null; + return status.has9Router ? "configured" : "not_configured"; + }; + + const configStatus = getConfigStatus(); + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || `${baseUrl}/v1`; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch("/api/cli-tools/kilo-settings"); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ installed: false, 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/kilo-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!" }); + 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/kilo-settings", { 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 getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + return [{ + filename: "~/.local/share/kilo/auth.json", + content: JSON.stringify({ + "openai-compatible": { + type: "api-key", + apiKey: keyToUse, + baseUrl: getEffectiveBaseUrl(), + model: selectedModel || "provider/model-id", + }, + }, null, 2), + }]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

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

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Kilo Code... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

Kilo Code not detected locally

+

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

+
+
+
+ + +
+
+ {showInstallGuide && ( +
+

Installation Guide

+

Install Kilo Code from kilocode.ai or VS Code extension marketplace.

+
+ )} +
+ )} + + {!checking && status?.installed && ( + <> +
+
+ Select Endpoint + arrow_forward + +
+ +
+ API Key + arrow_forward + +
+ +
+ 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={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Kilo Code" + /> + + setShowManualConfigModal(false)} + title="Kilo Code - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js index 51fab54..8b2ab41 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -306,11 +306,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
warning
-

Port 443 đang bị process khác chiếm:

+

Port 443 is currently used by another process:

{port443Conflict.owner.name} (PID {port443Conflict.owner.pid})

-

Kill process này để chạy MITM Server?

+

Kill this process to start MITM Server?

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 32c1b15..88a73c6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -4,6 +4,7 @@ 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 OpenClawToolCard({ @@ -125,7 +126,6 @@ export default function OpenClawToolCard({ const url = customBaseUrl || getLocalBaseUrl(); return url.endsWith("/v1") ? url : `${url}/v1`; }; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const handleApplySettings = async () => { setApplying(true); @@ -310,16 +310,7 @@ export default function OpenClawToolCard({
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
{/* Default Model */} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index 5d8a34c..0ffee80 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } 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 OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { @@ -83,7 +84,6 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, }; const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); const checkStatus = async () => { setChecking(true); @@ -289,16 +289,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
API Key arrow_forward - {apiKeys.length > 0 || selectedApiKey ? ( - - ) : ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - )} +
{/* Models */} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 1856560..5b0a312 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -8,6 +8,8 @@ export { default as AntigravityToolCard } from "./AntigravityToolCard"; export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; export { default as CoworkToolCard } from "./CoworkToolCard"; export { default as CopilotToolCard } from "./CopilotToolCard"; +export { default as ClineToolCard } from "./ClineToolCard"; +export { default as KiloToolCard } from "./KiloToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index cb18377..38fb118 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -466,6 +466,8 @@ export default function APIPageClient({ machineId }) { } else if (event === "done") { setTsInstalled(true); setTsInstalling(false); + setShowTsModal(false); + handleConnectTailscale(); return; } else if (event === "error") { setTsStatus({ type: "error", message: data.error || "Install failed" }); @@ -628,8 +630,7 @@ export default function APIPageClient({ machineId }) { setTsStatus(null); setTsInstallLog([]); const data = await checkTailscaleInstalled(); - if (data?.installed) { - // Skip modal, connect directly when already installed + if (data?.installed && data?.hasCachedPassword) { handleConnectTailscale(); } else { setShowTsModal(true); diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index bc5fed4..4f77a8b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -94,10 +94,13 @@ function getConnectionErrorTag(connection) { return "ERR"; } +const APIKEY_INITIAL_VISIBLE = 20; + export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [loading, setLoading] = useState(true); + const [showAllApikey, setShowAllApikey] = useState(false); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); @@ -117,6 +120,13 @@ export default function ProvidersPage() { !searchQuery.trim() || name.toLowerCase().includes(searchQuery.trim().toLowerCase()); + const sortByConnections = (entries, authType) => + [...entries].sort( + (a, b) => + getProviderStats(b[0], authType).total - + getProviderStats(a[0], authType).total, + ); + useEffect(() => { const fetchData = async () => { try { @@ -259,10 +269,19 @@ export default function ProvidersPage() { const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter( ([, info]) => matchSearch(info.name), ); - const apikeyEntries = Object.entries(APIKEY_PROVIDERS).filter( - ([, info]) => - (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), + const apikeyEntries = sortByConnections( + Object.entries(APIKEY_PROVIDERS).filter( + ([, info]) => + (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), + ), + "apikey", ); + const isApikeySearching = !!searchQuery.trim(); + const visibleApikeyEntries = + isApikeySearching || showAllApikey + ? apikeyEntries + : apikeyEntries.slice(0, APIKEY_INITIAL_VISIBLE); + const hiddenApikeyCount = apikeyEntries.length - APIKEY_INITIAL_VISIBLE; if (loading) { return ( @@ -466,7 +485,7 @@ export default function ProvidersPage() {
- {apikeyEntries.map(([key, info]) => ( + {visibleApikeyEntries.map(([key, info]) => ( ))}
+ {!isApikeySearching && !showAllApikey && hiddenApikeyCount > 0 && ( + + )}
)} diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index 33b1b7f..bea3708 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -9,6 +9,8 @@ import { GET as openclawGet } from "../openclaw-settings/route"; import { GET as hermesGet } from "../hermes-settings/route"; import { GET as coworkGet } from "../cowork-settings/route"; import { GET as copilotGet } from "../copilot-settings/route"; +import { GET as clineGet } from "../cline-settings/route"; +import { GET as kiloGet } from "../kilo-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -19,6 +21,8 @@ const STATUS_GETTERS = { hermes: hermesGet, cowork: coworkGet, copilot: copilotGet, + cline: clineGet, + kilo: kiloGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.js b/src/app/api/cli-tools/antigravity-mitm/alias/route.js index de78ba5..3814a34 100644 --- a/src/app/api/cli-tools/antigravity-mitm/alias/route.js +++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.js @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { getMitmAlias, setMitmAliasAll } from "@/models"; import { getMitmStatus } from "@/mitm/manager"; +import { writeAliasForTool } from "@/lib/mitmAliasCache"; // GET - Get MITM aliases for a tool export async function GET(request) { @@ -43,6 +44,7 @@ export async function PUT(request) { } await setMitmAliasAll(tool, filtered); + writeAliasForTool(tool, filtered); return NextResponse.json({ success: true, aliases: filtered }); } catch (error) { console.log("Error saving MITM aliases:", error.message); diff --git a/src/app/api/cli-tools/cline-settings/route.js b/src/app/api/cli-tools/cline-settings/route.js new file mode 100644 index 0000000..cc72397 --- /dev/null +++ b/src/app/api/cli-tools/cline-settings/route.js @@ -0,0 +1,133 @@ +"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 getDataDir = () => path.join(os.homedir(), ".cline", "data"); +const getGlobalStatePath = () => path.join(getDataDir(), "globalState.json"); +const getSecretsPath = () => path.join(getDataDir(), "secrets.json"); + +const checkInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where cline" : "which cline"; + const env = isWindows + ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } + : process.env; + await execAsync(command, { windowsHide: true, env }); + return true; + } catch { + try { + await fs.access(getGlobalStatePath()); + return true; + } catch { + return false; + } + } +}; + +const readJson = async (filePath) => { + try { + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +const has9RouterConfig = (globalState) => { + if (!globalState) return false; + const isOpenAi = + globalState.actModeApiProvider === "openai" || globalState.planModeApiProvider === "openai"; + const baseUrl = globalState.openAiBaseUrl || ""; + return isOpenAi && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router")); +}; + +export async function GET() { + try { + const installed = await checkInstalled(); + if (!installed) { + return NextResponse.json({ installed: false, settings: null, message: "Cline CLI is not installed" }); + } + const globalState = await readJson(getGlobalStatePath()); + return NextResponse.json({ + installed: true, + settings: { + actModeApiProvider: globalState?.actModeApiProvider, + planModeApiProvider: globalState?.planModeApiProvider, + openAiBaseUrl: globalState?.openAiBaseUrl, + openAiModelId: globalState?.openAiModelId, + }, + has9Router: has9RouterConfig(globalState), + globalStatePath: getGlobalStatePath(), + }); + } catch (error) { + console.log("Error checking cline settings:", error); + return NextResponse.json({ error: "Failed to check cline settings" }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + if (!baseUrl || !apiKey || !model) { + return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 }); + } + + await fs.mkdir(getDataDir(), { recursive: true }); + + // Cline expects base WITHOUT /v1 + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; + + const globalState = (await readJson(getGlobalStatePath())) || {}; + globalState.actModeApiProvider = "openai"; + globalState.planModeApiProvider = "openai"; + globalState.openAiBaseUrl = normalizedBaseUrl; + globalState.openAiModelId = model; + globalState.planModeOpenAiModelId = model; + await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2)); + + const secrets = (await readJson(getSecretsPath())) || {}; + secrets.openAiApiKey = apiKey; + await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2)); + + return NextResponse.json({ success: true, message: "Cline settings applied successfully!", globalStatePath: getGlobalStatePath() }); + } catch (error) { + console.log("Error updating cline settings:", error); + return NextResponse.json({ error: "Failed to update cline settings" }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const globalState = await readJson(getGlobalStatePath()); + if (!globalState) { + return NextResponse.json({ success: true, message: "No settings file to reset" }); + } + + if (globalState.actModeApiProvider === "openai") { + delete globalState.openAiBaseUrl; + delete globalState.openAiModelId; + delete globalState.planModeOpenAiModelId; + globalState.actModeApiProvider = "cline"; + globalState.planModeApiProvider = "cline"; + } + await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2)); + + const secrets = (await readJson(getSecretsPath())) || {}; + delete secrets.openAiApiKey; + await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2)); + + return NextResponse.json({ success: true, message: "9Router settings removed from Cline" }); + } catch (error) { + console.log("Error resetting cline settings:", error); + return NextResponse.json({ error: "Failed to reset cline settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/kilo-settings/route.js b/src/app/api/cli-tools/kilo-settings/route.js new file mode 100644 index 0000000..6802adc --- /dev/null +++ b/src/app/api/cli-tools/kilo-settings/route.js @@ -0,0 +1,131 @@ +"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 getDataDir = () => path.join(os.homedir(), ".local", "share", "kilo"); +const getAuthPath = () => path.join(getDataDir(), "auth.json"); +const getVscodeSettingsPath = () => path.join(os.homedir(), ".config", "Code", "User", "settings.json"); + +const checkInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where kilo" : "which kilo"; + const env = isWindows + ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } + : process.env; + await execAsync(command, { windowsHide: true, env }); + return true; + } catch { + try { + await fs.access(getAuthPath()); + return true; + } catch { + return false; + } + } +}; + +const readJson = async (filePath) => { + try { + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +const has9RouterConfig = (auth) => { + if (!auth) return false; + const entry = auth["openai-compatible"] || auth["9router"]; + if (!entry) return false; + const baseUrl = entry.baseUrl || entry.baseURL || ""; + return baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router"); +}; + +export async function GET() { + try { + const installed = await checkInstalled(); + if (!installed) { + return NextResponse.json({ installed: false, settings: null, message: "Kilo Code CLI is not installed" }); + } + const auth = await readJson(getAuthPath()); + return NextResponse.json({ + installed: true, + settings: { auth: auth ? Object.keys(auth) : [] }, + has9Router: has9RouterConfig(auth), + authPath: getAuthPath(), + }); + } catch (error) { + console.log("Error checking kilo settings:", error); + return NextResponse.json({ error: "Failed to check kilo settings" }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + if (!baseUrl || !apiKey || !model) { + return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 }); + } + + await fs.mkdir(getDataDir(), { recursive: true }); + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + const auth = (await readJson(getAuthPath())) || {}; + auth["openai-compatible"] = { + type: "api-key", + apiKey, + baseUrl: normalizedBaseUrl, + model, + }; + await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2)); + + // Best-effort: update VS Code extension settings + try { + const vscode = (await readJson(getVscodeSettingsPath())) || {}; + vscode["kilocode.customProvider"] = { name: "9Router", baseURL: normalizedBaseUrl, apiKey }; + vscode["kilocode.defaultModel"] = model; + await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2)); + } catch { /* VS Code settings not writable */ } + + return NextResponse.json({ success: true, message: "Kilo Code settings applied successfully!", authPath: getAuthPath() }); + } catch (error) { + console.log("Error updating kilo settings:", error); + return NextResponse.json({ error: "Failed to update kilo settings" }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const auth = await readJson(getAuthPath()); + if (!auth) { + return NextResponse.json({ success: true, message: "No settings file to reset" }); + } + delete auth["openai-compatible"]; + delete auth["9router"]; + await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2)); + + try { + const vscode = await readJson(getVscodeSettingsPath()); + if (vscode) { + delete vscode["kilocode.customProvider"]; + delete vscode["kilocode.defaultModel"]; + await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2)); + } + } catch { /* ignore */ } + + return NextResponse.json({ success: true, message: "9Router settings removed from Kilo Code" }); + } catch (error) { + console.log("Error resetting kilo settings:", error); + return NextResponse.json({ error: "Failed to reset kilo settings" }, { status: 500 }); + } +} diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js index 5fe339a..57cfd0c 100644 --- a/src/app/api/models/test/route.js +++ b/src/app/api/models/test/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getApiKeys } from "@/lib/localDb"; +import { UPDATER_CONFIG } from "@/shared/constants/config"; // POST /api/models/test - Ping a single model via internal completions or embeddings export async function POST(request) { @@ -7,8 +8,7 @@ export async function POST(request) { const { model, kind } = await request.json(); if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 }); - const baseUrl = process.env.BASE_URL || - (() => { const u = new URL(request.url); return `${u.protocol}//${u.host}`; })(); + const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`; // Get an active internal API key for auth (if requireApiKey is enabled) let apiKey = null; diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js index e71327e..6ffa11f 100644 --- a/src/app/api/providers/[id]/test-models/route.js +++ b/src/app/api/providers/[id]/test-models/route.js @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById, getApiKeys } from "@/lib/localDb"; import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { UPDATER_CONFIG } from "@/shared/constants/config"; /** * Get an active API key to pass through auth when requireApiKey is enabled. @@ -64,10 +65,12 @@ export async function POST(request, { params }) { let models = getProviderModels(alias); + const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`; + // Compatible providers: fetch live model list if (isCompatible && models.length === 0) { try { - const modelsRes = await fetch(`${getBaseUrl(request)}/api/providers/${id}/models`); + const modelsRes = await fetch(`${baseUrl}/api/providers/${id}/models`); if (modelsRes.ok) { const data = await modelsRes.json(); models = (data.models || []).map((m) => ({ id: m.id || m.name, name: m.name || m.id })); @@ -79,7 +82,6 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 }); } - const baseUrl = getBaseUrl(request); const apiKey = await getInternalApiKey(); // Warm up with first model to trigger token refresh (if needed) before parallel calls. @@ -104,8 +106,3 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Test failed" }, { status: 500 }); } } - -function getBaseUrl(request) { - const url = new URL(request.url); - return `${url.protocol}//${url.host}`; -} diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 72b34a8..086bba3 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -551,6 +551,11 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { const res = await fetchWithConnectionProxy("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + case "fal-ai": { + const res = await fetchWithConnectionProxy("https://api.fal.ai/v1/models?limit=1", { headers: { Authorization: `Key ${connection.apiKey}` } }, effectiveProxy); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } case "chutes": { const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js index b33757f..f751921 100644 --- a/src/app/api/tunnel/tailscale-check/route.js +++ b/src/app/api/tunnel/tailscale-check/route.js @@ -3,6 +3,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import { NextResponse } from "next/server"; import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale"; +import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager"; const execAsync = promisify(exec); const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; @@ -41,7 +42,8 @@ export async function GET() { installed ? isDaemonRunning() : Promise.resolve(false), ]); const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false; - return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning }); + const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword()); + return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, hasCachedPassword }); } catch (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/src/app/globals.css b/src/app/globals.css index 2559939..76051e9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,8 +1,11 @@ -@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap'); @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); +/* Hide icon ligature text until font is ready */ +.material-symbols-outlined { visibility: hidden; } +.fonts-loaded .material-symbols-outlined { visibility: visible; } + /* ============================================================ 9Router palette — adopted from 9remote_private/web Brand orange (dark) / soft coral (light), neutral warm bases diff --git a/src/app/layout.js b/src/app/layout.js index 17aecd7..43902e1 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,4 +1,5 @@ import { Inter } from "next/font/google"; +import "material-symbols/outlined.css"; import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; import "@/lib/initCloudSync"; // Auto-initialize cloud sync @@ -30,25 +31,11 @@ export default function RootLayout({ children }) { return ( - - {/* Non-blocking icon font: preload + inject stylesheet via script */} -