mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- 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:
@@ -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
|
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
||||||
export function cleanJSONSchemaForAntigravity(schema) {
|
export function cleanJSONSchemaForAntigravity(schema) {
|
||||||
if (!schema || typeof schema !== "object") return schema;
|
if (!schema || typeof schema !== "object") return schema;
|
||||||
@@ -286,6 +293,9 @@ export function cleanJSONSchemaForAntigravity(schema) {
|
|||||||
flattenAnyOfOneOf(cleaned);
|
flattenAnyOfOneOf(cleaned);
|
||||||
flattenTypeArrays(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)
|
// Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays)
|
||||||
removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS);
|
removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS);
|
||||||
|
|
||||||
|
|||||||
@@ -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/chat/completions` — OpenAI format
|
||||||
- `POST $NINEROUTER_URL/v1/messages` — Anthropic format
|
- `POST $NINEROUTER_URL/v1/messages` — Anthropic format
|
||||||
|
|
||||||
## Discover models
|
## Discover
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl $NINEROUTER_URL/v1/models | jq '.data[].id'
|
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.
|
Combos (e.g. `vip`, `mycodex`) auto-fallback through multiple providers.
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
curl $NINEROUTER_URL/v1/models/embedding | jq '.data[].id'
|
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
|
## Endpoint
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
curl $NINEROUTER_URL/v1/models/image | jq '.data[].id'
|
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
|
## Endpoint
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
curl $NINEROUTER_URL/v1/models/stt | jq '.data[].id'
|
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`).
|
`model` = STT model ID (e.g. `openai/whisper-1`, `groq/whisper-large-v3`, `deepgram/nova-3`, `gemini/gemini-2.5-flash`).
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
|
# 1) List models
|
||||||
curl $NINEROUTER_URL/v1/models/tts | jq '.data[].id'
|
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
|
## Endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webFetch") | .id'
|
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.
|
IDs end in `/fetch` (e.g. `firecrawl/fetch`, `jina/fetch`). `fetch-combo` chains providers with auto-fallback.
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webSearch") | .id'
|
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.
|
IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain providers with auto-fallback.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { APP_CONFIG } from "@/shared/constants/config";
|
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||||
|
|
||||||
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
||||||
const CUSTOM_VALUE = "__custom__";
|
const CUSTOM_VALUE = "__custom__";
|
||||||
@@ -13,8 +13,6 @@ const ensureV1 = (url) => {
|
|||||||
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
|
|
||||||
|
|
||||||
const readSavedPresets = () => {
|
const readSavedPresets = () => {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
@@ -31,21 +29,27 @@ const writeSavedPresets = (presets) => {
|
|||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build endpoint options ordered by priority
|
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
|
||||||
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }) => {
|
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const wrap = (url) => (withV1 ? ensureV1(url) : url.replace(/\/+$/, ""));
|
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
|
||||||
if (!requiresExternalUrl) {
|
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) {
|
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) {
|
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) => {
|
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: "" });
|
opts.push({ value: CUSTOM_VALUE, label: "Custom URL...", url: "" });
|
||||||
return opts;
|
return opts;
|
||||||
@@ -59,32 +63,37 @@ export default function BaseUrlSelect({
|
|||||||
tunnelPublicUrl = "",
|
tunnelPublicUrl = "",
|
||||||
tailscaleEnabled = false,
|
tailscaleEnabled = false,
|
||||||
tailscaleUrl = "",
|
tailscaleUrl = "",
|
||||||
|
cloudEnabled = false,
|
||||||
|
cloudUrl = "",
|
||||||
withV1 = true,
|
withV1 = true,
|
||||||
}) {
|
}) {
|
||||||
const [savedPresets, setSavedPresets] = useState([]);
|
const [savedPresets, setSavedPresets] = useState([]);
|
||||||
const [mode, setMode] = useState("");
|
const [mode, setMode] = useState("");
|
||||||
|
const [customInput, setCustomInput] = useState("");
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSavedPresets(readSavedPresets());
|
setSavedPresets(readSavedPresets());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }),
|
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
|
||||||
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, 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(() => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (initializedRef.current) return;
|
||||||
if (options[0] && options[0].value !== CUSTOM_VALUE) {
|
if (options.length === 0) return;
|
||||||
setMode(options[0].value);
|
initializedRef.current = true;
|
||||||
onChange(options[0].url);
|
const first = options.find((o) => o.value !== CUSTOM_VALUE);
|
||||||
|
if (first) {
|
||||||
|
setMode(first.value);
|
||||||
|
onChange(first.url);
|
||||||
|
} else {
|
||||||
|
setMode(CUSTOM_VALUE);
|
||||||
}
|
}
|
||||||
return;
|
}, [options, onChange]);
|
||||||
}
|
|
||||||
const match = options.find((o) => o.url && o.url === value);
|
|
||||||
setMode(match ? match.value : CUSTOM_VALUE);
|
|
||||||
}, [value, options]);
|
|
||||||
|
|
||||||
const handleSelect = (e) => {
|
const handleSelect = (e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
@@ -95,14 +104,15 @@ export default function BaseUrlSelect({
|
|||||||
try { defaultName = new URL(trimmed).host; } catch {}
|
try { defaultName = new URL(trimmed).host; } catch {}
|
||||||
const name = window.prompt("Save endpoint as:", defaultName);
|
const name = window.prompt("Save endpoint as:", defaultName);
|
||||||
if (!name?.trim()) return;
|
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));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setSavedPresets(next);
|
setSavedPresets(updated);
|
||||||
writeSavedPresets(next);
|
writeSavedPresets(updated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMode(next);
|
setMode(next);
|
||||||
if (next === CUSTOM_VALUE) {
|
if (next === CUSTOM_VALUE) {
|
||||||
|
setCustomInput("");
|
||||||
onChange("");
|
onChange("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -110,18 +120,26 @@ export default function BaseUrlSelect({
|
|||||||
if (opt) onChange(opt.url);
|
if (opt) onChange(opt.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCustomInput = (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setCustomInput(v);
|
||||||
|
onChange(v);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteSaved = () => {
|
const handleDeleteSaved = () => {
|
||||||
if (!mode.startsWith("saved:")) return;
|
if (!mode.startsWith("saved:")) return;
|
||||||
const name = mode.slice(6);
|
const name = mode.slice(6);
|
||||||
const next = savedPresets.filter((p) => p.name !== name);
|
const updated = savedPresets.filter((p) => p.name !== name);
|
||||||
setSavedPresets(next);
|
setSavedPresets(updated);
|
||||||
writeSavedPresets(next);
|
writeSavedPresets(updated);
|
||||||
setMode(CUSTOM_VALUE);
|
setMode(CUSTOM_VALUE);
|
||||||
|
setCustomInput("");
|
||||||
|
onChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSaved = mode.startsWith("saved:");
|
const isSaved = mode.startsWith("saved:");
|
||||||
const isCustom = mode === CUSTOM_VALUE;
|
const isCustom = mode === CUSTOM_VALUE;
|
||||||
const canSave = isCustom && (value || "").trim().length > 0;
|
const canSave = isCustom && (customInput || "").trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -145,8 +163,8 @@ export default function BaseUrlSelect({
|
|||||||
{isCustom && (
|
{isCustom && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ""}
|
value={customInput}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={handleCustomInput}
|
||||||
placeholder={withV1 ? "https://example.com/v1" : "https://example.com"}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||||
|
|
||||||
@@ -43,10 +44,7 @@ export default function ClaudeToolCard({
|
|||||||
if (!claudeStatus?.installed) return null;
|
if (!claudeStatus?.installed) return null;
|
||||||
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
|
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
|
||||||
if (!currentUrl) return "not_configured";
|
if (!currentUrl) return "not_configured";
|
||||||
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
|
if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured";
|
||||||
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
|
|
||||||
const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl);
|
|
||||||
if (localMatch || cloudMatch || tunnelMatch) return "configured";
|
|
||||||
return "other";
|
return "other";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -296,20 +294,9 @@ export default function ClaudeToolCard({
|
|||||||
{!checkingClaude && claudeStatus?.installed && (
|
{!checkingClaude && claudeStatus?.installed && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Current Base URL */}
|
{/* Endpoint (selector) */}
|
||||||
{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 */}
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm: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">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>
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
<BaseUrlSelect
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || getDisplayUrl()}
|
value={customBaseUrl || getDisplayUrl()}
|
||||||
@@ -322,6 +309,17 @@ export default function ClaudeToolCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
<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="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||||
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
|
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
|
||||||
@@ -64,8 +65,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
|||||||
const getConfigStatus = () => {
|
const getConfigStatus = () => {
|
||||||
if (!codexStatus?.installed) return null;
|
if (!codexStatus?.installed) return null;
|
||||||
if (!codexStatus.config) return "not_configured";
|
if (!codexStatus.config) return "not_configured";
|
||||||
const hasBaseUrl = codexStatus.config.includes(baseUrl) || codexStatus.config.includes("localhost") || codexStatus.config.includes("127.0.0.1");
|
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||||
return hasBaseUrl ? "configured" : "other";
|
const currentUrl = parsed ? parsed[1] : "";
|
||||||
|
return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||||
};
|
};
|
||||||
|
|
||||||
const configStatus = getConfigStatus();
|
const configStatus = getConfigStatus();
|
||||||
@@ -266,7 +268,22 @@ model = "${effectiveSubagentModel}"
|
|||||||
{!checkingCodex && codexStatus?.installed && (
|
{!checkingCodex && codexStatus?.installed && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<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 && (() => {
|
{codexStatus?.config && (() => {
|
||||||
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||||
const currentBaseUrl = parsed ? parsed[1] : null;
|
const currentBaseUrl = parsed ? parsed[1] : null;
|
||||||
@@ -281,21 +298,6 @@ model = "${effectiveSubagentModel}"
|
|||||||
) : null;
|
) : 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 */}
|
{/* API Key */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
<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="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||||
const [status, setStatus] = useState(initialStatus || null);
|
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) return null;
|
||||||
if (!status.has9Router) return "not_configured";
|
if (!status.has9Router) return "not_configured";
|
||||||
const url = status.currentUrl || "";
|
const url = status.currentUrl || "";
|
||||||
return url.includes("localhost") || url.includes("127.0.0.1") || url.includes(baseUrl)
|
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||||
? "configured" : "other";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configStatus = getConfigStatus();
|
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-3">
|
||||||
<div className="flex flex-col gap-1">
|
<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
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||||
onChange={setCustomBaseUrl}
|
onChange={setCustomBaseUrl}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ManualConfigModal, ComboFormModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
|
||||||
const ENDPOINT = "/api/cli-tools/cowork-settings";
|
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 stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
|
||||||
const ensureV1 = (url) => {
|
const ensureV1 = (url) => {
|
||||||
const trimmed = (url || "").replace(/\/+$/, "");
|
const trimmed = (url || "").replace(/\/+$/, "");
|
||||||
@@ -38,26 +37,11 @@ export default function CoworkToolCard({
|
|||||||
const [message, setMessage] = useState(null);
|
const [message, setMessage] = useState(null);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||||
const [selectedModels, setSelectedModels] = useState([]);
|
const [selectedModels, setSelectedModels] = useState([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [modelAliases, setModelAliases] = useState({});
|
|
||||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||||
const [endpointMode, setEndpointMode] = useState("custom");
|
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||||
|
const [selectedPlugins, setSelectedPlugins] = useState([]);
|
||||||
const endpointOptions = useMemo(() => {
|
const [pluginsExpanded, setPluginsExpanded] = useState(false);
|
||||||
const opts = [];
|
const [comboModalOpen, setComboModalOpen] = useState(false);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||||
@@ -70,11 +54,7 @@ export default function CoworkToolCard({
|
|||||||
}, [initialStatus]);
|
}, [initialStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded && !status) {
|
if (isExpanded && !status) checkStatus();
|
||||||
checkStatus();
|
|
||||||
fetchModelAliases();
|
|
||||||
}
|
|
||||||
if (isExpanded) fetchModelAliases();
|
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -83,28 +63,12 @@ export default function CoworkToolCard({
|
|||||||
}
|
}
|
||||||
if (status?.cowork?.baseUrl && !customBaseUrl) {
|
if (status?.cowork?.baseUrl && !customBaseUrl) {
|
||||||
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
|
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
|
||||||
setEndpointMode("custom");
|
}
|
||||||
|
if (Array.isArray(status?.cowork?.selectedPlugins)) {
|
||||||
|
setSelectedPlugins(status.cowork.selectedPlugins);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [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 () => {
|
const checkStatus = async () => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
try {
|
try {
|
||||||
@@ -124,31 +88,16 @@ export default function CoworkToolCard({
|
|||||||
if (!status?.installed) return null;
|
if (!status?.installed) return null;
|
||||||
const url = status?.cowork?.baseUrl;
|
const url = status?.cowork?.baseUrl;
|
||||||
if (!url) return "not_configured";
|
if (!url) return "not_configured";
|
||||||
if (isLocalhostUrl(url)) return "invalid";
|
|
||||||
return status.has9Router ? "configured" : "other";
|
return status.has9Router ? "configured" : "other";
|
||||||
};
|
};
|
||||||
|
|
||||||
const configStatus = getConfigStatus();
|
const configStatus = getConfigStatus();
|
||||||
const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
|
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 () => {
|
const handleApply = async () => {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const effectiveUrl = getEffectiveBaseUrl();
|
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) {
|
if (selectedModels.length === 0) {
|
||||||
setMessage({ type: "error", text: "Please select at least one model" });
|
setMessage({ type: "error", text: "Please select at least one model" });
|
||||||
return;
|
return;
|
||||||
@@ -167,6 +116,7 @@ export default function CoworkToolCard({
|
|||||||
baseUrl: effectiveUrl,
|
baseUrl: effectiveUrl,
|
||||||
apiKey: keyToUse,
|
apiKey: keyToUse,
|
||||||
models: selectedModels,
|
models: selectedModels,
|
||||||
|
plugins: selectedPlugins,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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 () => {
|
const handleReset = async () => {
|
||||||
setRestoring(true);
|
setRestoring(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -234,7 +207,6 @@ export default function CoworkToolCard({
|
|||||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
<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 === "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 === "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>}
|
{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>
|
</div>
|
||||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||||
@@ -245,11 +217,6 @@ export default function CoworkToolCard({
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
<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 && (
|
{checking && (
|
||||||
<div className="flex items-center gap-2 text-text-muted">
|
<div className="flex items-center gap-2 text-text-muted">
|
||||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||||
@@ -278,6 +245,21 @@ export default function CoworkToolCard({
|
|||||||
{!checking && status?.installed && (
|
{!checking && status?.installed && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<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 && (
|
{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">
|
<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="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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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">
|
<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="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>
|
<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>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
@@ -370,32 +372,28 @@ export default function CoworkToolCard({
|
|||||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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
|
<ManualConfigModal
|
||||||
isOpen={showManualConfigModal}
|
isOpen={showManualConfigModal}
|
||||||
onClose={() => setShowManualConfigModal(false)}
|
onClose={() => setShowManualConfigModal(false)}
|
||||||
title="Claude Cowork - Manual Configuration"
|
title="Claude Cowork - Manual Configuration"
|
||||||
configs={getManualConfigs()}
|
configs={getManualConfigs()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ComboFormModal
|
||||||
|
isOpen={comboModalOpen}
|
||||||
|
combo={null}
|
||||||
|
onClose={() => setComboModalOpen(false)}
|
||||||
|
onSave={handleCreateCombo}
|
||||||
|
activeProviders={activeProviders}
|
||||||
|
forcePrefix="claude-"
|
||||||
|
title="Create Cowork Combo"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
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, ...)
|
// 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"));
|
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"));
|
||||||
if (!currentConfig) return "not_configured";
|
if (!currentConfig) return "not_configured";
|
||||||
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
|
return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other";
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configStatus = getConfigStatus();
|
const configStatus = getConfigStatus();
|
||||||
@@ -291,20 +288,9 @@ export default function DroidToolCard({
|
|||||||
{!checkingDroid && droidStatus?.installed && (
|
{!checkingDroid && droidStatus?.installed && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Current Base URL */}
|
{/* Endpoint (selector) */}
|
||||||
{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 */}
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm: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">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>
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
<BaseUrlSelect
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || getDisplayUrl()}
|
value={customBaseUrl || getDisplayUrl()}
|
||||||
@@ -317,6 +303,17 @@ export default function DroidToolCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
<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="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
const ENDPOINT = "/api/cli-tools/hermes-settings";
|
const ENDPOINT = "/api/cli-tools/hermes-settings";
|
||||||
|
|
||||||
@@ -39,9 +40,7 @@ export default function HermesToolCard({
|
|||||||
if (!hermesStatus?.installed) return null;
|
if (!hermesStatus?.installed) return null;
|
||||||
const cfg = hermesStatus.settings?.model;
|
const cfg = hermesStatus.settings?.model;
|
||||||
if (!cfg?.base_url) return "not_configured";
|
if (!cfg?.base_url) return "not_configured";
|
||||||
const localMatch = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(cfg.base_url);
|
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
|
||||||
const tunnelMatch = baseUrl && cfg.base_url.startsWith(baseUrl);
|
|
||||||
if (localMatch || tunnelMatch) return "configured";
|
|
||||||
return "other";
|
return "other";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,18 +232,8 @@ export default function HermesToolCard({
|
|||||||
{!checking && hermesStatus?.installed && (
|
{!checking && hermesStatus?.installed && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<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>
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
<BaseUrlSelect
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||||
@@ -257,6 +246,16 @@ export default function HermesToolCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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="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>
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
export default function OpenClawToolCard({
|
export default function OpenClawToolCard({
|
||||||
tool,
|
tool,
|
||||||
@@ -39,10 +40,7 @@ export default function OpenClawToolCard({
|
|||||||
if (!openclawStatus?.installed) return null;
|
if (!openclawStatus?.installed) return null;
|
||||||
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
|
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
|
||||||
if (!currentProvider) return "not_configured";
|
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");
|
return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||||
const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl);
|
|
||||||
if (localMatch || tunnelMatch) return "configured";
|
|
||||||
return "other";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configStatus = getConfigStatus();
|
const configStatus = getConfigStatus();
|
||||||
@@ -282,20 +280,9 @@ export default function OpenClawToolCard({
|
|||||||
{!checkingOpenclaw && openclawStatus?.installed && (
|
{!checkingOpenclaw && openclawStatus?.installed && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Current Base URL */}
|
{/* Endpoint (selector) */}
|
||||||
{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 */}
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm: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">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>
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
<BaseUrlSelect
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || getDisplayUrl()}
|
value={customBaseUrl || getDisplayUrl()}
|
||||||
@@ -308,6 +295,17 @@ export default function OpenClawToolCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
<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="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
|
||||||
export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
|
||||||
const [status, setStatus] = useState(initialStatus || null);
|
const [status, setStatus] = useState(initialStatus || null);
|
||||||
@@ -69,9 +70,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
const getConfigStatus = () => {
|
const getConfigStatus = () => {
|
||||||
if (!status?.installed) return null;
|
if (!status?.installed) return null;
|
||||||
if (!status.config) return "not_configured";
|
if (!status.config) return "not_configured";
|
||||||
|
if (!status.has9Router) return "not_configured";
|
||||||
const url = status.config?.provider?.["9router"]?.options?.baseURL || "";
|
const url = status.config?.provider?.["9router"]?.options?.baseURL || "";
|
||||||
const isLocal = url.includes("localhost") || url.includes("127.0.0.1");
|
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
|
||||||
return status.has9Router && (isLocal || url.includes(baseUrl)) ? "configured" : status.has9Router ? "other" : "not_configured";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configStatus = getConfigStatus();
|
const configStatus = getConfigStatus();
|
||||||
@@ -258,19 +259,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Current base URL */}
|
{/* Current base URL */}
|
||||||
{status?.config?.provider?.["9router"]?.options?.baseURL && (
|
{/* Endpoint (selector) */}
|
||||||
<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 */}
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm: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">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>
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
<BaseUrlSelect
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || getDisplayUrl()}
|
value={customBaseUrl || getDisplayUrl()}
|
||||||
@@ -283,6 +274,17 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
<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="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -485,20 +485,20 @@ export default function ProviderDetailPage() {
|
|||||||
const applyProxyAssignments = async (assignments) => {
|
const applyProxyAssignments = async (assignments) => {
|
||||||
setBulkUpdatingProxy(true);
|
setBulkUpdatingProxy(true);
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(assignments.map(async ({ connectionId, proxyPoolId }) => {
|
let failed = 0;
|
||||||
|
for (const { connectionId, proxyPoolId } of assignments) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ proxyPoolId }),
|
body: JSON.stringify({ proxyPoolId }),
|
||||||
});
|
});
|
||||||
return res.ok;
|
if (!res.ok) failed += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Error applying proxy for", connectionId, 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).`);
|
if (failed > 0) alert(`Updated with ${failed} failed request(s).`);
|
||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
setShowBulkProxyModal(false);
|
setShowBulkProxyModal(false);
|
||||||
@@ -582,54 +582,38 @@ export default function ProviderDetailPage() {
|
|||||||
title={`Apply Proxy (${connections.length} connections)`}
|
title={`Apply Proxy (${connections.length} connections)`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
<button
|
<button
|
||||||
onClick={handleApplyOneToOne}
|
onClick={handleApplyOneToOne}
|
||||||
disabled={bulkUpdatingProxy || activePools.length === 0}
|
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-text-muted text-[18px]">sync_alt</span>
|
||||||
<span className="material-symbols-outlined text-primary text-[20px]">sync_alt</span>
|
<span className="text-sm text-text-main">One-to-one (rotate)</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>
|
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => handleApplySinglePool(null)}
|
onClick={() => handleApplySinglePool(null)}
|
||||||
disabled={bulkUpdatingProxy}
|
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="material-symbols-outlined text-text-muted text-[18px]">link_off</span>
|
||||||
<span className="text-sm text-text-main">None (unbind all)</span>
|
<span className="text-sm text-text-main">None (unbind all)</span>
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
{proxyPools.map((pool) => (
|
{proxyPools.map((pool) => (
|
||||||
<button
|
<button
|
||||||
key={pool.id}
|
key={pool.id}
|
||||||
onClick={() => handleApplySinglePool(pool.id)}
|
onClick={() => handleApplySinglePool(pool.id)}
|
||||||
disabled={bulkUpdatingProxy || pool.isActive !== true}
|
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="material-symbols-outlined text-text-muted text-[18px]">lan</span>
|
||||||
<span className="truncate text-sm text-text-main">{pool.name}</span>
|
<span className="truncate text-sm text-text-main">{pool.name}</span>
|
||||||
{pool.isActive !== true && (
|
{pool.isActive !== true && (
|
||||||
<span className="text-[10px] text-text-muted">(inactive)</span>
|
<span className="text-[10px] text-text-muted">(inactive)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{bulkUpdatingProxy && <p className="text-xs text-text-muted">Applying...</p>}
|
{bulkUpdatingProxy && <p className="text-xs text-text-muted">Applying...</p>}
|
||||||
|
|
||||||
|
|||||||
104
src/app/api/cli-tools/cowork-mcp-registry/route.js
Normal file
104
src/app/api/cli-tools/cowork-mcp-registry/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,115 @@ import fs from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
import { COWORK_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
||||||
|
|
||||||
const PROVIDER = "gateway";
|
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)
|
// Candidate user-data roots — Cowork can run from either Claude-3p (3p mode) or Claude (1p mode w/ cowork features)
|
||||||
const getCandidateRoots = () => {
|
const getCandidateRoots = () => {
|
||||||
if (os.platform() === "darwin") {
|
if (os.platform() === "darwin") {
|
||||||
@@ -107,8 +213,6 @@ const checkInstalled = async () => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
|
|
||||||
|
|
||||||
const readJson = async (filePath) => {
|
const readJson = async (filePath) => {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, "utf-8");
|
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)
|
? 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);
|
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -172,7 +282,9 @@ export async function GET() {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
models,
|
models,
|
||||||
provider: config?.inferenceProvider || null,
|
provider: config?.inferenceProvider || null,
|
||||||
|
selectedPlugins,
|
||||||
},
|
},
|
||||||
|
availablePlugins: COWORK_PLUGINS.map((p) => ({ name: p.name, description: p.description })),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error reading cowork settings:", error);
|
console.log("Error reading cowork settings:", error);
|
||||||
@@ -182,23 +294,20 @@ export async function GET() {
|
|||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { baseUrl, apiKey, models } = await request.json();
|
const { baseUrl, apiKey, models, plugins } = await request.json();
|
||||||
|
|
||||||
if (!baseUrl || !apiKey) {
|
if (!baseUrl || !apiKey) {
|
||||||
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
|
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()) : [];
|
const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : [];
|
||||||
if (modelsArray.length === 0) {
|
if (modelsArray.length === 0) {
|
||||||
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
|
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 bootstrapped = await bootstrapDeploymentMode();
|
||||||
const meta = await ensureMeta();
|
const meta = await ensureMeta();
|
||||||
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
|
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
|
||||||
@@ -208,10 +317,21 @@ export async function POST(request) {
|
|||||||
inferenceGatewayBaseUrl: baseUrl,
|
inferenceGatewayBaseUrl: baseUrl,
|
||||||
inferenceGatewayApiKey: apiKey,
|
inferenceGatewayApiKey: apiKey,
|
||||||
inferenceModels: modelsArray.map((name) => ({ name })),
|
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));
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
bootstrapped,
|
bootstrapped,
|
||||||
@@ -219,6 +339,8 @@ export async function POST(request) {
|
|||||||
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
|
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
|
||||||
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
||||||
configPath,
|
configPath,
|
||||||
|
plugins: pluginsResult,
|
||||||
|
skipApprovals: skipResult,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error applying cowork settings:", error);
|
console.log("Error applying cowork settings:", error);
|
||||||
@@ -238,6 +360,8 @@ export async function DELETE() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== "ENOENT") throw error;
|
if (error.code !== "ENOENT") throw error;
|
||||||
}
|
}
|
||||||
|
await writeOrgPluginsFolder([]);
|
||||||
|
try { await writeSkipApprovals([]); } catch { /* ignore */ }
|
||||||
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error resetting cowork settings:", error);
|
console.log("Error resetting cowork settings:", error);
|
||||||
|
|||||||
68
src/app/api/v1/audio/voices/route.js
Normal file
68
src/app/api/v1/audio/voices/route.js
Normal 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": "*" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/api/v1/models/info/route.js
Normal file
110
src/app/api/v1/models/info/route.js
Normal 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": "*" } });
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isOpenAICompatibleProvider,
|
isOpenAICompatibleProvider,
|
||||||
} from "@/shared/constants/providers";
|
} from "@/shared/constants/providers";
|
||||||
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||||
|
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
|
|
||||||
const parseOpenAIStyleModels = (data) => {
|
const parseOpenAIStyleModels = (data) => {
|
||||||
if (Array.isArray(data)) return data;
|
if (Array.isArray(data)) return data;
|
||||||
@@ -151,6 +152,14 @@ export async function buildModelsList(kindFilter) {
|
|||||||
console.log("Could not fetch model aliases");
|
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();
|
const activeConnectionByProvider = new Map();
|
||||||
for (const conn of connections) {
|
for (const conn of connections) {
|
||||||
if (!activeConnectionByProvider.has(conn.provider)) {
|
if (!activeConnectionByProvider.has(conn.provider)) {
|
||||||
@@ -159,7 +168,6 @@ export async function buildModelsList(kindFilter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const models = [];
|
const models = [];
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
// Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
|
// Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
|
||||||
for (const combo of combos) {
|
for (const combo of combos) {
|
||||||
@@ -167,7 +175,6 @@ export async function buildModelsList(kindFilter) {
|
|||||||
const entry = {
|
const entry = {
|
||||||
id: combo.name,
|
id: combo.name,
|
||||||
object: "model",
|
object: "model",
|
||||||
created: timestamp,
|
|
||||||
owned_by: "combo",
|
owned_by: "combo",
|
||||||
};
|
};
|
||||||
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
|
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
|
||||||
@@ -186,10 +193,10 @@ export async function buildModelsList(kindFilter) {
|
|||||||
if (!providerMatchesKinds(providerId, kindFilter)) continue;
|
if (!providerMatchesKinds(providerId, kindFilter)) continue;
|
||||||
for (const model of providerModels) {
|
for (const model of providerModels) {
|
||||||
if (!kindFilter.includes(modelKind(model))) continue;
|
if (!kindFilter.includes(modelKind(model))) continue;
|
||||||
|
if (isDisabled(alias, model.id)) continue;
|
||||||
models.push({
|
models.push({
|
||||||
id: `${alias}/${model.id}`,
|
id: `${alias}/${model.id}`,
|
||||||
object: "model",
|
object: "model",
|
||||||
created: timestamp,
|
|
||||||
owned_by: alias,
|
owned_by: alias,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -208,7 +215,6 @@ export async function buildModelsList(kindFilter) {
|
|||||||
models.push({
|
models.push({
|
||||||
id: `${providerAlias}/${modelId}`,
|
id: `${providerAlias}/${modelId}`,
|
||||||
object: "model",
|
object: "model",
|
||||||
created: timestamp,
|
|
||||||
owned_by: providerAlias,
|
owned_by: providerAlias,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -301,11 +307,11 @@ export async function buildModelsList(kindFilter) {
|
|||||||
// Resolve kind: prefer static metadata, otherwise infer from ID heuristics
|
// Resolve kind: prefer static metadata, otherwise infer from ID heuristics
|
||||||
const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
|
const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
|
||||||
if (!kindFilter.includes(kind)) continue;
|
if (!kindFilter.includes(kind)) continue;
|
||||||
|
if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue;
|
||||||
|
|
||||||
models.push({
|
models.push({
|
||||||
id: `${outputAlias}/${modelId}`,
|
id: `${outputAlias}/${modelId}`,
|
||||||
object: "model",
|
object: "model",
|
||||||
created: timestamp,
|
|
||||||
owned_by: outputAlias,
|
owned_by: outputAlias,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -324,10 +330,10 @@ export async function buildModelsList(kindFilter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const subId of subConfigModels) {
|
for (const subId of subConfigModels) {
|
||||||
|
if (isDisabled(outputAlias, subId) || isDisabled(staticAlias, subId)) continue;
|
||||||
models.push({
|
models.push({
|
||||||
id: `${outputAlias}/${subId}`,
|
id: `${outputAlias}/${subId}`,
|
||||||
object: "model",
|
object: "model",
|
||||||
created: timestamp,
|
|
||||||
owned_by: outputAlias,
|
owned_by: outputAlias,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -338,7 +344,6 @@ export async function buildModelsList(kindFilter) {
|
|||||||
id: `${outputAlias}/search`,
|
id: `${outputAlias}/search`,
|
||||||
object: "model",
|
object: "model",
|
||||||
kind: "webSearch",
|
kind: "webSearch",
|
||||||
created: timestamp,
|
|
||||||
owned_by: outputAlias,
|
owned_by: outputAlias,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -347,7 +352,6 @@ export async function buildModelsList(kindFilter) {
|
|||||||
id: `${outputAlias}/fetch`,
|
id: `${outputAlias}/fetch`,
|
||||||
object: "model",
|
object: "model",
|
||||||
kind: "webFetch",
|
kind: "webFetch",
|
||||||
created: timestamp,
|
|
||||||
owned_by: outputAlias,
|
owned_by: outputAlias,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
171
src/shared/components/ComboFormModal.js
Normal file
171
src/shared/components/ComboFormModal.js
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export { default as Footer } from "./Footer";
|
|||||||
export { default as OAuthModal } from "./OAuthModal";
|
export { default as OAuthModal } from "./OAuthModal";
|
||||||
export { default as ModelSelectModal } from "./ModelSelectModal";
|
export { default as ModelSelectModal } from "./ModelSelectModal";
|
||||||
export { default as ManualConfigModal } from "./ManualConfigModal";
|
export { default as ManualConfigModal } from "./ManualConfigModal";
|
||||||
|
export { default as ComboFormModal } from "./ComboFormModal";
|
||||||
export { default as UsageStats } from "./UsageStats";
|
export { default as UsageStats } from "./UsageStats";
|
||||||
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
export { default as NineRemoteButton } from "./NineRemoteButton";
|
export { default as NineRemoteButton } from "./NineRemoteButton";
|
||||||
|
|||||||
955
src/shared/constants/coworkPlugins.js
Normal file
955
src/shared/constants/coworkPlugins.js
Normal 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 };
|
||||||
@@ -41,6 +41,8 @@ const g = global.__appSingleton ??= {
|
|||||||
lastNetworkFingerprint: null,
|
lastNetworkFingerprint: null,
|
||||||
lastWatchdogTick: Date.now(),
|
lastWatchdogTick: Date.now(),
|
||||||
mitmStartInProgress: false,
|
mitmStartInProgress: false,
|
||||||
|
tunnelAutoResumed: false,
|
||||||
|
tailscaleAutoResumed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function initializeApp() {
|
export async function initializeApp() {
|
||||||
@@ -48,14 +50,16 @@ export async function initializeApp() {
|
|||||||
await cleanupProviderConnections();
|
await cleanupProviderConnections();
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
|
||||||
// Auto-resume tunnel
|
// Auto-resume tunnel (once per process)
|
||||||
if (settings.tunnelEnabled) {
|
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
||||||
|
g.tunnelAutoResumed = true;
|
||||||
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
||||||
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resume tailscale
|
// Auto-resume tailscale (once per process)
|
||||||
if (settings.tailscaleEnabled) {
|
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
|
||||||
|
g.tailscaleAutoResumed = true;
|
||||||
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
||||||
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user