MoltBot × Bocha:打造开源 AI 助手 + 智能联网搜索解决方案

MoltBot × Bocha:打造开源 AI 助手 + 智能联网搜索解决方案)

随着大模型与智能体技术的迅速发展,开源、可自托管的 AI 助手逐渐成为开发者与技术爱好者的新宠。今天我们将分享一款兼具强大本地部署能力与开放插件生态的开源项目——MoltBot,以及如何集成 Bocha Web Search 实现智能联网搜索,打造你的“超级智能体工作流”。

MoltBot

一、MoltBot × Bocha

MoltBot(原名 Clawdbot) 是由 PSPDFKit 创始人 Peter Steinberger 于 2025 年底发起的一个开源 AI 助手项目。它支持在本地运行,兼容 macOS、Windows 和 Linux 系统,并可通过插件接入多种通信工具(如 Telegram、Slack、WhatsApp、Discord 等)与生产力平台(如 GitHub、Vercel、Google Drive 等)。

MoltBot 内置了丰富的 Agent 工具,并支持通过 Skills 和插件灵活扩展能力,真正实现了完全自主、私有化部署的智能助手,可广泛应用于自动化任务、智能工作流与实时信息处理等场景。

项目 GitHub:https://github.com/moltbot/moltbot

官网与文档:https://clawd.bot/

在智能体执行任务的过程中,实时、准确的联网搜索能力是不可或缺的一环。博查 Web Search 是一款高性能、支持语义检索的搜索 API 服务,具备以下优势:

  • 极速响应:响应时间仅需150ms,让你先一步看见世界。
  • 语义理解:支持自然语言查询与意图识别。
  • 结构化返回:返回标题、摘要、发布时间等丰富信息。
  • 时间过滤:支持按时间范围筛选结果。
  • 高度兼容:API 设计兼容 Bing 搜索格式,易于集成。

通过将 Bocha 接入 MoltBot,你可以为智能体赋予 “实时查阅全网信息” 的能力,为百科问答、撰写内容、整理资料等不同场景带来更加准确、及时的体验。

bocha

如果您尚未在本地构建 MoltBot 项目,第二章节可以帮助您快速上手启动。如果您已经完成了 MoltBot 的本地运行,可跳转至第三章集成博查插件。

二、MoltBot本地运行

快速启动项目

  1. 获取远程仓库,切换进入目录:

    git clone https://github.com/moltbot/moltbot.git
    cd moltbot
    
  2. 安装依赖包:

    pnpm install
    

    安装依赖包

  3. 初次运行前,自动构建UI

    pnpm ui:build # auto-installs UI deps on first run
    

    构建UI

  4. 构建项目:

    pnpm build
    

    构建项目

  5. 快速启动项目:

    pnpm moltbot onboard --install-daemon
    

    快速启动项目

    1. 同意协议:

      同意协议

    2. 选择快速开始:

      屏幕截图 2026-01-28 164237

    3. 可选择跳过稍后配置模型,也可以直接配置所需模型并输入API Key:

      选择模型

    4. 再次确认默认模型:

      配置默认模型

    5. 暂不添加渠道,可在启动平台之后再配置:

      跳过渠道配置

      渠道配置

      渠道配置

    6. 暂不安装skills,按空格选中 Skip for now,跳过相关skill配置项:
      skills配置
      skills配置

    7. 启动网关服务:
      启动网关

    8. Gateway网关自动配置,配置中会弹出窗口显示相关信息:
      网关信息

    9. Bot已经内置有Web Search联网搜索模块,我们后续将参考其配置方法将博查Web Search工具接入使用:
      Web Search

  6. 以上配置完成后,可在浏览器内输入http://127.0.0.1:18789/启动MOLTBOT
    启动MoltBot

三、博查 Web Search 集成流程

下面我们将介绍 博查(Bocha)Web Search 在MoltBot中的集成实现,包括详细的代码变更点与集成效果,赶快上手尝试吧!

0 前置条件

  1. 克隆项目并安装依赖

  2. 完成项目初次构建(含 UI,见上文「MoltBot本地运行」中的 pnpm build )。

