- Cowork: ComboFormModal

- BaseUrlSelect: add cloud endpoint option, custom URL local state, always
  default to first option; new cliEndpointMatch helper; CLI tool cards refactor
- API: new /v1/audio/voices and /v1/models/info; /v1/models filters disabled
  models, drop unused timestamp
- initializeApp: guard tunnel/tailscale auto-resume to once-per-process
- geminiHelper: ensureObjectType for schemas with properties but no type
- skills: minor SKILL.md tweaks (chat/embeddings/image/stt/tts/web-*)
This commit is contained in:
decolua
2026-05-07 15:45:09 +07:00
parent 6344abcf8d
commit 5c62e73cc6
28 changed files with 1897 additions and 320 deletions

View File

@@ -270,6 +270,13 @@ function flattenTypeArrays(obj) {
}
}
// Infer missing type=object when properties exist (Gemini requires explicit type)
function ensureObjectType(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.properties && !obj.type) obj.type = "object";
for (const v of Object.values(obj)) if (v && typeof v === "object") ensureObjectType(v);
}
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
export function cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
@@ -286,6 +293,9 @@ export function cleanJSONSchemaForAntigravity(schema) {
flattenAnyOfOneOf(cleaned);
flattenTypeArrays(cleaned);
// Phase 2.5: Infer missing type=object when properties exist (Gemini requirement)
ensureObjectType(cleaned);
// Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays)
removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS);

View File

@@ -12,10 +12,12 @@ Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://ra
- `POST $NINEROUTER_URL/v1/chat/completions` — OpenAI format
- `POST $NINEROUTER_URL/v1/messages` — Anthropic format
## Discover models
## Discover
```bash
curl $NINEROUTER_URL/v1/models | jq '.data[].id'
# Per-model metadata (contextWindow, params)
curl "$NINEROUTER_URL/v1/models/info?id=openai/gpt-4o"
```
Combos (e.g. `vip`, `mycodex`) auto-fallback through multiple providers.

View File

@@ -7,10 +7,12 @@ description: Generate vector embeddings via 9Router /v1/embeddings using OpenAI
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
## Discover models
## Discover
```bash
curl $NINEROUTER_URL/v1/models/embedding | jq '.data[].id'
# Per-model dimensions
curl "$NINEROUTER_URL/v1/models/info?id=openai/text-embedding-3-small"
```
## Endpoint

View File

@@ -7,10 +7,12 @@ description: Generate images via 9Router /v1/images/generations using OpenAI / G
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
## Discover models
## Discover
```bash
curl $NINEROUTER_URL/v1/models/image | jq '.data[].id'
# Per-model params/options (size enum, quality enum, capabilities like edit)
curl "$NINEROUTER_URL/v1/models/info?id=openai/dall-e-3"
```
## Endpoint

View File

@@ -7,10 +7,12 @@ description: Speech-to-text via 9Router /v1/audio/transcriptions using OpenAI Wh
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
## Discover models
## Discover
```bash
curl $NINEROUTER_URL/v1/models/stt | jq '.data[].id'
# Per-model params (language, response_format, prompt, temperature support)
curl "$NINEROUTER_URL/v1/models/info?id=openai/whisper-1"
```
`model` = STT model ID (e.g. `openai/whisper-1`, `groq/whisper-large-v3`, `deepgram/nova-3`, `gemini/gemini-2.5-flash`).

View File

@@ -7,13 +7,18 @@ description: Text-to-speech via 9Router /v1/audio/speech using OpenAI / ElevenLa
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
## Discover voices
## Discover
```bash
# 1) List models
curl $NINEROUTER_URL/v1/models/tts | jq '.data[].id'
# 2) Per-model metadata (params, voicesUrl if voice-by-id)
curl "$NINEROUTER_URL/v1/models/info?id=el/eleven_multilingual_v2"
# 3) List voices (elevenlabs, edge-tts, deepgram, inworld, local-device). Optional ?lang=vi
curl "$NINEROUTER_URL/v1/audio/voices?provider=edge-tts&lang=vi" | jq '.data[].model'
```
`model` = voice ID (e.g. `openai/tts-1`, `el/eleven_multilingual_v2`, `edge-tts/en-US-AriaNeural`).
`model` field in `/v1/audio/speech` = voice ID directly (e.g. `edge-tts/vi-VN-HoaiMyNeural`, `el/<voice_id>`, or `openai/tts-1` model+default voice).
## Endpoint

View File

@@ -7,10 +7,12 @@ description: Fetch URL → markdown / text / HTML via 9Router /v1/web/fetch usin
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
## Discover providers
## Discover
```bash
curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webFetch") | .id'
# Per-provider params
curl "$NINEROUTER_URL/v1/models/info?id=firecrawl/fetch"
```
IDs end in `/fetch` (e.g. `firecrawl/fetch`, `jina/fetch`). `fetch-combo` chains providers with auto-fallback.

View File

