feat(mcp): add preset setup and capability mentions

This commit is contained in:
Xubin Ren
2026-05-24 19:43:20 +08:00
parent 8be258212e
commit 704ac558f6
54 changed files with 8425 additions and 708 deletions
+93
View File
@@ -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,
+20
View File
@@ -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,
}));
}
+7 -1
View File
@@ -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);
+188
View File
@@ -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;
}
+95
View File
@@ -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;