完成下述代码与配置变更后,重新执行 pnpm build,再启动或重启 Gateway(或开发模式下使用 pnpm gateway:watch 观察变更生效)。

1 配置文件与环境变量

  • 配置文件路径(本地存储路径通常如下):
    • Linux/macOS:~/.clawdbot/moltbot.json
    • Windows:%USERPROFILE%\.clawdbot\moltbot.json
  • 与博查相关的配置键
    • tools.web.search.provider:设为 "bocha" 时使用博查。
    • tools.web.search.bocha.apiKey:博查 API Key(可选,见下)。
    • tools.web.search.bocha.baseUrl:博查 API 根地址,默认会拼上 /v1/web-search
    • tools.web.search.bocha.summary:是否请求每条结果的文本摘要。
  • 环境变量:在运行 Gateway 的进程环境中设置 BOCHA_API_KEY 可作为 Key 来源;若同时存在配置文件中的 tools.web.search.bocha.apiKey,则以配置为准。

2 变更点及代码变更

1. 修改文件:src/agents/tools/web-search.ts

变更点 代码变更
支持博查为搜索提供商,让 web_search 能选择博查 API SEARCH_PROVIDERS 中扩展 ["bocha"]
在常量区增加 DEFAULT_BOCHA_BASE_URL = "https://api.bochaai.com/v1/web-search"
博查配置与 API 类型,从配置读取 apiKey/baseUrl/summary,并解析博查返回结构 在类型定义区增加:BochaConfigBochaWebPageValueBochaSearchResponse
提供商解析,配置为 bocha 时走博查实现逻辑 resolveSearchProvider(search) 中,增加情况:if (raw === "bocha") return "bocha";
博查配置解析与 Key/BaseUrl,统一从 config 和 env 解析博查参数 新增 resolveBochaConfig(search)resolveBochaApiKey(bocha)resolveBochaBaseUrl(bocha);可选 mapFreshnessToBocha(freshness) 将 Brave 风格 freshness 映射为博查 API 所需格式。
缺 Key 时的错误提示,用户未配置 Key 时给出明确指引 missingSearchKeyPayload(provider) 中增加 "bocha" 分支,返回 error: "missing_bocha_api_key" 及 message、docs 链接。
博查请求与结果映射,调用博查 API 并转为统一 results 结构 新增 runBochaSearch(params),Body 含 query、count、可选 freshness、summary;解析响应中 data.webPages.value,映射为 { title, url, description, published, siteName },返回 { query, provider: "bocha", count, tookMs, results }
缓存与执行分支,博查结果参与缓存,且执行时走博查分支 runWebSearch 的缓存 key 中为 "bocha" 单独组 key(含 query、count、bochaFreshness、bochaSummary);在 runWebSearch 内若 params.provider === "bocha" 则调用 runBochaSearch(...) 并写入缓存、返回。
创建工具时的博查分支,使用博查时传入正确 apiKey 与参数 createWebSearchTool 中:当 resolveSearchProvider(search) === "bocha" 时,用 resolveBochaApiKey(bochaConfig) 作为 apiKey;
若无 Key 则返回 missingSearchKeyPayload("bocha");执行 runWebSearch 时传入 bochaBaseUrlbochaFreshnessbochaSummary
工具描述中为 bocha 写一句说明(如支持 freshness/summary、Bing 兼容格式)。

web-search.ts 代码如下:

点击查看web-search.ts代码
import { Type } from "@sinclair/typebox";

import type { MoltbotConfig } from "../../config/config.js";
import { formatCliCommand } from "../../cli/command-format.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
import {
  CacheEntry,
  DEFAULT_CACHE_TTL_MINUTES,
  DEFAULT_TIMEOUT_SECONDS,
  normalizeCacheKey,
  readCache,
  readResponseText,
  resolveCacheTtlMs,
  resolveTimeoutSeconds,
  withTimeout,
  writeCache,
} from "./web-shared.js";

const SEARCH_PROVIDERS = ["brave", "perplexity", "bocha"] as const;
const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;