@@ -7,10 +7,12 @@ description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serp
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
## Discover providers
## Discover
```bash
curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webSearch") | .id'
# Per-provider params (searchTypes, maxResults, required options like cx for google-pse)
curl "$NINEROUTER_URL/v1/models/info?id=tavily/search"
```
IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain providers with auto-fallback.

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { APP_CONFIG } from "@/shared/constants/config";
import { useEffect, useMemo, useRef, useState } from "react";
import { UPDATER_CONFIG } from "@/shared/constants/config";
const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__";
@@ -13,8 +13,6 @@ const ensureV1 = (url) => {
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
};
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
const readSavedPresets = () => {
if (typeof window === "undefined") return [];
try {
@@ -31,21 +29,27 @@ const writeSavedPresets = (presets) => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
};
// Build endpoint options ordered by priority
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }) => {
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : url.replace(/\/+$/, ""));
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
if (!requiresExternalUrl) {
opts.push({ value: "local", label: `Localhost (127.0.0.1)`, url: wrap(`http://127.0.0.1:${APP_CONFIG.appPort}`) });
const localUrl = wrap(`http://127.0.0.1:${UPDATER_CONFIG.appPort}`);
opts.push({ value: "local", label: localUrl, url: localUrl });
}
if (tunnelEnabled && tunnelPublicUrl) {
opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: wrap(tunnelPublicUrl) });
const u = wrap(tunnelPublicUrl);
opts.push({ value: "tunnel", label: u, url: u });
}
if (tailscaleEnabled && tailscaleUrl) {
opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: wrap(tailscaleUrl) });
const u = wrap(tailscaleUrl);
opts.push({ value: "tailscale", label: u, url: u });
}
if (cloudEnabled && cloudUrl) {
const u = wrap(cloudUrl);
opts.push({ value: "cloud", label: u, url: u });
}
savedPresets.forEach((p) => {
opts.push({ value: `saved:${p.name}`, label: `${p.name} - ${p.baseUrl}`, url: p.baseUrl, saved: true });
opts.push({ value: `saved:${p.name}`, label: p.baseUrl, url: p.baseUrl, saved: true });
});
opts.push({ value: CUSTOM_VALUE, label: "Custom URL...", url: "" });
return opts;
@@ -59,32 +63,37 @@ export default function BaseUrlSelect({
tunnelPublicUrl = "",
tailscaleEnabled = false,
tailscaleUrl = "",
cloudEnabled = false,
cloudUrl = "",
withV1 = true,
}) {
const [savedPresets, setSavedPresets] = useState([]);
const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false);
useEffect(() => {
setSavedPresets(readSavedPresets());
}, []);
const options = useMemo(
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }),
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1]
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
);
// Auto-detect mode based on current value matching an option
// Always default to first option (127.0.0.1) on mount, ignore persisted value
useEffect(() => {
if (!value) {
if (options[0] && options[0].value !== CUSTOM_VALUE) {
setMode(options[0].value);
onChange(options[0].url);
if (initializedRef.current) return;
if (options.length === 0) return;
initializedRef.current = true;
const first = options.find((o) => o.value !== CUSTOM_VALUE);
if (first) {
setMode(first.value);
onChange(first.url);
} else {
setMode(CUSTOM_VALUE);
}
return;
}
const match = options.find((o) => o.url && o.url === value);
setMode(match ? match.value : CUSTOM_VALUE);
}, [value, options]);
}, [options, onChange]);
const handleSelect = (e) => {
const next = e.target.value;
@@ -95,14 +104,15 @@ export default function BaseUrlSelect({
try { defaultName = new URL(trimmed).host; } catch {}
const name = window.prompt("Save endpoint as:", defaultName);
if (!name?.trim()) return;
const next = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
.sort((a, b) => a.name.localeCompare(b.name));
setSavedPresets(next);
writeSavedPresets(next);
setSavedPresets(updated);
writeSavedPresets(updated);
return;
}
setMode(next);
if (next === CUSTOM_VALUE) {
setCustomInput("");
onChange("");
return;
}
@@ -110,18 +120,26 @@ export default function BaseUrlSelect({
if (opt) onChange(opt.url);
};
const handleCustomInput = (e) => {
const v = e.target.value;
setCustomInput(v);
onChange(v);
};
const handleDeleteSaved = () => {
if (!mode.startsWith("saved:")) return;
const name = mode.slice(6);
const next = savedPresets.filter((p) => p.name !== name);
setSavedPresets(next);
writeSavedPresets(next);
const updated = savedPresets.filter((p) => p.name !== name);
setSavedPresets(updated);
writeSavedPresets(updated);
setMode(CUSTOM_VALUE);
setCustomInput("");
onChange("");
};
const isSaved = mode.startsWith("saved:");
const isCustom = mode === CUSTOM_VALUE;
const canSave = isCustom && (value || "").trim().length > 0;
const canSave = isCustom && (customInput || "").trim().length > 0;
return (
<div className="flex flex-col gap-1.5">
@@ -145,8 +163,8 @@ export default function BaseUrlSelect({
{isCustom && (
<input
type="text"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
value={customInput}
onChange={handleCustomInput}
placeholder={withV1 ? "https://example.com/v1" : "https://example.com"}
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"
/>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -43,10 +44,7 @@ export default function ClaudeToolCard({
if (!claudeStatus?.installed) return null;
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
if (!currentUrl) return "not_configured";
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl);
if (localMatch || cloudMatch || tunnelMatch) return "configured";
if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured";
return "other";
};
@@ -296,20 +294,9 @@ export default function ClaudeToolCard({
{!checkingClaude && claudeStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Current Base URL */}
{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>
)}
{/* 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">Base URL</span>
<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()}
@@ -322,6 +309,17 @@ export default function ClaudeToolCard({
/>
</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>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { 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);
@@ -64,8 +65,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const getConfigStatus = () => {
if (!codexStatus?.installed) return null;
if (!codexStatus.config) return "not_configured";
const hasBaseUrl = codexStatus.config.includes(baseUrl) || codexStatus.config.includes("localhost") || codexStatus.config.includes("127.0.0.1");
return hasBaseUrl ? "configured" : "other";
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentUrl = parsed ? parsed[1] : "";
return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -266,7 +268,22 @@ model = "${effectiveSubagentModel}"
{!checkingCodex && codexStatus?.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 */}
{codexStatus?.config && (() => {
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentBaseUrl = parsed ? parsed[1] : null;
@@ -281,21 +298,6 @@ model = "${effectiveSubagentModel}"
) : null;
})()}
{/* Base URL */}
<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">Base URL</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>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { 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);
@@ -63,8 +64,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
if (!status) return null;
if (!status.has9Router) return "not_configured";
const url = status.currentUrl || "";
return url.includes("localhost") || url.includes("127.0.0.1") || url.includes(baseUrl)
? "configured" : "other";
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -207,7 +207,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-text-muted">Base URL</label>
<label className="text-xs font-medium text-text-muted">Select Endpoint</label>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}

View File

@@ -1,13 +1,12 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import { useState, useEffect } from "react";
import { Card, Button, ManualConfigModal, ComboFormModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
const ENDPOINT = "/api/cli-tools/cowork-settings";
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
const ensureV1 = (url) => {
const trimmed = (url || "").replace(/\/+$/, "");
@@ -38,26 +37,11 @@ export default function CoworkToolCard({
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModels, setSelectedModels] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [endpointMode, setEndpointMode] = useState("custom");
const [customBaseUrl, setCustomBaseUrl] = useState("");
const endpointOptions = useMemo(() => {
const opts = [];
if (tunnelEnabled && tunnelPublicUrl) {
opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: ensureV1(tunnelPublicUrl) });
}
if (tailscaleEnabled && tailscaleUrl) {
opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: ensureV1(tailscaleUrl) });
}
if (cloudEnabled && cloudUrl) {
opts.push({ value: "cloud", label: `Cloud - ${cloudUrl}`, url: ensureV1(cloudUrl) });
}
opts.push({ value: "custom", label: "Custom URL (VPS / public host)", url: "" });
return opts;
}, [tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl]);
const [selectedPlugins, setSelectedPlugins] = useState([]);
const [pluginsExpanded, setPluginsExpanded] = useState(false);
const [comboModalOpen, setComboModalOpen] = useState(false);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
@@ -70,11 +54,7 @@ export default function CoworkToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
if (isExpanded && !status) checkStatus();
}, [isExpanded]);
useEffect(() => {
@@ -83,28 +63,12 @@ export default function CoworkToolCard({
}
if (status?.cowork?.baseUrl && !customBaseUrl) {
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
setEndpointMode("custom");
}
if (Array.isArray(status?.cowork?.selectedPlugins)) {
setSelectedPlugins(status.cowork.selectedPlugins);
}
}, [status]);
// Auto-pick first available preset when expand if user has not set anything
useEffect(() => {
if (!customBaseUrl && endpointOptions[0]?.url) {
setEndpointMode(endpointOptions[0].value);
setCustomBaseUrl(stripV1(endpointOptions[0].url));
}
}, [endpointOptions]);
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 checkStatus = async () => {
setChecking(true);
try {
@@ -124,31 +88,16 @@ export default function CoworkToolCard({
if (!status?.installed) return null;
const url = status?.cowork?.baseUrl;
if (!url) return "not_configured";
if (isLocalhostUrl(url)) return "invalid";
return status.has9Router ? "configured" : "other";
};
const configStatus = getConfigStatus();
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
const handleEndpointModeChange = (value) => {
setEndpointMode(value);
const opt = endpointOptions.find((o) => o.value === value);
if (opt?.url) {
setCustomBaseUrl(stripV1(opt.url));
} else {
setCustomBaseUrl("");
}
};
const handleApply = async () => {
setMessage(null);
const effectiveUrl = getEffectiveBaseUrl();
if (isLocalhostUrl(effectiveUrl)) {
setMessage({ type: "error", text: "Localhost is not allowed. Enable Tunnel/Tailscale or use VPS." });
return;
}
if (selectedModels.length === 0) {
setMessage({ type: "error", text: "Please select at least one model" });
return;
@@ -167,6 +116,7 @@ export default function CoworkToolCard({
baseUrl: effectiveUrl,
apiKey: keyToUse,
models: selectedModels,
plugins: selectedPlugins,
}),
});
const data = await res.json();
@@ -183,6 +133,29 @@ export default function CoworkToolCard({
}
};
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;
}
// Add combo name into selected models for Cowork
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 handleReset = async () => {
setRestoring(true);
setMessage(null);
@@ -234,7 +207,6 @@ export default function CoworkToolCard({
<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 === "invalid" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full">Localhost (invalid)</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>
@@ -245,11 +217,6 @@ export default function CoworkToolCard({
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-xs text-blue-700 dark:text-blue-300">
<span className="material-symbols-outlined text-[16px] mt-0.5">info</span>
<span>Claude Cowork runs in a sandboxed VM and <b>cannot reach localhost</b>. Use Tunnel, Tailscale, or VPS public URL.</span>
</div>
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
@@ -278,6 +245,21 @@ export default function CoworkToolCard({
{!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>
@@ -288,32 +270,6 @@ export default function CoworkToolCard({
</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">Endpoint Mode</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<select
value={endpointMode}
onChange={(e) => handleEndpointModeChange(e.target.value)}
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
>
{endpointOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</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">Base URL</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<input
type="text"
value={getEffectiveBaseUrl()}
onChange={(e) => setCustomBaseUrl(stripV1(e.target.value))}
placeholder="https://your-host.com/v1"
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
</div>
<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>
@@ -347,9 +303,55 @@ export default function CoworkToolCard({
))
)}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Add Combo (claude-)</button>
</div>
</div>
{false && (<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">Connectors</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 items-center justify-between">
<span className="text-xs text-text-muted">{selectedPlugins.length} of {(status?.availablePlugins || []).length} selected</span>
<button onClick={() => setPluginsExpanded(!pluginsExpanded)} className="text-xs text-primary hover:underline">
{pluginsExpanded ? "Hide" : "Show"} all
</button>
</div>
{pluginsExpanded && (
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto px-2 py-2 bg-surface rounded border border-border">
{(status?.availablePlugins || []).map((p) => {
const checked = selectedPlugins.includes(p.name);
return (
<label key={p.name} className="flex items-start gap-2 text-xs cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 px-1 py-0.5 rounded">
<input
type="checkbox"
checked={checked}
onChange={() => setSelectedPlugins((prev) => checked ? prev.filter((n) => n !== p.name) : [...prev, p.name])}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{p.name}</div>
{p.description && <div className="text-text-muted text-[10px] truncate">{p.description}</div>}
</div>
</label>
);
})}
</div>
)}
{!pluginsExpanded && selectedPlugins.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
{selectedPlugins.map((name) => (
<span key={name} 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">
{name}
<button onClick={() => setSelectedPlugins((prev) => prev.filter((x) => x !== name))} className="ml-0.5 hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))}
</div>
)}
</div>
</div>)}
</div>
{message && (
@@ -370,32 +372,28 @@ export default function CoworkToolCard({
<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) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
}
setModalOpen(false);
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model for Claude Cowork"
/>
<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"
/>
</Card>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -42,11 +43,7 @@ export default function DroidToolCard({
// 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";
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL);
const tunnelMatch = baseUrl && currentConfig.baseUrl?.startsWith(baseUrl);
if (localMatch || cloudMatch || tunnelMatch) return "configured";
return "other";
return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -291,20 +288,9 @@ export default function DroidToolCard({
{!checkingDroid && droidStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Current Base URL */}
{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>
)}
{/* 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">Base URL</span>
<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()}
@@ -317,6 +303,17 @@ export default function DroidToolCard({
/>
</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>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const ENDPOINT = "/api/cli-tools/hermes-settings";
@@ -39,9 +40,7 @@ export default function HermesToolCard({
if (!hermesStatus?.installed) return null;
const cfg = hermesStatus.settings?.model;
if (!cfg?.base_url) return "not_configured";
const localMatch = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(cfg.base_url);
const tunnelMatch = baseUrl && cfg.base_url.startsWith(baseUrl);
if (localMatch || tunnelMatch) return "configured";
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
return "other";
};
@@ -233,18 +232,8 @@ export default function HermesToolCard({
{!checking && hermesStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{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] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Base URL</span>
<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()}
@@ -257,6 +246,16 @@ export default function HermesToolCard({
/>
</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>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function OpenClawToolCard({
tool,
@@ -39,10 +40,7 @@ export default function OpenClawToolCard({
if (!openclawStatus?.installed) return null;
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
if (!currentProvider) return "not_configured";
const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1") || currentProvider.baseUrl?.includes("0.0.0.0");
const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl);
if (localMatch || tunnelMatch) return "configured";
return "other";
return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -282,20 +280,9 @@ export default function OpenClawToolCard({
{!checkingOpenclaw && openclawStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Current Base URL */}
{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>
)}
{/* 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">Base URL</span>
<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()}
@@ -308,6 +295,17 @@ export default function OpenClawToolCard({
/>
</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>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { 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);
@@ -69,9 +70,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
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 || "";
const isLocal = url.includes("localhost") || url.includes("127.0.0.1");
return status.has9Router && (isLocal || url.includes(baseUrl)) ? "configured" : status.has9Router ? "other" : "not_configured";
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -258,19 +259,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
<>
<div className="flex flex-col gap-2">
{/* Current base URL */}
{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>
)}
{/* 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">Base URL</span>
<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()}
@@ -283,6 +274,17 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
/>
</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>

View File

@@ -0,0 +1,13 @@
// 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;
}

View File

@@ -485,20 +485,20 @@ export default function ProviderDetailPage() {
const applyProxyAssignments = async (assignments) => {
setBulkUpdatingProxy(true);
try {
const results = await Promise.all(assignments.map(async ({ connectionId, proxyPoolId }) => {
let failed = 0;
for (const { connectionId, proxyPoolId } of assignments) {
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxyPoolId }),
});
return res.ok;
if (!res.ok) failed += 1;
} catch (e) {
console.log("Error applying proxy for", connectionId, e);
return false;
failed += 1;
}
}
}));
const failed = results.filter((ok) => !ok).length;
if (failed > 0) alert(`Updated with ${failed} failed request(s).`);
await fetchConnections();
setShowBulkProxyModal(false);
@@ -582,54 +582,38 @@ export default function ProviderDetailPage() {
title={`Apply Proxy (${connections.length} connections)`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col">
<button
onClick={handleApplyOneToOne}
disabled={bulkUpdatingProxy || activePools.length === 0}
className="flex items-center justify-between gap-3 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2.5 text-left transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-primary text-[20px]">sync_alt</span>
<div className="min-w-0">
<p className="text-sm font-medium text-text-main">One-to-one (rotate)</p>
<p className="text-[11px] text-text-muted">
Distribute {activePools.length} active pool(s) across {connections.length} connection(s)
</p>
</div>
</div>
<span className="material-symbols-outlined text-text-muted">chevron_right</span>
<span className="material-symbols-outlined text-text-muted text-[18px]">sync_alt</span>
<span className="text-sm text-text-main">One-to-one (rotate)</span>
</button>
<div className="border-t border-black/[0.06] dark:border-white/[0.06] pt-2">
<p className="px-1 pb-1 text-[11px] uppercase tracking-wide text-text-muted">Apply single pool to all</p>
<div className="flex flex-col">
<button
onClick={() => handleApplySinglePool(null)}
disabled={bulkUpdatingProxy}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-text-muted text-[18px]">link_off</span>
<span className="text-sm text-text-main">None (unbind all)</span>
</div>
</button>
{proxyPools.map((pool) => (
<button
key={pool.id}
onClick={() => handleApplySinglePool(pool.id)}
disabled={bulkUpdatingProxy || pool.isActive !== true}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-text-muted text-[18px]">lan</span>
<span className="truncate text-sm text-text-main">{pool.name}</span>
{pool.isActive !== true && (
<span className="text-[10px] text-text-muted">(inactive)</span>
)}
</div>
</button>
))}
</div>
</div>
{bulkUpdatingProxy && <p className="text-xs text-text-muted">Applying...</p>}

View File

@@ -0,0 +1,104 @@
"use server";
import { NextResponse } from "next/server";
const REGISTRY_URL = "https://api.anthropic.com/mcp-registry/v0/servers";
const VISIBILITY = "commercial,gsuite,gsuite-google";
const PLUGINS_REPO = "anthropics/knowledge-work-plugins";
const GH_API = "https://api.github.com";
const GH_RAW = "https://raw.githubusercontent.com";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
const G_KEY = "__9routerCoworkMcpRegistryCache";
function gcache() {
if (!globalThis[G_KEY]) globalThis[G_KEY] = { ts: 0, data: null };
return globalThis[G_KEY];
}
// Fetch full registry across pagination
async function fetchRegistry() {
const out = [];
let cursor = "";
for (let i = 0; i < 20; i++) {
const url = `${REGISTRY_URL}?limit=500&visibility=${VISIBILITY}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const r = await fetch(url, { headers: { "accept": "application/json" } });
if (!r.ok) break;
const j = await r.json();
for (const item of j.servers || []) {
const s = item.server || {};
const remote = (s.remotes || [])[0];
if (!remote?.url) continue;
const transport = remote.type === "streamable-http" ? "http" : (remote.type === "sse" ? "sse" : "http");
out.push({
source: "registry",
name: s.name,
title: s.title || s.name,
description: s.description || "",
url: remote.url,
transport,
});
}
cursor = j.metadata?.nextCursor;
if (!cursor) break;
}
return out;
}
// Fetch plugins from anthropics/knowledge-work-plugins. Each plugin folder contains
// .claude-plugin/plugin.json with mcp_servers map.
async function fetchPlugins() {
const r = await fetch(`${GH_API}/repos/${PLUGINS_REPO}/contents/`, { headers: { "accept": "application/vnd.github.v3+json" } });
if (!r.ok) return [];
const items = await r.json();
const dirs = items.filter((i) => i.type === "dir" && !i.name.startsWith(".") && i.name !== "partner-built");
const out = [];
await Promise.all(dirs.map(async (d) => {
try {
const url = `${GH_RAW}/${PLUGINS_REPO}/main/${d.name}/.claude-plugin/plugin.json`;
const pr = await fetch(url);
if (!pr.ok) return;
const pj = await pr.json();
const servers = pj.mcp_servers || pj.mcpServers || {};
for (const [key, srv] of Object.entries(servers)) {
if (!srv?.url || typeof srv.url !== "string") continue;
if (!/^https?:\/\//i.test(srv.url)) continue;
const transport = /\/sse(\b|\/)/i.test(srv.url) ? "sse" : (srv.type === "sse" ? "sse" : "http");
out.push({
source: "plugins",
plugin: d.name,
name: `${d.name}-${key}`,
title: pj.name || d.name,
description: pj.description || "",
url: srv.url,
transport,
});
}
} catch { /* skip */ }
}));
return out;
}
export async function GET(request) {
const { searchParams } = new URL(request.url);
const force = searchParams.get("refresh") === "1";
const cache = gcache();
if (!force && cache.data && Date.now() - cache.ts < CACHE_TTL_MS) {
return NextResponse.json({ cached: true, ...cache.data });
}
try {
const [registry, plugins] = await Promise.all([fetchRegistry(), fetchPlugins()]);
// Deduplicate by url
const seen = new Set();
const merged = [...registry, ...plugins].filter((s) => {
if (seen.has(s.url)) return false;
seen.add(s.url);
return true;
});
const data = { servers: merged, counts: { registry: registry.length, plugins: plugins.length, total: merged.length } };
cache.ts = Date.now();
cache.data = data;
return NextResponse.json({ cached: false, ...data });
} catch (e) {
return NextResponse.json({ error: e.message, servers: [], counts: { total: 0 } }, { status: 500 });
}
}

View File

@@ -5,9 +5,115 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
import crypto from "crypto";
import { COWORK_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
const PROVIDER = "gateway";
// Plugin folder mount location.
// Claude Cowork 3p actually launches with --user-data-dir=Claude-3p, so plugins
// must live there (not the system /Library path which requires admin & isn't read in 3p).
const getOrgPluginsCandidates = () => {
if (os.platform() === "darwin") {
const home = os.homedir();
return [
path.join(home, "Library", "Application Support", "Claude-3p", "org-plugins"),
path.join(home, "Library", "Application Support", "Claude", "org-plugins"),
"/Library/Application Support/Claude/org-plugins",
];
}
if (os.platform() === "win32") {
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const programData = process.env.ProgramData || "C:\\ProgramData";
return [
path.join(localApp, "Claude-3p", "org-plugins"),
path.join(localApp, "Claude", "org-plugins"),
path.join(programData, "Claude", "org-plugins"),
];
}
return [path.join(os.homedir(), ".config", "Claude-3p", "org-plugins"), "/etc/Claude/org-plugins"];
};
// Pick first writable candidate for org-plugins
async function pickPluginsRoot() {
for (const dir of getOrgPluginsCandidates()) {
try {
await fs.mkdir(dir, { recursive: true });
// Probe write
const probe = path.join(dir, ".__9router_probe");
await fs.writeFile(probe, "ok");
await fs.unlink(probe);
return dir;
} catch { /* try next */ }
}
return null;
}
// Create plugin folder mount: org-plugins/<name>/claude-plugin/{plugin.json, version.json, .mcp.json}
async function writeOrgPluginsFolder(selectedPluginNames) {
const root = await pickPluginsRoot();
if (!root) return { error: "no_writable_plugins_dir", written: [] };
const set = new Set(selectedPluginNames || []);
const selectedPlugins = COWORK_PLUGINS.filter((p) => set.has(p.name));
// Remove previously-managed plugin subfolders (best-effort)
for (const p of COWORK_PLUGINS) {
try { await fs.rm(path.join(root, p.name), { recursive: true, force: true }); } catch { /* ignore */ }
}
const written = [];
for (const p of selectedPlugins) {
const pluginRoot = path.join(root, p.name);
const metaDir = path.join(pluginRoot, ".claude-plugin");
try {
await fs.mkdir(metaDir, { recursive: true });
const manifest = { name: p.name, version: "1.0.0", description: p.description || p.name, author: { name: "9router" } };
await fs.writeFile(path.join(metaDir, "plugin.json"), JSON.stringify(manifest, null, 2));
// .mcp.json at plugin root, schema: {mcpServers: {name: {type, url, oauth?}}}
const mcpServers = {};
for (const s of p.servers) {
const key = p.servers.length === 1 ? p.name : `${p.name}-${s.key}`;
mcpServers[key] = {
type: /\/sse(\b|\/)/i.test(s.url) ? "sse" : "http",
url: s.url,
};
}
await fs.writeFile(path.join(pluginRoot, ".mcp.json"), JSON.stringify({ mcpServers }, null, 2));
written.push(p.name);
} catch (e) {
return { error: e.code || e.message, written, root };
}
}
return { written, root };
}
// Set operonSkipMcpApprovals[serverName]=true in Claude-3p/config.json so user
// is not prompted for every tool call. Mirrors mcpToolAccessProvider.setSkipApprovals.
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 };
}
// Reset previous managed entries (those we own == COWORK_PLUGINS server names)
const ownedNames = new Set();
for (const p of COWORK_PLUGINS) {
for (const s of p.servers) {
ownedNames.add(p.servers.length === 1 ? p.name : `${p.name}-${s.key}`);
}
}
const skip = (cfg.operonSkipMcpApprovals && typeof cfg.operonSkipMcpApprovals === "object") ? cfg.operonSkipMcpApprovals : {};
for (const k of Object.keys(skip)) {
if (ownedNames.has(k)) delete skip[k];
}
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 };
}
// Candidate user-data roots — Cowork can run from either Claude-3p (3p mode) or Claude (1p mode w/ cowork features)
const getCandidateRoots = () => {
if (os.platform() === "darwin") {
@@ -107,8 +213,6 @@ const checkInstalled = async () => {
return false;
};
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
const readJson = async (filePath) => {
try {
const content = await fs.readFile(filePath, "utf-8");
@@ -160,6 +264,12 @@ export async function GET() {
? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
: [];
// managedMcpServers stored as native array in configLibrary <uuid>.json
const managedMcpArr = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
const selectedPlugins = COWORK_PLUGINS
.filter((p) => p.servers.some((s) => managedMcpArr.some((v) => v?.url === s.url)))
.map((p) => p.name);
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
return NextResponse.json({
@@ -172,7 +282,9 @@ export async function GET() {
baseUrl,
models,
provider: config?.inferenceProvider || null,
selectedPlugins,
},
availablePlugins: COWORK_PLUGINS.map((p) => ({ name: p.name, description: p.description })),
});
} catch (error) {
console.log("Error reading cowork settings:", error);
@@ -182,23 +294,20 @@ export async function GET() {
export async function POST(request) {
try {
const { baseUrl, apiKey, models } = await request.json();
const { baseUrl, apiKey, models, plugins } = await request.json();
if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
}
if (isLocalhostUrl(baseUrl)) {
return NextResponse.json({
error: "Claude Cowork sandbox cannot reach localhost. Enable Tunnel/Cloud Endpoint or use Tailscale/VPS.",
}, { 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 });
}
const pluginsArray = Array.isArray(plugins) ? plugins.filter((p) => typeof p === "string") : [];
const managedMcpServers = buildManagedMcpServers(pluginsArray);
const bootstrapped = await bootstrapDeploymentMode();
const meta = await ensureMeta();
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
@@ -208,10 +317,21 @@ export async function POST(request) {
inferenceGatewayBaseUrl: baseUrl,
inferenceGatewayApiKey: apiKey,
inferenceModels: modelsArray.map((name) => ({ name })),
isLocalDevMcpEnabled: true,
isDesktopExtensionEnabled: true,
};
if (managedMcpServers.length > 0) {
newConfig.managedMcpServers = managedMcpServers;
}
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
// Plugin folder mount (best-effort, doesn't fail the request)
const pluginsResult = await writeOrgPluginsFolder(pluginsArray);
// Auto-skip approvals for managed servers
let skipResult = null;
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
return NextResponse.json({
success: true,
bootstrapped,
@@ -219,6 +339,8 @@ export async function POST(request) {
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
: "Cowork settings applied. Quit & reopen Claude Desktop.",
configPath,
plugins: pluginsResult,
skipApprovals: skipResult,
});
} catch (error) {
console.log("Error applying cowork settings:", error);
@@ -238,6 +360,8 @@ export async function DELETE() {
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
await writeOrgPluginsFolder([]);
try { await writeSkipApprovals([]); } catch { /* ignore */ }
return NextResponse.json({ success: true, message: "Cowork config reset" });
} catch (error) {
console.log("Error resetting cowork settings:", error);

View File

@@ -0,0 +1,68 @@
import { AI_PROVIDERS } from "@/shared/constants/providers";
// Provider → internal voices API. Edge/local-device share the generic endpoint.
const PROVIDER_API = {
elevenlabs: (origin) => `${origin}/api/media-providers/tts/elevenlabs/voices`,
deepgram: (origin) => `${origin}/api/media-providers/tts/deepgram/voices`,
inworld: (origin) => `${origin}/api/media-providers/tts/inworld/voices`,
"edge-tts": (origin) => `${origin}/api/media-providers/tts/voices?provider=edge-tts`,
"local-device": (origin) => `${origin}/api/media-providers/tts/voices?provider=local-device`,
};
export async function OPTIONS() {
return new Response(null, {
headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" },
});
}
// GET /v1/audio/voices?provider={p}[&lang=xx]
// Returns OpenAI-style list with each voice's full model id ready for /v1/audio/speech
export async function GET(request) {
try {
const { searchParams, origin } = new URL(request.url);
const provider = searchParams.get("provider");
const lang = searchParams.get("lang");
if (!provider || !PROVIDER_API[provider]) {
return Response.json(
{ error: { message: `provider must be one of: ${Object.keys(PROVIDER_API).join(", ")}`, type: "invalid_request_error" } },
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
const baseUrl = PROVIDER_API[provider](origin);
const url = lang ? `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}lang=${encodeURIComponent(lang)}` : baseUrl;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok || data.error) {
return Response.json(
{ error: { message: data.error || `Upstream ${res.status}`, type: "server_error" } },
{ status: res.status, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
// Internal API shape: { voices } when lang filter, else { byLang, languages }
const rawVoices = lang
? (data.voices || [])
: Object.values(data.byLang || {}).flatMap((l) => l.voices || []);
// Use provider alias for /v1/audio/speech model param (matches skill convention e.g. el/, dg/, edge-tts/)
const alias = AI_PROVIDERS[provider]?.alias || provider;
const data_out = rawVoices.map((v) => ({
id: v.id,
name: v.name,
lang: v.lang || "",
gender: v.gender || "",
model: `${alias}/${v.id}`,
}));
return Response.json({ object: "list", data: data_out }, {
headers: { "Access-Control-Allow-Origin": "*" },
});
} catch (err) {
return Response.json(
{ error: { message: err.message || "Failed", type: "server_error" } },
{ status: 502, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
}

View File

@@ -0,0 +1,110 @@
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
const KIND_ENDPOINT = {
llm: "/v1/chat/completions",
image: "/v1/images/generations",
tts: "/v1/audio/speech",
stt: "/v1/audio/transcriptions",
embedding: "/v1/embeddings",
imageToText: "/v1/chat/completions",
webSearch: "/v1/search",
webFetch: "/v1/fetch",
};
const TTS_VOICES_API = new Set(["elevenlabs", "edge-tts", "deepgram", "inworld", "local-device"]);
function buildInfo({ alias, providerId, model, kind, providerInfo }) {
const out = {
id: `${alias}/${model.id}`,
name: model.name || model.id,
kind,
owned_by: alias,
endpoint: KIND_ENDPOINT[kind] || null,
};
if (model.params) out.params = model.params;
if (model.capabilities) out.capabilities = model.capabilities;
if (model.options) out.options = model.options;
if (model.dimensions) out.dimensions = model.dimensions;
if (model.contextWindow) out.contextWindow = model.contextWindow;
if (kind === "tts" && TTS_VOICES_API.has(providerId)) {
out.voicesUrl = `/v1/audio/voices?provider=${providerId}`;
}
if (kind === "webSearch" && providerInfo?.searchConfig) {
const cfg = providerInfo.searchConfig;
if (cfg.searchTypes) out.searchTypes = cfg.searchTypes;
if (cfg.maxMaxResults) out.maxResults = cfg.maxMaxResults;
if (cfg.requiredOptions) out.required = cfg.requiredOptions;
}
return out;
}
// id format: "{alias}/{modelId}" - alias may also be providerId
function lookup(fullId) {
if (!fullId || !fullId.includes("/")) return null;
const slash = fullId.indexOf("/");
const alias = fullId.slice(0, slash);
const modelId = fullId.slice(slash + 1);
const providerId = ALIAS_TO_ID[alias] || alias;
const providerInfo = AI_PROVIDERS[providerId];
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
const m = list.find((x) => x.id === modelId);
if (m) {
const kind = m.type || "llm";
return buildInfo({ alias, providerId, model: m, kind, providerInfo });
}
// Sub-configs (TTS/STT/embedding only-in-config)
const subs = [
["tts", providerInfo?.ttsConfig],
["stt", providerInfo?.sttConfig],
["embedding", providerInfo?.embeddingConfig],
];
for (const [kind, cfg] of subs) {
const sm = cfg?.models?.find((x) => x.id === modelId);
if (sm) return buildInfo({ alias, providerId, model: sm, kind, providerInfo });
}
// Web search/fetch — virtual model id "search" / "fetch"
if (modelId === "search" && providerInfo?.searchConfig) {
return buildInfo({
alias, providerId, kind: "webSearch", providerInfo,
model: { id: "search", name: `${providerInfo.name} Search`, params: ["query", "max_results", "country", "language", "time_range", "domain_filter", "search_type"] },
});
}
if (modelId === "fetch" && providerInfo?.fetchConfig) {
return buildInfo({
alias, providerId, kind: "webFetch", providerInfo,
model: { id: "fetch", name: `${providerInfo.name} Fetch`, params: ["url", "format", "max_characters"] },
});
}
return null;
}
export async function OPTIONS() {
return new Response(null, {
headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" },
});
}
// GET /v1/models/info?id={alias}/{modelId} — metadata for a single model
export async function GET(request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return Response.json(
{ error: { message: "Missing required query param: id (e.g. ?id=openai/dall-e-3)", type: "invalid_request_error" } },
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
const info = lookup(id);
if (!info) {
return Response.json(
{ error: { message: `Model not found: ${id}`, type: "not_found" } },
{ status: 404, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
return Response.json(info, { headers: { "Access-Control-Allow-Origin": "*" } });
}

View File

@@ -6,6 +6,7 @@ import {
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
import { getDisabledModels } from "@/lib/disabledModelsDb";
const parseOpenAIStyleModels = (data) => {
if (Array.isArray(data)) return data;
@@ -151,6 +152,14 @@ export async function buildModelsList(kindFilter) {
console.log("Could not fetch model aliases");
}
let disabledByAlias = {};
try {
disabledByAlias = await getDisabledModels();
} catch (e) {
console.log("Could not fetch disabled models");
}
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId);
const activeConnectionByProvider = new Map();
for (const conn of connections) {
if (!activeConnectionByProvider.has(conn.provider)) {
@@ -159,7 +168,6 @@ export async function buildModelsList(kindFilter) {
}
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
for (const combo of combos) {
@@ -167,7 +175,6 @@ export async function buildModelsList(kindFilter) {
const entry = {
id: combo.name,
object: "model",
created: timestamp,
owned_by: "combo",
};
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
@@ -186,10 +193,10 @@ export async function buildModelsList(kindFilter) {
if (!providerMatchesKinds(providerId, kindFilter)) continue;
for (const model of providerModels) {
if (!kindFilter.includes(modelKind(model))) continue;
if (isDisabled(alias, model.id)) continue;
models.push({
id: `${alias}/${model.id}`,
object: "model",
created: timestamp,
owned_by: alias,
});
}
@@ -208,7 +215,6 @@ export async function buildModelsList(kindFilter) {
models.push({
id: `${providerAlias}/${modelId}`,
object: "model",
created: timestamp,
owned_by: providerAlias,
});
}
@@ -301,11 +307,11 @@ export async function buildModelsList(kindFilter) {
// Resolve kind: prefer static metadata, otherwise infer from ID heuristics
const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
if (!kindFilter.includes(kind)) continue;
if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue;
models.push({
id: `${outputAlias}/${modelId}`,
object: "model",
created: timestamp,
owned_by: outputAlias,
});
}
@@ -324,10 +330,10 @@ export async function buildModelsList(kindFilter) {
}
}
for (const subId of subConfigModels) {
if (isDisabled(outputAlias, subId) || isDisabled(staticAlias, subId)) continue;
models.push({
id: `${outputAlias}/${subId}`,
object: "model",
created: timestamp,
owned_by: outputAlias,
});
}
@@ -338,7 +344,6 @@ export async function buildModelsList(kindFilter) {
id: `${outputAlias}/search`,
object: "model",
kind: "webSearch",
created: timestamp,
owned_by: outputAlias,
});
}
@@ -347,7 +352,6 @@ export async function buildModelsList(kindFilter) {
id: `${outputAlias}/fetch`,
object: "model",
kind: "webFetch",
created: timestamp,
owned_by: outputAlias,
});
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState, useEffect } from "react";
import Modal from "./Modal";
import Input from "./Input";
import Button from "./Button";
import ModelSelectModal from "./ModelSelectModal";
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
// Inline editable model item
function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(model);
const commit = () => {
const trimmed = draft.trim();
if (trimmed && trimmed !== model) onEdit(trimmed);
else setDraft(model);
setEditing(false);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") { setDraft(model); setEditing(false); }
};
return (
<div className="group flex min-w-0 items-center gap-1.5 rounded-md bg-black/[0.02] px-2 py-1 transition-colors hover:bg-black/[0.04] dark:bg-white/[0.02] dark:hover:bg-white/[0.04]">
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span>
{editing ? (
<input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={handleKeyDown}
className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" />
) : (
<div className="min-w-0 flex-1 cursor-text truncate rounded px-1.5 py-0.5 font-mono text-xs text-text-main hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => setEditing(true)} title="Click to edit">{model}</div>
)}
<div className="flex shrink-0 items-center gap-0.5">
<button onClick={onMoveUp} disabled={isFirst}
className={`p-0.5 rounded ${isFirst ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`} title="Move up">
<span className="material-symbols-outlined text-[12px]">arrow_upward</span>
</button>
<button onClick={onMoveDown} disabled={isLast}
className={`p-0.5 rounded ${isLast ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`} title="Move down">
<span className="material-symbols-outlined text-[12px]">arrow_downward</span>
</button>
</div>
<button onClick={onRemove} className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 transition-all" title="Remove">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
);
}
// Reusable Combo create/edit modal. forcePrefix auto-prepends to name.
export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null, forcePrefix = "", title }) {
// Strip prefix when editing existing combo so user only edits suffix
const initialName = combo?.name
? (forcePrefix && combo.name.startsWith(forcePrefix) ? combo.name.slice(forcePrefix.length) : combo.name)
: "";
const [name, setName] = useState(initialName);
const [models, setModels] = useState(combo?.models || []);
const [showModelSelect, setShowModelSelect] = useState(false);
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
const [modelAliases, setModelAliases] = useState({});
useEffect(() => {
if (!isOpen) return;
fetch("/api/models/alias").then((r) => r.ok ? r.json() : null).then((d) => d && setModelAliases(d.aliases || {})).catch(() => {});
}, [isOpen]);
const validateName = (value) => {
if (!value.trim()) { setNameError("Name is required"); return false; }
const full = forcePrefix + value;
if (!VALID_NAME_REGEX.test(full)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; }
setNameError("");
return true;
};
const handleNameChange = (e) => {
let value = e.target.value;
// If user types prefix manually, strip it (we always prepend)
if (forcePrefix && value.startsWith(forcePrefix)) value = value.slice(forcePrefix.length);
setName(value);
if (value) validateName(value); else setNameError("");
};
const handleAddModel = (model) => {
if (!models.includes(model.value)) setModels([...models, model.value]);
};
const handleRemoveModel = (i) => setModels(models.filter((_, idx) => idx !== i));
const handleMoveUp = (i) => {
if (i === 0) return;
const a = [...models]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; setModels(a);
};
const handleMoveDown = (i) => {
if (i === models.length - 1) return;
const a = [...models]; [a[i], a[i + 1]] = [a[i + 1], a[i]]; setModels(a);
};
const handleSave = async () => {
if (!validateName(name)) return;
setSaving(true);
await onSave({ name: forcePrefix + name.trim(), models });
setSaving(false);
};
const isEdit = !!combo;
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={title || (isEdit ? "Edit Combo" : "Create Combo")}>
<div className="flex flex-col gap-3">
<div>
{forcePrefix ? (
<>
<label className="text-sm font-medium mb-1 block">Combo Name</label>
<div className="flex items-stretch">
<span className="inline-flex items-center px-2 rounded-l border border-r-0 border-black/10 dark:border-white/10 bg-black/[0.04] dark:bg-white/[0.04] text-text-muted font-mono text-sm">{forcePrefix}</span>
<input value={name} onChange={handleNameChange} placeholder="my-combo"
className="flex-1 min-w-0 rounded-r border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 px-2 py-1.5 font-mono text-sm outline-none focus:border-primary" />
</div>
{nameError && <p className="text-[11px] text-red-500 mt-0.5">{nameError}</p>}
</>
) : (
<Input label="Combo Name" value={name} onChange={handleNameChange} placeholder="my-combo" error={nameError} />
)}
<p className="text-[10px] text-text-muted mt-0.5">
{forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed
</p>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Models</label>
{models.length === 0 ? (
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span>
<p className="text-xs text-text-muted">No models added yet</p>
</div>
) : (
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]">
{models.map((model, index) => (
<ModelItem key={index} index={index} model={model}
isFirst={index === 0} isLast={index === models.length - 1}
onEdit={(v) => { const a = [...models]; a[index] = v; setModels(a); }}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onRemove={() => handleRemoveModel(index)} />
))}
</div>
)}
<button onClick={() => setShowModelSelect(true)}
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-primary font-medium hover:text-primary hover:border-primary/50 transition-colors flex items-center justify-center gap-1">
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
</button>
</div>
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">Cancel</Button>
<Button onClick={handleSave} fullWidth size="sm" disabled={!name.trim() || !!nameError || saving}>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
</Modal>
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel} activeProviders={activeProviders} modelAliases={modelAliases}
title="Add Model to Combo" kindFilter={kindFilter} />
</>
);
}

View File

@@ -16,6 +16,7 @@ export { default as Footer } from "./Footer";
export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as ComboFormModal } from "./ComboFormModal";
export { default as UsageStats } from "./UsageStats";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as NineRemoteButton } from "./NineRemoteButton";

View File

@@ -0,0 +1,955 @@
// Cowork plugins extracted from anthropics/knowledge-work-plugins marketplace.
// Used to inject managedMcpServers into Claude Cowork (3p mode) configLibrary entries.
const COWORK_PLUGINS = [
{
"name": "tavily",
"description": "Tavily - Real-time web search API optimized for LLM agents. Search and extract content from the web.",
"servers": [
{
"key": "tavily",
"url": "https://mcp.tavily.com/mcp",
"type": "http"
}
]
},
{
"name": "lseg",
"description": "Price bonds, analyze yield curves, evaluate FX carry trades, value options, and build macro dashboards using LSEG financial data and analytics.",
"servers": [
{
"key": "lseg",
"url": "https://api.analytics.lseg.com/lfa/mcp",
"type": "http"
}
]
},
{
"name": "sp-global",
"description": "S&P Global - Financial data and analytics skills including company tearsheets, earnings previews, and transaction summaries",
"servers": [
{
"key": "spglobal",
"url": "https://kfinance.kensho.com/integrations/mcp",
"type": "http"
}
]
},
{
"name": "adobe-for-creativity",
"description": "Brings together Adobe Creative Cloud tools for images, vectors, design, and video. Edit multiple assets at once, adapt for different platforms, and complete multi-step creative workflows for polished ",
"servers": [
{
"key": "Adobe for creativity",
"url": "https://adobe-creativity.adobe.io/mcp",
"type": "http"
}
]
},
{
"name": "figma",
"description": "Figma design platform integration. Access design files, extract component information, read design tokens, and translate designs into code. Bridge the gap between design and development workflows.",
"servers": [
{
"key": "figma",
"url": "https://mcp.figma.com/mcp",
"type": "http"
}
]
},
{
"name": "atlan",
"description": "Atlan data catalog plugin for Claude Code. Search, explore, govern, and manage your data assets through natural language. Powered by the Atlan MCP server with semantic search, lineage traversal, gloss",
"servers": [
{
"key": "atlan",
"url": "https://mcp.atlan.com/mcp",
"type": "http"
}
]
},
{
"name": "cloudinary",
"description": "Use Cloudinary directly in Claude. Manage assets, apply transformations, optimize media, and more through natural conversation.",
"servers": [
{
"key": "cloudinary-asset-mgmt",
"url": "https://asset-management.mcp.cloudinary.com/mcp",
"type": "http"
},
{
"key": "cloudinary-env-config",
"url": "https://environment-config.mcp.cloudinary.com/mcp",
"type": "http"
},
{
"key": "cloudinary-smd",
"url": "https://structured-metadata.mcp.cloudinary.com/mcp",
"type": "http"
},
{
"key": "cloudinary-analysis",
"url": "https://analysis.mcp.cloudinary.com/sse",
"type": "http"
},
{
"key": "cloudinary-mediaflows",
"url": "https://mediaflows.mcp.cloudinary.com/v2/mcp",
"type": "http"
}
]
},
{
"name": "prisma",
"description": "Prisma MCP integration for Postgres database management, schema migrations, SQL queries, and connection string management. Provision Prisma Postgres databases, run migrations, and interact with your d",
"servers": [
{
"key": "Prisma-Remote",
"url": "https://mcp.prisma.io/mcp",
"type": "http"
}
]
},
{
"name": "cockroachdb",
"description": "CockroachDB plugin for Claude Code — explore schemas, write optimized SQL, debug queries, and manage distributed database clusters directly from your AI coding agent.",
"servers": [
{
"key": "cockroachdb-toolbox-http",
"url": "http://127.0.0.1:5000/mcp",
"type": "http"
},
{
"key": "cockroachdb-cloud",
"url": "https://cockroachlabs.cloud/mcp",
"type": "http"
}
]
},
{
"name": "daloopa",
"description": "Financial analysis skills powered by Daloopa's institutional-grade data",
"servers": [
{
"key": "daloopa",
"url": "https://mcp.daloopa.com/server/mcp",
"type": "http"
},
{
"key": "daloopa-docs",
"url": "https://docs.daloopa.com/mcp",
"type": "http"
}
]
},
{
"name": "intercom",
"description": "Intercom integration for Claude Code. Search conversations, analyze customer support patterns, look up contacts and companies, and install the Intercom Messenger. Connect your Intercom workspace to ge",
"servers": [
{
"key": "intercom",
"url": "https://mcp.intercom.com/mcp",
"type": "http"
}
]
},
{
"name": "zoominfo",
"description": "Search companies and contacts, enrich leads, find lookalikes, and get AI-ranked contact recommendations. Pre-built skills chain multiple ZoomInfo tools into complete B2B sales workflows.",
"servers": [
{
"key": "zoominfo",
"url": "https://mcp.zoominfo.com/mcp",
"type": "http"
}
]
},
{
"name": "sanity-plugin",
"description": "Sanity content platform integration with MCP server, agent skills, and slash commands. Query and author content, build and optimize GROQ queries, design schemas, and set up Visual Editing.",
"servers": [
{
"key": "Sanity",
"url": "https://mcp.sanity.io",
"type": "http"
}
]
},
{
"name": "adspirer-ads-agent",
"description": "Cross-platform ad management for Google Ads, Meta Ads, TikTok Ads, and LinkedIn Ads. 91 tools for keyword research, campaign creation, performance analysis, and budget optimization.",
"servers": [
{
"key": "adspirer",
"url": "https://mcp.adspirer.com/mcp",
"type": "http"
}
]
},
{
"name": "planetscale",
"description": "An authenticated hosted MCP server that accesses your PlanetScale organizations, databases, branches, schema, and Insights data. Query against your data, surface slow queries, and get organizational a",
"servers": [
{
"key": "planetscale",
"url": "https://mcp.pscale.dev/mcp/planetscale",
"type": "http"
}
]
},
{
"name": "miro",
"description": "Secure access to Miro boards. Enables AI to read board context, create diagrams, and generate code with enterprise-grade security.",
"servers": [
{
"key": "miro",
"url": "https://mcp.miro.com/",
"type": "http"
}
]
},
{
"name": "zoom-plugin",
"description": "Plan, build, and debug Zoom integrations across REST APIs, Meeting SDK, Video SDK, webhooks, bots, and MCP workflows. Search meetings, retrieve recordings, access transcripts, and design AI-powered Zo",
"servers": [
{
"key": "zoom-mcp",
"url": "https://mcp-us.zoom.us/mcp/zoom/streamable",
"type": "http"
},
{
"key": "zoom-docs-mcp",
"url": "https://mcp.zoom.us/mcp/docs/streamable",
"type": "http"
},
{
"key": "zoom-whiteboard-mcp",
"url": "https://mcp-us.zoom.us/mcp/whiteboard/streamable",
"type": "http"
}
]
},
{
"name": "bigdata-com",
"description": "Official Bigdata.com plugin providing financial research, analytics, and intelligence tools powered by Bigdata MCP.",
"servers": [
{
"key": "bigdata.com",
"url": "https://mcp.bigdata.com",
"type": "http"
}
]
},
{
"name": "operations",
"description": "Optimize business operations — vendor management, process documentation, change management, capacity planning, and compliance tracking. Keep your organization running efficiently.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "asana",
"url": "https://mcp.asana.com/v2/mcp",
"type": "http"
},
{
"key": "servicenow",
"url": "https://mcp.servicenow.com/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
}
]
},
{
"name": "brand-voice",
"description": "Discover your brand voice from existing documents and conversations, generate enforceable guidelines, and validate AI-generated content against your established tone and positioning.",
"servers": [
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "box",
"url": "https://mcp.box.com",
"type": "http"
},
{
"key": "figma",
"url": "https://mcp.figma.com/mcp",
"type": "http"
},
{
"key": "gong",
"url": "https://mcp.gong.io/mcp",
"type": "http"
},
{
"key": "microsoft-365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
},
{
"key": "granola",
"url": "https://mcp.granola.ai/mcp",
"type": "http"
}
]
},
{
"name": "human-resources",
"description": "Streamline people operations — recruiting, onboarding, performance reviews, compensation analysis, and policy guidance. Maintain compliance and keep your team running smoothly.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
}
]
},
{
"name": "design",
"description": "Accelerate design workflows — critique, design system management, UX writing, accessibility audits, research synthesis, and dev handoff. From exploration to pixel-perfect specs.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "figma",
"url": "https://mcp.figma.com/mcp",
"type": "http"
},
{
"key": "linear",
"url": "https://mcp.linear.app/mcp",
"type": "http"
},
{
"key": "asana",
"url": "https://mcp.asana.com/v2/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "intercom",
"url": "https://mcp.intercom.com/mcp",
"type": "http"
}
]
},
{
"name": "engineering",
"description": "Streamline engineering workflows — standups, code review, architecture decisions, incident response, and technical documentation. Works with your existing tools or standalone.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "linear",
"url": "https://mcp.linear.app/mcp",
"type": "http"
},
{
"key": "asana",
"url": "https://mcp.asana.com/v2/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "github",
"url": "https://api.githubcopilot.com/mcp/",
"type": "http"
},
{
"key": "pagerduty",
"url": "https://mcp.pagerduty.com/mcp",
"type": "http"
},
{
"key": "datadog",
"url": "https://mcp.datadoghq.com/mcp",
"type": "http"
}
]
},
{
"name": "common-room",
"description": "Turn Common Room into your GTM copilot. Research accounts and contacts, prep for calls with attendee profiles and talking points, and draft personalized outreach across email, LinkedIn, and phone.",
"servers": [
{
"key": "common-room",
"url": "https://mcp.commonroom.io/mcp",
"type": "http"
}
]
},
{
"name": "apollo",
"description": "Prospect, enrich leads, and load outreach sequences with Apollo.io — one-click MCP server integration for Claude Code and Cowork.",
"servers": [
{
"key": "apollo",
"url": "https://mcp.apollo.io/mcp",
"type": "http"
}
]
},
{
"name": "slack-by-salesforce",
"description": "Slack integration for searching messages, sending communications, managing canvases, and more",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
}
]
},
{
"name": "bio-research",
"description": "Connect to preclinical research tools and databases (literature search, genomics analysis, target prioritization) to accelerate early-stage life sciences R&D",
"servers": [
{
"key": "pubmed",
"url": "https://pubmed.mcp.claude.com/mcp",
"type": "http"
},
{
"key": "biorender",
"url": "https://mcp.services.biorender.com/mcp",
"type": "http"
},
{
"key": "biorxiv",
"url": "https://mcp.deepsense.ai/biorxiv/mcp",
"type": "http"
},
{
"key": "c-trials",
"url": "https://mcp.deepsense.ai/clinical_trials/mcp",
"type": "http"
},
{
"key": "chembl",
"url": "https://mcp.deepsense.ai/chembl/mcp",
"type": "http"
},
{
"key": "synapse",
"url": "https://mcp.synapse.org/mcp",
"type": "http"
},
{
"key": "wiley",
"url": "https://connector.scholargateway.ai/mcp",
"type": "http"
},
{
"key": "owkin",
"url": "https://mcp.k.owkin.com/mcp",
"type": "http"
},
{
"key": "ot",
"url": "https://mcp.platform.opentargets.org/mcp",
"type": "http"
}
]
},
{
"name": "sales",
"description": "Prospect, craft outreach, and build deal strategy faster. Prep for calls, manage your pipeline, and write personalized messaging that moves deals forward.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "hubspot",
"url": "https://mcp.hubspot.com/anthropic",
"type": "http"
},
{
"key": "close",
"url": "https://mcp.close.com/mcp",
"type": "http"
},
{
"key": "clay",
"url": "https://api.clay.com/v3/mcp",
"type": "http"
},
{
"key": "zoominfo",
"url": "https://mcp.zoominfo.com/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "fireflies",
"url": "https://api.fireflies.ai/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
},
{
"key": "apollo",
"url": "https://api.apollo.io/mcp",
"type": "http"
},
{
"key": "outreach",
"url": "https://mcp.outreach.io/mcp",
"type": "http"
},
{
"key": "similarweb",
"url": "https://mcp.similarweb.com/mcp",
"type": "http"
}
]
},
{
"name": "legal",
"description": "Speed up contract review, NDA triage, and compliance workflows for in-house legal teams. Draft legal briefs, organize precedent research, and manage institutional knowledge.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "box",
"url": "https://mcp.box.com",
"type": "http"
},
{
"key": "egnyte",
"url": "https://mcp-server.egnyte.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
},
{
"key": "docusign",
"url": "https://mcp.docusign.com/mcp",
"type": "http"
}
]
},
{
"name": "product-management",
"description": "Write feature specs, plan roadmaps, and synthesize user research faster. Keep stakeholders updated and stay ahead of the competitive landscape.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "linear",
"url": "https://mcp.linear.app/mcp",
"type": "http"
},
{
"key": "asana",
"url": "https://mcp.asana.com/v2/mcp",
"type": "http"
},
{
"key": "monday",
"url": "https://mcp.monday.com/mcp",
"type": "http"
},
{
"key": "clickup",
"url": "https://mcp.clickup.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "figma",
"url": "https://mcp.figma.com/mcp",
"type": "http"
},
{
"key": "amplitude",
"url": "https://mcp.amplitude.com/mcp",
"type": "http"
},
{
"key": "amplitude-eu",
"url": "https://mcp.eu.amplitude.com/mcp",
"type": "http"
},
{
"key": "pendo",
"url": "https://app.pendo.io/mcp/v0/shttp",
"type": "http"
},
{
"key": "intercom",
"url": "https://mcp.intercom.com/mcp",
"type": "http"
},
{
"key": "fireflies",
"url": "https://api.fireflies.ai/mcp",
"type": "http"
},
{
"key": "similarweb",
"url": "https://mcp.similarweb.com/mcp",
"type": "http"
}
]
},
{
"name": "productivity",
"description": "Manage tasks, plan your day, and build up memory of important context about your work. Syncs with your calendar, email, and chat to keep everything organized and on track.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "asana",
"url": "https://mcp.asana.com/v2/mcp",
"type": "http"
},
{
"key": "linear",
"url": "https://mcp.linear.app/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
},
{
"key": "monday",
"url": "https://mcp.monday.com/mcp",
"type": "http"
},
{
"key": "clickup",
"url": "https://mcp.clickup.com/mcp",
"type": "http"
}
]
},
{
"name": "marketing",
"description": "Create content, plan campaigns, and analyze performance across marketing channels. Maintain brand voice consistency, track competitors, and report on what's working.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "canva",
"url": "https://mcp.canva.com/mcp",
"type": "http"
},
{
"key": "figma",
"url": "https://mcp.figma.com/mcp",
"type": "http"
},
{
"key": "hubspot",
"url": "https://mcp.hubspot.com/anthropic",
"type": "http"
},
{
"key": "amplitude",
"url": "https://mcp.amplitude.com/mcp",
"type": "http"
},
{
"key": "amplitude-eu",
"url": "https://mcp.eu.amplitude.com/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "ahrefs",
"url": "https://api.ahrefs.com/mcp/mcp",
"type": "http"
},
{
"key": "similarweb",
"url": "https://mcp.similarweb.com",
"type": "http"
},
{
"key": "klaviyo",
"url": "https://mcp.klaviyo.com/mcp",
"type": "http"
},
{
"key": "supermetrics",
"url": "https://mcp.supermetrics.com/mcp",
"type": "http"
}
]
},
{
"name": "finance",
"description": "Streamline finance and accounting workflows, from journal entries and reconciliation to financial statements and variance analysis. Speed up audit prep, month-end close, and keeping your books clean.",
"servers": [
{
"key": "bigquery",
"url": "https://bigquery.googleapis.com/mcp",
"type": "http"
},
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
}
]
},
{
"name": "enterprise-search",
"description": "Search across all of your company's tools in one place. Find anything across email, chat, documents, and wikis without switching between apps.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "guru",
"url": "https://mcp.api.getguru.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "asana",
"url": "https://mcp.asana.com/v2/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
}
]
},
{
"name": "data",
"description": "Write SQL, explore datasets, and generate insights faster. Build visualizations and dashboards, and turn raw data into clear stories for stakeholders.",
"servers": [
{
"key": "bigquery",
"url": "https://bigquery.googleapis.com/mcp",
"type": "http"
},
{
"key": "hex",
"url": "https://app.hex.tech/mcp",
"type": "http"
},
{
"key": "amplitude",
"url": "https://mcp.amplitude.com/mcp",
"type": "http"
},
{
"key": "amplitude-eu",
"url": "https://mcp.eu.amplitude.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "definite",
"url": "https://api.definite.app/v3/mcp/http",
"type": "http"
}
]
},
{
"name": "customer-support",
"description": "Triage tickets, draft responses, escalate issues, and build your knowledge base. Research customer context and turn resolved issues into self-service content.",
"servers": [
{
"key": "slack",
"url": "https://mcp.slack.com/mcp",
"type": "http"
},
{
"key": "intercom",
"url": "https://mcp.intercom.com/mcp",
"type": "http"
},
{
"key": "hubspot",
"url": "https://mcp.hubspot.com/anthropic",
"type": "http"
},
{
"key": "guru",
"url": "https://mcp.api.getguru.com/mcp",
"type": "http"
},
{
"key": "atlassian",
"url": "https://mcp.atlassian.com/v1/mcp",
"type": "http"
},
{
"key": "notion",
"url": "https://mcp.notion.com/mcp",
"type": "http"
},
{
"key": "ms365",
"url": "https://microsoft365.mcp.claude.com/mcp",
"type": "http"
}
]
}
];
// Build managedMcpServers ARRAY (Anthropic schema) from selected plugin names.
// Schema: [{name, url, transport: "http"|"sse", oauth?: true}]
// Most enterprise SaaS MCPs require OAuth → enable PKCE auto-flow.
function buildManagedMcpServers(selectedPluginNames) {
const set = new Set(selectedPluginNames || []);
const out = [];
for (const p of COWORK_PLUGINS) {
if (!set.has(p.name)) continue;
for (const s of p.servers) {
const name = p.servers.length === 1 ? p.name : `${p.name}-${s.key}`;
const transport = /\/sse(\b|\/)/i.test(s.url) ? "sse" : "http";
out.push({ name, url: s.url, transport, oauth: true });
}
}
return out;
}
module.exports = { COWORK_PLUGINS, buildManagedMcpServers };

View File

@@ -41,6 +41,8 @@ const g = global.__appSingleton ??= {
lastNetworkFingerprint: null,
lastWatchdogTick: Date.now(),
mitmStartInProgress: false,
tunnelAutoResumed: false,
tailscaleAutoResumed: false,
};
export async function initializeApp() {
@@ -48,14 +50,16 @@ export async function initializeApp() {
await cleanupProviderConnections();
const settings = await getSettings();
// Auto-resume tunnel
if (settings.tunnelEnabled) {
// Auto-resume tunnel (once per process)
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
g.tunnelAutoResumed = true;
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
}
// Auto-resume tailscale
if (settings.tailscaleEnabled) {
// Auto-resume tailscale (once per process)
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
g.tailscaleAutoResumed = true;
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
}