From 0d61a1d5464ddd496975a2d7703e379cffdff906 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 7 May 2026 16:42:36 +0700 Subject: [PATCH] feat: add OllamaLocalExecutor and update provider handling - Introduced OllamaLocalExecutor to handle requests for the "ollama-local" provider. - Removed the direct URL construction for "ollama-local" from BaseExecutor. - Updated index.js to include the new OllamaLocalExecutor in the executors mapping. - Enhanced the ProvidersPage component to support dynamic addition of OpenAI/Anthropic compatible providers. --- open-sse/executors/base.js | 4 - open-sse/executors/index.js | 3 + open-sse/executors/ollama-local.js | 14 ++ .../(dashboard)/dashboard/providers/page.js | 128 +++++++----------- .../usage/components/ProviderTopology.js | 42 ++++-- src/mitm/dns/dnsConfig.js | 2 +- src/mitm/manager.js | 15 +- src/shared/components/UsageStats.js | 13 +- 8 files changed, 127 insertions(+), 94 deletions(-) create mode 100644 open-sse/executors/ollama-local.js diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 39f89bf2..8cb46721 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -1,5 +1,4 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; -import { resolveOllamaLocalHost } from "../config/providers.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; /** @@ -36,9 +35,6 @@ export class BaseExecutor { const normalized = baseUrl.replace(/\/$/, ""); return `${normalized}/messages`; } - if (this.provider === "ollama-local") { - return `${resolveOllamaLocalHost(credentials)}/api/chat`; - } const baseUrls = this.getBaseUrls(); return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; } diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 655fb3c0..9479f4a5 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -13,6 +13,7 @@ import { OpenCodeExecutor } from "./opencode.js"; import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; +import { OllamaLocalExecutor } from "./ollama-local.js"; import { DefaultExecutor } from "./default.js"; const executors = { @@ -33,6 +34,7 @@ const executors = { "opencode-go": new OpenCodeGoExecutor(), "grok-web": new GrokWebExecutor(), "perplexity-web": new PerplexityWebExecutor(), + "ollama-local": new OllamaLocalExecutor(), }; const defaultCache = new Map(); @@ -64,3 +66,4 @@ export { OpenCodeExecutor } from "./opencode.js"; export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; +export { OllamaLocalExecutor } from "./ollama-local.js"; diff --git a/open-sse/executors/ollama-local.js b/open-sse/executors/ollama-local.js new file mode 100644 index 00000000..83de7b87 --- /dev/null +++ b/open-sse/executors/ollama-local.js @@ -0,0 +1,14 @@ +import { DefaultExecutor } from "./default.js"; +import { resolveOllamaLocalHost } from "../config/providers.js"; + +export class OllamaLocalExecutor extends DefaultExecutor { + constructor() { + super("ollama-local"); + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + return `${resolveOllamaLocalHost(credentials)}/api/chat`; + } +} + +export default OllamaLocalExecutor; diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 0940cb91..bc5fed48 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -292,6 +292,58 @@ export default function ProvidersPage() { )} + {/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */} +
+
+

+ Custom Providers (OpenAI/Anthropic Compatible){" "} +