const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const DEFAULT_BOCHA_BASE_URL = "https://api.bochaai.com/v1/web-search";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];

const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;

const WebSearchSchema = Type.Object({
  query: Type.String({ description: "Search query string." }),
  count: Type.Optional(
    Type.Number({
      description: "Number of results to return (1-10).",
      minimum: 1,
      maximum: MAX_SEARCH_COUNT,
    }),
  ),
  country: Type.Optional(
    Type.String({
      description:
        "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
    }),
  ),
  search_lang: Type.Optional(
    Type.String({
      description: "ISO language code for search results (e.g., 'de', 'en', 'fr').",
    }),
  ),
  ui_lang: Type.Optional(
    Type.String({
      description: "ISO language code for UI elements.",
    }),
  ),
  freshness: Type.Optional(
    Type.String({
      description:
        "Filter by discovery time (Brave/Bocha). Brave: 'pd'|'pw'|'pm'|'py' or 'YYYY-MM-DDtoYYYY-MM-DD'. Bocha maps to oneDay|oneWeek|oneMonth|oneYear or date range.",
    }),
  ),
});

type WebSearchConfig = NonNullable<MoltbotConfig["tools"]>["web"] extends infer Web
  ? Web extends { search?: infer Search }
    ? Search
    : undefined
  : undefined;

type BraveSearchResult = {
  title?: string;
  url?: string;
  description?: string;
  age?: string;
};

type BraveSearchResponse = {
  web?: {
    results?: BraveSearchResult[];
  };
};

type PerplexityConfig = {
  apiKey?: string;
  baseUrl?: string;
  model?: string;
};

type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";

type PerplexitySearchResponse = {
  choices?: Array<{
    message?: {
      content?: string;
    };
  }>;
  citations?: string[];
};

type PerplexityBaseUrlHint = "direct" | "openrouter";

type BochaConfig = {
  apiKey?: string;
  baseUrl?: string;
  summary?: boolean;
};

type BochaWebPageValue = {
  name?: string;
  url?: string;
  snippet?: string;
  summary?: string;
  siteName?: string;
  datePublished?: string;
  dateLastCrawled?: string;
};

type BochaSearchResponse = {
  code?: number;
  data?: {
    webPages?: {
      value?: BochaWebPageValue[];
    };
  };
};

function resolveSearchConfig(cfg?: MoltbotConfig): WebSearchConfig {
  const search = cfg?.tools?.web?.search;
  if (!search || typeof search !== "object") return undefined;
  return search as WebSearchConfig;
}

function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean {
  if (typeof params.search?.enabled === "boolean") return params.search.enabled;
  if (params.sandboxed) return true;
  return true;
}

function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
  const fromConfig =
    search && "apiKey" in search && typeof search.apiKey === "string" ? search.apiKey.trim() : "";
  const fromEnv = (process.env.BRAVE_API_KEY ?? "").trim();
  return fromConfig || fromEnv || undefined;
}

function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
  if (provider === "perplexity") {
    return {
      error: "missing_perplexity_api_key",
      message:
        "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
      docs: "https://docs.molt.bot/tools/web",
    };
  }
  if (provider === "bocha") {
    return {
      error: "missing_bocha_api_key",
      message: `web_search (bocha) needs a Bocha Web Search API key. Run \`${formatCliCommand("moltbot configure --section web")}\` to store it, or set BOCHA_API_KEY in the Gateway environment. Get a key at https://open.bocha.cn`,
      docs: "https://docs.molt.bot/bocha-search",
    };
  }
  return {
    error: "missing_brave_api_key",
    message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("moltbot configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
    docs: "https://docs.molt.bot/tools/web",
  };
}

function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] {
  const raw =
    search && "provider" in search && typeof search.provider === "string"
      ? search.provider.trim().toLowerCase()
      : "";
  if (raw === "perplexity") return "perplexity";
  if (raw === "bocha") return "bocha";
  if (raw === "brave") return "brave";
  return "brave";
}

function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
  if (!search || typeof search !== "object") return {};
  const perplexity = "perplexity" in search ? search.perplexity : undefined;
  if (!perplexity || typeof perplexity !== "object") return {};
  return perplexity as PerplexityConfig;
}

