# 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:56:40 +07:00
parent 52c38cf94c
commit 7ad538bcf2
41 changed files with 1171 additions and 391 deletions
@@ -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
@@ -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);
@@ -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 });
}
}
@@ -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 });
}
}