mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Enhance provider models and chat handling with new thinking configurations
This commit is contained in:
@@ -221,7 +221,16 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "text-embedding-005", name: "Text Embedding 005", type: "embedding" },
|
||||
{ id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" },
|
||||
],
|
||||
openrouter: [],
|
||||
openrouter: [
|
||||
// Embedding models
|
||||
{ id: "openai/text-embedding-3-large", name: "OpenAI Text Embedding 3 Large", type: "embedding" },
|
||||
{ id: "openai/text-embedding-3-small", name: "OpenAI Text Embedding 3 Small", type: "embedding" },
|
||||
{ id: "openai/text-embedding-ada-002", name: "OpenAI Text Embedding Ada 002", type: "embedding" },
|
||||
{ id: "qwen/qwen3-embedding-8b", name: "Qwen3 Embedding 8B", type: "embedding" },
|
||||
{ id: "perplexity/pplx-embed-v1-4b", name: "Perplexity Embed V1 4B", type: "embedding" },
|
||||
{ id: "perplexity/pplx-embed-v1-0.6b", name: "Perplexity Embed V1 0.6B", type: "embedding" },
|
||||
{ id: "nvidia/llama-nemotron-embed-vl-1b-v2:free", name: "NVIDIA Nemotron Embed VL 1B V2 (Free)", type: "embedding" },
|
||||
],
|
||||
glm: [
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
|
||||
@@ -24,7 +24,7 @@ import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.j
|
||||
* @param {object} options.credentials - Provider credentials
|
||||
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
||||
*/
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, sourceFormatOverride }) {
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, sourceFormatOverride, providerThinking }) {
|
||||
const { provider, model } = modelInfo;
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
@@ -39,6 +39,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||
const stripList = getModelStrip(alias, model);
|
||||
|
||||
// Inject provider-level thinking config override (only if client hasn't set)
|
||||
// on/off → extended type (body.thinking), none/low/medium/high → effort type (body.reasoning_effort)
|
||||
if (providerThinking?.mode && providerThinking.mode !== "auto") {
|
||||
const mode = providerThinking.mode;
|
||||
if (mode === "on" && !body.thinking) {
|
||||
console.log("Injecting provider-level thinking config override: on");
|
||||
body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } };
|
||||
} else if (mode === "off" && !body.thinking) {
|
||||
body = { ...body, thinking: { type: "disabled" } };
|
||||
} else if (!body.reasoning_effort) {
|
||||
body = { ...body, reasoning_effort: mode };
|
||||
}
|
||||
}
|
||||
|
||||
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
|
||||
const providerRequiresStreaming = provider === "openai" || provider === "codex";
|
||||
let stream = providerRequiresStreaming ? true : (body.stream !== false);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||
@@ -37,6 +37,7 @@ export default function ProviderDetailPage() {
|
||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||
const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override
|
||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
@@ -60,6 +61,7 @@ export default function ProviderDetailPage() {
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
||||
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
||||
const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended;
|
||||
|
||||
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||
const providerDisplayAlias = isCompatible
|
||||
@@ -111,6 +113,9 @@ export default function ProviderDetailPage() {
|
||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||
setProviderStrategy(override.fallbackStrategy || null);
|
||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||
// Load per-provider thinking config
|
||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||
setThinkingMode(thinkingCfg.mode || "auto");
|
||||
if (nodesRes.ok) {
|
||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||
|
||||
@@ -197,6 +202,32 @@ export default function ProviderDetailPage() {
|
||||
saveProviderStrategy("round-robin", value);
|
||||
};
|
||||
|
||||
const saveThinkingConfig = async (mode) => {
|
||||
try {
|
||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const current = settingsData.providerThinking || {};
|
||||
const updated = { ...current };
|
||||
if (!mode || mode === "auto") {
|
||||
delete updated[providerId];
|
||||
} else {
|
||||
updated[providerId] = { mode };
|
||||
}
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerThinking: updated }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving thinking config:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThinkingModeChange = (mode) => {
|
||||
setThinkingMode(mode);
|
||||
saveThinkingConfig(mode);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
@@ -731,22 +762,22 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
|
||||
{providerInfo.deprecated && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02] border border-black/[0.05] dark:border-white/[0.05]">
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted mt-0.5 shrink-0">info</span>
|
||||
<p className="text-xs text-text-muted leading-relaxed">{providerInfo.deprecationNotice}</p>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<span className="material-symbols-outlined text-[16px] text-yellow-500 mt-0.5 shrink-0">warning</span>
|
||||
<p className="text-xs text-red-600 dark:text-yellow-400 leading-relaxed">{providerInfo.deprecationNotice}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providerInfo.notice && !providerInfo.deprecated && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02] border border-black/[0.05] dark:border-white/[0.05]">
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted shrink-0">info</span>
|
||||
<p className="text-xs text-text-muted leading-relaxed">{providerInfo.notice.text}</p>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/30">
|
||||
<span className="material-symbols-outlined text-[16px] text-blue-500 shrink-0">info</span>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 leading-relaxed">{providerInfo.notice.text}</p>
|
||||
{providerInfo.notice.apiKeyUrl && (
|
||||
<a
|
||||
href={providerInfo.notice.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
className="text-xs font-medium text-white bg-blue-500 hover:bg-blue-600 px-2 py-0.5 rounded shrink-0 transition-colors"
|
||||
>
|
||||
Get API Key →
|
||||
</a>
|
||||
@@ -826,26 +857,43 @@ export default function ProviderDetailPage() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
{/* Round Robin toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
<Toggle
|
||||
checked={providerStrategy === "round-robin"}
|
||||
onChange={handleRoundRobinToggle}
|
||||
/>
|
||||
{providerStrategy === "round-robin" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-text-muted">Sticky:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={providerStickyLimit}
|
||||
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
||||
placeholder="1"
|
||||
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Thinking config */}
|
||||
{/* {thinkingConfig && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Thinking</span>
|
||||
<select
|
||||
value={thinkingMode}
|
||||
onChange={(e) => handleThinkingModeChange(e.target.value)}
|
||||
className="text-xs px-2 py-1 border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{thinkingConfig.options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
{/* Round Robin toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
<Toggle
|
||||
checked={providerStrategy === "round-robin"}
|
||||
onChange={handleRoundRobinToggle}
|
||||
/>
|
||||
{providerStrategy === "round-robin" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-text-muted">Sticky:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={providerStickyLimit}
|
||||
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
||||
placeholder="1"
|
||||
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
// Use npm from the same Node.js that runs Next.js — ensures 9remote
|
||||
// lands in the correct global bin (nvm or system, whichever is active)
|
||||
const npmBin = join(dirname(process.execPath), "npm");
|
||||
|
||||
function installPackage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`"${npmBin}" install -g 9remote`, { windowsHide: true }, (err, stdout, stderr) => {
|
||||
if (err) reject(new Error(stderr || err.message));
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await installPackage();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ ok: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { spawn } from "child_process";
|
||||
import { join, dirname } from "path";
|
||||
import os from "os";
|
||||
import { setRemoteProcess } from "@/lib/9remoteManager";
|
||||
|
||||
const bin9remote = join(dirname(process.execPath), "9remote");
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const nodeDir = dirname(process.execPath);
|
||||
const existingPath = process.env.PATH || "";
|
||||
const path = existingPath.includes(nodeDir)
|
||||
? existingPath
|
||||
: `${nodeDir}:${existingPath}`;
|
||||
|
||||
const env = {
|
||||
HOME: os.homedir(),
|
||||
PATH: path,
|
||||
USER: process.env.USER || process.env.LOGNAME,
|
||||
LANG: process.env.LANG || "en_US.UTF-8",
|
||||
TERM: process.env.TERM || "xterm-256color",
|
||||
TMPDIR: process.env.TMPDIR || os.tmpdir(),
|
||||
SHELL: process.env.SHELL,
|
||||
};
|
||||
const home = os.homedir();
|
||||
|
||||
// Spawn without detached - process will be child of Next.js and receive SIGTERM
|
||||
const child = spawn(bin9remote, ["ui", "--start"], {
|
||||
cwd: home,
|
||||
stdio: "ignore",
|
||||
env,
|
||||
windowsHide: process.platform === "win32",
|
||||
});
|
||||
|
||||
// Store child process for manual cleanup if needed
|
||||
setRemoteProcess(child);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ ok: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
const bin9remote = join(dirname(process.execPath), "9remote");
|
||||
const AGENT_URL = "http://localhost:2208";
|
||||
|
||||
async function isRunning() {
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/api/health`, {
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const running = await isRunning();
|
||||
if (running) return NextResponse.json({ installed: true, running: true });
|
||||
|
||||
const installed = existsSync(bin9remote);
|
||||
return NextResponse.json({ installed, running: false });
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// 9remote process lifecycle manager
|
||||
let remoteProcess = null;
|
||||
|
||||
export function setRemoteProcess(child) {
|
||||
remoteProcess = child;
|
||||
}
|
||||
|
||||
export function getRemoteProcess() {
|
||||
return remoteProcess;
|
||||
}
|
||||
|
||||
export function killRemote() {
|
||||
if (!remoteProcess) return;
|
||||
|
||||
try {
|
||||
remoteProcess.kill("SIGTERM");
|
||||
console.log(`[9remote] Killed process ${remoteProcess.pid}`);
|
||||
remoteProcess = null;
|
||||
} catch (err) {
|
||||
console.log(`[9remote] Failed to kill:`, err.message);
|
||||
remoteProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register cleanup handlers
|
||||
if (typeof process !== "undefined") {
|
||||
const cleanup = () => {
|
||||
killRemote();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("beforeExit", killRemote);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||
try {
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
body.model = mappedModel;
|
||||
const routerRes = await fetchRouter(body);
|
||||
const routerRes = await fetchRouter(body, "/v1/chat/completions", req.headers);
|
||||
await pipeSSE(routerRes, res);
|
||||
} catch (error) {
|
||||
err(`[antigravity] ${error.message}`);
|
||||
|
||||
@@ -6,13 +6,26 @@ const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER)
|
||||
.replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER;
|
||||
const API_KEY = process.env.ROUTER_API_KEY;
|
||||
|
||||
// Headers that must not be forwarded to 9Router
|
||||
const STRIP_HEADERS = new Set([
|
||||
"host", "content-length", "connection", "transfer-encoding",
|
||||
"content-type", "authorization"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Send body to 9Router at the given path and return the fetch Response object
|
||||
* Send body to 9Router at the given path and return the fetch Response object.
|
||||
* Optionally forwards client headers (stripped of hop-by-hop / overridden keys).
|
||||
*/
|
||||
async function fetchRouter(openaiBody, path = "/v1/chat/completions") {
|
||||
async function fetchRouter(openaiBody, path = "/v1/chat/completions", clientHeaders = {}) {
|
||||
const forwarded = {};
|
||||
for (const [k, v] of Object.entries(clientHeaders)) {
|
||||
if (!STRIP_HEADERS.has(k.toLowerCase())) forwarded[k] = v;
|
||||
}
|
||||
|
||||
const response = await fetch(`${ROUTER_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...forwarded,
|
||||
"Content-Type": "application/json",
|
||||
...(API_KEY && { "Authorization": `Bearer ${API_KEY}` })
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
body.model = mappedModel;
|
||||
const routerPath = resolveRouterPath(req.url);
|
||||
const routerRes = await fetchRouter(body, routerPath);
|
||||
const routerRes = await fetchRouter(body, routerPath, req.headers);
|
||||
await pipeSSE(routerRes, res);
|
||||
} catch (error) {
|
||||
err(`[copilot] ${error.message}`);
|
||||
|
||||
@@ -8,7 +8,7 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||
try {
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
body.model = mappedModel;
|
||||
const routerRes = await fetchRouter(body);
|
||||
const routerRes = await fetchRouter(body, "/v1/chat/completions", req.headers);
|
||||
await pipeSSE(routerRes, res);
|
||||
} catch (error) {
|
||||
err(`[Kiro] ${error.message}`);
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const S = {
|
||||
CHECKING: "checking",
|
||||
NOT_INSTALLED: "not_installed",
|
||||
NOT_RUNNING: "not_running",
|
||||
INSTALLING: "installing",
|
||||
STARTING: "starting",
|
||||
RUNNING: "running",
|
||||
ERROR: "error",
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 60000;
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: "terminal", label: "Terminal", desc: "Full shell access" },
|
||||
{ icon: "cast", label: "Desktop", desc: "Screen sharing" },
|
||||
{ icon: "folder_open", label: "Files", desc: "Browse & edit files" },
|
||||
];
|
||||
|
||||
const BULLETS = [
|
||||
{ icon: "qr_code_scanner", text: "Scan QR to connect instantly" },
|
||||
{ icon: "wifi_off", text: "No port forwarding needed" },
|
||||
{ icon: "devices", text: "Works on any device" },
|
||||
];
|
||||
|
||||
export default function NineRemoteModal({ isOpen, onClose, onInstalled }) {
|
||||
const [state, setState] = useState(S.CHECKING);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; }
|
||||
};
|
||||
|
||||
const pollUntilRunning = useCallback(() => {
|
||||
stopPolling();
|
||||
const startedAt = Date.now();
|
||||
const poll = async () => {
|
||||
if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
|
||||
setState(S.ERROR);
|
||||
setErrorMsg("9Remote is taking too long to start. Try running `9remote ui` manually.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/9remote/status");
|
||||
const data = await res.json();
|
||||
if (data.running) { setState(S.RUNNING); return; }
|
||||
} catch {}
|
||||
pollRef.current = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
};
|
||||
poll();
|
||||
}, []);
|
||||
|
||||
const checkAndInit = useCallback(async () => {
|
||||
setState(S.CHECKING);
|
||||
setErrorMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/9remote/status");
|
||||
const data = await res.json();
|
||||
if (data.running) { setState(S.RUNNING); return; }
|
||||
if (!data.installed) { setState(S.NOT_INSTALLED); return; }
|
||||
setState(S.NOT_RUNNING);
|
||||
} catch (err) { setState(S.ERROR); setErrorMsg(err.message); }
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setState(S.STARTING);
|
||||
try {
|
||||
const res = await fetch("/api/9remote/start", { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed to start 9Remote");
|
||||
pollUntilRunning();
|
||||
} catch (err) { setState(S.ERROR); setErrorMsg(err.message); }
|
||||
}, [pollUntilRunning]);
|
||||
|
||||
const handleInstall = async () => {
|
||||
setState(S.INSTALLING);
|
||||
setErrorMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/9remote/install", { method: "POST" });
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || "Install failed"); }
|
||||
onInstalled?.();
|
||||
await handleStart();
|
||||
} catch (err) { setState(S.ERROR); setErrorMsg(err.message); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) checkAndInit();
|
||||
else stopPolling();
|
||||
return stopPolling;
|
||||
}, [isOpen, checkAndInit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onEsc = (e) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onEsc);
|
||||
return () => { document.body.style.overflow = ""; document.removeEventListener("keydown", onEsc); };
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const btnCfg = {
|
||||
[S.NOT_INSTALLED]: { label: "Install 9Remote", icon: "download", onClick: handleInstall, loading: false },
|
||||
[S.INSTALLING]: { label: "Installing...", icon: "hourglass_top", onClick: null, loading: true },
|
||||
[S.NOT_RUNNING]: { label: "Start 9Remote", icon: "play_arrow", onClick: handleStart, loading: false },
|
||||
[S.STARTING]: { label: "Starting...", icon: "hourglass_top", onClick: null, loading: true },
|
||||
[S.CHECKING]: { label: "Checking...", icon: "hourglass_top", onClick: null, loading: true },
|
||||
}[state];
|
||||
|
||||
// Running — iframe only
|
||||
if (state === S.RUNNING) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200" style={{ width: 480, height: "90vh" }}>
|
||||
<iframe
|
||||
src="http://localhost:2208"
|
||||
className="border-0 block w-full h-full"
|
||||
title="9Remote UI"
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (state === S.ERROR) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-sm rounded-xl overflow-hidden shadow-2xl bg-surface border border-black/10 dark:border-white/10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<span className="text-sm font-semibold text-text-main">9Remote</span>
|
||||
<button onClick={onClose} className="w-7 h-7 flex items-center justify-center rounded-lg bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 text-text-muted hover:text-text-main transition-colors">
|
||||
<span className="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-5 py-12 px-6 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-red-400 text-[28px]">error</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-text-main">Something went wrong</p>
|
||||
<p className="text-xs text-text-muted font-mono break-all">{errorMsg}</p>
|
||||
</div>
|
||||
<button onClick={checkAndInit} className="flex items-center gap-2 px-4 py-2 rounded-lg bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 text-sm text-text-main hover:bg-black/10 dark:hover:bg-white/10 transition-colors">
|
||||
<span className="material-symbols-outlined text-base">refresh</span>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// Main card — layout cố định, chỉ đổi button
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200 flex flex-col bg-surface border border-black/10 dark:border-white/10"
|
||||
style={{ minHeight: "90vh" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#FF570A" }}>
|
||||
<span className="material-symbols-outlined text-white text-base">terminal</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider" style={{ fontFamily: "monospace", color: "#FF570A" }}>9Remote</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-7 h-7 flex items-center justify-center rounded-lg bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 text-text-muted hover:text-text-main transition-colors">
|
||||
<span className="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-7 py-7 pb-9 flex flex-col flex-1 justify-between">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center gap-2 text-center mt-2">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mb-1"
|
||||
style={{ background: "#FF570A", boxShadow: "rgba(255,87,10,0.35) 0px 8px 32px" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 30 }}>terminal</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-text-main tracking-tight">9Remote</h1>
|
||||
<p className="text-xs text-text-muted leading-5 max-w-[220px]">
|
||||
Access your terminal, desktop & files from anywhere
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="flex gap-2 w-full mt-6">
|
||||
{FEATURES.map(({ icon, label, desc }) => (
|
||||
<div key={label} className="flex-1 flex flex-col items-center gap-1.5 py-4 px-1 rounded-xl border border-black/10 dark:border-white/10 bg-bg-alt">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22, color: "#ff6e33" }}>{icon}</span>
|
||||
<p className="text-xs font-semibold text-text-main">{label}</p>
|
||||
<p className="text-[10px] text-text-muted text-center leading-4">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="flex flex-col gap-3 w-full mt-5">
|
||||
{BULLETS.map(({ icon, text }) => (
|
||||
<div key={icon} className="flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 16, color: "#ff6e33" }}>{icon}</span>
|
||||
<span className="text-xs text-text-muted">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA button */}
|
||||
{btnCfg && (
|
||||
<button
|
||||
onClick={btnCfg.onClick ?? undefined}
|
||||
disabled={btnCfg.loading}
|
||||
className={`mt-7 w-full py-3.5 flex items-center justify-center gap-2 text-sm font-semibold text-white rounded-xl transition-all ${
|
||||
btnCfg.loading
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "hover:opacity-90 active:scale-[0.98]"
|
||||
}`}
|
||||
style={{
|
||||
background: "#FF570A",
|
||||
boxShadow: btnCfg.loading ? "none" : "0 4px 16px rgba(255,87,10,0.35)",
|
||||
}}
|
||||
>
|
||||
{btnCfg.loading ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-base">{btnCfg.icon}</span>
|
||||
)}
|
||||
{btnCfg.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ export { default as ManualConfigModal } from "./ManualConfigModal";
|
||||
export { default as UsageStats } from "./UsageStats";
|
||||
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
||||
export { default as NineRemoteButton } from "./NineRemoteButton";
|
||||
export { default as NineRemoteModal } from "./NineRemoteModal";
|
||||
export { default as RequestLogger } from "./RequestLogger";
|
||||
export { default as KiroAuthModal } from "./KiroAuthModal";
|
||||
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
export const FREE_PROVIDERS = {
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Google has tightened Gemini CLI abuse detection and restricted Pro models to paid accounts (Mar 25, 2026). Using this provider may violate ToS and risk account bans." },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
|
||||
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
// qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" },
|
||||
@@ -14,18 +14,35 @@ export const FREE_PROVIDERS = {
|
||||
|
||||
// Free Tier Providers (has free access but may require account/API key)
|
||||
export const FREE_TIER_PROVIDERS = {
|
||||
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true },
|
||||
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, serviceKinds: ["llm", "embedding"] },
|
||||
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim", notice: { text: "Free access for NVIDIA Developer Program members (prototyping & testing).", apiKeyUrl: "https://build.nvidia.com/settings/api-keys" } },
|
||||
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } },
|
||||
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
|
||||
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding"] },
|
||||
};
|
||||
|
||||
// Thinking config definitions
|
||||
// options: list of selectable modes ("auto" = no override from server)
|
||||
// defaultMode: fallback when user hasn't configured
|
||||
// extended: claude-style thinking (thinking.type + budget_tokens) — used by most providers
|
||||
// effort: openai-style reasoning_effort — only openai + codex
|
||||
export const THINKING_CONFIG = {
|
||||
extended: {
|
||||
options: ["auto", "on", "off"],
|
||||
defaultMode: "auto",
|
||||
defaultBudgetTokens: 10000
|
||||
},
|
||||
effort: {
|
||||
options: ["auto", "none", "low", "medium", "high"],
|
||||
defaultMode: "auto"
|
||||
}
|
||||
};
|
||||
|
||||
// OAuth Providers
|
||||
export const OAUTH_PROVIDERS = {
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "Antigravity has tightened abuse detection and restricted model access. Using this provider may violate ToS and risk account bans." },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
||||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
@@ -42,7 +59,7 @@ export const APIKEY_PROVIDERS = {
|
||||
"minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" },
|
||||
alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
|
||||
"alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
|
||||
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts"] },
|
||||
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts"], thinkingConfig: THINKING_CONFIG.effort },
|
||||
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm"] },
|
||||
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
|
||||
// Use shared chatCore
|
||||
const chatSettings = await getSettings();
|
||||
const providerThinking = (chatSettings.providerThinking || {})[provider] || null;
|
||||
const result = await handleChatCore({
|
||||
body: { ...body, model: `${provider}/${model}` },
|
||||
modelInfo: { provider, model },
|
||||
@@ -199,6 +200,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
userAgent,
|
||||
apiKey,
|
||||
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
||||
providerThinking,
|
||||
// Detect source format by endpoint + body
|
||||
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
|
||||
Reference in New Issue
Block a user