function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
  apiKey?: string;
  source: PerplexityApiKeySource;
} {
  const fromConfig = normalizeApiKey(perplexity?.apiKey);
  if (fromConfig) {
    return { apiKey: fromConfig, source: "config" };
  }

  const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY);
  if (fromEnvPerplexity) {
    return { apiKey: fromEnvPerplexity, source: "perplexity_env" };
  }

  const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY);
  if (fromEnvOpenRouter) {
    return { apiKey: fromEnvOpenRouter, source: "openrouter_env" };
  }

  return { apiKey: undefined, source: "none" };
}

function normalizeApiKey(key: unknown): string {
  return typeof key === "string" ? key.trim() : "";
}

function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
  if (!apiKey) return undefined;
  const normalized = apiKey.toLowerCase();
  if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
    return "direct";
  }
  if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
    return "openrouter";
  }
  return undefined;
}

function resolvePerplexityBaseUrl(
  perplexity?: PerplexityConfig,
  apiKeySource: PerplexityApiKeySource = "none",
  apiKey?: string,
): string {
  const fromConfig =
    perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
      ? perplexity.baseUrl.trim()
      : "";
  if (fromConfig) return fromConfig;
  if (apiKeySource === "perplexity_env") return PERPLEXITY_DIRECT_BASE_URL;
  if (apiKeySource === "openrouter_env") return DEFAULT_PERPLEXITY_BASE_URL;
  if (apiKeySource === "config") {
    const inferred = inferPerplexityBaseUrlFromApiKey(apiKey);
    if (inferred === "direct") return PERPLEXITY_DIRECT_BASE_URL;
    if (inferred === "openrouter") return DEFAULT_PERPLEXITY_BASE_URL;
  }
  return DEFAULT_PERPLEXITY_BASE_URL;
}

function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
  const fromConfig =
    perplexity && "model" in perplexity && typeof perplexity.model === "string"
      ? perplexity.model.trim()
      : "";
  return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}

function resolveBochaConfig(search?: WebSearchConfig): BochaConfig {
  if (!search || typeof search !== "object") return {};
  const bocha = "bocha" in search ? search.bocha : undefined;
  if (!bocha || typeof bocha !== "object") return {};
  return bocha as BochaConfig;
}

function resolveBochaApiKey(bocha?: BochaConfig): string | undefined {
  const fromConfig = bocha && typeof bocha.apiKey === "string" ? bocha.apiKey.trim() : "";
  const fromEnv = (process.env.BOCHA_API_KEY ?? "").trim();
  return fromConfig || fromEnv || undefined;
}

function resolveBochaBaseUrl(bocha?: BochaConfig): string {
  const fromConfig =
    bocha && typeof bocha.baseUrl === "string" ? bocha.baseUrl.trim() : "";
  return fromConfig || DEFAULT_BOCHA_BASE_URL;
}

/** Map Brave-style freshness to Bocha API values (oneDay, oneWeek, oneMonth, oneYear, or date range). */
function mapFreshnessToBocha(braveFreshness: string | undefined): string | undefined {
  if (!braveFreshness) return undefined;
  const lower = braveFreshness.toLowerCase();
  if (lower === "pd") return "oneDay";
  if (lower === "pw") return "oneWeek";
  if (lower === "pm") return "oneMonth";
  if (lower === "py") return "oneYear";
  if (braveFreshness.includes("to")) return braveFreshness.replace(/to/gi, "..");
  return braveFreshness;
}

function resolveSearchCount(value: unknown, fallback: number): number {
  const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
  const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
  return clamped;
}

function normalizeFreshness(value: string | undefined): string | undefined {
  if (!value) return undefined;
  const trimmed = value.trim();
  if (!trimmed) return undefined;

  const lower = trimmed.toLowerCase();
  if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;

  const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
  if (!match) return undefined;

  const [, start, end] = match;
  if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
  if (start > end) return undefined;

  return `${start}to${end}`;
}

