# 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 (2 min) in open-sse

## Fixes
- Fal.ai key test: use stable models endpoint
This commit is contained in:
decolua
2026-05-10 21:54:54 +07:00
parent 52c38cf94c
commit 7ad538bcf2
41 changed files with 1171 additions and 391 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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);

View File

@@ -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",

View File

@@ -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() {
<div className="absolute left-0 top-[calc(100%+10px)] z-30 w-[min(520px,calc(100vw-2rem))] overflow-hidden rounded-[20px] border border-white/10 bg-[#262626] shadow-2xl shadow-black/50">
<div className="border-b border-white/10 px-4 py-3">
<p className="text-xs uppercase tracking-[0.22em] text-white/45">Models</p>
<p className="text-sm text-white/75">Chỉ lấy từ provider đã connect</p>
<p className="text-sm text-white/75">Only from connected providers</p>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2 custom-scrollbar">
{providerGroups.map((group) => (
@@ -815,7 +815,7 @@ export default function BasicChatPageClient() {
<div className="max-h-[48vh] space-y-2 overflow-y-auto p-1 custom-scrollbar">
{sessionItems.length === 0 ? (
<div className="rounded-[16px] border border-dashed border-white/10 bg-white/5 p-4 text-sm text-white/55">
Chưa cuộc trò chuyện nào.
No conversations yet.
</div>
) : sessionItems.map((session) => {
const isActive = session.id === activeSessionId;

View File

@@ -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 <HermesToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.hermes} />;
case "copilot":
return <CopilotToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.copilot} />;
case "cline":
return <ClineToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.cline} />;
case "kilo":
return <KiloToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.kilo} />;
default:
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
}

View File

@@ -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 (
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
);
}
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
<select
value={mode}
onChange={handleSelect}
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
>
{apiKeys.map((k) => (
<option key={k.id} value={k.key}>{k.key}</option>
))}
<option value={CUSTOM_VALUE}>Custom...</option>
</select>
{mode === CUSTOM_VALUE && (
<input
type="text"
value={customInput}
onChange={handleCustomInput}
placeholder="sk-..."
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
)}
</div>
);
}

View File

@@ -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({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Model Mappings */}

View File

@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Cline...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Cline not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<p className="text-text-muted">Install Cline VS Code extension or CLI from <a className="text-primary underline" href="https://docs.cline.bot/" target="_blank" rel="noreferrer">docs.cline.bot</a>.</p>
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{status?.settings?.openAiBaseUrl && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.settings.openAiBaseUrl}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Cline"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}

View File

@@ -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}"
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Model */}

View File

@@ -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
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}

View File

