feat(mcp): add preset setup and capability mentions
This commit is contained in:
@@ -2,6 +2,8 @@ import type {
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
ModelConfigurationCreate,
|
||||
ProviderSettingsUpdate,
|
||||
SettingsPayload,
|
||||
SettingsUpdate,
|
||||
@@ -39,6 +41,21 @@ async function request<T>(
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
|
||||
const payload: Record<string, unknown> = {};
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) payload[key] = trimmed;
|
||||
return;
|
||||
}
|
||||
payload[key] = value;
|
||||
});
|
||||
if (!Object.keys(payload).length) return undefined;
|
||||
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function splitKey(key: string): { channel: string; chatId: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { channel: "", chatId: key };
|
||||
@@ -125,6 +142,66 @@ export async function runCliAppAction(
|
||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
||||
}
|
||||
|
||||
export async function fetchMcpPresets(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
|
||||
}
|
||||
|
||||
export async function runMcpPresetAction(
|
||||
token: string,
|
||||
action: "enable" | "remove" | "test",
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/${action}?${query}`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader(values) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCustomMcpServer(
|
||||
token: string,
|
||||
values: Record<string, string>,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/custom`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader(values) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function importMcpConfig(
|
||||
token: string,
|
||||
config: string,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/import`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader({ config }) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateMcpServerTools(
|
||||
token: string,
|
||||
name: string,
|
||||
enabledTools: string[],
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/tools`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listSlashCommands(
|
||||
token: string,
|
||||
base: string = "",
|
||||
@@ -188,6 +265,22 @@ export async function updateSettings(
|
||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||
}
|
||||
|
||||
export async function createModelConfiguration(
|
||||
token: string,
|
||||
configuration: ModelConfigurationCreate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (configuration.name !== undefined) query.set("name", configuration.name);
|
||||
query.set("label", configuration.label);
|
||||
query.set("provider", configuration.provider);
|
||||
query.set("model", configuration.model);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/create?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateProviderSettings(
|
||||
token: string,
|
||||
update: ProviderSettingsUpdate,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { McpPresetInfo, McpPresetsPayload } from "@/lib/types";
|
||||
|
||||
export const MCP_PRESETS_CHANGED_EVENT = "nanobot:mcp-presets-changed";
|
||||
|
||||
export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload {
|
||||
return !!value
|
||||
&& typeof value === "object"
|
||||
&& Array.isArray((value as { presets?: unknown }).presets);
|
||||
}
|
||||
|
||||
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
||||
return payload.presets.filter((preset) => preset.installed && preset.configured);
|
||||
}
|
||||
|
||||
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<McpPresetsPayload>(MCP_PRESETS_CHANGED_EVENT, {
|
||||
detail: payload,
|
||||
}));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Outbound,
|
||||
OutboundCliAppMention,
|
||||
OutboundImageGeneration,
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
} from "./types";
|
||||
@@ -305,7 +306,11 @@ export class NanobotClient {
|
||||
chatId: string,
|
||||
content: string,
|
||||
media?: OutboundMedia[],
|
||||
options?: { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[] },
|
||||
options?: {
|
||||
imageGeneration?: OutboundImageGeneration;
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
@@ -315,6 +320,7 @@ export class NanobotClient {
|
||||
...(media && media.length > 0 ? { media } : {}),
|
||||
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
|
||||
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
||||
webui: true,
|
||||
};
|
||||
this.queueSend(frame);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
export interface ProviderBrand {
|
||||
logoUrl: string;
|
||||
logoUrls: string[];
|
||||
color: string;
|
||||
initials: string;
|
||||
}
|
||||
|
||||
function officialFaviconUrl(domain: string): string {
|
||||
return `https://${domain}/favicon.ico`;
|
||||
}
|
||||
|
||||
function duckDuckGoFaviconUrl(domain: string): string {
|
||||
return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
|
||||
}
|
||||
|
||||
function googleFaviconUrl(domain: string): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
|
||||
}
|
||||
|
||||
export function faviconUrls(domain: string): string[] {
|
||||
const faviconDomain = faviconDomainFromValue(domain);
|
||||
return [
|
||||
officialFaviconUrl(faviconDomain),
|
||||
duckDuckGoFaviconUrl(faviconDomain),
|
||||
googleFaviconUrl(domain),
|
||||
];
|
||||
}
|
||||
|
||||
function brand(
|
||||
domain: string,
|
||||
color: string,
|
||||
initials: string,
|
||||
logoOverrides: string[] = [],
|
||||
): ProviderBrand {
|
||||
const logoUrls = [...logoOverrides];
|
||||
faviconUrls(domain).forEach((url) => addUniqueLogoUrl(logoUrls, url));
|
||||
return {
|
||||
logoUrl: logoUrls[0],
|
||||
logoUrls,
|
||||
color,
|
||||
initials,
|
||||
};
|
||||
}
|
||||
|
||||
function addUniqueLogoUrl(urls: string[], url: string | null | undefined): void {
|
||||
const value = url?.trim();
|
||||
if (value && !urls.includes(value)) urls.push(value);
|
||||
}
|
||||
|
||||
function domainFromLogoUrl(url: string): string | null {
|
||||
if (url.startsWith("/")) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!/^https?:$/.test(parsed.protocol)) return null;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host === "www.google.com" || host === "google.com") {
|
||||
return parsed.searchParams.get("domain");
|
||||
}
|
||||
if (host === "icons.duckduckgo.com") {
|
||||
const match = parsed.pathname.match(/^\/ip3\/(.+)\.ico$/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
return host.replace(/^www\./, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function faviconDomainFromValue(value: string): string {
|
||||
const host = value.split("/")[0]?.trim();
|
||||
return host || value;
|
||||
}
|
||||
|
||||
export function logoFallbackUrls(logoUrl: string | null | undefined): string[] {
|
||||
const value = logoUrl?.trim();
|
||||
if (!value) return [];
|
||||
if (value.startsWith("/")) return [value];
|
||||
|
||||
const urls: string[] = [];
|
||||
const domain = domainFromLogoUrl(value);
|
||||
const isFaviconProxy = /^(https?:\/\/)?(www\.google\.com|google\.com|icons\.duckduckgo\.com)\//i.test(value);
|
||||
if (domain && isFaviconProxy) {
|
||||
addUniqueLogoUrl(urls, value);
|
||||
faviconUrls(domain).forEach((url) => addUniqueLogoUrl(urls, url));
|
||||
return urls;
|
||||
}
|
||||
addUniqueLogoUrl(urls, value);
|
||||
if (domain) faviconUrls(domain).forEach((url) => addUniqueLogoUrl(urls, url));
|
||||
return urls;
|
||||
}
|
||||
|
||||
export const PROVIDER_BRAND_ALIASES: Record<string, string> = {
|
||||
brave_search: "brave",
|
||||
byteplus_coding_plan: "byteplus",
|
||||
minimaxAnthropic: "minimax",
|
||||
minimax_anthropic: "minimax",
|
||||
openai_codex: "openai",
|
||||
volcengine_coding_plan: "volcengine",
|
||||
};
|
||||
|
||||
export const PROVIDER_LABEL_ALIASES: Record<string, string> = {
|
||||
brave_search: "Brave Search",
|
||||
byteplus_coding_plan: "BytePlus",
|
||||
minimaxAnthropic: "MiniMax",
|
||||
minimax_anthropic: "MiniMax",
|
||||
openai_codex: "OpenAI",
|
||||
volcengine_coding_plan: "Volcengine",
|
||||
};
|
||||
|
||||
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
|
||||
aihubmix: brand("aihubmix.com", "#111827", "AH"),
|
||||
ant_ling: brand("ant-ling.com", "#7C3AED", "AL"),
|
||||
anthropic: brand("anthropic.com", "#D97757", "A"),
|
||||
atomic_chat: brand("atomic.chat", "#111827", "AC"),
|
||||
azure_openai: brand("azure.microsoft.com", "#0078D4", "AZ"),
|
||||
bedrock: brand("aws.amazon.com", "#FF9900", "AWS"),
|
||||
brave: brand("brave.com", "#FB542B", "B"),
|
||||
byteplus: brand("byteplus.com", "#325CFF", "BP"),
|
||||
dashscope: brand("dashscope.aliyun.com", "#FF6A00", "DS"),
|
||||
deepseek: brand("deepseek.com", "#4D6BFE", "DS"),
|
||||
duckduckgo: brand("duckduckgo.com", "#DE5833", "DDG"),
|
||||
exa: brand("exa.ai", "#5B5BF6", "E"),
|
||||
gemini: brand("gemini.google.com", "#4285F4", "G"),
|
||||
github_copilot: brand("github.com", "#24292F", "GH"),
|
||||
groq: brand("groq.com", "#F55036", "GQ"),
|
||||
huggingface: brand("huggingface.co", "#FF9D00", "HF"),
|
||||
jina: brand("jina.ai", "#7C3AED", "J"),
|
||||
kagi: brand("kagi.com", "#FFB319", "K"),
|
||||
lm_studio: brand("lmstudio.ai", "#111827", "LM"),
|
||||
longcat: brand("longcat.chat", "#111827", "LC"),
|
||||
minimax: brand("minimax.io", "#111827", "MM"),
|
||||
mistral: brand("mistral.ai", "#FA520F", "M"),
|
||||
moonshot: brand("moonshot.ai", "#111827", "MS"),
|
||||
novita: brand("novita.ai", "#7C3AED", "N"),
|
||||
olostep: brand("olostep.com", "#111827", "O"),
|
||||
nvidia: brand("nvidia.com", "#76B900", "NV"),
|
||||
ollama: brand("ollama.com", "#111827", "O"),
|
||||
openai: brand("openai.com", "#111827", "AI"),
|
||||
openrouter: brand("openrouter.ai", "#111827", "OR"),
|
||||
ovms: brand("openvino.ai", "#0071C5", "OV"),
|
||||
qianfan: brand("cloud.baidu.com", "#2932E1", "QF"),
|
||||
searxng: brand("searxng.org", "#3050FF", "SX"),
|
||||
siliconflow: brand("siliconflow.cn", "#111827", "SF"),
|
||||
skywork: brand("skywork.ai", "#5B5BF6", "SW"),
|
||||
stepfun: brand("stepfun.com", "#2F6BFF", "SF"),
|
||||
tavily: brand("tavily.com", "#111827", "T"),
|
||||
volcengine: brand("volcengine.com", "#1664FF", "VE"),
|
||||
vllm: brand("vllm.ai", "#2563EB", "VL"),
|
||||
xiaomi_mimo: brand("xiaomimimo.com", "#FF6900", "MI"),
|
||||
zhipu: brand("z.ai", "#155EEF", "Z", [
|
||||
"https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
|
||||
"https://www.google.com/s2/favicons?domain=z.ai&sz=64",
|
||||
]),
|
||||
};
|
||||
|
||||
export function providerBrand(provider: string | null | undefined): ProviderBrand | null {
|
||||
if (!provider) return null;
|
||||
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
|
||||
return PROVIDER_BRANDS[key] ?? null;
|
||||
}
|
||||
|
||||
export function providerDisplayLabel(
|
||||
providers: Array<{ name: string; label: string }>,
|
||||
value: string | null | undefined,
|
||||
): string {
|
||||
if (!value) return "";
|
||||
return providers.find((provider) => provider.name === value)?.label
|
||||
?? PROVIDER_LABEL_ALIASES[value]
|
||||
?? value;
|
||||
}
|
||||
|
||||
export function inferProviderFromModelName(modelName: string | null | undefined): string | null {
|
||||
const normalized = (modelName ?? "").trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
const prefix = normalized.split(/[/:]/)[0];
|
||||
if (providerBrand(prefix)) return prefix;
|
||||
if (/claude|anthropic/.test(normalized)) return "anthropic";
|
||||
if (/gpt-|^o\d|chatgpt|openai/.test(normalized)) return "openai";
|
||||
if (/deepseek/.test(normalized)) return "deepseek";
|
||||
if (/gemini/.test(normalized)) return "gemini";
|
||||
if (/qwen|dashscope/.test(normalized)) return "dashscope";
|
||||
if (/kimi|moonshot/.test(normalized)) return "moonshot";
|
||||
if (/minimax/.test(normalized)) return "minimax";
|
||||
if (/mistral|mixtral/.test(normalized)) return "mistral";
|
||||
if (/skywork|skyclaw/.test(normalized)) return "skywork";
|
||||
if (/ring-/.test(normalized)) return "ant_ling";
|
||||
return null;
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export interface UIMessage {
|
||||
media?: UIMediaAttachment[];
|
||||
/** App-specific CLI adapters explicitly attached to this user turn. */
|
||||
cliApps?: UICliAppAttachment[];
|
||||
/** Settings-managed MCP presets explicitly attached to this user turn. */
|
||||
mcpPresets?: UIMcpPresetAttachment[];
|
||||
/** Assistant turn: accumulated model reasoning / thinking text. Built up
|
||||
* incrementally from ``reasoning_delta`` frames; finalized when
|
||||
* ``reasoning_end`` arrives. */
|
||||
@@ -73,6 +75,17 @@ export interface UICliAppAttachment {
|
||||
brand_color?: string | null;
|
||||
}
|
||||
|
||||
export interface UIMcpPresetAttachment {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
category?: string;
|
||||
transport?: string;
|
||||
status?: string;
|
||||
configured?: boolean;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
}
|
||||
|
||||
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
|
||||
export interface AgentUIBlob {
|
||||
kind: string;
|
||||
@@ -294,6 +307,69 @@ export interface CliAppsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpPresetField {
|
||||
name: string;
|
||||
label: string;
|
||||
secret: boolean;
|
||||
required: boolean;
|
||||
configured: boolean;
|
||||
placeholder?: string;
|
||||
env_var?: string | null;
|
||||
}
|
||||
|
||||
export interface McpPresetInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
docs_url: string;
|
||||
transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
|
||||
requires: string;
|
||||
note: string;
|
||||
install_supported: boolean;
|
||||
installed: boolean;
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
required_fields: McpPresetField[];
|
||||
connection_summary: string;
|
||||
tool_count?: number;
|
||||
tool_names?: string[];
|
||||
checked_at?: string | null;
|
||||
error?: string | null;
|
||||
enabled_tools?: string[];
|
||||
source?: "preset" | "custom" | string;
|
||||
}
|
||||
|
||||
export interface McpPresetsPayload {
|
||||
presets: McpPresetInfo[];
|
||||
installed_count: number;
|
||||
requires_restart?: boolean;
|
||||
hot_reload?: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
added?: string[];
|
||||
changed?: string[];
|
||||
removed?: string[];
|
||||
retried?: string[];
|
||||
connected?: string[];
|
||||
configured?: string[];
|
||||
failed?: string[];
|
||||
tools_removed?: number;
|
||||
requires_restart?: boolean;
|
||||
};
|
||||
last_action?: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
tool_count?: number;
|
||||
tool_names?: string[];
|
||||
checked_at?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsUpdate {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
@@ -304,6 +380,13 @@ export interface SettingsUpdate {
|
||||
toolHintMaxLength?: number;
|
||||
}
|
||||
|
||||
export interface ModelConfigurationCreate {
|
||||
name?: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsUpdate {
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
@@ -446,6 +529,17 @@ export interface OutboundCliAppMention {
|
||||
brand_color?: string | null;
|
||||
}
|
||||
|
||||
export interface OutboundMcpPresetMention {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
category?: string;
|
||||
transport?: string;
|
||||
status?: string;
|
||||
configured?: boolean;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
}
|
||||
|
||||
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
|
||||
export interface WebuiThreadPersistedPayload {
|
||||
schemaVersion: number;
|
||||
@@ -464,6 +558,7 @@ export type Outbound =
|
||||
media?: OutboundMedia[];
|
||||
image_generation?: OutboundImageGeneration;
|
||||
cli_apps?: OutboundCliAppMention[];
|
||||
mcp_presets?: OutboundMcpPresetMention[];
|
||||
/** Marks messages sent by the embedded WebUI, without changing the
|
||||
* generic websocket protocol for other clients. */
|
||||
webui?: true;
|
||||
|
||||
Reference in New Issue
Block a user