function isValidIsoDate(value: string): boolean {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
  const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;

  const date = new Date(Date.UTC(year, month - 1, day));
  return (
    date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
  );
}

function resolveSiteName(url: string | undefined): string | undefined {
  if (!url) return undefined;
  try {
    return new URL(url).hostname;
  } catch {
    return undefined;
  }
}

async function runPerplexitySearch(params: {
  query: string;
  apiKey: string;
  baseUrl: string;
  model: string;
  timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> {
  const endpoint = `${params.baseUrl.replace(/\/$/, "")}/chat/completions`;

  const res = await fetch(endpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${params.apiKey}`,
      "HTTP-Referer": "https://molt.bot",
      "X-Title": "Moltbot Web Search",
    },
    body: JSON.stringify({
      model: params.model,
      messages: [
        {
          role: "user",
          content: params.query,
        },
      ],
    }),
    signal: withTimeout(undefined, params.timeoutSeconds * 1000),
  });

  if (!res.ok) {
    const detail = await readResponseText(res);
    throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`);
  }

  const data = (await res.json()) as PerplexitySearchResponse;
  const content = data.choices?.[0]?.message?.content ?? "No response";
  const citations = data.citations ?? [];

  return { content, citations };
}

async function runBochaSearch(params: {
  query: string;
  count: number;
  apiKey: string;
  baseUrl: string;
  timeoutSeconds: number;
  freshness?: string;
  summary?: boolean;
}): Promise<Record<string, unknown>> {
  const endpoint = `${params.baseUrl.replace(/\/$/, "")}/v1/web-search`;
  const body: Record<string, unknown> = {
    query: params.query,
    count: Math.min(50, Math.max(1, params.count)),
  };
  if (params.freshness) body.freshness = params.freshness;
  if (typeof params.summary === "boolean") body.summary = params.summary;

  const res = await fetch(endpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${params.apiKey}`,
    },
    body: JSON.stringify(body),
    signal: withTimeout(undefined, params.timeoutSeconds * 1000),
  });

  const text = await readResponseText(res);
  if (!res.ok) {
    throw new Error(`Bocha Web Search API error (${res.status}): ${text || res.statusText}`);
  }

  const data = JSON.parse(text || "{}") as BochaSearchResponse;
  if (data.code !== undefined && data.code !== 200) {
    throw new Error(`Bocha Web Search API error (code ${data.code}): ${(data as { msg?: string }).msg ?? text}`);
  }

  const items = data.data?.webPages?.value ?? [];
  const mapped = items.map((entry) => ({
    title: entry.name ?? "",
    url: entry.url ?? "",
    description: (entry.summary ?? entry.snippet) ?? "",
    published: entry.datePublished ?? entry.dateLastCrawled ?? undefined,
    siteName: entry.siteName ?? resolveSiteName(entry.url ?? ""),
  }));

  return {
    query: params.query,
    provider: "bocha",
    count: mapped.length,
    tookMs: 0,
    results: mapped,
  };
}

async function runWebSearch(params: {
  query: string;
  count: number;
  apiKey: string;
  timeoutSeconds: number;
  cacheTtlMs: number;
  provider: (typeof SEARCH_PROVIDERS)[number];
  country?: string;
  search_lang?: string;
  ui_lang?: string;
  freshness?: string;
  perplexityBaseUrl?: string;
  perplexityModel?: string;
  bochaBaseUrl?: string;
  bochaFreshness?: string;
  bochaSummary?: boolean;
}): Promise<Record<string, unknown>> {
  const cacheKey = normalizeCacheKey(
    params.provider === "brave"
      ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
      : params.provider === "bocha"
        ? `${params.provider}:${params.query}:${params.count}:${params.bochaFreshness || "default"}:${params.bochaSummary ?? "default"}`
        : `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
  );
  const cached = readCache(SEARCH_CACHE, cacheKey);
  if (cached) return { ...cached.value, cached: true };

  const start = Date.now();

  if (params.provider === "bocha") {
    const payload = await runBochaSearch({
      query: params.query,
      count: params.count,
      apiKey: params.apiKey,
      baseUrl: params.bochaBaseUrl ?? DEFAULT_BOCHA_BASE_URL,
      timeoutSeconds: params.timeoutSeconds,
      freshness: params.bochaFreshness,
      summary: params.bochaSummary,
    });
    payload.tookMs = Date.now() - start;
    writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
    return payload;
  }

  if (params.provider === "perplexity") {
    const { content, citations } = await runPerplexitySearch({
      query: params.query,
      apiKey: params.apiKey,
      baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL,
      model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
      timeoutSeconds: params.timeoutSeconds,
    });

    const payload = {
      query: params.query,
      provider: params.provider,
      model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
      tookMs: Date.now() - start,
      content,
      citations,
    };
    writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
    return payload;
  }

  if (params.provider !== "brave") {
    throw new Error("Unsupported web search provider.");
  }

  const url = new URL(BRAVE_SEARCH_ENDPOINT);
  url.searchParams.set("q", params.query);
  url.searchParams.set("count", String(params.count));
  if (params.country) {
    url.searchParams.set("country", params.country);
  }
  if (params.search_lang) {
    url.searchParams.set("search_lang", params.search_lang);
  }
  if (params.ui_lang) {
    url.searchParams.set("ui_lang", params.ui_lang);
  }
  if (params.freshness) {
    url.searchParams.set("freshness", params.freshness);
  }

  const res = await fetch(url.toString(), {
    method: "GET",
    headers: {
      Accept: "application/json",
      "X-Subscription-Token": params.apiKey,
    },
    signal: withTimeout(undefined, params.timeoutSeconds * 1000),
  });

  if (!res.ok) {
    const detail = await readResponseText(res);
    throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
  }

  const data = (await res.json()) as BraveSearchResponse;
  const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
  const mapped = results.map((entry) => ({
    title: entry.title ?? "",
    url: entry.url ?? "",
    description: entry.description ?? "",
    published: entry.age ?? undefined,
    siteName: resolveSiteName(entry.url ?? ""),
  }));

  const payload = {
    query: params.query,
    provider: params.provider,
    count: mapped.length,
    tookMs: Date.now() - start,
    results: mapped,
  };
  writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
  return payload;
}

export function createWebSearchTool(options?: {
  config?: MoltbotConfig;
  sandboxed?: boolean;
}): AnyAgentTool | null {
  const search = resolveSearchConfig(options?.config);
  if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) return null;

  const provider = resolveSearchProvider(search);
  const perplexityConfig = resolvePerplexityConfig(search);
  const bochaConfig = resolveBochaConfig(search);

  const description =
    provider === "perplexity"
      ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
      : provider === "bocha"
        ? "Search the web using Bocha Web Search API. Supports time-range (freshness) and optional text summary per result. Returns titles, URLs, and snippets; response format is Bing-compatible."
        : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";

  return {
    label: "Web Search",
    name: "web_search",
    description,
    parameters: WebSearchSchema,
    execute: async (_toolCallId, args) => {
      const perplexityAuth =
        provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
      const apiKey =
        provider === "perplexity"
          ? perplexityAuth?.apiKey
          : provider === "bocha"
            ? resolveBochaApiKey(bochaConfig)
            : resolveSearchApiKey(search);

      if (!apiKey) {
        return jsonResult(missingSearchKeyPayload(provider));
      }
      const params = args as Record<string, unknown>;
      const query = readStringParam(params, "query", { required: true });
      const count =
        readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined;
      const country = readStringParam(params, "country");
      const search_lang = readStringParam(params, "search_lang");
      const ui_lang = readStringParam(params, "ui_lang");
      const rawFreshness = readStringParam(params, "freshness");
      if (rawFreshness && provider !== "brave" && provider !== "bocha") {
        return jsonResult({
          error: "unsupported_freshness",
          message: "freshness is only supported by the Brave and Bocha web_search providers.",
          docs: "https://docs.molt.bot/tools/web",
        });
      }
      const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
      if (rawFreshness && !freshness) {
        return jsonResult({
          error: "invalid_freshness",
          message:
            "freshness must be one of pd, pw, pm, py, or a range like YYYY-MM-DDtoYYYY-MM-DD.",
          docs: "https://docs.molt.bot/tools/web",
        });
      }
      const result = await runWebSearch({
        query,
        count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
        apiKey,
        timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
        cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
        provider,
        country,
        search_lang,
        ui_lang,
        freshness,
        perplexityBaseUrl: resolvePerplexityBaseUrl(
          perplexityConfig,
          perplexityAuth?.source,
          perplexityAuth?.apiKey,
        ),
        perplexityModel: resolvePerplexityModel(perplexityConfig),
        bochaBaseUrl: resolveBochaBaseUrl(bochaConfig),
        bochaFreshness: provider === "bocha" && freshness ? mapFreshnessToBocha(freshness) : undefined,
        bochaSummary: bochaConfig?.summary,
      });
      return jsonResult(result);
    },
  };
}