+
+ + +
+
+ {compatibleProviders.length === 0 && + anthropicCompatibleProviders.length === 0 ? ( +
+ extension + No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints +
+ ) : ( +
+ {[...compatibleProviders, ...anthropicCompatibleProviders].map( + (info) => ( + + handleToggleProvider(info.id, "apikey", active) + } + /> + ), + )} +
+ )} +
+ {/* OAuth Providers */} {oauthEntries.length > 0 && (
@@ -449,82 +501,6 @@ export default function ProvidersPage() {
*/} - {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} -
-
-

- API Key Compatible Providers{" "} -

-
- {/* {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && ( - - )} */} - - -
-
- {compatibleProviders.length === 0 && - anthropicCompatibleProviders.length === 0 ? ( -
- - extension - -

- No compatible providers added yet -

-

- Use the buttons above to add OpenAI or Anthropic compatible - endpoints -

-
- ) : ( -
- {[...compatibleProviders, ...anthropicCompatibleProviders].map( - (info) => ( - - handleToggleProvider(info.id, "apikey", active) - } - /> - ), - )} -
- )} -
- setShowAddCompatibleModal(false)} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js index 2dc3c6ff..1e2bf634 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js @@ -6,6 +6,7 @@ import { ReactFlow, Handle, Position, + Controls, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AI_PROVIDERS } from "@/shared/constants/providers"; @@ -250,13 +251,34 @@ export default function ProviderTopology({ providers = [], activeRequests = [], ); const rfInstance = useRef(null); + const containerRef = useRef(null); + const fitOpts = { padding: 0.2, duration: 200 }; const onInit = useCallback((instance) => { rfInstance.current = instance; - setTimeout(() => instance.fitView({ padding: 0.3 }), 50); + setTimeout(() => instance.fitView(fitOpts), 50); }, []); + // Re-fit on container resize + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + if (rfInstance.current) rfInstance.current.fitView(fitOpts); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Re-fit when node count/layout changes + useEffect(() => { + if (rfInstance.current) { + const id = setTimeout(() => rfInstance.current.fitView(fitOpts), 50); + return () => clearTimeout(id); + } + }, [nodes.length]); + return ( -
+
{providers.length === 0 ? (
No providers connected @@ -268,18 +290,22 @@ export default function ProviderTopology({ providers = [], activeRequests = [], edges={edges} nodeTypes={nodeTypes} fitView - fitViewOptions={{ padding: 0.3 }} + fitViewOptions={fitOpts} + minZoom={0.1} + maxZoom={2} onInit={onInit} proOptions={{ hideAttribution: true }} - panOnDrag={false} - zoomOnScroll={false} - zoomOnPinch={false} - zoomOnDoubleClick={false} + panOnDrag + zoomOnScroll + zoomOnPinch + zoomOnDoubleClick preventScrolling={false} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} - /> + > + + )}
); diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index c9cd7364..ae1568a8 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); const { log, err } = require("../logger"); -const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts"); +const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts.js"); const { runElevatedPowerShell, isAdmin } = require("../winElevated.js"); /** diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 03e1ce22..357582e0 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -549,6 +549,15 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { } // Step 2: Spawn server (Root CA already installed in Step 1.5) + // Verify server.js exists — recopy if runtime file was deleted (antivirus/cleanup) + let effectiveServerPath = SERVER_PATH; + if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { + log(`[MITM] server.js missing at ${effectiveServerPath} → recopying`); + effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath()); + if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { + throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`); + } + } const mitmRouterBase = await resolveMitmRouterBaseUrl(); log(`🚀 Starting server... (router: ${mitmRouterBase})`); if (IS_WIN) { @@ -569,7 +578,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { // Spawn directly — process already has admin rights serverProcess = spawn( process.execPath, - [SERVER_PATH], + [effectiveServerPath], { detached: false, windowsHide: true, @@ -593,7 +602,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`, "NODE_ENV=production", shellQuoteSingle(process.execPath), - shellQuoteSingle(SERVER_PATH), + shellQuoteSingle(effectiveServerPath), ].join(" "); serverProcess = spawn( "sudo", ["-S", "-E", "sh", "-c", inlineCmd], @@ -603,7 +612,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { serverProcess.stdin.end(); } else { // Docker/minimal images: no sudo — same as Windows-style direct spawn - serverProcess = spawn(process.execPath, [SERVER_PATH], { + serverProcess = spawn(process.execPath, [effectiveServerPath], { detached: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index e55e1226..a0469f00 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -2,7 +2,14 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { FREE_PROVIDERS } from "@/shared/constants/providers"; +import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers"; + +// Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds +function isLLMProvider(id) { + const p = AI_PROVIDERS[id]; + if (!p?.serviceKinds) return true; + return p.serviceKinds.includes("llm"); +} import Badge from "./Badge"; import Card from "./Card"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; @@ -206,12 +213,14 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro .then((d) => { const seen = new Set(); const unique = (d?.connections || []).filter((c) => { + if (c.isActive === false) return false; + if (!isLLMProvider(c.provider)) return false; if (seen.has(c.provider)) return false; seen.add(c.provider); return true; }); const noAuthProviders = Object.values(FREE_PROVIDERS) - .filter((p) => p.noAuth && !seen.has(p.id)) + .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id)) .map((p) => ({ provider: p.id, name: p.name })); setProviders([...unique, ...noAuthProviders]); })