1
0
Эх сурвалжийг харах

feat(web-search): 恢复设置入口并接入国内搜索源 (#35)

挂载网页搜索设置与启动 hydrate,新增博查/七牛/秘塔,并按官方响应结构归一化结果,修复 web_search 一直 not_configured。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 сар өмнө
parent
commit
aeb6b40d03

+ 5 - 1
src/App.tsx

@@ -6,7 +6,7 @@ import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
@@ -298,6 +298,10 @@ function App() {
         }
         const savedMcpConfig = await loadMcpConfig()
         useWikiStore.getState().setMcpConfig(savedMcpConfig)
+        const savedSearchApiConfig = await loadSearchApiConfig()
+        if (savedSearchApiConfig) {
+          useWikiStore.getState().setSearchApiConfig(savedSearchApiConfig)
+        }
         const savedProxy = await loadProxyConfig()
         if (savedProxy) {
           useWikiStore.getState().setProxyConfig(savedProxy)

+ 31 - 10
src/components/settings/sections/web-search-section.tsx

@@ -13,26 +13,47 @@ import { SEARXNG_CATEGORY_OPTIONS, SERPAPI_ENGINE_OPTIONS, resolveSearchConfig }
 
 const SEARCH_PROVIDERS = [
   {
-    id: "tavily",
-    label: "Tavily",
-    hint: "General web search for Deep Research",
-    keyPlaceholder: "Enter your Tavily API key (tavily.com)",
+    id: "bocha",
+    label: "博查 Bocha",
+    hint: "国内推荐 · AI Agent 中文搜索(open.bochaai.com)",
+    keyPlaceholder: "博查 API Key",
     needsApiKey: true,
   },
   {
-    id: "serpapi",
-    label: "SerpApi",
-    hint: "Google, Bing, DuckDuckGo, Scholar, News, Images, Videos, YouTube",
-    keyPlaceholder: "Enter your SerpApi API key (serpapi.com)",
+    id: "qiniu",
+    label: "七牛(百度搜索)",
+    hint: "国内二级 · 百度索引,适合中文新闻与社区(api.qnaigc.com)",
+    keyPlaceholder: "七牛云 AI API Key",
+    needsApiKey: true,
+  },
+  {
+    id: "metaso",
+    label: "秘塔 Metaso",
+    hint: "国内二级 · AI 搜索,结果干净(metaso.cn)",
+    keyPlaceholder: "秘塔 API Key",
     needsApiKey: true,
   },
   {
     id: "searxng",
     label: "SearXNG",
-    hint: "Self-hosted metasearch via the SearXNG JSON API",
+    hint: "自建 · SearXNG JSON API",
     urlPlaceholder: "https://search.example.com",
     needsApiKey: false,
   },
+  {
+    id: "tavily",
+    label: "Tavily",
+    hint: "国际 · Agent 生态通用搜索(tavily.com)",
+    keyPlaceholder: "Tavily API key",
+    needsApiKey: true,
+  },
+  {
+    id: "serpapi",
+    label: "SerpApi",
+    hint: "国际 · Google / Bing / DuckDuckGo 等 SERP(serpapi.com)",
+    keyPlaceholder: "SerpApi API key",
+    needsApiKey: true,
+  },
 ] as const
 
 export function WebSearchSection() {
@@ -70,7 +91,7 @@ export function WebSearchSection() {
   return (
     <div className="space-y-4">
       <div>
-        <h2 className="text-xl font-semibold">{t("settings.sections.webSearch.title")} (Deep Research)</h2>
+        <h2 className="text-xl font-semibold">{t("settings.sections.webSearch.title")}</h2>
         <p className="mt-1 text-sm text-muted-foreground">
           {t("settings.sections.webSearch.description")}
         </p>

+ 6 - 0
src/components/settings/settings-view.tsx

@@ -15,6 +15,7 @@ import {
   FileText,
   Download,
   Brain,
+  Search,
 } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import i18n from "@/i18n"
@@ -35,6 +36,7 @@ import { InterfaceSection } from "./sections/interface-section"
 import { NovelSection } from "./sections/novel-section"
 import { ClassificationSection } from "./sections/classification-section"
 import { NetworkSection } from "./sections/network-section"
+import { WebSearchSection } from "./sections/web-search-section"
 import { McpSection } from "./sections/mcp-section"
 import { ChangelogSection } from "./sections/changelog-section"
 import { MaintenanceSection } from "./sections/maintenance-section"
@@ -50,6 +52,7 @@ type CategoryId =
   | "rerank"
   | "embedding"
   | "network"
+  | "web-search"
   | "mcp"
   | "interface"
   | "novel"
@@ -80,6 +83,7 @@ const CATEGORIES: Category[] = [
   { id: "rerank", labelKey: "settings.categories.rerank", icon: ListFilter },
   { id: "embedding", labelKey: "settings.categories.embedding", icon: Database },
   { id: "network", labelKey: "settings.categories.network", icon: Network },
+  { id: "web-search", labelKey: "settings.categories.webSearch", icon: Search },
   { id: "mcp", labelKey: "settings.categories.mcp", icon: Network },
   { id: "interface", labelKey: "settings.categories.interface", icon: Palette },
   { id: "novel", labelKey: "settings.categories.novel", hintKey: "settings.categories.novelHint", icon: BookOpen },
@@ -534,6 +538,8 @@ export function SettingsView() {
         return <EmbeddingSection draft={draft} setDraft={setDraft} />
       case "network":
         return <NetworkSection draft={draft} setDraft={setDraft} />
+      case "web-search":
+        return <WebSearchSection />
       case "mcp":
         return <McpSection />
       case "interface":

+ 58 - 0
src/components/settings/web-search-section.spec.ts

@@ -0,0 +1,58 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import zh from "@/i18n/zh.json"
+import en from "@/i18n/en.json"
+
+const settingsViewSource = readFileSync(resolve(__dirname, "settings-view.tsx"), "utf8")
+const webSearchSectionSource = readFileSync(
+  resolve(__dirname, "sections/web-search-section.tsx"),
+  "utf8",
+)
+const appSource = readFileSync(resolve(process.cwd(), "src/App.tsx"), "utf8")
+
+describe("Web Search settings restore", () => {
+  it("mounts web-search as an independent settings category between network and mcp", () => {
+    expect(settingsViewSource).toContain('| "web-search"')
+    expect(settingsViewSource).toContain(
+      '{ id: "web-search", labelKey: "settings.categories.webSearch", icon: Search }',
+    )
+    expect(settingsViewSource).toContain('case "web-search":')
+    expect(settingsViewSource).toContain("return <WebSearchSection />")
+    expect(settingsViewSource).toContain('import { WebSearchSection } from "./sections/web-search-section"')
+
+    const networkIdx = settingsViewSource.indexOf('{ id: "network"')
+    const webSearchIdx = settingsViewSource.indexOf('{ id: "web-search"')
+    const mcpIdx = settingsViewSource.indexOf('{ id: "mcp"')
+    expect(networkIdx).toBeGreaterThan(-1)
+    expect(webSearchIdx).toBeGreaterThan(networkIdx)
+    expect(mcpIdx).toBeGreaterThan(webSearchIdx)
+  })
+
+  it("lists domestic providers before self-hosted and international ones", () => {
+    const order = ["bocha", "qiniu", "metaso", "searxng", "tavily", "serpapi"].map((id) =>
+      webSearchSectionSource.indexOf(`id: "${id}"`),
+    )
+    for (let i = 1; i < order.length; i++) {
+      expect(order[i]).toBeGreaterThan(order[i - 1])
+    }
+    expect(webSearchSectionSource).toContain("国内推荐")
+    expect(webSearchSectionSource).toContain("国内二级")
+    expect(webSearchSectionSource).toContain("自建")
+    expect(webSearchSectionSource).toContain("国际")
+  })
+
+  it("hydrates searchApiConfig on app startup", () => {
+    expect(appSource).toContain("loadSearchApiConfig")
+    expect(appSource).toContain("setSearchApiConfig(savedSearchApiConfig)")
+  })
+
+  it("provides Chinese and English category/section copy", () => {
+    expect(zh.settings.categories.webSearch).toBe("网页搜索")
+    expect(zh.settings.sections.webSearch.title).toBe("网页搜索")
+    expect(zh.settings.sections.webSearch.description).toContain("博查")
+    expect(en.settings.categories.webSearch).toBe("Web Search")
+    expect(en.settings.sections.webSearch.title).toBe("Web Search")
+    expect(en.settings.sections.webSearch.description).toContain("Bocha")
+  })
+})

+ 6 - 0
src/hooks/use-agent-config.spec.ts

@@ -140,6 +140,12 @@ async function renderHook(systemPrompt: string, overrides: StoreStates & {
 
   vi.doMock("@/lib/web-search", () => ({
     resolveSearchConfig: (config: SearchApiConfig) => config,
+    providerRequiresApiKey: (provider: SearchApiConfig["provider"]) =>
+      provider === "bocha" ||
+      provider === "qiniu" ||
+      provider === "metaso" ||
+      provider === "tavily" ||
+      provider === "serpapi",
     webSearch: (...args: unknown[]) => webSearchMock(...args),
   }))
 

+ 1 - 1
src/i18n/en.json

@@ -959,7 +959,7 @@
       },
       "webSearch": {
         "title": "Web Search",
-        "description": "Deep Research uses this to fetch fresh external context.",
+        "description": "Configure Agent web search. Prefer Bocha for Chinese; Qiniu (Baidu), Metaso, self-hosted SearXNG, or international Tavily / SerpApi are also available.",
         "expand": "Expand",
         "collapse": "Collapse",
         "activate": "Activate",

+ 1 - 1
src/i18n/zh.json

@@ -666,7 +666,7 @@
       },
       "webSearch": {
         "title": "网页搜索",
-        "description": "Deep Research 会用它拉取最新的外部上下文。",
+        "description": "配置 Agent 联网搜索。国内推荐博查;也可选七牛(百度)、秘塔,或自建 SearXNG / 国际 Tavily、SerpApi。",
         "expand": "展开",
         "collapse": "收起",
         "activate": "启用",

+ 8 - 0
src/lib/agent/tools/web-search.spec.ts

@@ -6,6 +6,12 @@ const webSearchMock = vi.fn()
 
 vi.mock("@/lib/web-search", () => ({
   resolveSearchConfig: (config: SearchApiConfig) => config,
+  providerRequiresApiKey: (provider: SearchApiConfig["provider"]) =>
+    provider === "bocha" ||
+    provider === "qiniu" ||
+    provider === "metaso" ||
+    provider === "tavily" ||
+    provider === "serpapi",
   webSearch: (...args: unknown[]) => webSearchMock(...args),
 }))
 
@@ -43,6 +49,8 @@ describe("createWebSearchTool", () => {
     expect(result.resultCount).toBe(0)
     expect(result.message).toContain("当前未配置外部搜索")
     expect(result.message).toContain("未执行联网搜索")
+    expect(result.message).toContain("设置 → 网页搜索")
+    expect(result.message).toContain("博查")
     expect(webSearchMock).not.toHaveBeenCalled()
   })
 

+ 3 - 3
src/lib/agent/tools/web-search.ts

@@ -1,5 +1,5 @@
 import type { Tool } from "../types"
-import { resolveSearchConfig, webSearch } from "@/lib/web-search"
+import { providerRequiresApiKey, resolveSearchConfig, webSearch } from "@/lib/web-search"
 import type { SearchApiConfig } from "@/stores/wiki-store"
 
 export interface WebSearchToolResult {
@@ -26,7 +26,7 @@ function isSearchConfigured(config: SearchApiConfig | null | undefined): config
   if (!config) return false
   const resolved = resolveSearchConfig(config)
   if (resolved.provider === "none") return false
-  if ((resolved.provider === "tavily" || resolved.provider === "serpapi") && !resolved.apiKey?.trim()) return false
+  if (providerRequiresApiKey(resolved.provider) && !resolved.apiKey?.trim()) return false
   if (resolved.provider === "searxng" && !resolved.searXngUrl?.trim()) return false
   return true
 }
@@ -65,7 +65,7 @@ export function createWebSearchTool(getSearchApiConfig?: () => SearchApiConfig |
           provider,
           resultCount: 0,
           results: [],
-          message: "当前未配置外部搜索,无法联网查询。未执行联网搜索;我可以基于模型已有知识回答,或你可以先在设置中配置 Web Search 后重试。",
+          message: "当前未配置外部搜索,无法联网查询。未执行联网搜索;我可以基于模型已有知识回答,或你可先在「设置 → 网页搜索」配置博查/七牛/秘塔等后重试。",
         }
         return JSON.stringify(result)
       }

+ 294 - 0
src/lib/web-search.spec.ts

@@ -0,0 +1,294 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { SearchApiConfig } from "@/stores/wiki-store"
+import {
+  normalizeBochaResults,
+  normalizeMetasoResults,
+  normalizeQiniuResults,
+  providerRequiresApiKey,
+  webSearch,
+} from "./web-search"
+
+const fetchMock = vi.fn()
+
+vi.mock("@/lib/tauri-fetch", () => ({
+  getHttpFetch: async () => fetchMock,
+  isFetchNetworkError: () => false,
+}))
+
+function makeResponse(status: number, body: unknown): Response {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { "content-type": "application/json" },
+  })
+}
+
+function config(provider: SearchApiConfig["provider"], apiKey = "test-key"): SearchApiConfig {
+  return {
+    provider,
+    apiKey,
+    serpApiEngine: "google",
+    searXngUrl: "",
+    searXngCategories: ["general"],
+    providerConfigs: {
+      [provider]: { apiKey },
+    },
+  }
+}
+
+describe("CN web search providers", () => {
+  beforeEach(() => {
+    fetchMock.mockReset()
+  })
+
+  afterEach(() => {
+    fetchMock.mockReset()
+  })
+
+  it("providerRequiresApiKey covers domestic and international key-based providers", () => {
+    expect(providerRequiresApiKey("bocha")).toBe(true)
+    expect(providerRequiresApiKey("qiniu")).toBe(true)
+    expect(providerRequiresApiKey("metaso")).toBe(true)
+    expect(providerRequiresApiKey("tavily")).toBe(true)
+    expect(providerRequiresApiKey("serpapi")).toBe(true)
+    expect(providerRequiresApiKey("searxng")).toBe(false)
+    expect(providerRequiresApiKey("none")).toBe(false)
+  })
+
+  it("normalizeBochaResults maps nested data.webPages.value and bare webPages", () => {
+    const nested = normalizeBochaResults(
+      {
+        code: 200,
+        data: {
+          webPages: {
+            value: [
+              {
+                name: "博查结果",
+                url: "https://www.example.com/a",
+                snippet: "摘要",
+                siteName: "example",
+              },
+              {
+                title: "fallback title",
+                url: "https://news.example.com/b",
+                summary: "summary only",
+              },
+              { name: "no url" },
+            ],
+          },
+        },
+      },
+      10,
+    )
+
+    expect(nested).toEqual([
+      {
+        title: "博查结果",
+        url: "https://www.example.com/a",
+        snippet: "摘要",
+        source: "example",
+      },
+      {
+        title: "fallback title",
+        url: "https://news.example.com/b",
+        snippet: "summary only",
+        source: "news.example.com",
+      },
+    ])
+
+    // Prefer summary over short snippet when both exist.
+    expect(
+      normalizeBochaResults(
+        {
+          code: 200,
+          data: {
+            webPages: {
+              value: [
+                {
+                  name: "both",
+                  url: "https://example.com/both",
+                  snippet: "short",
+                  summary: "long summary",
+                  siteName: "ex",
+                },
+              ],
+            },
+          },
+        },
+        5,
+      )[0].snippet,
+    ).toBe("long summary")
+
+    expect(
+      normalizeBochaResults(
+        {
+          webPages: {
+            value: [{ name: "bare", url: "https://bare.example/", snippet: "x" }],
+          },
+        },
+        5,
+      ),
+    ).toHaveLength(1)
+
+    expect(() => normalizeBochaResults({ code: 401, msg: "unauthorized" }, 5)).toThrow("unauthorized")
+  })
+
+  it("normalizeQiniuResults maps data.results and rejects non-success", () => {
+    expect(() =>
+      normalizeQiniuResults({ success: false, message: "quota exceeded" }, 5),
+    ).toThrow("quota exceeded")
+    expect(() => normalizeQiniuResults({ success: undefined }, 5)).toThrow("Qiniu web search failed")
+
+    const results = normalizeQiniuResults(
+      {
+        success: true,
+        data: {
+          results: [
+            {
+              title: "七牛结果",
+              url: "https://baidu.example.com/x",
+              content: "正文",
+              source: "百度",
+            },
+          ],
+        },
+      },
+      5,
+    )
+    expect(results).toEqual([
+      {
+        title: "七牛结果",
+        url: "https://baidu.example.com/x",
+        snippet: "正文",
+        source: "百度",
+      },
+    ])
+  })
+
+  it("normalizeMetasoResults maps webpages link/snippet", () => {
+    const results = normalizeMetasoResults(
+      {
+        webpages: [
+          {
+            title: "秘塔结果",
+            link: "https://www.metaso.example/page",
+            snippet: "干净摘要",
+          },
+        ],
+      },
+      3,
+    )
+    expect(results).toEqual([
+      {
+        title: "秘塔结果",
+        url: "https://www.metaso.example/page",
+        snippet: "干净摘要",
+        source: "metaso.example",
+      },
+    ])
+  })
+
+  it("bochaSearch posts Bearer auth and returns normalized results", async () => {
+    fetchMock.mockResolvedValueOnce(
+      makeResponse(200, {
+        code: 200,
+        data: {
+          webPages: {
+            value: [
+              {
+                name: "博查",
+                url: "https://example.com/bocha",
+                snippet: "ok",
+                siteName: "example",
+              },
+            ],
+          },
+        },
+      }),
+    )
+
+    const results = await webSearch("黄蓉", config("bocha"), 3)
+
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.bochaai.com/v1/web-search",
+      expect.objectContaining({
+        method: "POST",
+        headers: expect.objectContaining({
+          Authorization: "Bearer test-key",
+        }),
+      }),
+    )
+    expect(results[0]).toMatchObject({
+      title: "博查",
+      url: "https://example.com/bocha",
+      source: "example",
+    })
+  })
+
+  it("qiniuSearch posts Bearer auth and returns normalized results", async () => {
+    fetchMock.mockResolvedValueOnce(
+      makeResponse(200, {
+        success: true,
+        data: {
+          results: [
+            {
+              title: "七牛",
+              url: "https://example.com/qiniu",
+              content: "baidu",
+              source: "baidu",
+            },
+          ],
+        },
+      }),
+    )
+
+    const results = await webSearch("黄蓉", config("qiniu"), 2)
+
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://api.qnaigc.com/v1/search/web",
+      expect.objectContaining({
+        method: "POST",
+        headers: expect.objectContaining({
+          Authorization: "Bearer test-key",
+        }),
+      }),
+    )
+    expect(results[0].title).toBe("七牛")
+  })
+
+  it("metasoSearch posts Bearer auth and returns normalized results", async () => {
+    fetchMock.mockResolvedValueOnce(
+      makeResponse(200, {
+        webpages: [
+          {
+            title: "秘塔",
+            link: "https://example.com/metaso",
+            snippet: "clean",
+          },
+        ],
+      }),
+    )
+
+    const results = await webSearch("黄蓉", config("metaso"), 2)
+
+    expect(fetchMock).toHaveBeenCalledWith(
+      "https://metaso.cn/api/v1/search",
+      expect.objectContaining({
+        method: "POST",
+        headers: expect.objectContaining({
+          Authorization: "Bearer test-key",
+        }),
+      }),
+    )
+    expect(results[0]).toMatchObject({
+      title: "秘塔",
+      url: "https://example.com/metaso",
+      source: "example.com",
+    })
+  })
+
+  it("rejects missing api key for domestic providers", async () => {
+    await expect(webSearch("q", config("bocha", ""))).rejects.toThrow("Settings → 网页搜索")
+    await expect(webSearch("q", config("qiniu", ""))).rejects.toThrow("Settings → 网页搜索")
+    await expect(webSearch("q", config("metaso", ""))).rejects.toThrow("Settings → 网页搜索")
+  })
+})

+ 227 - 4
src/lib/web-search.ts

@@ -86,6 +86,18 @@ export function resolveSearchConfig(config: SearchApiConfig): SearchApiConfig {
   }
 }
 
+const API_KEY_PROVIDERS = new Set<SearchProvider>([
+  "bocha",
+  "qiniu",
+  "metaso",
+  "tavily",
+  "serpapi",
+])
+
+export function providerRequiresApiKey(provider: SearchProvider): boolean {
+  return API_KEY_PROVIDERS.has(provider)
+}
+
 export async function webSearch(
   query: string,
   config: SearchApiConfig,
@@ -93,16 +105,22 @@ export async function webSearch(
 ): Promise<WebSearchResult[]> {
   const resolved = resolveSearchConfig(config)
   if (resolved.provider === "none") {
-    throw new Error("Web search not configured. Select a search provider in Settings.")
+    throw new Error("Web search not configured. Select a search provider in Settings → 网页搜索.")
   }
-  if ((resolved.provider === "tavily" || resolved.provider === "serpapi") && !resolved.apiKey) {
-    throw new Error("Web search not configured. Add a Tavily or SerpApi API key in Settings.")
+  if (providerRequiresApiKey(resolved.provider) && !resolved.apiKey?.trim()) {
+    throw new Error("Web search not configured. Add an API key in Settings → 网页搜索.")
   }
   if (resolved.provider === "searxng" && !resolved.searXngUrl?.trim()) {
-    throw new Error("Web search not configured. Add a SearXNG instance URL in Settings.")
+    throw new Error("Web search not configured. Add a SearXNG instance URL in Settings → 网页搜索.")
   }
 
   switch (resolved.provider) {
+    case "bocha":
+      return bochaSearch(query, resolved.apiKey, maxResults)
+    case "qiniu":
+      return qiniuSearch(query, resolved.apiKey, maxResults)
+    case "metaso":
+      return metasoSearch(query, resolved.apiKey, maxResults)
     case "tavily":
       return tavilySearch(query, resolved.apiKey, maxResults)
     case "serpapi":
@@ -201,6 +219,211 @@ function hostnameFromUrl(url: string): string {
   }
 }
 
+export function normalizeBochaResults(data: {
+  code?: number | string
+  msg?: string | null
+  message?: string
+  data?: { webPages?: { value?: unknown[] } }
+  webPages?: { value?: unknown[] }
+}, maxResults: number): WebSearchResult[] {
+  // Bocha wraps Bing-style payload as `{ code, data: { webPages } }`.
+  // Also accept a bare `{ webPages }` body for tests / older docs.
+  if (data.code != null && Number(data.code) !== 200) {
+    throw new Error(data.msg?.trim() || data.message?.trim() || `Bocha search failed (code ${data.code})`)
+  }
+  const pages = data.data?.webPages?.value ?? data.webPages?.value ?? []
+  return pages
+    .slice(0, maxResults)
+    .map((item) => {
+      const r = item as {
+        name?: string
+        title?: string
+        url?: string
+        snippet?: string
+        summary?: string
+        siteName?: string
+      }
+      const url = r.url ?? ""
+      return {
+        title: r.name ?? r.title ?? "Untitled",
+        url,
+        // Docs: snippet = short hit; summary = longer page summary (when summary:true).
+        snippet: r.summary ?? r.snippet ?? "",
+        source: r.siteName || hostnameFromUrl(url),
+      }
+    })
+    .filter((item) => item.url.length > 0)
+}
+
+export function normalizeQiniuResults(data: {
+  success?: boolean
+  message?: string
+  data?: { results?: unknown[] }
+}, maxResults: number): WebSearchResult[] {
+  if (data.success !== true) {
+    throw new Error(data.message?.trim() || "Qiniu web search failed")
+  }
+  return (data.data?.results ?? [])
+    .slice(0, maxResults)
+    .map((item) => {
+      const r = item as {
+        title?: string
+        url?: string
+        content?: string
+        source?: string
+      }
+      const url = r.url ?? ""
+      return {
+        title: r.title ?? "Untitled",
+        url,
+        snippet: r.content ?? "",
+        source: r.source || hostnameFromUrl(url),
+      }
+    })
+    .filter((item) => item.url.length > 0)
+}
+
+export function normalizeMetasoResults(data: {
+  webpages?: unknown[]
+}, maxResults: number): WebSearchResult[] {
+  return (data.webpages ?? [])
+    .slice(0, maxResults)
+    .map((item) => {
+      const r = item as {
+        title?: string
+        link?: string
+        url?: string
+        snippet?: string
+        summary?: string
+      }
+      const url = r.link ?? r.url ?? ""
+      return {
+        title: r.title ?? "Untitled",
+        url,
+        snippet: r.snippet ?? r.summary ?? "",
+        source: hostnameFromUrl(url),
+      }
+    })
+    .filter((item) => item.url.length > 0)
+}
+
+async function bochaSearch(
+  query: string,
+  apiKey: string,
+  maxResults: number,
+): Promise<WebSearchResult[]> {
+  const httpFetch = await getHttpFetch()
+  let response: Response
+  try {
+    response = await httpFetch("https://api.bochaai.com/v1/web-search", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${apiKey}`,
+      },
+      body: JSON.stringify({
+        query,
+        count: maxResults,
+        summary: true,
+        // Official default / skill guidance: let the API rewrite time range from the query.
+        freshness: "noLimit",
+      }),
+    })
+  } catch (err) {
+    if (isFetchNetworkError(err)) {
+      throw new Error(
+        "Network error reaching api.bochaai.com. Check connectivity and whether the Bocha API key is still valid.",
+      )
+    }
+    throw err
+  }
+
+  if (!response.ok) {
+    const errorText = await response.text().catch(() => "Unknown error")
+    throw new Error(`Bocha search failed (${response.status}): ${errorText}`)
+  }
+
+  return normalizeBochaResults(await response.json(), maxResults)
+}
+
+async function qiniuSearch(
+  query: string,
+  apiKey: string,
+  maxResults: number,
+): Promise<WebSearchResult[]> {
+  const httpFetch = await getHttpFetch()
+  let response: Response
+  try {
+    response = await httpFetch("https://api.qnaigc.com/v1/search/web", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${apiKey}`,
+      },
+      body: JSON.stringify({
+        query,
+        max_results: maxResults,
+        search_type: "web",
+      }),
+    })
+  } catch (err) {
+    if (isFetchNetworkError(err)) {
+      throw new Error(
+        "Network error reaching api.qnaigc.com. Check connectivity and whether the Qiniu AI API key is still valid.",
+      )
+    }
+    throw err
+  }
+
+  if (!response.ok) {
+    const errorText = await response.text().catch(() => "Unknown error")
+    throw new Error(`Qiniu search failed (${response.status}): ${errorText}`)
+  }
+
+  return normalizeQiniuResults(await response.json(), maxResults)
+}
+
+async function metasoSearch(
+  query: string,
+  apiKey: string,
+  maxResults: number,
+): Promise<WebSearchResult[]> {
+  const httpFetch = await getHttpFetch()
+  let response: Response
+  try {
+    response = await httpFetch("https://metaso.cn/api/v1/search", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Accept: "application/json",
+        Authorization: `Bearer ${apiKey}`,
+      },
+      body: JSON.stringify({
+        q: query,
+        scope: "webpage",
+        size: maxResults,
+        includeSummary: false,
+        includeRawContent: false,
+        conciseSnippet: false,
+      }),
+    })
+  } catch (err) {
+    if (isFetchNetworkError(err)) {
+      throw new Error(
+        "Network error reaching metaso.cn. Check connectivity and whether the Metaso API key is still valid.",
+      )
+    }
+    throw err
+  }
+
+  if (!response.ok) {
+    const errorText = await response.text().catch(() => "Unknown error")
+    throw new Error(`Metaso search failed (${response.status}): ${errorText}`)
+  }
+
+  return normalizeMetasoResults(await response.json(), maxResults)
+}
+
 async function tavilySearch(
   query: string,
   apiKey: string,

+ 1 - 1
src/stores/wiki-store.ts

@@ -149,7 +149,7 @@ interface LlmConfig {
   functionCallingEnabled?: boolean
 }
 
-export type SearchProvider = "tavily" | "serpapi" | "searxng" | "none"
+export type SearchProvider = "bocha" | "qiniu" | "metaso" | "tavily" | "serpapi" | "searxng" | "none"
 export type SerpApiEngine =
   | "google"
   | "google_news"