export const __testing = {
  inferPerplexityBaseUrlFromApiKey,
  resolvePerplexityBaseUrl,
  normalizeFreshness,
  mapFreshnessToBocha,
} as const;

2. 修改文件:src/config/types.tools.ts

变更点 代码变更
provider 类型增加 bocha,配置层允许选择博查 tools.web.searchprovider 类型中, 扩展为 "brave" | "perplexity" | "bocha"
bocha 子配置对象,存放 apiKey、baseUrl、summary tools.web.search 下增加 bocha?: { apiKey?: string; baseUrl?: string; summary?: boolean; }

代码变更:约330行上下

点击查看变更代码 ```typescript search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; /** Search provider ("brave", "perplexity", or "bocha"). */ provider?: "brave" | "perplexity" | "bocha"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; /** Default search results count (1-10). */ maxResults?: number; /** Timeout in seconds for search requests. */ timeoutSeconds?: number; /** Cache TTL in minutes for search results. */ cacheTtlMinutes?: number; /** Perplexity-specific configuration (used when provider="perplexity"). */ perplexity?: { /** API key for Perplexity or OpenRouter (defaults to PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). */ apiKey?: string; /** Base URL for API requests (defaults to OpenRouter: https://openrouter.ai/api/v1). */ baseUrl?: string; /** Model to use (defaults to "perplexity/sonar-pro"). */ model?: string; }; /** Bocha Web Search configuration (used when provider="bocha"). */ bocha?: { /** API key (defaults to BOCHA_API_KEY env var). Get from https://open.bocha.cn */ apiKey?: string; /** Base URL (default: https://api.bocha.com). */ baseUrl?: string; /** Request text summary for each result (default: false). */ summary?: boolean; }; }; ```

