diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index d706e24..8d60609 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -60,6 +60,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const providerRequiresStreaming = provider === "openai" || provider === "codex" || provider === "commandcode"; let stream = providerRequiresStreaming ? true : (body.stream !== false); + // DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE. + // Non-interactive mode (-p flag) sends without stream and can't parse SSE. + // Only force non-streaming when client didn't explicitly request it. + const detectedTool = detectClientTool(clientRawRequest?.headers || {}, body); + if (detectedTool === "deepseek-tui" && body.stream !== true) stream = false; + // Check client Accept header preference for non-streaming requests // This fixes AI SDK compatibility where clients send Accept: application/json const acceptHeader = clientRawRequest?.headers?.accept || ""; @@ -121,7 +127,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const executor = getExecutor(provider); trackPendingRequest(model, provider, connectionId, true); - appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0; log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); @@ -179,7 +185,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, @@ -188,7 +194,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred providerRequest: translatedBody || null, response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null }, status: "error" - })).catch(() => {}); + })).catch(() => { }); if (error.name === "AbortError") { streamController.handleError(error); @@ -225,7 +231,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false, true); const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, @@ -234,7 +240,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred providerRequest: finalBody || translatedBody || null, response: { error: message, status: statusCode, thinking: null }, status: "error" - })).catch(() => {}); + })).catch(() => { }); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); @@ -243,7 +249,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess }; - const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => {}); + const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { }); const trackDone = () => trackPendingRequest(model, provider, connectionId, false); // Provider forced streaming but client wants JSON diff --git a/open-sse/utils/clientDetector.js b/open-sse/utils/clientDetector.js index 1368b97..2d1381b 100644 --- a/open-sse/utils/clientDetector.js +++ b/open-sse/utils/clientDetector.js @@ -5,10 +5,10 @@ // Map of CLI tool identifiers to provider IDs they are "native" to const NATIVE_PAIRS = { - "claude": ["claude", "anthropic"], - "gemini-cli": ["gemini-cli"], + "claude": ["claude", "anthropic"], + "gemini-cli": ["gemini-cli"], "antigravity": ["antigravity"], - "codex": ["codex"], + "codex": ["codex"], }; /** @@ -40,6 +40,9 @@ export function detectClientTool(headers = {}, body = {}) { // Codex CLI if (ua.includes("codex-cli")) return "codex"; + // DeepSeek TUI + if (ua.includes("deepseek-tui")) return "deepseek-tui"; + return null; } diff --git a/public/providers/deepseek-tui.png b/public/providers/deepseek-tui.png new file mode 100644 index 0000000..21e6614 Binary files /dev/null and b/public/providers/deepseek-tui.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index e6fa973..0148372 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, ClineToolCard, KiloToolCard, MitmLinkCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, MitmLinkCard } from "./components"; import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -194,6 +194,8 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "kilo": return ; + case "deepseek-tui": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js new file mode 100644 index 0000000..b342bfd --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js @@ -0,0 +1,387 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal } 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/deepseek-tui-settings"; + +export default function DeepSeekTuiToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, +}) { + const [deepseekStatus, setDeepseekStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + + const getConfigStatus = () => { + if (!deepseekStatus?.installed) return null; + const cfg = deepseekStatus.settings; + if (!cfg) return "not_configured"; + const openaiSection = cfg["providers.openai"]; + if (!openaiSection?.base_url) return "not_configured"; + if (matchKnownEndpoint(openaiSection.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setDeepseekStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !deepseekStatus) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (deepseekStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const cfg = deepseekStatus.settings; + const openaiSection = cfg?.["providers.openai"]; + if (openaiSection?.model) setSelectedModel(openaiSection.model); + } + }, [deepseekStatus]); + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const data = await res.json(); + setDeepseekStatus(data); + } catch (error) { + setDeepseekStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; + + const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = () => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:20128"; + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset to defaults!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleSelectModel = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const renderIcon = () => { + if (tool.image) { + return ( + {tool.name} { e.target.style.display = "none"; }} + /> + ); + } + if (tool.icon) { + return {tool.icon}; + } + return ( + {tool.name} { e.target.style.display = "none"; }} + /> + ); + }; + + const renderStatusBadge = () => { + if (!deepseekStatus?.installed) { + return ( + + close + Not Installed + + ); + } + if (configStatus === "configured") { + return ( + + check_circle + Configured + + ); + } + if (configStatus === "other") { + return ( + + settings + Other Config + + ); + } + return ( + + info + Not Configured + + ); + }; + + return ( + +
+
+
+ {renderIcon()} +
+
+
+