@@ -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({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">

View File

@@ -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 (
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
{apiKeys && apiKeys.length > 0 ? (
<>
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
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"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>{key.key}</option>
))}
</select>
<button
onClick={() => handleCopy(selectedApiKey, "apiKey")}
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
>
<span className="material-symbols-outlined text-lg">
{copiedField === "apiKey" ? "check" : "content_copy"}
</span>
</button>
</>
) : (
<span className="text-sm text-text-muted">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
</span>
)}
</div>
);
};
const renderApiKeySelector = () => (
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" />
</div>
);
const renderModelSelector = () => {
return (

View File

@@ -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({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}

View File

@@ -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({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">

View File

@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Kilo Code...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Kilo Code not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<p className="text-sm text-text-muted">Install Kilo Code from <a className="text-primary underline" href="https://kilocode.ai" target="_blank" rel="noreferrer">kilocode.ai</a> or VS Code extension marketplace.</p>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Kilo Code"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}

View File

@@ -306,11 +306,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
<div className="flex flex-col gap-1 text-xs text-text-muted">
<p>Port 443 đang bị process khác chiếm:</p>
<p>Port 443 is currently used by another process:</p>
<p className="font-mono text-text-main" data-i18n-skip="true">
{port443Conflict.owner.name} (PID {port443Conflict.owner.pid})
</p>
<p>Kill process này để chạy MITM Server?</p>
<p>Kill this process to start MITM Server?</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">

View File

@@ -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({
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Default Model */}

View File

@@ -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,
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
{apiKeys.length > 0 || selectedApiKey ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{hasCustomSelectedApiKey && <option value={selectedApiKey}>{selectedApiKey}</option>}
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
)}
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}

View File

@@ -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";

View File

@@ -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);

View File

@@ -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() {
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{apikeyEntries.map(([key, info]) => (
{visibleApikeyEntries.map(([key, info]) => (
<ApiKeyProviderCard
key={key}
providerId={key}
@@ -477,6 +496,15 @@ export default function ProvidersPage() {
/>
))}
</div>
{!isApikeySearching && !showAllApikey && hiddenApikeyCount > 0 && (
<button
onClick={() => setShowAllApikey(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2.5 text-sm font-medium text-primary transition-colors hover:border-primary hover:bg-primary/5"
>
<span className="material-symbols-outlined text-[16px]">expand_more</span>
Show all {apikeyEntries.length} providers
</button>
)}
</div>
)}

View File

@@ -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

View File

@@ -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);

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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;

View File

@@ -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}`;
}

View File

@@ -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" };

View File

@@ -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 });
}

View File

@@ -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

View File

@@ -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 (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* Non-blocking icon font: preload + inject stylesheet via script */}
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
/>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var l=document.createElement('link');l.rel='stylesheet';l.href='https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap';document.head.appendChild(l);})();`,
__html: `if(document.fonts&&document.fonts.ready){document.fonts.ready.then(function(){document.documentElement.classList.add('fonts-loaded')})}else{document.documentElement.classList.add('fonts-loaded')}`,
}}
/>
<noscript>
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
rel="stylesheet"
/>
</noscript>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider>

View File

@@ -62,7 +62,7 @@ function collectAppPids() {
});
} catch { /* no processes */ }
// Kill cloudflared + tray binaries (giữ lock app dir)
// Kill cloudflared + tray binaries (hold app dir lock)
for (const procName of ["cloudflared", "tray_windows_release"]) {
try {
const cmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-Process ${procName} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"`;

46
src/lib/mitmAliasCache.js Normal file
View File

@@ -0,0 +1,46 @@
// JSON cache for mitmAlias — read by standalone MITM server (no SQLite native binding).
// Source of truth = SQLite kv['mitmAlias']. JSON is a read-replica synced on app start
// and after every UI write.
import fs from "fs";
import path from "path";
import os from "os";
const DATA_DIR = process.env.DATA_DIR
|| (process.platform === "win32"
? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router")
: path.join(os.homedir(), ".9router"));
const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json");
function writeAtomic(data) {
const dir = path.dirname(CACHE_FILE);
fs.mkdirSync(dir, { recursive: true });
const tmp = `${CACHE_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
fs.renameSync(tmp, CACHE_FILE);
}
// Sync entire mitmAlias map from DB → JSON file
export async function syncToJson() {
try {
const { getMitmAlias } = await import("@/lib/db/repos/aliasRepo.js");
const all = await getMitmAlias();
writeAtomic(all || {});
} catch (e) {
console.log("[mitmAliasCache] sync failed:", e.message);
}
}
// Update cache for a single tool after UI saves to DB
export function writeAliasForTool(tool, mappings) {
try {
let current = {};
if (fs.existsSync(CACHE_FILE)) {
try { current = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch { /* corrupted → reset */ }
}
current[tool] = mappings || {};
writeAtomic(current);
} catch (e) {
console.log("[mitmAliasCache] write failed:", e.message);
}
}

View File

@@ -433,8 +433,22 @@ async function ensureUserOwnedDir(dir) {
} catch { /* ignore */ }
}
/** Start tailscaled in userspace-networking mode (no root, no sudo prompt). */
export async function startDaemonWithPassword(_sudoPasswordUnused) {
/** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */
function isDaemonTunMode() {
try {
const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
if (!ps) return null;
return !ps.includes("--tun=userspace-networking");
} catch { return null; }
}
/**
* Start tailscaled.
* - With sudoPassword: TUN mode (root) → Funnel TLS works
* - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable)
* State always lives in ~/.9router/tailscale/ via --statedir.
*/
export async function startDaemonWithPassword(sudoPassword) {
if (IS_WINDOWS) {
// Windows: tailscale runs as a Windows Service. Start it then poll BackendState
// until daemon finishes init (avoids "NoState" errors when calling funnel/up too early).
@@ -459,64 +473,62 @@ export async function startDaemonWithPassword(_sudoPasswordUnused) {
return;
}
// Detect unhealthy state: dir/files not owned by current user OR multiple daemons running.
// Either condition blocks userspace daemon → must kill all + reclaim ownership.
let needsRestart = false;
try {
const st = fs.statSync(TAILSCALE_DIR);
if (st.uid !== process.getuid()) needsRestart = true;
// Also check state file (the actual unhealthy resource)
const stateFile = path.join(TAILSCALE_DIR, "tailscaled.state");
if (fs.existsSync(stateFile) && fs.statSync(stateFile).uid !== process.getuid()) needsRestart = true;
} catch { /* dir doesn't exist yet */ }
const wantTun = !!sudoPassword;
const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running
// Detect duplicate daemons on same socket → also requires restart
if (!needsRestart) {
try {
const ps = execSync(`pgrep -f "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
if (ps && ps.split("\n").length > 1) needsRestart = true;
} catch { /* no match → ok */ }
}
if (needsRestart) {
// Kill ALL tailscaled processes (root + user duplicates). Best-effort with/without sudo.
try { execSync("pkill -9 -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
try { execSync("sudo -n pkill -9 -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
await new Promise((r) => setTimeout(r, 1500));
} else {
// Check if our userspace daemon already responds
// Daemon already running in correct mode → reuse
if (currentMode !== null && currentMode === wantTun) {
try {
const bin = getTailscaleBin() || "tailscale";
execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
stdio: "ignore",
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
timeout: 3000
stdio: "ignore", windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000
});
return; // Already running and user-owned
} catch { /* not running, start it */ }
return;
} catch { /* unresponsive, restart below */ }
}
// Reclaim folder ownership if a previous root daemon left it locked
// Mode mismatch or unresponsive → kill all daemons on our socket
try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
if (sudoPassword) {
try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ }
} else {
try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
}
await new Promise((r) => setTimeout(r, 1500));
// Reclaim folder ownership (previous root daemon may have locked it)
await ensureUserOwnedDir(TAILSCALE_DIR);
// Userspace-networking mode: no TUN device → no root needed → no sudo prompt
const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled";
const args = [
const daemonArgs = [
`--socket=${TAILSCALE_SOCKET}`,
`--statedir=${TAILSCALE_DIR}`,
"--tun=userspace-networking",
];
if (!wantTun) daemonArgs.push("--tun=userspace-networking");
const child = spawn(tailscaledBin, args, {
detached: true,
stdio: "ignore",
cwd: os.tmpdir(),
env: { ...process.env, PATH: EXTENDED_PATH },
});
child.unref();
if (wantTun) {
// TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit.
const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], {
detached: true,
stdio: ["pipe", "ignore", "ignore"],
cwd: os.tmpdir(),
env: { ...process.env, PATH: EXTENDED_PATH },
});
child.stdin.write(`${sudoPassword}\n`);
child.stdin.end();
child.unref();
} else {
const child = spawn(tailscaledBin, daemonArgs, {
detached: true,
stdio: "ignore",
cwd: os.tmpdir(),
env: { ...process.env, PATH: EXTENDED_PATH },
});
child.unref();
}
// Wait for daemon socket to be ready
// Wait for socket ready
await new Promise((r) => setTimeout(r, 3000));
}

View File

@@ -1,49 +1,22 @@
// CJS reader for MITM standalone process. Reads SQLite mitmAlias scope.
// Falls back to legacy db.json or db.json.migrated if SQLite unavailable.
// CJS reader for MITM standalone process. Reads mitmAlias from JSON cache
// at $DATA_DIR/mitm/aliases.json (synced by app from SQLite on startup + writes).
// JSON-only: no SQLite native binding required in MITM bundle.
const fs = require("fs");
const path = require("path");
const { DATA_DIR } = require("./paths");
const DB_FILE = path.join(DATA_DIR, "db", "data.sqlite");
const LEGACY_JSON = path.join(DATA_DIR, "db.json");
const LEGACY_MIGRATED = path.join(DATA_DIR, "db.json.migrated");
const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json");
let sqliteDb = null;
let sqliteFailed = false;
function trySqlite() {
if (sqliteDb) return sqliteDb;
if (sqliteFailed) return null;
function readCache() {
try {
if (!fs.existsSync(DB_FILE)) return null;
const Database = require("better-sqlite3");
sqliteDb = new Database(DB_FILE, { readonly: true, fileMustExist: true });
return sqliteDb;
} catch {
sqliteFailed = true;
return null;
}
}
function readLegacyJson() {
for (const file of [LEGACY_JSON, LEGACY_MIGRATED]) {
if (!fs.existsSync(file)) continue;
try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch {}
}
return null;
if (!fs.existsSync(CACHE_FILE)) return null;
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
} catch { return null; }
}
function getMitmAlias(toolName) {
const db = trySqlite();
if (db) {
try {
const row = db.prepare(`SELECT value FROM kv WHERE scope = 'mitmAlias' AND key = ?`).get(toolName);
if (row) return JSON.parse(row.value);
} catch {}
}
// Fallback to legacy JSON
const legacy = readLegacyJson();
return legacy?.mitmAlias?.[toolName] || null;
const all = readCache();
return all?.[toolName] || null;
}
module.exports = { getMitmAlias };

View File

@@ -165,14 +165,7 @@ export const CLI_TOOLS = {
image: "/providers/cline.png",
color: "#00D1B2",
description: "Cline AI Coding Assistant",
configType: "guide",
guideSteps: [
{ step: 1, title: "Open Settings", desc: "Go to Cline Settings panel" },
{ step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" },
{ step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true },
{ step: 4, title: "API Key", type: "apiKeySelector" },
{ step: 5, title: "Select Model", type: "modelSelector" },
],
configType: "custom",
},
kilo: {
id: "kilo",
@@ -180,14 +173,7 @@ export const CLI_TOOLS = {
image: "/providers/kilocode.png",
color: "#FF6B6B",
description: "Kilo Code AI Assistant",
configType: "guide",
guideSteps: [
{ step: 1, title: "Open Settings", desc: "Go to Kilo Code Settings panel" },
{ step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" },
{ step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true },
{ step: 4, title: "API Key", type: "apiKeySelector" },
{ step: 5, title: "Select Model", type: "modelSelector" },
],
configType: "custom",
},
roo: {
id: "roo",

View File

@@ -147,7 +147,7 @@ export const PATTERN_PRICING = [
{ pattern: "claude-haiku-*", pricing: { input: 1.00, output: 5.00, cached: 0.10, reasoning: 5.00, cache_creation: 1.25 } },
{ pattern: "claude-*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.75 } },
// --- Gemini (specific trước, chung sau) ---
// --- Gemini (specific first, generic last) ---
{ pattern: "gemini-*-flash-lite", pricing: { input: 0.15, output: 1.25, cached: 0.015, reasoning: 1.875, cache_creation: 0.15 } },
{ pattern: "gemini-*-flash", pricing: { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 } },
{ pattern: "gemini-*-pro", pricing: { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 } },
@@ -155,7 +155,7 @@ export const PATTERN_PRICING = [
{ pattern: "gemini-2.5-*", pricing: { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 } },
{ pattern: "gemini-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } },
// --- GPT (specific trước, chung sau) ---
// --- GPT (specific first, generic last) ---
{ pattern: "gpt-5.3-*", pricing: { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 } },
{ pattern: "gpt-5.2-*", pricing: { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 } },
{ pattern: "gpt-5.1-*", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } },

View File

@@ -111,7 +111,7 @@ export const APIKEY_PROVIDERS = {
searchapi: { id: "searchapi", alias: "searchapi", name: "SearchAPI", icon: "search", color: "#0EA5A4", textIcon: "SA", website: "https://www.searchapi.io", notice: { apiKeyUrl: "https://www.searchapi.io/dashboard" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
youcom: { id: "youcom", alias: "youcom", name: "You.com Search", icon: "search", color: "#7C3AED", textIcon: "YC", website: "https://you.com", notice: { apiKeyUrl: "https://api.you.com" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", notice: { apiKeyUrl: "https://www.firecrawl.dev/app/api-keys" }, serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://api.firecrawl.dev/v1/scrape", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.002, freeMonthlyQuota: 500, formats: ["markdown", "html", "text"], maxCharacters: 200000, timeoutMs: 30000 } },
"fal-ai": { id: "fal-ai", alias: "fal", name: "Fal.ai", icon: "image", color: "#2563EB", textIcon: "FL", website: "https://fal.ai", notice: { apiKeyUrl: "https://fal.ai/dashboard/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://queue.fal.run/fal-ai/flux/schnell", method: "POST", authType: "apikey", authHeader: "key" } },
"fal-ai": { id: "fal-ai", alias: "fal", name: "Fal.ai", icon: "image", color: "#2563EB", textIcon: "FL", website: "https://fal.ai", notice: { apiKeyUrl: "https://fal.ai/dashboard/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.fal.ai/v1/models?limit=1", method: "GET", authType: "apikey", authHeader: "key" } },
"stability-ai": { id: "stability-ai", alias: "stability", name: "Stability AI", icon: "image", color: "#8B5CF6", textIcon: "SA", website: "https://stability.ai", notice: { apiKeyUrl: "https://platform.stability.ai/account/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.stability.ai/v1/user/account", method: "GET", authType: "apikey", authHeader: "bearer" } },
"black-forest-labs": { id: "black-forest-labs", alias: "bfl", name: "Black Forest Labs", icon: "image", color: "#111827", textIcon: "BF", website: "https://blackforestlabs.ai", notice: { apiKeyUrl: "https://api.bfl.ai" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.bfl.ai/v1/get_result?id=ping", method: "GET", authType: "apikey", authHeader: "x-key" } },
recraft: { id: "recraft", alias: "recraft", name: "Recraft", icon: "image", color: "#EC4899", textIcon: "RC", website: "https://recraft.ai", notice: { apiKeyUrl: "https://www.recraft.ai/profile/api" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://external.api.recraft.ai/v1/users/me", method: "GET", authType: "apikey", authHeader: "bearer" } },

View File

@@ -17,6 +17,7 @@ import {
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS,
} from "@/lib/tunnel/tunnelConfig";
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
(function bootstrapMitm() {
@@ -78,6 +79,9 @@ export async function initializeApp() {
ensureCloudflared().catch(() => {});
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it
syncMitmAliasCache().catch(() => {});
startWatchdog();
startNetworkMonitor();
autoStartMitm();