变更位置:

变更位置

3. 修改文件:src/config/schema.ts

变更点 代码变更
provider 描述含 bocha,配置 UI 与文档中显示“可选 bocha” tools.web.search.provider 的描述文案中加入 "bocha"
博查相关键的显示名与描述,配置界面和帮助中可识别博查项 在 schema 的 key 名称/描述表中新增:tools.web.search.bocha.apiKeytools.web.search.bocha.baseUrltools.web.search.bocha.summary,并配上简短说明。

代码变更1:变动位置位于195行上下:

点击查看变更代码 ```ts "tools.web.search.bocha.apiKey": "Bocha Web Search API Key", "tools.web.search.bocha.baseUrl": "Bocha Web Search Base URL", "tools.web.search.bocha.summary": "Bocha Request Summary", ```

代码变更2:变动位置位于440行上下

点击查看变更代码 ```ts "tools.web.search.provider": 'Search provider ("brave", "perplexity", or "bocha").', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.bocha.apiKey": "Bocha Web Search API key (fallback: BOCHA_API_KEY env var). Get from https://open.bocha.cn", "tools.web.search.bocha.baseUrl": "Bocha API base URL (default: https://api.bocha.cn).", "tools.web.search.bocha.summary": "Request Bocha text summary per result (default: false).", ```

4. 修改文件:src/config/zod-schema.agent-runtime.ts