{tool.name}

+ {renderStatusBadge()} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {/* Notes */} + {tool.notes && tool.notes.length > 0 && ( +
+ {tool.notes.map((note, index) => { + const isWarning = note.type === "warning"; + const isError = note.type === "error"; + let bgClass = "bg-blue-500/10 border-blue-500/30"; + let textClass = "text-blue-600 dark:text-blue-400"; + let iconClass = "text-blue-500"; + let icon = "info"; + + if (isWarning) { + bgClass = "bg-yellow-500/10 border-yellow-500/30"; + textClass = "text-yellow-600 dark:text-yellow-400"; + iconClass = "text-yellow-500"; + icon = "warning"; + } else if (isError) { + bgClass = "bg-red-500/10 border-red-500/30"; + textClass = "text-red-600 dark:text-red-400"; + iconClass = "text-red-500"; + icon = "error"; + } + + return ( +
+ {icon} +

{note.text}

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

DeepSeek TUI is not detected on your system.

+
+

Install via npm:

+ npm install -g deepseek-tui +
+ +
+ )} + + {/* Config section */} + {deepseekStatus?.installed && ( +
+ {/* Config path */} +
+ folder + {deepseekStatus.configPath} +
+ + {/* Base URL */} +
+ + +
+ + {/* API Key */} +
+ + +
+ + {/* Model */} +
+ +
+ setSelectedModel(e.target.value)} + placeholder="ollama/gpt-oss:120b" + className="w-full sm:w-auto flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + +
+
+ + {/* Message */} + {message && ( +
+ {message.text} +
+ )} + + {/* Actions */} +
+ + +
+
+ )} +
+ )} + + setModalOpen(false)} + onSelect={handleSelectModel} + selectedModel={selectedModel} + activeProviders={activeProviders} + title="Select Model" + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 5b0a312..a9e78e3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -10,6 +10,7 @@ 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 DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index bea3708..14eb010 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -11,6 +11,7 @@ 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"; +import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -23,6 +24,7 @@ const STATUS_GETTERS = { copilot: copilotGet, cline: clineGet, kilo: kiloGet, + "deepseek-tui": deepseekTuiGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/deepseek-tui-settings/route.js b/src/app/api/cli-tools/deepseek-tui-settings/route.js new file mode 100644 index 0000000..0edf74d --- /dev/null +++ b/src/app/api/cli-tools/deepseek-tui-settings/route.js @@ -0,0 +1,164 @@ +"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 PROVIDER_NAME = "9router"; + +const getDeepSeekDir = () => path.join(os.homedir(), ".deepseek"); +const getDeepSeekConfigPath = () => path.join(getDeepSeekDir(), "config.toml"); + +// Simple TOML parser for key = "value" and [section] patterns +const parseToml = (content) => { + const result = {}; + let currentSection = result; + + const lines = content.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith("#")) continue; + + // Section header: [section] or [section.subsection] + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const sectionName = sectionMatch[1]; + if (!result[sectionName]) result[sectionName] = {}; + currentSection = result[sectionName]; + continue; + } + + // Key = "value" or key = value + const keyValueMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]*)"$/); + if (keyValueMatch) { + currentSection[keyValueMatch[1]] = keyValueMatch[2]; + continue; + } + + // Key = value (unquoted) + const unquotedMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/); + if (unquotedMatch) { + currentSection[unquotedMatch[1]] = unquotedMatch[2].trim(); + } + } + + return result; +}; + +// Build TOML config for 9Router (openai provider mode) +const build9RouterConfig = (baseUrl, apiKey, model) => { + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + return `provider = "openai" + +[providers.openai] +base_url = "${normalizedBaseUrl}" +api_key = "${apiKey}" +model = "${model}" +`; +}; + +// Default DeepSeek config (reset state) +const DEFAULT_CONFIG = `provider = "deepseek" +`; + +const checkDeepSeekInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where deepseek" : "which deepseek"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getDeepSeekConfigPath()); + return true; + } catch { + return false; + } + } +}; + +const readConfigToml = async () => { + try { + return await fs.readFile(getDeepSeekConfigPath(), "utf-8"); + } catch (error) { + if (error.code === "ENOENT") return ""; + throw error; + } +}; + +// Detect 9Router by checking if provider is "openai" and base_url points to localhost/127.0.0.1 +const has9RouterConfig = (config) => { + if (!config) return false; + const provider = config.provider; + if (provider !== "openai") return false; + const openaiSection = config["providers.openai"]; + if (!openaiSection?.base_url) return false; + return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(openaiSection.base_url); +}; + +export async function GET() { + try { + const installed = await checkDeepSeekInstalled(); + if (!installed) { + return NextResponse.json({ installed: false, settings: null, message: "DeepSeek TUI is not installed" }); + } + const toml = await readConfigToml(); + const config = parseToml(toml); + return NextResponse.json({ + installed: true, + settings: config, + has9Router: has9RouterConfig(config), + configPath: getDeepSeekConfigPath(), + }); + } catch (error) { + console.log("Error checking deepseek-tui settings:", error); + return NextResponse.json({ error: "Failed to check deepseek-tui settings" }, { status: 500 }); + } +} + +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 dir = getDeepSeekDir(); + await fs.mkdir(dir, { recursive: true }); + + const newConfig = build9RouterConfig(baseUrl, apiKey || "sk_9router", model); + await fs.writeFile(getDeepSeekConfigPath(), newConfig); + + return NextResponse.json({ + success: true, + message: "DeepSeek TUI settings applied successfully!", + configPath: getDeepSeekConfigPath(), + }); + } catch (error) { + console.log("Error updating deepseek-tui settings:", error); + return NextResponse.json({ error: "Failed to update deepseek-tui settings" }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getDeepSeekConfigPath(); + try { + await fs.access(configPath); + } catch { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + + await fs.writeFile(configPath, DEFAULT_CONFIG); + return NextResponse.json({ success: true, message: `${PROVIDER_NAME} config reset to DeepSeek defaults` }); + } catch (error) { + console.log("Error resetting deepseek-tui settings:", error); + return NextResponse.json({ error: "Failed to reset deepseek-tui settings" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 5541390..6a92d42 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -100,7 +100,7 @@ export const CLI_TOOLS = { }, codex: { id: "codex", - name: "OpenAI Codex CLI / App", + name: "OpenAI Codex CLI / App", image: "/providers/codex.png", color: "#10A37F", description: "OpenAI Codex CLI", @@ -294,6 +294,26 @@ amp --model "{{model}}" }`, }, }, + "deepseek-tui": { + id: "deepseek-tui", + name: "DeepSeek TUI", + image: "/providers/deepseek-tui.png", + color: "#4D6BFE", + description: "DeepSeek Terminal Coding Agent (Rust TUI)", + docsUrl: "https://github.com/DeepSeek-TUI/DeepSeek-TUI", + configType: "custom", + defaultCommand: "deepseek", + modelAliases: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"], + defaultModels: [ + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", alias: "deepseek-v4-pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", alias: "deepseek-v4-flash" }, + { id: "deepseek-chat", name: "DeepSeek V3 Chat", alias: "deepseek-chat" }, + ], + notes: [ + { type: "info", text: "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model." }, + { type: "warning", text: "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml" }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli",