mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 05:31:47 +00:00
fix: update cli tools
This commit is contained in:
@@ -357,38 +357,6 @@ async function deleteCombo(id) {
|
||||
return makeRequest("DELETE", `/api/combos/${id}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI TOOLS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get CLI tool settings
|
||||
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
||||
* @returns {Promise<Object>} { success, data: { installed, has9Router, ... } }
|
||||
*/
|
||||
async function getCliToolSettings(tool) {
|
||||
return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CLI tool settings (POST)
|
||||
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
||||
* @param {Object} body - Payload depends on tool
|
||||
* @returns {Promise<Object>} { success, data }
|
||||
*/
|
||||
async function applyCliToolSettings(tool, body) {
|
||||
return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset CLI tool settings (DELETE)
|
||||
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
||||
* @returns {Promise<Object>} { success, data }
|
||||
*/
|
||||
async function resetCliToolSettings(tool) {
|
||||
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SETTINGS API
|
||||
// ============================================================================
|
||||
@@ -528,11 +496,6 @@ module.exports = {
|
||||
updateCombo,
|
||||
deleteCombo,
|
||||
|
||||
// CLI Tools
|
||||
getCliToolSettings,
|
||||
applyCliToolSettings,
|
||||
resetCliToolSettings,
|
||||
|
||||
// Settings
|
||||
getSettings,
|
||||
updateSettings,
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
const api = require("../api/client");
|
||||
const { pause, confirm } = require("../utils/input");
|
||||
const { showStatus } = require("../utils/display");
|
||||
const { selectModelFromList } = require("../utils/modelSelector");
|
||||
const { showMenuWithBack } = require("../utils/menuHelper");
|
||||
const { getEndpoint } = require("../utils/endpoint");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
green: "\x1b[32m",
|
||||
red: "\x1b[31m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
// Claude model types with defaults (matching Web UI)
|
||||
const CLAUDE_MODEL_TYPES = [
|
||||
{ id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
|
||||
{ id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
|
||||
{ id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
|
||||
];
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get first available API key from server
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getFirstApiKey() {
|
||||
const result = await api.getApiKeys();
|
||||
const keys = result.success ? (result.data.keys || []) : [];
|
||||
return keys.length > 0 ? keys[0].key : null;
|
||||
}
|
||||
|
||||
// ─── Claude Code ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current Claude config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildClaudeHeader() {
|
||||
const result = await api.getCliToolSettings("claude");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const settings = result.data.settings;
|
||||
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
|
||||
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
const lines = [];
|
||||
|
||||
if (currentUrl) {
|
||||
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
|
||||
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
|
||||
if (currentKey) {
|
||||
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
|
||||
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current Claude model from settings
|
||||
* @param {string} envKey
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getClaudeModel(envKey) {
|
||||
const result = await api.getCliToolSettings("claude");
|
||||
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Claude Code — sets endpoint, key, and all default models
|
||||
* @param {number} port
|
||||
*/
|
||||
async function claudeQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
|
||||
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
|
||||
|
||||
const result = await api.applyCliToolSettings("claude", { env });
|
||||
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select and save a specific Claude model type
|
||||
* @param {Object} modelType
|
||||
* @param {number} port
|
||||
*/
|
||||
async function claudeSelectModel(modelType, port) {
|
||||
const current = await getClaudeModel(modelType.envKey);
|
||||
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
|
||||
if (!selected) return;
|
||||
|
||||
const env = { [modelType.envKey]: selected };
|
||||
|
||||
// Also set base URL if not configured yet
|
||||
const settingsResult = await api.getCliToolSettings("claude");
|
||||
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
env.ANTHROPIC_BASE_URL = endpoint;
|
||||
env.API_TIMEOUT_MS = "600000";
|
||||
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
}
|
||||
|
||||
const result = await api.applyCliToolSettings("claude", { env });
|
||||
showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Claude Code settings
|
||||
*/
|
||||
async function claudeReset() {
|
||||
const result = await api.resetCliToolSettings("claude");
|
||||
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showClaudeCodeMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🔧 Claude Code Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildClaudeHeader,
|
||||
refresh: async () => ({
|
||||
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
|
||||
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
||||
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup (recommended)",
|
||||
action: async () => { await claudeQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => `Sonnet → ${d.sonnet}`,
|
||||
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => `Opus → ${d.opus}`,
|
||||
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => `Haiku → ${d.haiku}`,
|
||||
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await claudeReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Codex CLI ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current Codex config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildCodexHeader() {
|
||||
const result = await api.getCliToolSettings("codex");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, config } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Parse base_url and model from raw TOML string
|
||||
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
|
||||
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
|
||||
const model = modelMatch ? modelMatch[1] : "";
|
||||
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
|
||||
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Codex CLI
|
||||
* @param {number} port
|
||||
*/
|
||||
async function codexQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get model selection
|
||||
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Codex CLI settings
|
||||
*/
|
||||
async function codexReset() {
|
||||
const result = await api.resetCliToolSettings("codex");
|
||||
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex CLI submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showCodexMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🤖 Codex CLI Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildCodexHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup",
|
||||
action: async () => { await codexQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await codexReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Factory Droid ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current Droid config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildDroidHeader() {
|
||||
const result = await api.getCliToolSettings("droid");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, settings } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Extract 9Router custom model config
|
||||
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
|
||||
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Factory Droid
|
||||
* @param {number} port
|
||||
*/
|
||||
async function droidQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Factory Droid settings
|
||||
*/
|
||||
async function droidReset() {
|
||||
const result = await api.resetCliToolSettings("droid");
|
||||
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory Droid submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showDroidMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🤖 Factory Droid Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildDroidHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup",
|
||||
action: async () => { await droidQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await droidReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Open Claw ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current OpenClaw config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildOpenClawHeader() {
|
||||
const result = await api.getCliToolSettings("openclaw");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, settings } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Extract 9Router provider config
|
||||
const provider = settings?.models?.providers?.["9router"];
|
||||
const primary = settings?.agents?.defaults?.model?.primary || "";
|
||||
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
|
||||
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Open Claw
|
||||
* @param {number} port
|
||||
*/
|
||||
async function openClawQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Open Claw settings
|
||||
*/
|
||||
async function openClawReset() {
|
||||
const result = await api.resetCliToolSettings("openclaw");
|
||||
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Claw submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showOpenClawMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🦞 Open Claw Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildOpenClawHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup",
|
||||
action: async () => { await openClawQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await openClawReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildOpenCodeHeader() {
|
||||
const result = await api.getCliToolSettings("opencode");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, opencode } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
|
||||
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
|
||||
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
|
||||
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function openCodeQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick first model (also becomes active model by default)
|
||||
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
|
||||
if (!firstModel) return;
|
||||
|
||||
const models = [firstModel];
|
||||
|
||||
// Optionally add more models
|
||||
while (true) {
|
||||
const more = await confirm(`Add another model? (current: ${models.length})`);
|
||||
if (!more) break;
|
||||
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
|
||||
if (!next) break;
|
||||
if (!models.includes(next)) models.push(next);
|
||||
}
|
||||
|
||||
// Optional subagent model
|
||||
let subagentModel = firstModel;
|
||||
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
|
||||
if (wantSubagent) {
|
||||
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
|
||||
if (picked) subagentModel = picked;
|
||||
}
|
||||
|
||||
const result = await api.applyCliToolSettings("opencode", {
|
||||
baseUrl: endpoint,
|
||||
apiKey,
|
||||
models,
|
||||
activeModel: firstModel,
|
||||
subagentModel,
|
||||
});
|
||||
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function openCodeReset() {
|
||||
const result = await api.resetCliToolSettings("opencode");
|
||||
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function showOpenCodeMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "💻 OpenCode CLI Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildOpenCodeHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
|
||||
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildHermesHeader() {
|
||||
const result = await api.getCliToolSettings("hermes");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, settings } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const model = settings?.model || {};
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
|
||||
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function hermesQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function hermesReset() {
|
||||
const result = await api.resetCliToolSettings("hermes");
|
||||
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function showHermesMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "⚡ Hermes Agent Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildHermesHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
|
||||
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Main CLI Tools menu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showCliToolsMenu(port, breadcrumb = []) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
await showMenuWithBack({
|
||||
title: "🔧 CLI Tools",
|
||||
breadcrumb,
|
||||
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
|
||||
items: [
|
||||
{
|
||||
label: "Claude Code",
|
||||
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Codex CLI",
|
||||
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Factory Droid",
|
||||
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Open Claw",
|
||||
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "OpenCode",
|
||||
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Hermes",
|
||||
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { showCliToolsMenu };
|
||||
@@ -4,7 +4,6 @@ const { showProvidersMenu } = require("./menus/providers");
|
||||
const { showApiKeysMenu } = require("./menus/apiKeys");
|
||||
const { showCombosMenu } = require("./menus/combos");
|
||||
const { showSettingsMenu } = require("./menus/settings");
|
||||
const { showCliToolsMenu } = require("./menus/cliTools");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
@@ -99,13 +98,6 @@ async function startTerminalUI(port) {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "CLI Tools",
|
||||
action: async () => {
|
||||
await showCliToolsMenu(port, [...basePath, "CLI Tools"]);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
action: async () => {
|
||||
|
||||
@@ -1,45 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { CardSkeleton } from "@/shared/components";
|
||||
import { CLI_TOOLS, MITM_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { MitmLinkCard } from "./components";
|
||||
import ToolSummaryCard from "./components/ToolSummaryCard";
|
||||
|
||||
const ALL_STATUSES_URL = "/api/cli-tools/all-statuses";
|
||||
|
||||
export default function CLIToolsPageClient({ machineId }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toolStatuses, setToolStatuses] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(ALL_STATUSES_URL);
|
||||
if (res.ok && mounted) setToolStatuses(await res.json());
|
||||
} catch (error) {
|
||||
console.log("Error fetching tool statuses:", error);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const regularTools = Object.entries(CLI_TOOLS);
|
||||
const mitmTools = Object.entries(MITM_TOOLS);
|
||||
|
||||
@@ -47,7 +12,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-1 sm:px-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{regularTools.map(([toolId, tool]) => (
|
||||
<ToolSummaryCard key={toolId} toolId={toolId} tool={tool} status={toolStatuses[toolId]} />
|
||||
<ToolSummaryCard key={toolId} toolId={toolId} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:gap-4">
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { CardSkeleton } from "@/shared/components";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import {
|
||||
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
|
||||
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
|
||||
CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
|
||||
JcodeToolCard,
|
||||
} from "../components";
|
||||
import { ConfigGeneratorCard, DefaultToolCard } from "../components";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -18,7 +12,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
||||
const tool = CLI_TOOLS[toolId];
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelMappings, setModelMappings] = useState({});
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [tunnelEnabled, setTunnelEnabled] = useState(false);
|
||||
const [tunnelPublicUrl, setTunnelPublicUrl] = useState("");
|
||||
@@ -67,31 +60,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
||||
|
||||
const getActiveProviders = () => connections.filter(c => c.isActive !== false);
|
||||
|
||||
const getAllAvailableModels = () => {
|
||||
const activeProviders = getActiveProviders();
|
||||
const models = [];
|
||||
const seenModels = new Set();
|
||||
activeProviders.forEach(conn => {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
|
||||
const providerModels = getModelsByProviderId(conn.provider);
|
||||
providerModels.forEach(m => {
|
||||
const modelValue = `${alias}/${m.id}`;
|
||||
if (!seenModels.has(modelValue)) {
|
||||
seenModels.add(modelValue);
|
||||
models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
return models;
|
||||
};
|
||||
|
||||
const handleModelMappingChange = useCallback((tId, alias, target) => {
|
||||
setModelMappings(prev => {
|
||||
if (prev[tId]?.[alias] === target) return prev;
|
||||
return { ...prev, [tId]: { ...prev[tId], [alias]: target } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl;
|
||||
if (cloudEnabled && CLOUD_URL) return CLOUD_URL;
|
||||
@@ -100,48 +68,21 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
||||
};
|
||||
|
||||
const renderToolCard = () => {
|
||||
const availableModels = getAllAvailableModels();
|
||||
const hasActiveProviders = availableModels.length > 0;
|
||||
const commonProps = {
|
||||
tool,
|
||||
isExpanded: true,
|
||||
onToggle: () => {},
|
||||
toolId,
|
||||
baseUrl: getBaseUrl(),
|
||||
apiKeys,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
activeProviders: getActiveProviders(),
|
||||
cloudEnabled,
|
||||
};
|
||||
|
||||
switch (toolId) {
|
||||
case "claude":
|
||||
return <ClaudeToolCard {...commonProps} activeProviders={getActiveProviders()} modelMappings={modelMappings[toolId] || {}} onModelMappingChange={(a, t) => handleModelMappingChange(toolId, a, t)} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "codex":
|
||||
return <CodexToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
case "opencode":
|
||||
return <OpenCodeToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
case "cowork":
|
||||
return <CoworkToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} cloudUrl={CLOUD_URL} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} />;
|
||||
case "droid":
|
||||
return <DroidToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "openclaw":
|
||||
return <OpenClawToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "hermes":
|
||||
return <HermesToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "copilot":
|
||||
return <CopilotToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
case "cline":
|
||||
return <ClineToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
case "kilo":
|
||||
return <KiloToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
case "deepseek-tui":
|
||||
return <DeepSeekTuiToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "jcode":
|
||||
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
default:
|
||||
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
|
||||
}
|
||||
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
|
||||
return <ConfigGeneratorCard {...commonProps} />;
|
||||
};
|
||||
|
||||
// Guard removed/unknown tools (e.g. disabled Cowork) to avoid crash on direct URL.
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
|
||||
export default function ClaudeToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
activeProviders,
|
||||
modelMappings,
|
||||
onModelMappingChange,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [claudeStatus, setClaudeStatus] = useState(initialStatus || null);
|
||||
const [checkingClaude, setCheckingClaude] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [ccFilterNaming, setCcFilterNaming] = useState(false);
|
||||
const hasInitializedModels = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!claudeStatus?.installed) return null;
|
||||
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
|
||||
if (!currentUrl) return "not_configured";
|
||||
if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setClaudeStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !claudeStatus) {
|
||||
checkClaudeStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings").then(r => r.json()).then(data => {
|
||||
setCcFilterNaming(!!data.ccFilterNaming);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleCcFilterNamingToggle = async (e) => {
|
||||
const value = e.target.checked;
|
||||
setCcFilterNaming(value);
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ccFilterNaming: value }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (claudeStatus?.installed && !hasInitializedModels.current) {
|
||||
hasInitializedModels.current = true;
|
||||
const env = claudeStatus.settings?.env || {};
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
if (model.envKey) {
|
||||
const value = env[model.envKey] || model.defaultValue || "";
|
||||
// Only sync initial values from file once
|
||||
if (value) {
|
||||
onModelMappingChange(model.alias, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only set selectedApiKey if it exists in apiKeys list
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) {
|
||||
setSelectedApiKey(tokenFromFile);
|
||||
}
|
||||
}
|
||||
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
|
||||
|
||||
const checkClaudeStatus = async () => {
|
||||
setCheckingClaude(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/claude-settings");
|
||||
const data = await res.json();
|
||||
setClaudeStatus(data);
|
||||
} catch (error) {
|
||||
setClaudeStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingClaude(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
|
||||
// Get key from dropdown, fallback to first key or sk_9router for localhost
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
if (keyToUse) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
const res = await fetch("/api/cli-tools/claude-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } }));
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
|
||||
setSelectedApiKey("");
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openModelSelector = (alias) => {
|
||||
setCurrentEditingAlias(alias);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value);
|
||||
};
|
||||
|
||||
// Generate settings.json content for manual copy
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse };
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
filename: "~/.claude/settings.json",
|
||||
content: JSON.stringify({ hasCompletedOnboarding: true, env }, 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/claude.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">
|
||||
{checkingClaude && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Claude CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingClaude && claudeStatus && !claudeStatus.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">Claude CLI 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">
|
||||
<div>
|
||||
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
|
||||
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @anthropic-ai/claude-code</code>
|
||||
</div>
|
||||
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to verify.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingClaude && claudeStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Endpoint (selector) */}
|
||||
<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>
|
||||
|
||||
{/* Current configured */}
|
||||
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
|
||||
<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">
|
||||
{claudeStatus.settings.env.ANTHROPIC_BASE_URL}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Model Mappings */}
|
||||
{tool.defaultModels.map((model) => (
|
||||
<div key={model.alias} 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.name}</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={modelMappings[model.alias] || ""} onChange={(e) => onModelMappingChange(model.alias, 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" />
|
||||
{modelMappings[model.alias] && <button onClick={() => onModelMappingChange(model.alias, "")} 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={() => openModelSelector(model.alias)} disabled={!hasActiveProviders} 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 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* CC Filter Naming */}
|
||||
<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">Filter naming</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input type="checkbox" checked={ccFilterNaming} onChange={handleCcFilterNamingToggle} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
|
||||
<span className="text-xs text-text-muted">Filter naming requests</span>
|
||||
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</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={handleApplySettings} disabled={!hasActiveProviders} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} 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={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Claude CLI - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
"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 CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
|
||||
const [checkingCodex, setCheckingCodex] = 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 [subagentModel, setSubagentModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [subagentModalOpen, setSubagentModalOpen] = 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) setCodexStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !codexStatus) {
|
||||
checkCodexStatus();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse model and subagent settings from config content
|
||||
useEffect(() => {
|
||||
if (codexStatus?.config) {
|
||||
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m);
|
||||
if (modelMatch) setSelectedModel(modelMatch[1]);
|
||||
|
||||
// Parse subagent settings
|
||||
const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m);
|
||||
if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]);
|
||||
}
|
||||
}, [codexStatus]);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!codexStatus?.installed) return null;
|
||||
if (!codexStatus.config) return "not_configured";
|
||||
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
const currentUrl = parsed ? parsed[1] : "";
|
||||
return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || `${baseUrl}/v1`;
|
||||
// Ensure URL ends with /v1
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
|
||||
const checkCodexStatus = async () => {
|
||||
setCheckingCodex(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-settings");
|
||||
const data = await res.json();
|
||||
setCodexStatus(data);
|
||||
} catch (error) {
|
||||
setCodexStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingCodex(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Use sk_9router for localhost if no key, otherwise use selected key
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||
|
||||
const res = await fetch("/api/cli-tools/codex-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
model: selectedModel,
|
||||
subagentModel: subagentModel || selectedModel
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkCodexStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
setSubagentModel("");
|
||||
checkCodexStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
// Auto-set subagent model if not set
|
||||
if (!subagentModel) {
|
||||
setSubagentModel(model.value);
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const effectiveSubagentModel = subagentModel || selectedModel;
|
||||
|
||||
const configContent = `# 9Router Configuration for Codex CLI
|
||||
model = "${selectedModel}"
|
||||
model_provider = "9router"
|
||||
|
||||
[model_providers.9router]
|
||||
name = "9Router"
|
||||
base_url = "${getEffectiveBaseUrl()}"
|
||||
wire_api = "responses"
|
||||
|
||||
[agents.subagent]
|
||||
model = "${effectiveSubagentModel}"
|
||||
`;
|
||||
|
||||
const authContent = JSON.stringify({
|
||||
auth_mode: "apikey",
|
||||
OPENAI_API_KEY: keyToUse
|
||||
}, null, 2);
|
||||
|
||||
return [
|
||||
{
|
||||
filename: "~/.codex/config.toml",
|
||||
content: configContent,
|
||||
},
|
||||
{
|
||||
filename: "~/.codex/auth.json",
|
||||
content: authContent,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
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/codex.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">
|
||||
{checkingCodex && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Codex CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingCodex && codexStatus && !codexStatus.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">Codex CLI 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">
|
||||
<div>
|
||||
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
|
||||
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @openai/codex</code>
|
||||
</div>
|
||||
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to verify.</p>
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-text-muted text-xs">
|
||||
Codex uses <code className="px-1 bg-black/5 dark:bg-white/5 rounded">~/.codex/auth.json</code> with <code className="px-1 bg-black/5 dark:bg-white/5 rounded">OPENAI_API_KEY</code>.
|
||||
Click "Apply" to auto-configure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingCodex && codexStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Endpoint (selector) */}
|
||||
<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>
|
||||
|
||||
{/* Current configured */}
|
||||
{codexStatus?.config && (() => {
|
||||
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
const currentBaseUrl = parsed ? parsed[1] : null;
|
||||
return currentBaseUrl ? (
|
||||
<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">
|
||||
{currentBaseUrl}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Model */}
|
||||
<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>
|
||||
|
||||
{/* Subagent Model */}
|
||||
<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">Subagent 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={subagentModel}
|
||||
onChange={(e) => setSubagentModel(e.target.value)}
|
||||
placeholder={selectedModel || "provider/model-id (defaults to main model)"}
|
||||
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"
|
||||
/>
|
||||
{subagentModel && (
|
||||
<button
|
||||
onClick={() => setSubagentModel("")}
|
||||
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 (will use main model)"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSubagentModalOpen(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={handleApplySettings} 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={handleResetSettings} 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={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Codex"
|
||||
/>
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={subagentModalOpen}
|
||||
onClose={() => setSubagentModalOpen(false)}
|
||||
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
|
||||
selectedModel={subagentModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Subagent Model for Codex"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Codex CLI - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
|
||||
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
|
||||
const DEFAULT_MODEL = "provider/model-id";
|
||||
|
||||
const normalizeV1 = (url) => {
|
||||
const trimmed = (url || "").replace(/\/+$/, "");
|
||||
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
||||
};
|
||||
|
||||
const toJson = (value) => JSON.stringify(value, null, 2);
|
||||
|
||||
const withThinkingLevel = (model, thinkingLevel) => (
|
||||
model && thinkingLevel ? `${model}(${thinkingLevel})` : model
|
||||
);
|
||||
|
||||
function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "" }) {
|
||||
const endpoint = normalizeV1(baseUrl);
|
||||
const selectedModels = models.length ? models : [DEFAULT_MODEL];
|
||||
const model = selectedModels[0];
|
||||
|
||||
switch (toolId) {
|
||||
case "claude":
|
||||
return [{
|
||||
filename: "~/.claude/settings.json",
|
||||
content: toJson({
|
||||
hasCompletedOnboarding: true,
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: endpoint,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: withThinkingLevel(claudeModels.sonnet || DEFAULT_MODEL, claudeThinking.sonnet),
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: withThinkingLevel(claudeModels.opus || DEFAULT_MODEL, claudeThinking.opus),
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: withThinkingLevel(claudeModels.haiku || DEFAULT_MODEL, claudeThinking.haiku),
|
||||
},
|
||||
}),
|
||||
}];
|
||||
case "codex":
|
||||
return [
|
||||
{
|
||||
filename: "~/.codex/config.toml",
|
||||
content: `model = "${withThinkingLevel(codexModel || DEFAULT_MODEL, codexThinking)}"\nmodel_provider = "9router"\n\n[model_providers.9router]\nname = "9Router"\nbase_url = "${endpoint}"\nwire_api = "responses"\n`,
|
||||
},
|
||||
{ filename: "~/.codex/auth.json", content: toJson({ auth_mode: "apikey", OPENAI_API_KEY: apiKey }) },
|
||||
];
|
||||
case "openclaw":
|
||||
return [{
|
||||
filename: "~/.openclaw/openclaw.json",
|
||||
content: toJson({
|
||||
agents: { defaults: { model: { primary: `9router/${model}` } } },
|
||||
models: { providers: { "9router": {
|
||||
baseUrl: endpoint,
|
||||
apiKey,
|
||||
api: "openai-completions",
|
||||
models: selectedModels.map((id) => ({ id, name: id.split("/").pop() })),
|
||||
} } },
|
||||
}),
|
||||
}];
|
||||
case "opencode": {
|
||||
const modelEntries = Object.fromEntries(selectedModels.map((id) => [id, {
|
||||
name: id,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
}]));
|
||||
return [{
|
||||
filename: "~/.config/opencode/opencode.json",
|
||||
content: toJson({
|
||||
provider: { "9router": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: { baseURL: endpoint, apiKey },
|
||||
models: modelEntries,
|
||||
} },
|
||||
model: `9router/${model}`,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
case "copilot":
|
||||
return [{
|
||||
filename: "chatLanguageModels.json",
|
||||
content: toJson(selectedModels.map((id) => ({
|
||||
name: id,
|
||||
vendor: "9Router",
|
||||
model: id,
|
||||
apiBase: endpoint,
|
||||
apiKey,
|
||||
}))),
|
||||
}];
|
||||
case "cline":
|
||||
return [{
|
||||
filename: "~/.cline/data/globalState.json",
|
||||
content: toJson({
|
||||
openAiModelId: model,
|
||||
openAiBaseUrl: endpoint,
|
||||
openAiApiKey: apiKey,
|
||||
}),
|
||||
}];
|
||||
case "kilo":
|
||||
return [{
|
||||
filename: "~/.local/share/kilo/auth.json",
|
||||
content: toJson({
|
||||
"9router": { baseUrl: endpoint, apiKey, model },
|
||||
}),
|
||||
}];
|
||||
case "deepseek-tui":
|
||||
return [{
|
||||
filename: "~/.deepseek/config.toml",
|
||||
content: `[model]\nprovider = "openai"\nbase_url = "${endpoint}"\napi_key = "${apiKey}"\ndefault = "${model}"\n`,
|
||||
}];
|
||||
case "hermes":
|
||||
return [{
|
||||
filename: "~/.hermes/config.yaml",
|
||||
content: `model:\n provider: openai\n base_url: ${endpoint}\n api_key: ${apiKey}\n default: ${model}\n`,
|
||||
}];
|
||||
case "droid":
|
||||
return [{
|
||||
filename: "~/.factory/settings.json",
|
||||
content: toJson({ customModels: selectedModels.map((id, index) => ({
|
||||
id: `custom:9Router-${index}`,
|
||||
name: `9Router: ${id}`,
|
||||
baseUrl: endpoint,
|
||||
apiKey,
|
||||
model: id,
|
||||
})) }),
|
||||
}];
|
||||
case "jcode":
|
||||
return [{
|
||||
filename: "~/.config/jcode/config.json",
|
||||
content: toJson({ provider: "openai", baseUrl: endpoint, apiKey, model }),
|
||||
}];
|
||||
case "cowork":
|
||||
return [{
|
||||
filename: "Claude Desktop third-party inference configuration.json",
|
||||
content: toJson({ baseUrl: endpoint, apiKey, models: selectedModels }),
|
||||
}];
|
||||
default:
|
||||
return [{ filename: "config.json", content: toJson({ baseUrl: endpoint, apiKey, model }) }];
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfigGeneratorCard({
|
||||
tool,
|
||||
toolId,
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" });
|
||||
const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" });
|
||||
const [claudeModelSlot, setClaudeModelSlot] = useState("");
|
||||
const [codexModel, setCodexModel] = useState("");
|
||||
const [codexThinking, setCodexThinking] = useState("");
|
||||
const [connectedModels, setConnectedModels] = useState(null);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [modelModalOpen, setModelModalOpen] = useState(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
|
||||
const effectiveBaseUrl = customBaseUrl || baseUrl;
|
||||
const apiKey = selectedApiKey.trim() || (cloudEnabled ? "<API_KEY_FROM_DASHBOARD>" : "sk_9router");
|
||||
const configs = useMemo(
|
||||
() => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking }),
|
||||
[toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking]
|
||||
);
|
||||
|
||||
const getThinkingLevelsForModel = (fullModel) => {
|
||||
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
|
||||
if (!connectedModel?.provider?.id || !connectedModel.model) return null;
|
||||
return getThinkingLevels(connectedModel.provider.id, connectedModel.model);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (toolId !== "claude" && toolId !== "codex") return;
|
||||
|
||||
let cancelled = false;
|
||||
const loadConnectedModels = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/models/connected", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Failed to load connected models");
|
||||
const data = await response.json();
|
||||
if (!cancelled) setConnectedModels(data.models || []);
|
||||
} catch (error) {
|
||||
console.log(`Error loading connected models for ${tool.name}:`, error);
|
||||
if (!cancelled) setConnectedModels([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadConnectedModels();
|
||||
return () => { cancelled = true; };
|
||||
}, [toolId, tool.name]);
|
||||
|
||||
const addModel = (selected) => {
|
||||
if (!selected?.value || selectedModels.includes(selected.value)) return;
|
||||
setSelectedModels((current) => [...current, selected.value]);
|
||||
};
|
||||
|
||||
const selectClaudeModel = (selected) => {
|
||||
if (!selected?.value || !claudeModelSlot) return;
|
||||
setClaudeModels((current) => ({ ...current, [claudeModelSlot]: selected.value }));
|
||||
setClaudeThinking((current) => ({ ...current, [claudeModelSlot]: "" }));
|
||||
setClaudeModelSlot("");
|
||||
};
|
||||
|
||||
const openClaudeModelSelector = (slot) => {
|
||||
setClaudeModelSlot(slot);
|
||||
setModelModalOpen(true);
|
||||
};
|
||||
|
||||
const selectCodexModel = (selected) => {
|
||||
if (!selected?.value) return;
|
||||
setCodexModel(selected.value);
|
||||
setCodexThinking("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-start gap-3 sm:items-center">
|
||||
<div className="size-9 shrink-0">
|
||||
<Image src={tool.image} alt={tool.name} width={36} height={36} className="size-9 rounded-lg object-contain" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-text-main">{tool.name}</h3>
|
||||
<p className="text-xs text-text-muted">Generate a configuration file to copy to your own machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-4 border-t border-border pt-4">
|
||||
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
||||
Endpoint
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || baseUrl}
|
||||
onChange={setCustomBaseUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
cloudEnabled={cloudEnabled}
|
||||
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
||||
API key
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</label>
|
||||
{toolId === "claude" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-medium text-text-muted">Default Claude models</span>
|
||||
{[
|
||||
{ slot: "sonnet", label: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL" },
|
||||
{ slot: "opus", label: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL" },
|
||||
{ slot: "haiku", label: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL" },
|
||||
].map(({ slot, label, envKey }) => {
|
||||
const thinkingLevels = getThinkingLevelsForModel(claudeModels[slot]);
|
||||
return (
|
||||
<div key={slot} className="flex flex-col gap-1.5">
|
||||
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
||||
<span>{label} <code className="font-normal">({envKey})</code></span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={claudeModels[slot]}
|
||||
placeholder={DEFAULT_MODEL}
|
||||
aria-label={`Default ${label} model`}
|
||||
onClick={() => openClaudeModelSelector(slot)}
|
||||
className="min-w-0 flex-1 cursor-pointer rounded-lg border border-border bg-bg-secondary px-3 py-2 text-xs text-text-main outline-none transition-colors hover:border-primary/60 focus:border-primary"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => openClaudeModelSelector(slot)}>Select</Button>
|
||||
{claudeModels[slot] && <Button type="button" variant="ghost" size="sm" onClick={() => { setClaudeModels((current) => ({ ...current, [slot]: "" })); setClaudeThinking((current) => ({ ...current, [slot]: "" })); }}>Clear</Button>}
|
||||
</div>
|
||||
</label>
|
||||
{thinkingLevels && (
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
|
||||
Reasoning / thinking
|
||||
<select
|
||||
value={claudeThinking[slot]}
|
||||
onChange={(event) => setClaudeThinking((current) => ({ ...current, [slot]: event.target.value }))}
|
||||
className="min-w-36 rounded-lg border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{thinkingLevels.map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-text-muted">Choose a model for each Claude Code alias. Empty fields use <code>{DEFAULT_MODEL}</code> as a placeholder.</p>
|
||||
</div>
|
||||
) : toolId === "codex" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
||||
Default model <code className="font-normal">(model)</code>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={codexModel}
|
||||
placeholder={DEFAULT_MODEL}
|
||||
aria-label="Default Codex model"
|
||||
onClick={() => setModelModalOpen(true)}
|
||||
className="min-w-0 flex-1 cursor-pointer rounded-lg border border-border bg-bg-secondary px-3 py-2 text-xs text-text-main outline-none transition-colors hover:border-primary/60 focus:border-primary"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>Select</Button>
|
||||
{codexModel && <Button type="button" variant="ghost" size="sm" onClick={() => { setCodexModel(""); setCodexThinking(""); }}>Clear</Button>}
|
||||
</div>
|
||||
</label>
|
||||
{getThinkingLevelsForModel(codexModel) && (
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
|
||||
Reasoning / thinking
|
||||
<select
|
||||
value={codexThinking}
|
||||
onChange={(event) => setCodexThinking(event.target.value)}
|
||||
className="min-w-36 rounded-lg border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{getThinkingLevelsForModel(codexModel).map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<p className="text-xs text-text-muted">The selected reasoning level is appended to the model ID, for example <code>cx/gpt-5.6-sol(high)</code>.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-text-muted">Models</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>
|
||||
<span className="material-symbols-outlined mr-1 text-[16px]">add</span>
|
||||
Add model
|
||||
</Button>
|
||||
</div>
|
||||
{selectedModels.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedModels.map((model) => (
|
||||
<button key={model} type="button" onClick={() => setSelectedModels((current) => current.filter((item) => item !== model))} className="inline-flex items-center gap-1 rounded-full border border-border bg-bg-secondary px-2 py-1 text-xs text-text-main hover:border-red-500/50" title="Remove model">
|
||||
{model}<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto sm:self-start">
|
||||
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
|
||||
Show configuration file
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
|
||||
</div>
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modelModalOpen}
|
||||
onClose={() => { setModelModalOpen(false); setClaudeModelSlot(""); }}
|
||||
onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : addModel}
|
||||
selectedModel=""
|
||||
activeProviders={activeProviders}
|
||||
title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : `Add model for ${tool.name}`}
|
||||
closeOnSelect={toolId === "claude" || toolId === "codex"}
|
||||
addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : selectedModels}
|
||||
availableModels={toolId === "claude" || toolId === "codex" ? connectedModels : null}
|
||||
/>
|
||||
<ManualConfigModal isOpen={configModalOpen} onClose={() => setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 CopilotToolCard({ 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 [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const selectedModelsRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModelsRef.current = selectedModels;
|
||||
}, [selectedModels]);
|
||||
|
||||
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]);
|
||||
|
||||
// Pre-fill from existing config
|
||||
useEffect(() => {
|
||||
if (status?.config && Array.isArray(status.config) && selectedModels.length === 0) {
|
||||
const entry = status.config.find((e) => e.name === "9Router");
|
||||
if (entry?.models?.length > 0) {
|
||||
setSelectedModels(entry.models.map((m) => m.id));
|
||||
}
|
||||
}
|
||||
}, [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 saveModels = async (models) => {
|
||||
try {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||
await fetch("/api/cli-tools/copilot-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!status) return null;
|
||||
if (!status.has9Router) return "not_configured";
|
||||
const url = status.currentUrl || "";
|
||||
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
|
||||
|
||||
const removeModel = (id) => setSelectedModels((prev) => prev.filter((m) => m !== id));
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/copilot-settings");
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
setStatus({ 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/copilot-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models: selectedModels }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." });
|
||||
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/copilot-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModels([]);
|
||||
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 effectiveBaseUrl = getEffectiveBaseUrl();
|
||||
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
|
||||
|
||||
return [{
|
||||
filename: "~/Library/Application Support/Code/User/chatLanguageModels.json",
|
||||
content: JSON.stringify([{
|
||||
name: "9Router",
|
||||
vendor: "azure",
|
||||
apiKey: keyToUse,
|
||||
models: modelsToShow.map((id) => ({
|
||||
id, name: id,
|
||||
url: `${effectiveBaseUrl}/chat/completions#models.ai.azure.com`,
|
||||
toolCalling: true, vision: false,
|
||||
maxInputTokens: 128000, maxOutputTokens: 16000,
|
||||
})),
|
||||
}], 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/copilot.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 Copilot config...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<p className="font-medium">Writes to <code className="px-1 bg-black/5 dark:bg-white/10 rounded">chatLanguageModels.json</code></p>
|
||||
<p className="mt-0.5 opacity-80">Reload VS Code after applying for changes to take effect.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Endpoint */}
|
||||
<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>
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Models */}
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{selectedModels.length === 0 ? (
|
||||
<span className="text-xs text-text-muted">No models selected</span>
|
||||
) : (
|
||||
selectedModels.map((model) => (
|
||||
<span key={model} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
|
||||
{model}
|
||||
<button onClick={(e) => { e.stopPropagation(); removeModel(model); }} className="ml-0.5 hover:text-red-500">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
|
||||
</div>
|
||||
</div>
|
||||
</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={selectedModels.length === 0} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status?.has9Router} loading={restoring}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} disabled={selectedModels.length === 0}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
saveModels(selectedModelsRef.current);
|
||||
}}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
}
|
||||
}}
|
||||
onDeselect={(model) => {
|
||||
setSelectedModels(selectedModels.filter(m => m !== model.value));
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
title="Add Model for GitHub Copilot"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="GitHub Copilot - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/cowork-settings";
|
||||
|
||||
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
|
||||
const ensureV1 = (url) => {
|
||||
const trimmed = (url || "").replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||
};
|
||||
|
||||
export default function CoworkToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
hasActiveProviders,
|
||||
cloudEnabled,
|
||||
cloudUrl,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
initialStatus,
|
||||
}) {
|
||||
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 [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [plugins, setPlugins] = useState([]);
|
||||
const [localPlugins, setLocalPlugins] = useState([]);
|
||||
const [customPlugins, setCustomPlugins] = useState([]);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [comboModalOpen, setComboModalOpen] = useState(false);
|
||||
const [modelSelectOpen, setModelSelectOpen] = useState(false);
|
||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||
const [addMcpOpen, setAddMcpOpen] = useState(false);
|
||||
const [addMcpForm, setAddMcpForm] = useState({ name: "", url: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) checkStatus();
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
fetch("/api/models/alias")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (data) setModelAliases(data.aliases || {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.cowork?.models?.length) {
|
||||
setSelectedModels(status.cowork.models);
|
||||
}
|
||||
if (status?.cowork?.baseUrl && !customBaseUrl) {
|
||||
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
|
||||
}
|
||||
// Initialize plugins: from current config, fallback to defaultPlugins
|
||||
if (Array.isArray(status?.cowork?.plugins) && status.cowork.plugins.length > 0) {
|
||||
setPlugins(status.cowork.plugins);
|
||||
} else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) {
|
||||
setPlugins(status.defaultPlugins);
|
||||
}
|
||||
if (Array.isArray(status?.cowork?.localPlugins)) {
|
||||
setLocalPlugins(status.cowork.localPlugins);
|
||||
}
|
||||
if (Array.isArray(status?.cowork?.customPlugins) && status.cowork.customPlugins.length > 0) {
|
||||
setCustomPlugins(status.cowork.customPlugins);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT);
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
setStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => ensureV1(customBaseUrl);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!status?.installed) return null;
|
||||
const url = status?.cowork?.baseUrl;
|
||||
if (!url) return "not_configured";
|
||||
return status.has9Router ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const handleApply = async () => {
|
||||
setMessage(null);
|
||||
const effectiveUrl = getEffectiveBaseUrl();
|
||||
|
||||
if (selectedModels.length === 0) {
|
||||
setMessage({ type: "error", text: "Please select at least one model" });
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(true);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: effectiveUrl,
|
||||
apiKey: keyToUse,
|
||||
models: selectedModels,
|
||||
plugins,
|
||||
localPlugins,
|
||||
customPlugins,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." });
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCombo = async ({ name, models }) => {
|
||||
try {
|
||||
const res = await fetch("/api/combos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, models }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
setMessage({ type: "error", text: err.error || "Failed to create combo" });
|
||||
return;
|
||||
}
|
||||
if (!selectedModels.includes(name)) {
|
||||
setSelectedModels([...selectedModels, name]);
|
||||
}
|
||||
setComboModalOpen(false);
|
||||
setMessage({ type: "success", text: `Combo "${name}" created and added.` });
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModel = (model) => {
|
||||
const value = model?.value || model?.name || model;
|
||||
if (!value || selectedModels.includes(value)) return;
|
||||
setSelectedModels((prev) => [...prev, value]);
|
||||
};
|
||||
|
||||
const handleRemoveModel = (model) => {
|
||||
const value = model?.value || model?.name || model;
|
||||
setSelectedModels((prev) => prev.filter((item) => item !== value));
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully" });
|
||||
setSelectedModels([]);
|
||||
setPlugins(status?.defaultPlugins || []);
|
||||
setLocalPlugins([]);
|
||||
setCustomPlugins([]);
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addPlugin = (p) => {
|
||||
if (plugins.some((x) => x.name === p.name)) return;
|
||||
setPlugins([...plugins, p]);
|
||||
};
|
||||
|
||||
const removePlugin = (name) => {
|
||||
setPlugins(plugins.filter((p) => p.name !== name));
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
|
||||
const cfg = {
|
||||
inferenceProvider: "gateway",
|
||||
inferenceGatewayBaseUrl: getEffectiveBaseUrl() || "https://your-public-host/v1",
|
||||
inferenceGatewayApiKey: keyToUse,
|
||||
inferenceModels: modelsToShow.map((name) => ({ name })),
|
||||
};
|
||||
|
||||
return [{
|
||||
filename: "~/Library/Application Support/Claude-3p/configLibrary/<appliedId>.json",
|
||||
content: JSON.stringify(cfg, 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={tool.image} 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 Claude Cowork...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status && !status.installed && (
|
||||
<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">Claude Desktop (Cowork mode) not detected</p>
|
||||
<p className="text-sm text-text-muted">Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="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>
|
||||
</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={getEffectiveBaseUrl()}
|
||||
onChange={(url) => setCustomBaseUrl(stripV1(url))}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
cloudEnabled={cloudEnabled}
|
||||
cloudUrl={cloudUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status?.cowork?.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">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.cowork.baseUrl}
|
||||
</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] sm:items-center sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Models</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex-1 flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{selectedModels.length === 0 ? (
|
||||
<span className="text-xs text-text-muted">No models selected</span>
|
||||
) : (
|
||||
selectedModels.map((m) => (
|
||||
<span key={m} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
|
||||
{m}
|
||||
<button onClick={() => handleRemoveModel(m)} className="ml-0.5 hover:text-red-500">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Combo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-2">MCP</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-2">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
{/* Preset plugins */}
|
||||
{plugins.filter((p) => p.name !== "exa").map((p) => (
|
||||
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
|
||||
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.title || p.name}</span>
|
||||
{p.oauth && <span className="text-[8px] text-amber-600 shrink-0">OAuth</span>}
|
||||
<div className="flex-1 flex flex-wrap gap-1 overflow-hidden" style={{ maxHeight: "1.5rem" }}>
|
||||
{Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => (
|
||||
<span key={t} className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">{t}</span>
|
||||
))}
|
||||
{Array.isArray(p.toolNames) && p.toolNames.length > 6 && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">+{p.toolNames.length - 6}</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => removePlugin(p.name)} className="shrink-0 hover:text-red-500 ml-auto">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Custom plugins */}
|
||||
{customPlugins.map((p) => (
|
||||
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
|
||||
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
|
||||
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
|
||||
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url}</span>
|
||||
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && (
|
||||
<div className="px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-muted">No MCPs added</div>
|
||||
)}
|
||||
{/* Actions row */}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
|
||||
+ Browse
|
||||
</button>
|
||||
<button onClick={() => { setAddMcpForm({ name: "", url: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
|
||||
+ Custom
|
||||
</button>
|
||||
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Tools</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
{(() => {
|
||||
const exaEnabled = plugins.some((p) => p.name === "exa");
|
||||
const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa");
|
||||
return (
|
||||
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exaEnabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && exaDef) setPlugins([...plugins.filter((p) => p.name !== "exa"), exaDef]);
|
||||
else setPlugins(plugins.filter((p) => p.name !== "exa"));
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium">Web Search & Fetch (Exa)</div>
|
||||
<p className="text-[10px] text-text-muted leading-snug">Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp");
|
||||
if (!browserDef) return null;
|
||||
const browserEnabled = localPlugins.includes("browsermcp");
|
||||
return (
|
||||
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={browserEnabled}
|
||||
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, "browsermcp"] : localPlugins.filter((n) => n !== "browsermcp"))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium">Browser Control (Browser MCP)</div>
|
||||
<p className="text-[10px] text-text-muted leading-snug">
|
||||
Controls your running Chrome. Auto-strips Cowork's built-in browser tools.{" "}
|
||||
<a href={browserDef.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">Install Chrome extension</a>
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Local Plugins</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => {
|
||||
const enabled = localPlugins.includes(p.name);
|
||||
return (
|
||||
<label key={p.name} className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, p.name] : localPlugins.filter((n) => n !== p.name))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-medium">{p.title}</span>
|
||||
<span className="text-[8px] text-amber-600">stdio</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted leading-snug">{p.description}</p>
|
||||
{p.extensionUrl && (
|
||||
<a href={p.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-[10px] text-primary underline">Install Chrome extension</a>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted leading-snug">
|
||||
⚠️ Local plugins run as subprocess via <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">npx</code>. Requires Node.js installed.
|
||||
</p>
|
||||
</div>
|
||||
</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="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Claude Cowork - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
|
||||
<ComboFormModal
|
||||
isOpen={comboModalOpen}
|
||||
combo={null}
|
||||
onClose={() => setComboModalOpen(false)}
|
||||
onSave={handleCreateCombo}
|
||||
activeProviders={activeProviders}
|
||||
forcePrefix="claude-"
|
||||
title="Create Cowork Combo"
|
||||
/>
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modelSelectOpen}
|
||||
onClose={() => setModelSelectOpen(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleRemoveModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Cowork Model"
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
|
||||
<McpMarketplaceModal
|
||||
isOpen={marketplaceOpen}
|
||||
onClose={() => setMarketplaceOpen(false)}
|
||||
onAdd={addPlugin}
|
||||
addedNames={plugins.map((p) => p.name)}
|
||||
/>
|
||||
|
||||
{/* Add Custom MCP modal */}
|
||||
{addMcpOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setAddMcpOpen(false)}>
|
||||
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm">Add Custom MCP</h3>
|
||||
<button onClick={() => setAddMcpOpen(false)} className="text-text-muted hover:text-text-main">
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] text-text-muted font-medium">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="my-mcp"
|
||||
value={addMcpForm.name}
|
||||
onChange={(e) => setAddMcpForm((f) => ({ ...f, name: e.target.value.replace(/\s+/g, "-").toLowerCase() }))}
|
||||
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://your-mcp-server.com/sse"
|
||||
value={addMcpForm.url}
|
||||
onChange={(e) => setAddMcpForm((f) => ({ ...f, url: e.target.value }))}
|
||||
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setAddMcpOpen(false)} className="px-3 py-1.5 rounded border border-border text-xs text-text-muted hover:bg-surface cursor-pointer">Cancel</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = addMcpForm.name.trim();
|
||||
if (!name || !addMcpForm.url.trim()) return;
|
||||
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
|
||||
setAddMcpOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
|
||||
>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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/deepseek-tui-settings";
|
||||
|
||||
export default function DeepSeekTuiToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [deepseekStatus, setDeepseekStatus] = useState(initialStatus || null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const hasInitializedModel = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!deepseekStatus?.installed) return null;
|
||||
const openaiSection = deepseekStatus.settings?.["providers.openai"];
|
||||
if (!openaiSection?.base_url) return "not_configured";
|
||||
if (matchKnownEndpoint(openaiSection.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setDeepseekStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !deepseekStatus) {
|
||||
checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (deepseekStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const openaiSection = deepseekStatus.settings?.["providers.openai"];
|
||||
if (openaiSection?.model) setSelectedModel(openaiSection.model);
|
||||
}
|
||||
}, [deepseekStatus]);
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT);
|
||||
const data = await res.json();
|
||||
setDeepseekStatus(data);
|
||||
} catch (error) {
|
||||
setDeepseekStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
|
||||
|
||||
const getLocalBaseUrl = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeLocalhost(window.location.origin);
|
||||
}
|
||||
return "http://127.0.0.1:20128";
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
model: selectedModel,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset 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 handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const tomlContent = `[providers.openai]
|
||||
base_url = "${getEffectiveBaseUrl()}"
|
||||
api_key = "${keyToUse}"
|
||||
model = "${selectedModel || "provider/model-id"}"
|
||||
`;
|
||||
|
||||
return [
|
||||
{ filename: "~/.deepseek/config.toml", content: tomlContent },
|
||||
];
|
||||
};
|
||||
|
||||
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={tool.image || "/providers/deepseek-tui.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 DeepSeek TUI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && deepseekStatus && !deepseekStatus.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">DeepSeek TUI not detected locally</p>
|
||||
<p className="text-sm text-text-muted mt-1">Install via npm:</p>
|
||||
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">npm install -g deepseek-tui</code>
|
||||
<p className="text-sm text-text-muted mt-2">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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && deepseekStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tool.notes && tool.notes.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{tool.notes.map((note, idx) => (
|
||||
<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${
|
||||
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
note.type === "error" ? "bg-red-500/10 text-red-600 dark:text-red-400" :
|
||||
"bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
}`}>
|
||||
<span className="material-symbols-outlined text-[14px] mt-0.5">
|
||||
{note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"}
|
||||
</span>
|
||||
<span>{note.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 || getEffectiveBaseUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deepseekStatus?.settings?.["providers.openai"]?.base_url && (
|
||||
<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">
|
||||
{deepseekStatus.settings["providers.openai"].base_url}
|
||||
</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">Default 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={!hasActiveProviders} 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 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</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={!selectedModel} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!deepseekStatus?.has9Router} 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={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for DeepSeek TUI"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="DeepSeek TUI - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
|
||||
export default function DroidToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [droidStatus, setDroidStatus] = useState(initialStatus || null);
|
||||
const [checkingDroid, setCheckingDroid] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [modelList, setModelList] = useState([]);
|
||||
const [modelInput, setModelInput] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const hasInitializedModel = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!droidStatus?.installed) return null;
|
||||
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
|
||||
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"));
|
||||
if (!currentConfig) return "not_configured";
|
||||
return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setDroidStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !droidStatus) {
|
||||
checkDroidStatus();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-fill model list from existing config (supports multi-model)
|
||||
useEffect(() => {
|
||||
if (droidStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const existingModels = (droidStatus.settings?.customModels || [])
|
||||
.filter(m => m.id?.startsWith("custom:9Router"))
|
||||
.sort((a, b) => (a.index || 0) - (b.index || 0))
|
||||
.map(m => m.model);
|
||||
if (existingModels.length > 0) {
|
||||
setModelList(existingModels);
|
||||
} else {
|
||||
// Legacy: single model stored as custom:9Router-0
|
||||
const legacy = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
|
||||
if (legacy?.model) {
|
||||
setModelList([legacy.model]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [droidStatus]);
|
||||
|
||||
const checkDroidStatus = async () => {
|
||||
setCheckingDroid(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/droid-settings");
|
||||
const data = await res.json();
|
||||
setDroidStatus(data);
|
||||
} catch (error) {
|
||||
setDroidStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingDroid(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const addModel = () => {
|
||||
const val = modelInput.trim();
|
||||
if (!val || modelList.includes(val)) return;
|
||||
setModelList((prev) => [...prev, val]);
|
||||
setModelInput("");
|
||||
};
|
||||
|
||||
const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id));
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
if (!model.value || modelList.includes(model.value)) return;
|
||||
setModelList((prev) => [...prev, model.value]);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch("/api/cli-tools/droid-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
models: modelList,
|
||||
activeModel: modelList[0] || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkDroidStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setModelList([]);
|
||||
checkDroidStatus();
|
||||
} 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 settingsContent = {
|
||||
customModels: modelList.map((m, i) => ({
|
||||
model: m,
|
||||
id: `custom:9Router-${i}`,
|
||||
index: i,
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
displayName: m,
|
||||
maxOutputTokens: 131072,
|
||||
noImageSupport: false,
|
||||
provider: "openai",
|
||||
})),
|
||||
};
|
||||
|
||||
const platform = typeof navigator !== "undefined" && navigator.platform;
|
||||
const isWindows = platform?.toLowerCase().includes("win");
|
||||
const settingsPath = isWindows
|
||||
? "%USERPROFILE%\\.factory\\settings.json"
|
||||
: "~/.factory/settings.json";
|
||||
|
||||
return [
|
||||
{
|
||||
filename: settingsPath,
|
||||
content: JSON.stringify(settingsContent, 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/droid.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">
|
||||
{checkingDroid && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Factory Droid CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingDroid && droidStatus && !droidStatus.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">Factory Droid CLI 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">
|
||||
<div>
|
||||
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
|
||||
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">curl -fsSL https://app.factory.ai/cli | sh</code>
|
||||
</div>
|
||||
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">droid</code> to verify.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingDroid && droidStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Endpoint (selector) */}
|
||||
<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>
|
||||
|
||||
{/* Current configured */}
|
||||
{droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.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">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">
|
||||
{droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Models */}
|
||||
<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">
|
||||
Models {modelList.length > 0 && <span className="text-primary">({modelList.length})</span>}
|
||||
</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
{/* Model list */}
|
||||
{modelList.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 mb-1">
|
||||
{modelList.map((id) => (
|
||||
<div key={id} className="flex items-center gap-1.5 px-2 py-1 bg-bg-secondary rounded border border-border">
|
||||
<span className="flex-1 text-xs font-mono truncate">{id}</span>
|
||||
<button onClick={() => removeModel(id)} className="text-text-muted hover:text-red-500 transition-colors shrink-0" title="Remove">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Model input row */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
|
||||
placeholder="provider/model-id"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={!hasActiveProviders}
|
||||
className={`px-2 py-1.5 rounded border text-xs shrink-0 ${hasActiveProviders ? "bg-surface border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
<button onClick={addModel} disabled={!modelInput.trim()} className="px-2 py-1.5 rounded border bg-surface border-border hover:border-primary text-xs shrink-0 disabled:opacity-50" title="Add model">
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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={handleApplySettings} disabled={modelList.length === 0} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} 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={handleModelSelect}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Factory Droid"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Factory Droid - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export default function HermesToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [hermesStatus, setHermesStatus] = useState(initialStatus || null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const hasInitializedModel = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!hermesStatus?.installed) return null;
|
||||
const cfg = hermesStatus.settings?.model;
|
||||
if (!cfg?.base_url) return "not_configured";
|
||||
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setHermesStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !hermesStatus) {
|
||||
checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hermesStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const cfg = hermesStatus.settings?.model;
|
||||
if (cfg?.default) setSelectedModel(cfg.default);
|
||||
}
|
||||
}, [hermesStatus]);
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT);
|
||||
const data = await res.json();
|
||||
setHermesStatus(data);
|
||||
} catch (error) {
|
||||
setHermesStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
|
||||
|
||||
const getLocalBaseUrl = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeLocalhost(window.location.origin);
|
||||
}
|
||||
return "http://127.0.0.1:20128";
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
model: selectedModel,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset 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 handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
|
||||
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
||||
|
||||
return [
|
||||
{ filename: "~/.hermes/config.yaml", content: yamlContent },
|
||||
{ filename: "~/.hermes/.env", content: envContent },
|
||||
];
|
||||
};
|
||||
|
||||
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/hermes.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 Hermes Agent...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && hermesStatus && !hermesStatus.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">Hermes Agent not detected locally</p>
|
||||
<p className="text-sm text-text-muted">Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-0 sm:pl-9">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto !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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && hermesStatus?.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 || getEffectiveBaseUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hermesStatus?.settings?.model?.base_url && (
|
||||
<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">
|
||||
{hermesStatus.settings.model.base_url}
|
||||
</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">Default 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={!hasActiveProviders} 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 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</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="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!hermesStatus?.has9Router} loading={restoring} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Hermes Agent"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Hermes Agent - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 JcodeToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [jcodeStatus, setJcodeStatus] = useState(initialStatus || null);
|
||||
const [checkingJcode, setCheckingJcode] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const hasInitializedModel = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!jcodeStatus?.installed) return null;
|
||||
if (!jcodeStatus?.has9Router) return "not_configured";
|
||||
const currentProvider = jcodeStatus.config?.providers?.["9router"];
|
||||
if (!currentProvider) return "not_configured";
|
||||
return matchKnownEndpoint(currentProvider.base_url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setJcodeStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !jcodeStatus) {
|
||||
checkJcodeStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (jcodeStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const provider = jcodeStatus.config?.providers?.["9router"];
|
||||
if (provider) {
|
||||
if (provider.default_model) {
|
||||
setSelectedModel(provider.default_model);
|
||||
}
|
||||
// Try to match API key from env file
|
||||
const envApiKey = jcodeStatus.envApiKey;
|
||||
if (envApiKey && apiKeys?.some(k => k.key === envApiKey)) {
|
||||
setSelectedApiKey(envApiKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [jcodeStatus, apiKeys]);
|
||||
|
||||
const checkJcodeStatus = async () => {
|
||||
setCheckingJcode(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/jcode-settings");
|
||||
const data = await res.json();
|
||||
setJcodeStatus(data);
|
||||
} catch (error) {
|
||||
setJcodeStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingJcode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
|
||||
|
||||
const getLocalBaseUrl = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeLocalhost(window.location.origin);
|
||||
}
|
||||
return "http://127.0.0.1:20128";
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch("/api/cli-tools/jcode-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
models: selectedModel ? [selectedModel] : [],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkJcodeStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/jcode-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
setSelectedApiKey("");
|
||||
checkJcodeStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const configToml = `[providers.9router]
|
||||
type = "openai-compatible"
|
||||
base_url = "${getEffectiveBaseUrl()}"
|
||||
auth = "bearer"
|
||||
api_key_env = "JCODE_9ROUTER_API_KEY"
|
||||
env_file = "provider-9router.env"
|
||||
default_model = "${selectedModel || "cc/claude-opus-4-7"}"
|
||||
requires_api_key = true
|
||||
|
||||
[[providers.9router.models]]
|
||||
id = "${selectedModel || "cc/claude-opus-4-7"}"`;
|
||||
|
||||
const envContent = `JCODE_9ROUTER_API_KEY="${keyToUse}"`;
|
||||
|
||||
return [
|
||||
{
|
||||
filename: "~/.jcode/config.toml",
|
||||
content: configToml,
|
||||
},
|
||||
{
|
||||
filename: "~/.config/jcode/provider-9router.env",
|
||||
content: envContent,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
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={tool.image || "/providers/jcode.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">
|
||||
{checkingJcode && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking jcode CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingJcode && jcodeStatus && !jcodeStatus.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">jcode CLI not detected locally</p>
|
||||
<p className="text-sm text-text-muted mt-1">Install jcode to enable automatic configuration:</p>
|
||||
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">
|
||||
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
|
||||
</code>
|
||||
<p className="text-sm text-text-muted mt-2">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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingJcode && jcodeStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Info notes */}
|
||||
{tool.notes && tool.notes.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{tool.notes.map((note, idx) => (
|
||||
<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${
|
||||
note.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
|
||||
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
"bg-gray-500/10 text-text-muted"
|
||||
}`}>
|
||||
<span className="material-symbols-outlined text-[14px] mt-0.5">
|
||||
{note.type === "info" ? "info" : note.type === "warning" ? "warning" : "help"}
|
||||
</span>
|
||||
<span>{note.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endpoint (selector) */}
|
||||
<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>
|
||||
|
||||
{/* Current configured */}
|
||||
{jcodeStatus?.config?.providers?.["9router"]?.base_url && (
|
||||
<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">
|
||||
{jcodeStatus.config.providers["9router"].base_url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Default Model */}
|
||||
<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">Default 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="cc/claude-opus-4-7" 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={!hasActiveProviders} 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 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
||||
</div>
|
||||
|
||||
{/* Usage hint */}
|
||||
<div className="flex flex-col gap-1 p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<p className="text-xs font-medium text-blue-600 dark:text-blue-400">Usage:</p>
|
||||
<code className="text-xs font-mono text-text-muted">jcode --provider-profile 9router</code>
|
||||
<code className="text-xs font-mono text-text-muted">jcode --provider-profile 9router --model {selectedModel || "cc/claude-opus-4-7"}</code>
|
||||
</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={handleApplySettings} disabled={!selectedModel} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!jcodeStatus?.has9Router} 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={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for jcode"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="jcode - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [openclawStatus, setOpenclawStatus] = useState(initialStatus || null);
|
||||
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [agentModels, setAgentModels] = useState({}); // { [agentId]: modelId }
|
||||
const [agentModalFor, setAgentModalFor] = useState(null); // agentId opening modal
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const hasInitializedModel = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!openclawStatus?.installed) return null;
|
||||
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
|
||||
if (!currentProvider) return "not_configured";
|
||||
return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setOpenclawStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !openclawStatus) {
|
||||
checkOpenclawStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (openclawStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const provider = openclawStatus.settings?.models?.providers?.["9router"];
|
||||
if (provider) {
|
||||
const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary;
|
||||
if (primaryModel) setSelectedModel(primaryModel.replace("9router/", ""));
|
||||
if (provider.apiKey && apiKeys?.some(k => k.key === provider.apiKey)) {
|
||||
setSelectedApiKey(provider.apiKey);
|
||||
}
|
||||
}
|
||||
// Init per-agent models from enriched agents list
|
||||
const agentList = openclawStatus.agents || [];
|
||||
const initAgentModels = {};
|
||||
agentList.forEach((agent) => {
|
||||
if (agent.currentModel) initAgentModels[agent.id] = agent.currentModel;
|
||||
});
|
||||
setAgentModels(initAgentModels);
|
||||
}
|
||||
}, [openclawStatus, apiKeys]);
|
||||
|
||||
const checkOpenclawStatus = async () => {
|
||||
setCheckingOpenclaw(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/openclaw-settings");
|
||||
const data = await res.json();
|
||||
setOpenclawStatus(data);
|
||||
} catch (error) {
|
||||
setOpenclawStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingOpenclaw(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
|
||||
|
||||
const getLocalBaseUrl = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeLocalhost(window.location.origin);
|
||||
}
|
||||
return "http://127.0.0.1:20128";
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch("/api/cli-tools/openclaw-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
model: selectedModel,
|
||||
agentModels,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkOpenclawStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
setSelectedApiKey("");
|
||||
checkOpenclawStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
if (agentModalFor) {
|
||||
setAgentModels(prev => ({ ...prev, [agentModalFor]: model.value }));
|
||||
setAgentModalFor(null);
|
||||
} else {
|
||||
setSelectedModel(model.value);
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const settingsContent = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: `9router/${selectedModel || "provider/model-id"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"9router": {
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: selectedModel || "provider/model-id",
|
||||
name: (selectedModel || "provider/model-id").split("/").pop(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
filename: "~/.openclaw/openclaw.json",
|
||||
content: JSON.stringify(settingsContent, 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/openclaw.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">
|
||||
{checkingOpenclaw && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Open Claw CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingOpenclaw && openclawStatus && !openclawStatus.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">Open Claw CLI 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingOpenclaw && openclawStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Endpoint (selector) */}
|
||||
<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>
|
||||
|
||||
{/* Current configured */}
|
||||
{openclawStatus?.settings?.models?.providers?.["9router"]?.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">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">
|
||||
{openclawStatus.settings.models.providers["9router"].baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Default Model */}
|
||||
<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">Default 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={() => { setAgentModalFor(null); setModalOpen(true); }} disabled={!hasActiveProviders} 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 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
||||
</div>
|
||||
|
||||
{/* Per-agent model overrides */}
|
||||
{(openclawStatus.agents || []).filter(a => a.agentDir).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 pl-4">
|
||||
<span className="w-32 shrink-0 text-xs text-primary text-right truncate" title={agent.name || agent.id}>Agent {agent.name || agent.id}</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={agentModels[agent.id] || ""}
|
||||
onChange={(e) => setAgentModels(prev => ({ ...prev, [agent.id]: e.target.value }))}
|
||||
placeholder={`default (${selectedModel || "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"
|
||||
/>
|
||||
{agentModels[agent.id] && <button onClick={() => setAgentModels(prev => ({ ...prev, [agent.id]: "" }))} 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={() => { setAgentModalFor(agent.id); setModalOpen(true); }} disabled={!hasActiveProviders} 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 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</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={handleApplySettings} disabled={!selectedModel} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!openclawStatus?.has9Router} 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={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Open Claw"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Open Claw - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 OpenCodeToolCard({ 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 [subagentModel, setSubagentModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [subagentModalOpen, setSubagentModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [activeModel, setActiveModel] = useState("");
|
||||
const selectedModelsRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModelsRef.current = selectedModels;
|
||||
}, [selectedModels]);
|
||||
|
||||
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]);
|
||||
|
||||
// Sync models from existing config
|
||||
useEffect(() => {
|
||||
if (status?.opencode?.models) {
|
||||
setSelectedModels(status.opencode.models);
|
||||
}
|
||||
if (status?.opencode?.activeModel) {
|
||||
setActiveModel(status.opencode.activeModel);
|
||||
}
|
||||
|
||||
// Parse subagent settings from agent.explorer if exists
|
||||
if (status?.config?.agent?.explorer?.model?.startsWith("9router/")) {
|
||||
setSubagentModel(status.config.agent.explorer.model.replace("9router/", ""));
|
||||
}
|
||||
}, [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 saveModels = async (models) => {
|
||||
try {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||
const validActiveModel = models.includes(activeModel) ? activeModel : (models[0] || "");
|
||||
await fetch("/api/cli-tools/opencode-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
models,
|
||||
activeModel: validActiveModel,
|
||||
subagentModel,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!status?.installed) return null;
|
||||
if (!status.config) return "not_configured";
|
||||
if (!status.has9Router) return "not_configured";
|
||||
const url = status.config?.provider?.["9router"]?.options?.baseURL || "";
|
||||
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
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/opencode-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/opencode-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
models: selectedModels,
|
||||
activeModel: activeModel === "" ? "" : (activeModel || selectedModels[0]),
|
||||
subagentModel: subagentModel
|
||||
}),
|
||||
});
|
||||
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/opencode-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
setSubagentModel("");
|
||||
setSelectedModels([]);
|
||||
setActiveModel("");
|
||||
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 modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
|
||||
const activeModelToShow = activeModel || selectedModels[0] || modelsToShow[0];
|
||||
const effectiveSubagentModel = subagentModel || activeModelToShow;
|
||||
|
||||
const modelsObj = {};
|
||||
modelsToShow.forEach(m => {
|
||||
modelsObj[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } };
|
||||
});
|
||||
|
||||
return [{
|
||||
filename: "~/.config/opencode/opencode.json",
|
||||
content: JSON.stringify({
|
||||
provider: {
|
||||
"9router": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse },
|
||||
models: modelsObj,
|
||||
},
|
||||
},
|
||||
model: `9router/${activeModelToShow}`,
|
||||
agent: {
|
||||
explorer: {
|
||||
description: "Fast explorer subagent for codebase exploration",
|
||||
mode: "subagent",
|
||||
model: `9router/${effectiveSubagentModel}`
|
||||
}
|
||||
}
|
||||
}, 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/opencode.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 OpenCode CLI...</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">OpenCode CLI 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">
|
||||
<div>
|
||||
<p className="text-text-muted mb-1">macOS / Linux:</p>
|
||||
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g opencode-ai</code>
|
||||
</div>
|
||||
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">opencode</code> to verify.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Current base URL */}
|
||||
{/* Endpoint (selector) */}
|
||||
<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>
|
||||
|
||||
{/* Current configured */}
|
||||
{status?.config?.provider?.["9router"]?.options?.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">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.config.provider["9router"].options.baseURL}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<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>
|
||||
|
||||
{/* Models */}
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{selectedModels.length === 0 ? (
|
||||
<span className="text-xs text-text-muted">No models selected</span>
|
||||
) : (
|
||||
selectedModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
onClick={async () => {
|
||||
if (model === activeModel) {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/opencode-settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clearActiveModel: true }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setActiveModel("");
|
||||
checkStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error clearing active model:", error);
|
||||
}
|
||||
} else {
|
||||
setActiveModel(model);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${
|
||||
model === activeModel
|
||||
? "bg-primary/10 text-primary border border-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border"
|
||||
}`}
|
||||
title={model === activeModel ? "Click to clear active model" : "Click to set as active"}
|
||||
>
|
||||
{model === activeModel && <span className="material-symbols-outlined text-[10px]">star</span>}
|
||||
{model}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const res = await fetch(`/api/cli-tools/opencode-settings?model=${encodeURIComponent(model)}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
const newModels = selectedModels.filter((m) => m !== model);
|
||||
setSelectedModels(newModels);
|
||||
if (activeModel === model) {
|
||||
setActiveModel("");
|
||||
}
|
||||
checkStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error removing model:", error);
|
||||
}
|
||||
}}
|
||||
className="ml-0.5 hover:text-red-500"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
|
||||
<span className="text-xs text-text-muted">
|
||||
{selectedModels.length > 0 && activeModel ? (
|
||||
<>Active: <span className="text-primary">{activeModel}</span></>
|
||||
) : selectedModels.length > 0 ? (
|
||||
<span className="text-yellow-500">Click a model to set/clear active</span>
|
||||
) : (
|
||||
"Select models to add"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subagent Model */}
|
||||
<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">Subagent Model</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<input
|
||||
type="text"
|
||||
value={subagentModel}
|
||||
onChange={(e) => setSubagentModel(e.target.value)}
|
||||
placeholder={selectedModel || "provider/model-id (defaults to main model)"}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSubagentModalOpen(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>
|
||||
{subagentModel && (
|
||||
<button
|
||||
onClick={() => setSubagentModel("")}
|
||||
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
|
||||
title="Clear (will use main model)"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</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={selectedModels.length === 0} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} 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);
|
||||
saveModels(selectedModelsRef.current);
|
||||
}}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
if (!activeModel) setActiveModel(model.value);
|
||||
}
|
||||
}}
|
||||
onDeselect={(model) => {
|
||||
const remaining = selectedModels.filter(m => m !== model.value);
|
||||
setSelectedModels(remaining);
|
||||
if (activeModel === model.value) {
|
||||
setActiveModel(remaining[0] || "");
|
||||
}
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
title="Add Model for OpenCode"
|
||||
/>
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={subagentModalOpen}
|
||||
onClose={() => setSubagentModalOpen(false)}
|
||||
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
|
||||
selectedModel={subagentModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Subagent Model for OpenCode"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="OpenCode - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
// Derive simple connected/configured/not-installed status from API payload
|
||||
function getStatus(status) {
|
||||
if (!status) return { label: "Unknown", cls: "bg-gray-500/10 text-gray-500" };
|
||||
if (!status.installed) return { label: "Not installed", cls: "bg-gray-500/10 text-gray-500" };
|
||||
if (status.has9Router) return { label: "Connected", cls: "bg-green-500/10 text-green-600 dark:text-green-400" };
|
||||
return { label: "Not configured", cls: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" };
|
||||
}
|
||||
|
||||
export default function ToolSummaryCard({ toolId, tool, status }) {
|
||||
const s = getStatus(status);
|
||||
export default function ToolSummaryCard({ toolId, tool }) {
|
||||
return (
|
||||
<Link href={`/dashboard/cli-tools/${toolId}`} className="block">
|
||||
<Card padding="sm" className="h-full overflow-hidden hover:border-primary/50 transition-colors cursor-pointer">
|
||||
@@ -28,7 +19,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
|
||||
<span className={`inline-block mt-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full ${s.cls}`}>{s.label}</span>
|
||||
<p className="mt-1 truncate text-xs text-text-muted">Generate a copyable configuration file</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted text-[18px] shrink-0">chevron_right</span>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Match a configured CLI base URL against all known endpoints (local/tunnel/tailscale/cloud)
|
||||
const stripTrailingSlash = (s) => (s || "").replace(/\/+$/, "");
|
||||
|
||||
export function matchKnownEndpoint(currentUrl, opts = {}) {
|
||||
if (!currentUrl) return false;
|
||||
const url = stripTrailingSlash(currentUrl);
|
||||
const { tunnelPublicUrl, tailscaleUrl, cloudUrl } = opts;
|
||||
if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(url)) return true;
|
||||
if (tunnelPublicUrl && url.startsWith(stripTrailingSlash(tunnelPublicUrl))) return true;
|
||||
if (tailscaleUrl && url.startsWith(stripTrailingSlash(tailscaleUrl))) return true;
|
||||
if (cloudUrl && url.startsWith(stripTrailingSlash(cloudUrl))) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
export { default as ClaudeToolCard } from "./ClaudeToolCard";
|
||||
export { default as CodexToolCard } from "./CodexToolCard";
|
||||
export { default as DroidToolCard } from "./DroidToolCard";
|
||||
export { default as OpenClawToolCard } from "./OpenClawToolCard";
|
||||
export { default as HermesToolCard } from "./HermesToolCard";
|
||||
export { default as DefaultToolCard } from "./DefaultToolCard";
|
||||
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 DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
|
||||
export { default as JcodeToolCard } from "./JcodeToolCard";
|
||||
export { default as MitmServerCard } from "./MitmServerCard";
|
||||
export { default as MitmToolCard } from "./MitmToolCard";
|
||||
export { default as MitmLinkCard } from "./MitmLinkCard";
|
||||
export { default as EndpointPresetControl } from "./EndpointPresetControl";
|
||||
export { default as BaseUrlSelect } from "./BaseUrlSelect";
|
||||
export { default as ConfigGeneratorCard } from "./ConfigGeneratorCard";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { GET as claudeGet } from "../claude-settings/route";
|
||||
import { GET as codexGet } from "../codex-settings/route";
|
||||
import { GET as opencodeGet } from "../opencode-settings/route";
|
||||
import { GET as droidGet } from "../droid-settings/route";
|
||||
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";
|
||||
import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route";
|
||||
import { GET as jcodeGet } from "../jcode-settings/route";
|
||||
|
||||
const STATUS_GETTERS = {
|
||||
claude: claudeGet,
|
||||
codex: codexGet,
|
||||
opencode: opencodeGet,
|
||||
droid: droidGet,
|
||||
openclaw: openclawGet,
|
||||
hermes: hermesGet,
|
||||
cowork: coworkGet,
|
||||
copilot: copilotGet,
|
||||
cline: clineGet,
|
||||
kilo: kiloGet,
|
||||
"deepseek-tui": deepseekTuiGet,
|
||||
jcode: jcodeGet,
|
||||
};
|
||||
|
||||
// Batch endpoint: gather all CLI tool statuses in one round-trip
|
||||
export async function GET() {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(STATUS_GETTERS).map(async ([toolId, getter]) => {
|
||||
try {
|
||||
const res = await getter();
|
||||
const data = await res.json();
|
||||
return [toolId, data];
|
||||
} catch {
|
||||
return [toolId, null];
|
||||
}
|
||||
})
|
||||
);
|
||||
return NextResponse.json(Object.fromEntries(entries));
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
"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";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Get claude settings path based on OS
|
||||
const getClaudeSettingsPath = () => {
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, ".claude", "settings.json");
|
||||
};
|
||||
|
||||
|
||||
// Check if claude CLI is installed (via which/where or config file exists)
|
||||
const checkClaudeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where claude" : "which claude";
|
||||
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(getClaudeSettingsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read current settings
|
||||
const readSettings = async () => {
|
||||
try {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// GET - Check claude CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkClaudeInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
settings: null,
|
||||
message: "Claude CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await readSettings();
|
||||
const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: redactSecrets(settings),
|
||||
has9Router: has9Router,
|
||||
settingsPath: getClaudeSettingsPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking claude settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check claude settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Backup old fields and write new settings
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { env } = await request.json();
|
||||
|
||||
if (!env || typeof env !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid env object" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const claudeDir = path.dirname(settingsPath);
|
||||
|
||||
// Ensure .claude directory exists
|
||||
await fs.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Read current settings
|
||||
let currentSettings = {};
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
currentSettings = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix
|
||||
if (env.ANTHROPIC_BASE_URL) {
|
||||
env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1")
|
||||
? env.ANTHROPIC_BASE_URL
|
||||
: `${env.ANTHROPIC_BASE_URL}/v1`;
|
||||
}
|
||||
|
||||
// Merge new env with existing settings
|
||||
const newSettings = {
|
||||
...currentSettings,
|
||||
hasCompletedOnboarding: true,
|
||||
env: {
|
||||
...(currentSettings.env || {}),
|
||||
...env,
|
||||
},
|
||||
};
|
||||
|
||||
// Write new settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Settings updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating claude settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update claude settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields to remove when resetting
|
||||
const RESET_ENV_KEYS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"API_TIMEOUT_MS",
|
||||
];
|
||||
|
||||
// DELETE - Reset settings (remove env fields)
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
|
||||
// Read current settings
|
||||
let currentSettings = {};
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
currentSettings = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No settings file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove specified env fields
|
||||
if (currentSettings.env) {
|
||||
RESET_ENV_KEYS.forEach((key) => {
|
||||
delete currentSettings.env[key];
|
||||
});
|
||||
|
||||
// Clean up empty env object
|
||||
if (Object.keys(currentSettings.env).length === 0) {
|
||||
delete currentSettings.env;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Settings reset successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting claude settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reset claude settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"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");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
"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";
|
||||
import { parseTOML, stringifyTOML } from "confbox";
|
||||
import { redactSecretsInText } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getCodexDir = () => path.join(os.homedir(), ".codex");
|
||||
const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml");
|
||||
const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json");
|
||||
|
||||
// Flatten confbox-parsed TOML into a writable object, preserving nested tables
|
||||
const parsedToWritable = (obj) => obj ?? {};
|
||||
|
||||
// Set a nested key from a flat dotted path, creating intermediate objects as needed
|
||||
const setNestedSection = (obj, dottedKey, value) => {
|
||||
const keys = dottedKey.split(".");
|
||||
let cur = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (cur[keys[i]] == null || typeof cur[keys[i]] !== "object") {
|
||||
cur[keys[i]] = {};
|
||||
}
|
||||
cur = cur[keys[i]];
|
||||
}
|
||||
cur[keys[keys.length - 1]] = value;
|
||||
};
|
||||
|
||||
// Delete a nested key from a flat dotted path
|
||||
const deleteNestedSection = (obj, dottedKey) => {
|
||||
const keys = dottedKey.split(".");
|
||||
let cur = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
cur = cur?.[keys[i]];
|
||||
if (cur == null) return;
|
||||
}
|
||||
delete cur[keys[keys.length - 1]];
|
||||
};
|
||||
|
||||
// Check if codex CLI is installed (via which/where or config file exists)
|
||||
const checkCodexInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where codex" : "which codex";
|
||||
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(getCodexConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read current config.toml
|
||||
const readConfig = async () => {
|
||||
try {
|
||||
const configPath = getCodexConfigPath();
|
||||
const content = await fs.readFile(configPath, "utf-8");
|
||||
return content;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if config has 9Router settings
|
||||
const has9RouterConfig = (config) => {
|
||||
if (!config) return false;
|
||||
return config.includes("model_provider = \"9router\"") || config.includes("[model_providers.9router]");
|
||||
};
|
||||
|
||||
// GET - Check codex CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkCodexInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
config: null,
|
||||
message: "Codex CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await readConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config: redactSecretsInText(config),
|
||||
has9Router: has9RouterConfig(config),
|
||||
configPath: getCodexConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking codex settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check codex settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Update 9Router settings (merge with existing config)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model, subagentModel } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const codexDir = getCodexDir();
|
||||
const configPath = getCodexConfigPath();
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(codexDir, { recursive: true });
|
||||
|
||||
// Read and parse existing config
|
||||
let parsed = {};
|
||||
try {
|
||||
const existingConfig = await fs.readFile(configPath, "utf-8");
|
||||
parsed = parsedToWritable(parseTOML(existingConfig));
|
||||
} catch { /* No existing config */ }
|
||||
|
||||
// Update only 9Router related fields (api_key goes to auth.json, not config.toml)
|
||||
parsed.model = model;
|
||||
parsed.model_provider = "9router";
|
||||
|
||||
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
|
||||
// Ensure /v1 suffix is added only once
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
setNestedSection(parsed, "model_providers.9router", {
|
||||
name: "9Router",
|
||||
base_url: normalizedBaseUrl,
|
||||
wire_api: "responses",
|
||||
});
|
||||
|
||||
// Add subagent configuration
|
||||
const effectiveSubagentModel = subagentModel || model;
|
||||
setNestedSection(parsed, "agents.subagent", {
|
||||
model: effectiveSubagentModel,
|
||||
});
|
||||
|
||||
// Write merged config
|
||||
const configContent = stringifyTOML(parsed);
|
||||
await fs.writeFile(configPath, configContent);
|
||||
|
||||
// Update auth.json with OPENAI_API_KEY (Codex reads this first)
|
||||
const authPath = getCodexAuthPath();
|
||||
let authData = {};
|
||||
try {
|
||||
const existingAuth = await fs.readFile(authPath, "utf-8");
|
||||
authData = JSON.parse(existingAuth);
|
||||
} catch { /* No existing auth */ }
|
||||
|
||||
// Force apikey mode (keep existing tokens untouched for ChatGPT login reuse)
|
||||
authData.OPENAI_API_KEY = apiKey;
|
||||
authData.auth_mode = "apikey";
|
||||
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Codex settings applied successfully!",
|
||||
configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating codex settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update codex settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove 9Router settings only (keep other settings)
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const configPath = getCodexConfigPath();
|
||||
|
||||
// Read and parse existing config
|
||||
let parsed = {};
|
||||
try {
|
||||
const existingConfig = await fs.readFile(configPath, "utf-8");
|
||||
parsed = parsedToWritable(parseTOML(existingConfig));
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No config file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove 9Router related root fields only if they point to 9router
|
||||
if (parsed.model_provider === "9router") {
|
||||
delete parsed.model;
|
||||
delete parsed.model_provider;
|
||||
}
|
||||
|
||||
// Remove 9router provider section
|
||||
deleteNestedSection(parsed, "model_providers.9router");
|
||||
|
||||
// Remove subagent configuration
|
||||
deleteNestedSection(parsed, "agents.subagent");
|
||||
|
||||
// Write updated config
|
||||
const configContent = stringifyTOML(parsed);
|
||||
await fs.writeFile(configPath, configContent);
|
||||
|
||||
// Remove OPENAI_API_KEY from auth.json
|
||||
const authPath = getCodexAuthPath();
|
||||
try {
|
||||
const existingAuth = await fs.readFile(authPath, "utf-8");
|
||||
const authData = JSON.parse(existingAuth);
|
||||
delete authData.OPENAI_API_KEY;
|
||||
delete authData.auth_mode;
|
||||
|
||||
// Write back or delete if empty
|
||||
if (Object.keys(authData).length === 0) {
|
||||
await fs.unlink(authPath);
|
||||
} else {
|
||||
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
|
||||
}
|
||||
} catch { /* No auth file */ }
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "9Router settings removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting codex settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset codex settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
// Resolve chatLanguageModels.json path per OS
|
||||
const getConfigPath = () => {
|
||||
const home = os.homedir();
|
||||
const platform = os.platform();
|
||||
if (platform === "win32") {
|
||||
return path.join(process.env.APPDATA || home, "Code", "User", "chatLanguageModels.json");
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return path.join(home, "Library", "Application Support", "Code", "User", "chatLanguageModels.json");
|
||||
}
|
||||
return path.join(home, ".config", "Code", "User", "chatLanguageModels.json");
|
||||
};
|
||||
|
||||
const readConfig = async () => {
|
||||
try {
|
||||
const content = await fs.readFile(getConfigPath(), "utf-8");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (config) => {
|
||||
if (!Array.isArray(config)) return false;
|
||||
return config.some((entry) => entry.name === "9Router");
|
||||
};
|
||||
|
||||
const get9RouterEntry = (config) => {
|
||||
if (!Array.isArray(config)) return null;
|
||||
return config.find((entry) => entry.name === "9Router") || null;
|
||||
};
|
||||
|
||||
// GET - Read current copilot config
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await readConfig();
|
||||
const entry = get9RouterEntry(config);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config: redactSecrets(config),
|
||||
has9Router: has9RouterConfig(config),
|
||||
configPath: getConfigPath(),
|
||||
currentModel: entry?.models?.[0]?.id || null,
|
||||
currentUrl: entry?.models?.[0]?.url || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking copilot settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check copilot settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Apply 9Router config to chatLanguageModels.json
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, models } = await request.json();
|
||||
|
||||
if (!baseUrl || !models?.length) {
|
||||
return NextResponse.json({ error: "baseUrl and models are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const configPath = getConfigPath();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
|
||||
// Read existing config array
|
||||
let config = [];
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, "utf-8");
|
||||
const parsed = JSON.parse(existing);
|
||||
config = Array.isArray(parsed) ? parsed : [];
|
||||
} catch { /* No existing config */ }
|
||||
|
||||
const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`;
|
||||
const keyToUse = apiKey || "sk_9router";
|
||||
|
||||
const newEntry = {
|
||||
name: "9Router",
|
||||
vendor: "azure",
|
||||
apiKey: keyToUse,
|
||||
models: models.map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
url: endpointUrl,
|
||||
toolCalling: true,
|
||||
vision: false,
|
||||
maxInputTokens: 128000,
|
||||
maxOutputTokens: 16000,
|
||||
})),
|
||||
};
|
||||
|
||||
// Replace existing 9Router entry or append
|
||||
const idx = config.findIndex((e) => e.name === "9Router");
|
||||
if (idx >= 0) {
|
||||
config[idx] = newEntry;
|
||||
} else {
|
||||
config.push(newEntry);
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Copilot settings applied! Reload VS Code to take effect.",
|
||||
configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating copilot settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update copilot settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove 9Router entry from chatLanguageModels.json
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
let config = [];
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, "utf-8");
|
||||
const parsed = JSON.parse(existing);
|
||||
config = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
config = config.filter((e) => e.name !== "9Router");
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "9Router removed from Copilot config",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting copilot settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset copilot settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
const APP_PORT = UPDATER_CONFIG.appPort;
|
||||
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
const LOCAL_MCP_PREFIX = `http://localhost:${APP_PORT}/api/mcp/`;
|
||||
|
||||
let cachedCliToken = null;
|
||||
const getCliToken = async () => {
|
||||
if (!cachedCliToken) cachedCliToken = await getConsistentMachineId(CLI_TOKEN_SALT);
|
||||
return cachedCliToken;
|
||||
};
|
||||
|
||||
// Inject CLI token header into entries pointing at our local /api/mcp/ bridge.
|
||||
const injectAuthHeaders = async (entries) => {
|
||||
const token = await getCliToken();
|
||||
for (const e of entries) {
|
||||
if (typeof e?.url === "string" && e.url.startsWith(LOCAL_MCP_PREFIX)) {
|
||||
e.headers = { ...(e.headers || {}), [CLI_TOKEN_HEADER]: token };
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const PROVIDER = "gateway";
|
||||
|
||||
// Hardcoded relax-security profile applied on every Apply.
|
||||
const SECURITY_RELAX = {
|
||||
coworkEgressAllowedHosts: ["*"],
|
||||
disabledBuiltinTools: [],
|
||||
isLocalDevMcpEnabled: true,
|
||||
isDesktopExtensionEnabled: true,
|
||||
isDesktopExtensionDirectoryEnabled: true,
|
||||
isDesktopExtensionSignatureRequired: false,
|
||||
isClaudeCodeForDesktopEnabled: true,
|
||||
disableEssentialTelemetry: true,
|
||||
disableNonessentialTelemetry: true,
|
||||
disableNonessentialServices: true,
|
||||
};
|
||||
|
||||
// Tools auto-allow per server via toolPolicy["*"] = "allow" semantics.
|
||||
// 3p schema requires explicit tool names; we mark "*" via operonSkipMcpApprovals instead.
|
||||
|
||||
const getCandidateRoots = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
const base = path.join(os.homedir(), "Library", "Application Support");
|
||||
return [path.join(base, "Claude-3p"), path.join(base, "Claude")];
|
||||
}
|
||||
if (os.platform() === "win32") {
|
||||
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return [
|
||||
path.join(localApp, "Claude-3p"),
|
||||
path.join(roaming, "Claude-3p"),
|
||||
path.join(localApp, "Claude"),
|
||||
path.join(roaming, "Claude"),
|
||||
];
|
||||
}
|
||||
return [
|
||||
path.join(os.homedir(), ".config", "Claude-3p"),
|
||||
path.join(os.homedir(), ".config", "Claude"),
|
||||
];
|
||||
};
|
||||
|
||||
const getAppInstallPaths = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
return ["/Applications/Claude.app", path.join(os.homedir(), "Applications", "Claude.app")];
|
||||
}
|
||||
if (os.platform() === "win32") {
|
||||
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const programFiles = process.env["ProgramFiles"] || "C:\\Program Files";
|
||||
return [
|
||||
path.join(localApp, "AnthropicClaude"),
|
||||
path.join(programFiles, "Claude"),
|
||||
path.join(programFiles, "AnthropicClaude"),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const resolveAppRootForRead = async () => {
|
||||
const candidates = getCandidateRoots();
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
await fs.access(path.join(dir, "configLibrary"));
|
||||
return dir;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return candidates[0];
|
||||
};
|
||||
|
||||
const getWriteRoot = () => getCandidateRoots()[0];
|
||||
const getConfigDir = async () => path.join(await resolveAppRootForRead(), "configLibrary");
|
||||
const getWriteConfigDir = () => path.join(getWriteRoot(), "configLibrary");
|
||||
const getMetaPath = async () => path.join(await getConfigDir(), "_meta.json");
|
||||
const getWriteMetaPath = () => path.join(getWriteConfigDir(), "_meta.json");
|
||||
|
||||
const get1pRoot = () => {
|
||||
if (os.platform() === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Claude");
|
||||
if (os.platform() === "win32") {
|
||||
const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(roaming, "Claude");
|
||||
}
|
||||
return path.join(os.homedir(), ".config", "Claude");
|
||||
};
|
||||
|
||||
const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json");
|
||||
|
||||
const read1pConfig = async () => {
|
||||
try {
|
||||
const content = await fs.readFile(get1pConfigPath(), "utf-8");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as empty config
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped) || {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const write1pConfig = async (cfg) => {
|
||||
await fs.mkdir(get1pRoot(), { recursive: true });
|
||||
await fs.writeFile(get1pConfigPath(), JSON.stringify(cfg, null, 2));
|
||||
};
|
||||
|
||||
const bootstrapDeploymentMode = async () => {
|
||||
const cfg = await read1pConfig();
|
||||
if (cfg.deploymentMode === "3p") return false;
|
||||
cfg.deploymentMode = "3p";
|
||||
await write1pConfig(cfg);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Remove any legacy stdio entries previously written into 1p claude_desktop_config.json.
|
||||
const cleanup1pLegacy = async () => {
|
||||
const cfg = await read1pConfig();
|
||||
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") return;
|
||||
const managedNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name));
|
||||
for (const k of Object.keys(cfg.mcpServers)) {
|
||||
if (managedNames.has(k)) delete cfg.mcpServers[k];
|
||||
}
|
||||
if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
|
||||
await write1pConfig(cfg);
|
||||
};
|
||||
|
||||
// Build SSE bridge entries pointing at this app's inline /api/mcp/{name} endpoint.
|
||||
const buildLocalBridgeEntries = (localPluginNames) => {
|
||||
const names = Array.isArray(localPluginNames) ? localPluginNames : [];
|
||||
const out = [];
|
||||
for (const n of names) {
|
||||
const def = LOCAL_STDIO_PLUGINS.find((p) => p.name === n);
|
||||
if (!def) continue;
|
||||
const entry = {
|
||||
name: def.name,
|
||||
url: `http://localhost:${APP_PORT}/api/mcp/${def.name}/sse`,
|
||||
transport: "sse",
|
||||
};
|
||||
if (Array.isArray(def.toolNames) && def.toolNames.length > 0) {
|
||||
const prefix = `${def.name}-`;
|
||||
const policy = {};
|
||||
for (const t of def.toolNames) {
|
||||
policy[t] = "allow";
|
||||
policy[`${prefix}${t}`] = "allow";
|
||||
}
|
||||
entry.toolPolicy = policy;
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Build entries for user-defined custom MCP plugins (URL or stdio command).
|
||||
const buildCustomEntries = (customPlugins) => {
|
||||
if (!Array.isArray(customPlugins)) return [];
|
||||
const out = [];
|
||||
for (const p of customPlugins) {
|
||||
if (!p?.name || !p?.url) continue;
|
||||
out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const checkInstalled = async () => {
|
||||
for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) {
|
||||
try { await fs.access(dir); return true; } catch { /* try next */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const readJson = async (filePath) => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureMeta = async () => {
|
||||
const writeMetaPath = getWriteMetaPath();
|
||||
let meta = await readJson(writeMetaPath);
|
||||
if (!meta || !meta.appliedId) {
|
||||
const existingRead = await readJson(await getMetaPath());
|
||||
if (existingRead?.appliedId) {
|
||||
meta = existingRead;
|
||||
} else {
|
||||
const newId = crypto.randomUUID();
|
||||
meta = { appliedId: newId, entries: [{ id: newId, name: "Default" }] };
|
||||
}
|
||||
await fs.mkdir(getWriteConfigDir(), { recursive: true });
|
||||
await fs.writeFile(writeMetaPath, JSON.stringify(meta, null, 2));
|
||||
}
|
||||
return meta;
|
||||
};
|
||||
|
||||
// Auto-skip approvals for every managed server (no per-tool prompts).
|
||||
async function writeSkipApprovals(managedServers) {
|
||||
const cfgPath = path.join(getWriteRoot(), "config.json");
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {}; }
|
||||
catch (e) { if (e.code !== "ENOENT") return { error: e.code }; }
|
||||
const skip = {};
|
||||
for (const srv of managedServers) {
|
||||
if (srv?.name) skip[srv.name] = true;
|
||||
}
|
||||
cfg.operonSkipMcpApprovals = skip;
|
||||
await fs.mkdir(getWriteRoot(), { recursive: true });
|
||||
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
|
||||
return { written: Object.keys(skip).length };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, config: null, message: "Claude Desktop (Cowork mode) not detected" });
|
||||
}
|
||||
const meta = await readJson(await getMetaPath());
|
||||
const appliedId = meta?.appliedId || null;
|
||||
const configDir = await getConfigDir();
|
||||
const configPath = appliedId ? path.join(configDir, `${appliedId}.json`) : null;
|
||||
const config = configPath ? await readJson(configPath) : null;
|
||||
|
||||
const baseUrl = config?.inferenceGatewayBaseUrl || null;
|
||||
const models = Array.isArray(config?.inferenceModels)
|
||||
? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
|
||||
: [];
|
||||
const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
|
||||
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
|
||||
|
||||
// Active local plugins = managedMcp entries whose URL points at our inline bridge.
|
||||
const stdioNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name));
|
||||
const activeLocalNames = managedMcp
|
||||
.filter((m) => stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))
|
||||
.map((m) => m.name);
|
||||
|
||||
// Custom plugins = bridge entries not in preset LOCAL_STDIO_PLUGINS (custom:true or unknown name).
|
||||
const activeCustomPlugins = managedMcp
|
||||
.filter((m) => m.custom || (!stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/")))
|
||||
.map((m) => ({ name: m.name, url: m.url, transport: m.transport, custom: true }));
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config: redactSecrets(config),
|
||||
has9Router,
|
||||
configPath,
|
||||
cowork: {
|
||||
appliedId,
|
||||
baseUrl,
|
||||
models,
|
||||
provider: config?.inferenceProvider || null,
|
||||
plugins: managedMcp.filter((m) => !m.custom && !(stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))).map((m) => {
|
||||
// Strip "{name}-" prefix and dedupe so re-applies don't multiply entries.
|
||||
const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : [];
|
||||
const prefix = `${m.name}-`;
|
||||
const bare = new Set();
|
||||
for (const k of keys) {
|
||||
let t = k;
|
||||
while (t.startsWith(prefix)) t = t.slice(prefix.length);
|
||||
bare.add(t);
|
||||
}
|
||||
// If plugin matches a default, prefer default toolNames (curated/correct).
|
||||
const def = DEFAULT_PLUGINS.find((d) => d.name === m.name);
|
||||
const toolNames = def && Array.isArray(def.toolNames) ? def.toolNames : Array.from(bare);
|
||||
return { name: m.name, url: m.url, transport: m.transport, oauth: !!m.oauth, toolNames };
|
||||
}),
|
||||
localPlugins: activeLocalNames,
|
||||
customPlugins: activeCustomPlugins,
|
||||
},
|
||||
defaultPlugins: DEFAULT_PLUGINS,
|
||||
localStdioPlugins: LOCAL_STDIO_PLUGINS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error reading cowork settings:", error);
|
||||
return NextResponse.json({ error: "Failed to read cowork settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
|
||||
}
|
||||
const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : [];
|
||||
if (modelsArray.length === 0) {
|
||||
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Respect empty array (user toggled all off); fallback to defaults only when undefined.
|
||||
const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS;
|
||||
const localPluginNames = Array.isArray(localPlugins) ? localPlugins : [];
|
||||
// Only URL-based custom plugins allowed (no stdio command spawning).
|
||||
const customPluginsArray = (Array.isArray(customPlugins) ? customPlugins : []).filter((p) => p?.url);
|
||||
|
||||
const bridgeEntries = await injectAuthHeaders(buildLocalBridgeEntries(localPluginNames));
|
||||
const customEntries = await injectAuthHeaders(buildCustomEntries(customPluginsArray));
|
||||
const managedMcpServers = [...buildManagedMcpServers(pluginsArray), ...bridgeEntries, ...customEntries];
|
||||
|
||||
const bootstrapped = await bootstrapDeploymentMode();
|
||||
const meta = await ensureMeta();
|
||||
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
|
||||
|
||||
const newConfig = {
|
||||
...SECURITY_RELAX,
|
||||
inferenceProvider: PROVIDER,
|
||||
inferenceGatewayBaseUrl: baseUrl,
|
||||
inferenceGatewayApiKey: apiKey,
|
||||
inferenceModels: modelsArray.map((name) => ({ name })),
|
||||
};
|
||||
if (managedMcpServers.length > 0) newConfig.managedMcpServers = managedMcpServers;
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
|
||||
|
||||
let skipResult = null;
|
||||
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
|
||||
|
||||
// Best-effort cleanup of legacy 1p mcpServers entries written by earlier versions.
|
||||
let localMcpResult = { applied: localPluginNames, via: "3p-sse-bridge" };
|
||||
try { await cleanup1pLegacy(); } catch { /* ignore */ }
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bootstrapped,
|
||||
message: bootstrapped
|
||||
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
|
||||
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
||||
configPath,
|
||||
skipApprovals: skipResult,
|
||||
localMcp: localMcpResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error applying cowork settings:", error);
|
||||
return NextResponse.json({ error: "Failed to apply cowork settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const meta = await readJson(await getMetaPath());
|
||||
if (!meta?.appliedId) {
|
||||
return NextResponse.json({ success: true, message: "No active config to reset" });
|
||||
}
|
||||
const configPath = path.join(await getConfigDir(), `${meta.appliedId}.json`);
|
||||
try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
try { await writeSkipApprovals([]); } catch { /* ignore */ }
|
||||
try { await cleanup1pLegacy(); } catch { /* ignore */ }
|
||||
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting cowork settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset cowork settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"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";
|
||||
import { redactSecretsInText } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PROVIDER_NAME = "9router";
|
||||
|
||||
const getDeepSeekDir = () => path.join(os.homedir(), ".deepseek");
|
||||
const getDeepSeekConfigPath = () => path.join(getDeepSeekDir(), "config.toml");
|
||||
|
||||
// Simple TOML parser for key = "value" and [section] patterns
|
||||
const parseToml = (content) => {
|
||||
const result = {};
|
||||
let currentSection = result;
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
// Section header: [section] or [section.subsection]
|
||||
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
const sectionName = sectionMatch[1];
|
||||
if (!result[sectionName]) result[sectionName] = {};
|
||||
currentSection = result[sectionName];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key = "value" or key = value
|
||||
const keyValueMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]*)"$/);
|
||||
if (keyValueMatch) {
|
||||
currentSection[keyValueMatch[1]] = keyValueMatch[2];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key = value (unquoted)
|
||||
const unquotedMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
|
||||
if (unquotedMatch) {
|
||||
currentSection[unquotedMatch[1]] = unquotedMatch[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Build TOML config for 9Router (openai provider mode)
|
||||
const build9RouterConfig = (baseUrl, apiKey, model) => {
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
return `provider = "openai"
|
||||
|
||||
[providers.openai]
|
||||
base_url = "${normalizedBaseUrl}"
|
||||
api_key = "${apiKey}"
|
||||
model = "${model}"
|
||||
`;
|
||||
};
|
||||
|
||||
// Default DeepSeek config (reset state)
|
||||
const DEFAULT_CONFIG = `provider = "deepseek"
|
||||
`;
|
||||
|
||||
const checkDeepSeekInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where deepseek" : "which deepseek";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getDeepSeekConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readConfigToml = async () => {
|
||||
try {
|
||||
return await fs.readFile(getDeepSeekConfigPath(), "utf-8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Detect 9Router by checking if provider is "openai" and base_url points to localhost/127.0.0.1
|
||||
const has9RouterConfig = (config) => {
|
||||
if (!config) return false;
|
||||
const provider = config.provider;
|
||||
if (provider !== "openai") return false;
|
||||
const openaiSection = config["providers.openai"];
|
||||
if (!openaiSection?.base_url) return false;
|
||||
return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(openaiSection.base_url);
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkDeepSeekInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, settings: null, message: "DeepSeek TUI is not installed" });
|
||||
}
|
||||
const toml = await readConfigToml();
|
||||
const config = parseToml(toml);
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: redactSecretsInText(config),
|
||||
has9Router: has9RouterConfig(config),
|
||||
configPath: getDeepSeekConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking deepseek-tui settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check deepseek-tui settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !model) {
|
||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getDeepSeekDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const newConfig = build9RouterConfig(baseUrl, apiKey || "sk_9router", model);
|
||||
await fs.writeFile(getDeepSeekConfigPath(), newConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "DeepSeek TUI settings applied successfully!",
|
||||
configPath: getDeepSeekConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating deepseek-tui settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update deepseek-tui settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const configPath = getDeepSeekConfigPath();
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, DEFAULT_CONFIG);
|
||||
return NextResponse.json({ success: true, message: `${PROVIDER_NAME} config reset to DeepSeek defaults` });
|
||||
} catch (error) {
|
||||
console.log("Error resetting deepseek-tui settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset deepseek-tui settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
"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";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getDroidDir = () => path.join(os.homedir(), ".factory");
|
||||
const getDroidSettingsPath = () => path.join(getDroidDir(), "settings.json");
|
||||
|
||||
// Check if droid CLI is installed (via which/where or config file exists)
|
||||
const checkDroidInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where droid" : "which droid";
|
||||
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(getDroidSettingsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read current settings.json
|
||||
const readSettings = async () => {
|
||||
try {
|
||||
const settingsPath = getDroidSettingsPath();
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if settings has 9Router customModels
|
||||
const has9RouterConfig = (settings) => {
|
||||
if (!settings || !settings.customModels) return false;
|
||||
return settings.customModels.some(m => m.id?.startsWith("custom:9Router"));
|
||||
};
|
||||
|
||||
// GET - Check droid CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkDroidInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
settings: null,
|
||||
message: "Factory Droid CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await readSettings();
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: redactSecrets(settings),
|
||||
has9Router: has9RouterConfig(settings),
|
||||
settingsPath: getDroidSettingsPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking droid settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check droid settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Update 9Router customModels (merge with existing settings)
|
||||
// Accepts either `model` (string, legacy single-model) or `models` (array of strings, multi-model)
|
||||
// Also accepts `activeModel` to set which model is active/primary
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model, models, activeModel } = await request.json();
|
||||
|
||||
// Accept either `models` (array) or `model` (string, legacy)
|
||||
const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []);
|
||||
|
||||
if (!baseUrl || modelsArray.length === 0) {
|
||||
return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const droidDir = getDroidDir();
|
||||
const settingsPath = getDroidSettingsPath();
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(droidDir, { recursive: true });
|
||||
|
||||
// Read existing settings or create new
|
||||
let settings = {};
|
||||
try {
|
||||
const existingSettings = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(existingSettings);
|
||||
} catch { /* No existing settings */ }
|
||||
|
||||
// Ensure customModels array exists
|
||||
if (!settings.customModels) {
|
||||
settings.customModels = [];
|
||||
}
|
||||
|
||||
// Remove all existing 9Router configs
|
||||
settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));
|
||||
|
||||
// Normalize baseUrl to ensure /v1 suffix
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const keyToUse = apiKey || "your_api_key";
|
||||
|
||||
// Determine active model: prefer explicit activeModel, else first of modelsArray
|
||||
// If activeModel is explicitly empty string, no model will be set as default
|
||||
let defaultIndex = 0;
|
||||
if (typeof activeModel === "string") {
|
||||
if (activeModel === "") {
|
||||
defaultIndex = -1; // signal: don't set a default
|
||||
} else {
|
||||
const idx = modelsArray.indexOf(activeModel);
|
||||
defaultIndex = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Add entries for all requested models
|
||||
// The first one (index 0) will be the default if defaultIndex >= 0
|
||||
for (let i = 0; i < modelsArray.length; i++) {
|
||||
const m = modelsArray[i];
|
||||
if (!m || typeof m !== "string") continue;
|
||||
settings.customModels.push({
|
||||
model: m,
|
||||
id: `custom:9Router-${i}`,
|
||||
index: i,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: keyToUse,
|
||||
displayName: m,
|
||||
maxOutputTokens: 131072,
|
||||
noImageSupport: false,
|
||||
provider: "openai",
|
||||
});
|
||||
}
|
||||
|
||||
// Set default model if applicable
|
||||
if (defaultIndex >= 0 && settings.customModels[defaultIndex]) {
|
||||
// Reorder so the default comes first
|
||||
const [defaultEntry] = settings.customModels.splice(defaultIndex, 1);
|
||||
settings.customModels.unshift({ ...defaultEntry, index: 0 });
|
||||
// Re-index the rest
|
||||
settings.customModels.forEach((m, i) => { m.index = i; });
|
||||
}
|
||||
|
||||
// Write settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Factory Droid settings applied successfully!",
|
||||
settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating droid settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update droid settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove 9Router customModels only (keep other settings)
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const settingsPath = getDroidSettingsPath();
|
||||
|
||||
// Read existing settings
|
||||
let settings = {};
|
||||
try {
|
||||
const existingSettings = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(existingSettings);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No settings file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove 9Router customModels
|
||||
if (settings.customModels) {
|
||||
settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));
|
||||
|
||||
// Remove customModels array if empty
|
||||
if (settings.customModels.length === 0) {
|
||||
delete settings.customModels;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "9Router settings removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting droid settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PROVIDER_NAME = "9router";
|
||||
const API_KEY_ENV = "OPENAI_API_KEY";
|
||||
|
||||
const getHermesDir = () => path.join(os.homedir(), ".hermes");
|
||||
const getHermesConfigPath = () => path.join(getHermesDir(), "config.yaml");
|
||||
const getHermesEnvPath = () => path.join(getHermesDir(), ".env");
|
||||
|
||||
// Match top-level "model:" block (until next non-indented, non-empty line)
|
||||
const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
|
||||
|
||||
const buildModelBlock = (model, baseUrl) =>
|
||||
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`;
|
||||
|
||||
// Parse current model block back to fields (best-effort, simple key:value)
|
||||
const parseModelBlock = (yaml) => {
|
||||
const match = yaml.match(MODEL_BLOCK_RE);
|
||||
if (!match) return null;
|
||||
const body = match[1] || "";
|
||||
const get = (key) => {
|
||||
const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m"));
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
return {
|
||||
default: get("default"),
|
||||
provider: get("provider"),
|
||||
base_url: get("base_url"),
|
||||
};
|
||||
};
|
||||
|
||||
const upsertModelBlock = (yaml, newBlock) => {
|
||||
if (MODEL_BLOCK_RE.test(yaml)) return yaml.replace(MODEL_BLOCK_RE, newBlock);
|
||||
return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock;
|
||||
};
|
||||
|
||||
const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, "");
|
||||
|
||||
// .env helpers — upsert/remove single KEY=VALUE line
|
||||
const upsertEnvVar = (envText, key, value) => {
|
||||
const re = new RegExp(`^${key}=.*$`, "m");
|
||||
const line = `${key}=${value}`;
|
||||
if (re.test(envText)) return envText.replace(re, line);
|
||||
return envText.length > 0 && !envText.endsWith("\n") ? `${envText}\n${line}\n` : `${envText}${line}\n`;
|
||||
};
|
||||
|
||||
const removeEnvVar = (envText, key) => {
|
||||
const re = new RegExp(`^${key}=.*\\r?\\n?`, "m");
|
||||
return envText.replace(re, "");
|
||||
};
|
||||
|
||||
const checkHermesInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where hermes" : "which hermes";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getHermesConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readConfigYaml = async () => {
|
||||
try {
|
||||
return await fs.readFile(getHermesConfigPath(), "utf-8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readEnvFile = async () => {
|
||||
try {
|
||||
return await fs.readFile(getHermesEnvPath(), "utf-8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Detect 9router by base_url containing localhost/127.0.0.1 or matching tunnel URL
|
||||
const has9RouterConfig = (modelCfg) => {
|
||||
if (!modelCfg?.base_url) return false;
|
||||
return modelCfg.provider === "custom" && /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(modelCfg.base_url);
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkHermesInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({ installed: false, settings: null, message: "Hermes Agent is not installed" });
|
||||
}
|
||||
const yaml = await readConfigYaml();
|
||||
const model = parseModelBlock(yaml);
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: { model },
|
||||
has9Router: has9RouterConfig(model),
|
||||
configPath: getHermesConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking hermes settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check hermes settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !model) {
|
||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getHermesDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
|
||||
// Update config.yaml — replace/insert model: block, keep everything else
|
||||
const existingYaml = await readConfigYaml();
|
||||
const newYaml = upsertModelBlock(existingYaml, buildModelBlock(model, normalizedBaseUrl));
|
||||
await fs.writeFile(getHermesConfigPath(), newYaml);
|
||||
|
||||
// Update .env — upsert OPENAI_API_KEY only when caller provides one
|
||||
if (apiKey) {
|
||||
const existingEnv = await readEnvFile();
|
||||
const newEnv = upsertEnvVar(existingEnv, API_KEY_ENV, apiKey);
|
||||
await fs.writeFile(getHermesEnvPath(), newEnv);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Hermes settings applied successfully!",
|
||||
configPath: getHermesConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating hermes settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update hermes settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const configPath = getHermesConfigPath();
|
||||
let yaml = "";
|
||||
try {
|
||||
yaml = await fs.readFile(configPath, "utf-8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const newYaml = removeModelBlock(yaml);
|
||||
await fs.writeFile(configPath, newYaml);
|
||||
return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model block removed` });
|
||||
} catch (error) {
|
||||
console.log("Error resetting hermes settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { parseTOML, stringifyTOML } from "confbox";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getJcodeConfigDir = () => path.join(os.homedir(), ".jcode");
|
||||
const getConfigPath = () => path.join(getJcodeConfigDir(), "config.toml");
|
||||
|
||||
const getProviderEnvPath = () => {
|
||||
const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
return path.join(configDir, "jcode", "provider-9router.env");
|
||||
};
|
||||
|
||||
const checkJcodeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where jcode" : "which jcode";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getJcodeConfigDir());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readConfig = async () => {
|
||||
try {
|
||||
const configPath = getConfigPath();
|
||||
const content = await fs.readFile(configPath, "utf-8");
|
||||
return parseTOML(content);
|
||||
} catch (error) {
|
||||
return { providers: {} };
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (config) => {
|
||||
if (!config || !config.providers) return false;
|
||||
|
||||
const providers = config.providers;
|
||||
|
||||
if (providers["9router"]) return true;
|
||||
|
||||
for (const [name, provider] of Object.entries(providers)) {
|
||||
if (provider.base_url && provider.base_url.includes("localhost:20128")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const writeConfig = async (config) => {
|
||||
const configPath = getConfigPath();
|
||||
const content = stringifyTOML(config);
|
||||
await fs.writeFile(configPath, content, "utf-8");
|
||||
};
|
||||
|
||||
const readProviderEnv = async () => {
|
||||
try {
|
||||
const envPath = getProviderEnvPath();
|
||||
const content = await fs.readFile(envPath, "utf-8");
|
||||
const env = {};
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeProviderEnv = async (env) => {
|
||||
const envPath = getProviderEnvPath();
|
||||
let content = "# jcode provider environment variables\n";
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
content += `${key}="${value}"\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, content, "utf-8");
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const isInstalled = await checkJcodeInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
message: "jcode not installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await readConfig();
|
||||
const has9Router = has9RouterConfig(config);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config: redactSecrets(config),
|
||||
has9Router,
|
||||
configPath: getConfigPath(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, models } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "baseUrl and apiKey are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1")
|
||||
? baseUrl
|
||||
: `${baseUrl}/v1`;
|
||||
|
||||
let config = await readConfig();
|
||||
|
||||
if (!config.providers) {
|
||||
config.providers = {};
|
||||
}
|
||||
|
||||
config.providers["9router"] = {
|
||||
type: "openai-compatible",
|
||||
base_url: normalizedBaseUrl,
|
||||
auth: "bearer",
|
||||
api_key_env: "JCODE_9ROUTER_API_KEY",
|
||||
env_file: "provider-9router.env",
|
||||
default_model: models && models.length > 0 ? models[0] : "cc/claude-opus-4-7",
|
||||
requires_api_key: true,
|
||||
};
|
||||
|
||||
const configDir = getJcodeConfigDir();
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
await writeConfig(config);
|
||||
|
||||
const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
const jcodeConfigDir = path.join(xdgConfigDir, "jcode");
|
||||
await fs.mkdir(jcodeConfigDir, { recursive: true });
|
||||
|
||||
const env = await readProviderEnv();
|
||||
env.JCODE_9ROUTER_API_KEY = apiKey;
|
||||
await writeProviderEnv(env);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "jcode configured successfully. Use: jcode --provider-profile 9router",
|
||||
configPath: getConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error configuring jcode:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const config = await readConfig();
|
||||
|
||||
if (!config.providers) {
|
||||
return NextResponse.json({ success: true, message: "No configuration to remove" });
|
||||
}
|
||||
|
||||
delete config.providers["9router"];
|
||||
|
||||
await writeConfig(config);
|
||||
|
||||
const env = await readProviderEnv();
|
||||
delete env.JCODE_9ROUTER_API_KEY;
|
||||
await writeProviderEnv(env);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "9router configuration removed from jcode",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error removing jcode configuration:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"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");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
"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";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// OpenClaw 2026.5.x writes agents[].model as either a plain string
|
||||
// (legacy) or as an object `{ primary, fallbacks }`. Normalize to the
|
||||
// string id so downstream consumers can call `.startsWith()` safely.
|
||||
const resolveAgentModel = (m) => {
|
||||
if (typeof m === "string") return m;
|
||||
if (m && typeof m === "object") return m.primary ?? "";
|
||||
return "";
|
||||
};
|
||||
|
||||
const getOpenClawDir = () => path.join(os.homedir(), ".openclaw");
|
||||
const getOpenClawSettingsPath = () => path.join(getOpenClawDir(), "openclaw.json");
|
||||
|
||||
// Check if openclaw CLI is installed (via which/where or config file exists)
|
||||
const checkOpenClawInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where openclaw" : "which openclaw";
|
||||
// On Windows, inject %APPDATA%\npm into PATH so npm global packages are found
|
||||
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(getOpenClawSettingsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read current settings.json
|
||||
const readSettings = async () => {
|
||||
try {
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
// Tolerate JSONC (trailing commas) and treat unparseable files as "no config"
|
||||
// rather than throwing a 500 that the UI misreads as "tool not installed".
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if settings has 9Router config
|
||||
const has9RouterConfig = (settings) => {
|
||||
if (!settings || !settings.models || !settings.models.providers) return false;
|
||||
return !!settings.models.providers["9router"];
|
||||
};
|
||||
|
||||
// Read per-agent models.json and return current model id (without "9router/" prefix)
|
||||
const readAgentModel = async (agentDir) => {
|
||||
try {
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
const content = await fs.readFile(modelsPath, "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
const models = data?.providers?.["9router"]?.models;
|
||||
return models?.[0]?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// GET - Check openclaw CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkOpenClawInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
settings: null,
|
||||
message: "Open Claw CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await readSettings();
|
||||
|
||||
// Enrich agents list with current per-agent model from models.json.
|
||||
// Coerce agent.model to its string id when OpenClaw stores it as
|
||||
// `{ primary, fallbacks }` so downstream `.startsWith()` calls work.
|
||||
const agentList = settings?.agents?.list || [];
|
||||
const enrichedAgents = await Promise.all(
|
||||
agentList.map(async (agent) => {
|
||||
const agentModel = agent.agentDir ? await readAgentModel(agent.agentDir) : null;
|
||||
return { ...agent, model: resolveAgentModel(agent.model), currentModel: agentModel };
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: redactSecrets(settings),
|
||||
agents: redactSecrets(enrichedAgents),
|
||||
has9Router: has9RouterConfig(settings),
|
||||
settingsPath: getOpenClawSettingsPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking openclaw settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check openclaw settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Write per-agent models.json
|
||||
const writeAgentModels = async (agentDir, model, baseUrl, apiKey) => {
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
let existing = {};
|
||||
try {
|
||||
const content = await fs.readFile(modelsPath, "utf-8");
|
||||
existing = JSON.parse(content);
|
||||
} catch { /* No existing */ }
|
||||
|
||||
if (!existing.providers) existing.providers = {};
|
||||
existing.providers["9router"] = {
|
||||
baseUrl,
|
||||
apiKey: apiKey || "your_api_key",
|
||||
api: "openai-completions",
|
||||
models: [{ id: model, name: model.split("/").pop() || model }],
|
||||
};
|
||||
await fs.writeFile(modelsPath, JSON.stringify(existing, null, 2));
|
||||
};
|
||||
|
||||
// POST - Update 9Router settings (merge with existing settings)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
// agentModels: { [agentId]: modelId } for per-agent override
|
||||
const { baseUrl, apiKey, model, agentModels = {} } = await request.json();
|
||||
|
||||
if (!baseUrl || !model) {
|
||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const openclawDir = getOpenClawDir();
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
await fs.mkdir(openclawDir, { recursive: true });
|
||||
|
||||
let settings = {};
|
||||
try {
|
||||
const existingSettings = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(existingSettings);
|
||||
} catch { /* No existing settings */ }
|
||||
|
||||
if (!settings.agents) settings.agents = {};
|
||||
if (!settings.agents.defaults) settings.agents.defaults = {};
|
||||
if (!settings.agents.defaults.model) settings.agents.defaults.model = {};
|
||||
if (!settings.agents.defaults.models) settings.agents.defaults.models = {};
|
||||
if (!settings.models) settings.models = {};
|
||||
if (!settings.models.providers) settings.models.providers = {};
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const fullModelId = `9router/${model}`;
|
||||
|
||||
// Remove all old 9router/* entries from agents.defaults.models
|
||||
Object.keys(settings.agents.defaults.models)
|
||||
.filter((k) => k.startsWith("9router/"))
|
||||
.forEach((k) => { delete settings.agents.defaults.models[k]; });
|
||||
|
||||
// Update default model
|
||||
settings.agents.defaults.model.primary = fullModelId;
|
||||
|
||||
// Collect all unique models (default + per-agent)
|
||||
const allModelIds = new Set([model]);
|
||||
Object.values(agentModels).forEach((m) => { if (m) allModelIds.add(m); });
|
||||
|
||||
// Add fresh 9router models to allowlist
|
||||
allModelIds.forEach((m) => {
|
||||
settings.agents.defaults.models[`9router/${m}`] = {};
|
||||
});
|
||||
|
||||
// Remove old 9router model from each agent in agents.list. The
|
||||
// model field may be a plain string or `{ primary, fallbacks }`.
|
||||
if (settings.agents.list) {
|
||||
settings.agents.list = settings.agents.list.map((agent) => {
|
||||
if (resolveAgentModel(agent.model).startsWith("9router/")) {
|
||||
const { model: _, ...rest } = agent;
|
||||
return rest;
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
|
||||
// Update models.providers.9router with all models
|
||||
settings.models.providers["9router"] = {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: apiKey || "your_api_key",
|
||||
api: "openai-completions",
|
||||
models: [...allModelIds].map((m) => ({ id: m, name: m.split("/").pop() || m })),
|
||||
};
|
||||
|
||||
// Set per-agent model in agents.list and write models.json
|
||||
if (settings.agents.list) {
|
||||
settings.agents.list = settings.agents.list.map((agent) => {
|
||||
const agentModel = agentModels[agent.id];
|
||||
if (agentModel) return { ...agent, model: `9router/${agentModel}` };
|
||||
return agent;
|
||||
});
|
||||
|
||||
// Write per-agent models.json for agents with agentDir
|
||||
await Promise.all(
|
||||
settings.agents.list.map(async (agent) => {
|
||||
if (!agent.agentDir) return;
|
||||
const agentModel = agentModels[agent.id];
|
||||
const modelToWrite = agentModel || model; // fallback to default
|
||||
await writeAgentModels(agent.agentDir, modelToWrite, normalizedBaseUrl, apiKey);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Open Claw settings applied successfully!",
|
||||
settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating openclaw settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update openclaw settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove 9Router settings only (keep other settings)
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
// Read existing settings
|
||||
let settings = {};
|
||||
try {
|
||||
const existingSettings = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(existingSettings);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No settings file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove 9Router from models.providers
|
||||
if (settings.models && settings.models.providers) {
|
||||
delete settings.models.providers["9router"];
|
||||
|
||||
// Remove providers object if empty
|
||||
if (Object.keys(settings.models.providers).length === 0) {
|
||||
delete settings.models.providers;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove 9router models from agents.defaults.models allowlist
|
||||
if (settings.agents?.defaults?.models) {
|
||||
const keysToRemove = Object.keys(settings.agents.defaults.models).filter((k) => k.startsWith("9router/"));
|
||||
for (const key of keysToRemove) {
|
||||
delete settings.agents.defaults.models[key];
|
||||
}
|
||||
if (Object.keys(settings.agents.defaults.models).length === 0) {
|
||||
delete settings.agents.defaults.models;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset agents.defaults.model.primary if it uses 9router
|
||||
if (settings.agents?.defaults?.model?.primary?.startsWith("9router/")) {
|
||||
delete settings.agents.defaults.model.primary;
|
||||
}
|
||||
|
||||
// Write updated settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "9Router settings removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting openclaw settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset openclaw settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
"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";
|
||||
import { redactSecrets } from "@/lib/security/redactSecrets";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getConfigDir = () => path.join(os.homedir(), ".config", "opencode");
|
||||
const getConfigPath = () => path.join(getConfigDir(), "opencode.json");
|
||||
|
||||
// Check if opencode CLI is installed (via which/where or config file exists)
|
||||
const checkOpenCodeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where opencode" : "which opencode";
|
||||
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(getConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readConfig = async () => {
|
||||
try {
|
||||
const content = await fs.readFile(getConfigPath(), "utf-8");
|
||||
// opencode config files may use JSONC format (trailing commas, comments).
|
||||
// Strip trailing commas before parsing to avoid SyntaxError on valid JSONC.
|
||||
const stripped = content.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
// If the config file exists but is unparseable (corrupted, exotic JSONC),
|
||||
// treat it as "no config" rather than throwing a 500 that the UI
|
||||
// misinterprets as "opencode not installed".
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const has9RouterConfig = (config) => {
|
||||
if (!config?.provider) return false;
|
||||
return !!config.provider["9router"];
|
||||
};
|
||||
|
||||
// GET - Check opencode CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkOpenCodeInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
config: null,
|
||||
message: "OpenCode CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await readConfig();
|
||||
const providerConfig = config?.provider?.["9router"];
|
||||
const modelMap = providerConfig?.models || {};
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config: redactSecrets(config),
|
||||
has9Router: has9RouterConfig(config),
|
||||
configPath: getConfigPath(),
|
||||
opencode: {
|
||||
models: Object.keys(modelMap),
|
||||
activeModel: config?.model?.startsWith("9router/") ? config.model.replace(/^9router\//, "") : null,
|
||||
baseURL: providerConfig?.options?.baseURL || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking opencode settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check opencode settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Apply 9Router as openai-compatible provider (multi-model support)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model, models, activeModel, subagentModel } = await request.json();
|
||||
|
||||
// Accept either `model` (string, legacy) or `models` (array of strings)
|
||||
const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []);
|
||||
|
||||
if (!baseUrl || modelsArray.length === 0) {
|
||||
return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const configDir = getConfigDir();
|
||||
const configPath = getConfigPath();
|
||||
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Read existing config or start fresh
|
||||
let config = {};
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, "utf-8");
|
||||
config = JSON.parse(existing);
|
||||
} catch { /* No existing config */ }
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const keyToUse = apiKey || "sk_9router";
|
||||
const effectiveSubagentModel = subagentModel || modelsArray[0];
|
||||
|
||||
// Ensure provider object
|
||||
if (!config.provider) config.provider = {};
|
||||
|
||||
// Preserve any existing 9router provider entry and its models
|
||||
const existingProvider = config.provider["9router"] || { npm: "@ai-sdk/openai-compatible", options: {}, models: {} };
|
||||
|
||||
// Merge options (overwrite baseURL/apiKey)
|
||||
existingProvider.options = {
|
||||
...existingProvider.options,
|
||||
baseURL: normalizedBaseUrl,
|
||||
apiKey: keyToUse,
|
||||
};
|
||||
|
||||
// Ensure models map exists
|
||||
existingProvider.models = existingProvider.models || {};
|
||||
|
||||
// Add or update entries for all requested models
|
||||
for (const m of modelsArray) {
|
||||
if (!m || typeof m !== "string") continue;
|
||||
existingProvider.models[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } };
|
||||
}
|
||||
|
||||
// Save merged provider back
|
||||
config.provider["9router"] = existingProvider;
|
||||
|
||||
// Set the active model: prefer explicit activeModel, else first of modelsArray
|
||||
// If activeModel is explicitly empty string, clear the model
|
||||
if (activeModel === "") {
|
||||
config.model = "";
|
||||
} else {
|
||||
const finalActive = activeModel || modelsArray[0];
|
||||
if (finalActive) {
|
||||
config.model = `9router/${finalActive}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add subagent configuration
|
||||
if (!config.agent) config.agent = {};
|
||||
config.agent.explorer = {
|
||||
description: "Fast explorer subagent for codebase exploration",
|
||||
mode: "subagent",
|
||||
model: `9router/${effectiveSubagentModel}`,
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "OpenCode settings applied successfully!",
|
||||
configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error applying opencode settings:", error);
|
||||
return NextResponse.json({ error: "Failed to apply settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH - Update specific settings (e.g., clear active model)
|
||||
export async function PATCH(request) {
|
||||
try {
|
||||
const { clearActiveModel } = await request.json();
|
||||
const configPath = getConfigPath();
|
||||
|
||||
let config = {};
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, "utf-8");
|
||||
config = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({ success: true, message: "No config file found" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (clearActiveModel === true) {
|
||||
// Clear active model but keep models in the list
|
||||
if (config.model?.startsWith("9router/")) {
|
||||
config.model = "";
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Settings updated",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error patching opencode settings:", error);
|
||||
return NextResponse.json({ error: "Failed to patch settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove 9Router provider or specific models from config
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const modelToRemove = searchParams.get("model");
|
||||
const configPath = getConfigPath();
|
||||
|
||||
let config = {};
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, "utf-8");
|
||||
config = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If specific model provided, remove just that model
|
||||
if (modelToRemove && config.provider?.["9router"]?.models) {
|
||||
delete config.provider["9router"].models[modelToRemove];
|
||||
|
||||
// If no models left, remove the provider
|
||||
if (Object.keys(config.provider["9router"].models).length === 0) {
|
||||
delete config.provider["9router"];
|
||||
if (config.model?.startsWith("9router/")) delete config.model;
|
||||
} else if (config.model === `9router/${modelToRemove}`) {
|
||||
// If removed model was active, switch to first remaining model
|
||||
const remainingModels = Object.keys(config.provider["9router"].models);
|
||||
config.model = `9router/${remainingModels[0]}`;
|
||||
}
|
||||
} else {
|
||||
// No specific model - remove entire 9router provider
|
||||
if (config.provider) delete config.provider["9router"];
|
||||
if (config.model?.startsWith("9router/")) delete config.model;
|
||||
}
|
||||
|
||||
// Remove subagent configuration
|
||||
if (config.agent?.explorer?.model?.startsWith("9router/")) {
|
||||
delete config.agent.explorer;
|
||||
// Clean up empty agent object
|
||||
if (Object.keys(config.agent).length === 0) delete config.agent;
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: modelToRemove ? `Model "${modelToRemove}" removed` : "9Router settings removed from OpenCode",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting opencode settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset opencode settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+6
-11
@@ -44,15 +44,12 @@ const ALWAYS_PROTECTED = [
|
||||
];
|
||||
|
||||
// User administration is never exposed to normal users, even if dashboard login
|
||||
// is disabled for local single-user deployments. CLI Tools directly read and
|
||||
// mutate the account running 9Router's local CLI configuration, so they are
|
||||
// host administration rather than per-user dashboard preferences.
|
||||
// is disabled for local single-user deployments.
|
||||
const ADMIN_ONLY_PATHS = [
|
||||
"/api/users",
|
||||
"/api/tunnel",
|
||||
"/api/headroom",
|
||||
"/api/pxpipe",
|
||||
"/api/cli-tools",
|
||||
"/api/media-providers",
|
||||
"/api/proxy-pools",
|
||||
"/api/translator/console-logs",
|
||||
@@ -63,7 +60,6 @@ const ADMIN_ONLY_PATHS = [
|
||||
const ADMIN_ONLY_DASHBOARD_PATHS = [
|
||||
"/dashboard/token-saver",
|
||||
"/dashboard/pxpipe",
|
||||
"/dashboard/cli-tools",
|
||||
"/dashboard/media-providers",
|
||||
"/dashboard/proxy-pools",
|
||||
"/dashboard/console-log",
|
||||
@@ -84,7 +80,6 @@ const PROTECTED_API_PATHS = [
|
||||
"/api/media-providers",
|
||||
"/api/pricing",
|
||||
"/api/tags",
|
||||
"/api/cli-tools",
|
||||
"/api/mcp",
|
||||
"/api/translator",
|
||||
"/api/tunnel",
|
||||
@@ -223,15 +218,15 @@ export async function proxy(request) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// CLI Tools access the server process's home directory and, in the MITM
|
||||
// case, can change privileged system networking. Do not allow a shared CLI
|
||||
// bearer token to become a remote host-administration credential: only a
|
||||
// local, authenticated administrator may use these endpoints.
|
||||
// case, can change privileged system networking. Keep them available only
|
||||
// to authenticated dashboard users on the local machine; never allow the
|
||||
// shared CLI bearer token to become a remote host-administration credential.
|
||||
if (pathname === "/api/cli-tools" || pathname.startsWith("/api/cli-tools/")) {
|
||||
if (!isLocalRequest(request)) {
|
||||
return NextResponse.json({ error: "CLI Tools are available only from the local machine" }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdmin(request))) {
|
||||
return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function ModelSelectModal({
|
||||
kindFilter = null,
|
||||
addedModelValues = [],
|
||||
closeOnSelect = true,
|
||||
availableModels = null,
|
||||
}) {
|
||||
// Filter activeProviders by serviceKinds when kindFilter set (e.g. "webSearch", "webFetch")
|
||||
const filteredActiveProviders = useMemo(() => {
|
||||
@@ -119,6 +120,36 @@ export default function ModelSelectModal({
|
||||
const groupedModels = useMemo(() => {
|
||||
const groups = {};
|
||||
|
||||
// Consumers that need the exact catalog shown on the Models page supply its
|
||||
// /api/models/connected result. This avoids recreating a separate, partial
|
||||
// catalog from client-side provider constants, aliases, and custom models.
|
||||
if (Array.isArray(availableModels)) {
|
||||
availableModels.forEach((model) => {
|
||||
const providerId = model.providerAlias || model.provider?.alias || model.provider?.id;
|
||||
if (!providerId) return;
|
||||
|
||||
if (!groups[providerId]) {
|
||||
groups[providerId] = {
|
||||
name: model.provider?.name || providerId,
|
||||
alias: model.providerAlias || model.provider?.alias || providerId,
|
||||
color: model.provider?.color || "#666",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
groups[providerId].models.push({
|
||||
id: model.model,
|
||||
name: model.name || model.alias || model.model,
|
||||
value: model.fullModel,
|
||||
alias: model.alias,
|
||||
caps: model.caps,
|
||||
isCustom: model.isCustom,
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Kinds where the provider IS the model (no per-model selection needed)
|
||||
const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]);
|
||||
// Kinds that map directly to model.type field
|
||||
@@ -349,15 +380,15 @@ export default function ModelSelectModal({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]);
|
||||
}, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]);
|
||||
|
||||
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
|
||||
const filteredCombos = useMemo(() => {
|
||||
if (kindFilter) return [];
|
||||
if (kindFilter || availableModels) return [];
|
||||
if (!searchQuery.trim()) return combos;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return combos.filter(c => c.name.toLowerCase().includes(query));
|
||||
}, [combos, searchQuery, kindFilter]);
|
||||
}, [combos, searchQuery, kindFilter, availableModels]);
|
||||
|
||||
// Sort models alphabetically, with added models floated to top
|
||||
const sortModels = (models) => {
|
||||
@@ -578,4 +609,15 @@ ModelSelectModal.propTypes = {
|
||||
kindFilter: PropTypes.string,
|
||||
addedModelValues: PropTypes.arrayOf(PropTypes.string),
|
||||
closeOnSelect: PropTypes.bool,
|
||||
availableModels: PropTypes.arrayOf(PropTypes.shape({
|
||||
fullModel: PropTypes.string.isRequired,
|
||||
model: PropTypes.string.isRequired,
|
||||
providerAlias: PropTypes.string,
|
||||
provider: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
alias: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -224,14 +224,14 @@ describe("dashboard guard local-only access", () => {
|
||||
expect(response.body.error).toBe("Local only: CLI token required");
|
||||
});
|
||||
|
||||
it("requires an administrator for CLI Tools", async () => {
|
||||
it("requires authentication for CLI Tools", async () => {
|
||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe("Administrator access required");
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("rejects local-only route from a tunnel host", async () => {
|
||||
@@ -261,7 +261,7 @@ describe("dashboard guard local-only access", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard guard CLI Tools administration access", () => {
|
||||
describe("dashboard guard CLI Tools access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
@@ -272,14 +272,13 @@ describe("dashboard guard CLI Tools administration access", () => {
|
||||
mocks.verifyDashboardAuthToken.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("rejects normal users from host-level CLI Tools operations", async () => {
|
||||
it("allows local authenticated users to use CLI Tools", async () => {
|
||||
const response = await proxy(request("/api/cli-tools/claude-settings", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
}, "user-token"));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe("Administrator access required");
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
});
|
||||
|
||||
it("rejects remote CLI Tools access even with an administrator session", async () => {
|
||||
|
||||
Reference in New Issue
Block a user