变更点 代码变更
provider 的 union 增加 bocha,运行时校验允许 provider=bocha ToolsWebSearchSchemaprovider 字段的 z.union 中增加 z.literal("bocha")
bocha 子对象 schema,校验 bocha.apiKey/baseUrl/summary ToolsWebSearchSchema.object() 中增加 bocha: z.object({ apiKey, baseUrl, summary }).strict().optional()

变更代码:

点击查看变更代码 ```ts export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), provider: z .union([z.literal("brave"), z.literal("perplexity"), z.literal("bocha")]) .optional(), apiKey: z.string().optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), perplexity: z .object({ apiKey: z.string().optional(), baseUrl: z.string().optional(), model: z.string().optional(), }) .strict() .optional(), bocha: z .object({ apiKey: z.string().optional(), baseUrl: z.string().optional(), summary: z.boolean().optional(), }) .strict() .optional(), }) ```

代码变更位置示意:

变更位置

5. 更新说明文档(可选)

  1. 新增 docs/bocha-search.md:说明博查 Web Search 简介、如何获取 API Key/Base URL、Moltbot 配置示例(provider: "bocha")、环境变量、工具参数(query、count、freshness)、API 约定与注意事项。

  2. 更新 docs/tools/web.md:在摘要与“Choosing a search provider”表格中增加 Bocha 一行;在 Requirements/Config 中补充 BOCHA_API_KEYtools.web.search.bocha.*


四、场景实战:在 Moltbot 里打造联网搜索智能体

完成上述自集成指南中的代码与配置变更并部署后,通过 MoltBot + Bocha,你可以构建出类似 Tom Osman 所描述的“超级智能体工作流”,让你的Agent窥见流动的世界。

三步使用

  1. 获取 API Key
    打开博查开放平台 https://open.bocha.cn,登录后在 API KEY 管理中创建或复制密钥。

  2. 配置 Moltbot
    在 Moltbot 配置中指定 tools.web.search.provider: "bocha",并填写博查的 apiKey(以及可选的 baseUrlsummary)。可通过 moltbot configure --section web 按安装向导填写,或直接编辑配置文件(如 ~/.clawdbot/moltbot.json)。环境变量 BOCHA_API_KEY 也可作为 Key 来源。

  3. 使用
    启动/重启 Gateway 后,在 MoltBot UI 中与 Bot 对话,当模型需要实时信息时会自动调用 web_search,此时将使用博查接口。

配置示例

方法一:直接编辑配置文件(如 ~/.clawdbot/moltbot.json)。

{
  tools: {
    web: {
      search: {
        provider: "bocha",
        bocha: {
          apiKey: "YOUR_BOCHA_API_KEY",
          baseUrl: "https://api.bocha.cn",
          summary: true
        },
        maxResults: 10
      }
    }
  }
}

方法二:前端平台内配置

可以直接通过前端中Config页面配置博查搜索插件,配置博查的API KEY、Base URL等参数,在大模型Token量可控的情况下,建议开启summary正文摘要参数,以便博查web search返回更完整的网页正文内容供大模型参考。

config配置websearch

最后开启联网搜索支持:

开启联网搜索支持

联网搜索

进入Chat页面,询问需要联网搜索的问题,智能体将自动调用Web Search并返回结果供配置好的大模型使用:

联网搜索

点击右上角Toggle还能展开显示工具思考或输出详情,可以看见中间博查联网搜索工具的详细召回结果:

联网搜索

有了博查联网搜索加持的 MoltBot, 我们可以开始畅想更多有趣且实用的应用场景。

它可以轻松完成十几家竞品对比、整理价格差异、生成行业分析报告等复杂任务。
也可以完成整理技术文档生成日志报告、发现并预订航班等日常事务……

正如 Tom Osman 所说:

“未来我们与工具的大部分交互,都将通过你的超级智能体以编程方式进行。”

MoltBot 正是一个让你提前进入这一未来的入口,希望博查的赋能能够让你的智能体与世界的同频!

Enjoy Coding and Enjoy Connecting with the World!

posted @ 2026-01-30 00:51  查查君的手记  阅读(1)  评论(0)    收藏  举报