From c18abc6d8d7cd7fac15aa01d6115232c67c47cf8 Mon Sep 17 00:00:00 2001 From: Raphael Walker Date: Thu, 11 Jun 2026 17:12:14 +0200 Subject: [PATCH 1/2] support kagi search --- src/renderer/packages/web-search/index.ts | 9 ++- src/renderer/packages/web-search/kagi.ts | 73 ++++++++++++++++++ .../web-search/parse-link-consistency.test.ts | 2 + src/renderer/routes/settings/web-search.tsx | 77 ++++++++++++++++++- src/shared/models/errors.ts | 6 ++ src/shared/types/settings.ts | 3 +- 6 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/renderer/packages/web-search/kagi.ts diff --git a/src/renderer/packages/web-search/index.ts b/src/renderer/packages/web-search/index.ts index e1a5f360eb..654a140a14 100644 --- a/src/renderer/packages/web-search/index.ts +++ b/src/renderer/packages/web-search/index.ts @@ -9,6 +9,7 @@ import { BingSearch } from './bing' import { BingNewsSearch } from './bing-news' import { BochaSearch } from './bocha' import { ChatboxSearch } from './chatbox-search' +import { KagiSearch } from './kagi' import { QueritSearch } from './querit' import { TavilySearch } from './tavily' @@ -63,6 +64,12 @@ function getSearchProviders() { ) ) break + case 'kagi': + if (!settings.webSearch.kagiApiKey) { + throw ChatboxAIAPIError.fromCodeName('kagi_api_key_required', 'kagi_api_key_required') + } + selectedProviders.push(new KagiSearch(settings.webSearch.kagiApiKey)) + break default: throw new Error(`Unsupported search provider: ${provider}`) } @@ -131,7 +138,7 @@ export const webSearchExecutor = async ( * Single source of truth: which configured providers offer the parse_link tool. * Keep in sync with the provider classes' `supportsParseLink` flags. */ -export const PROVIDERS_WITH_PARSE_LINK: ReadonlySet = new Set(['build-in', 'tavily']) +export const PROVIDERS_WITH_PARSE_LINK: ReadonlySet = new Set(['build-in', 'tavily', 'kagi']) /** * Returns the first configured search provider that supports parseLink. diff --git a/src/renderer/packages/web-search/kagi.ts b/src/renderer/packages/web-search/kagi.ts new file mode 100644 index 0000000000..6591358ed8 --- /dev/null +++ b/src/renderer/packages/web-search/kagi.ts @@ -0,0 +1,73 @@ +import { ChatboxAIAPIError } from '@shared/models/errors' +import type { SearchResult } from '@shared/types' +import WebSearch, { type ParseLinkResult } from './base' + +export class KagiSearch extends WebSearch { + private apiKey: string + + override supportsParseLink = true + + constructor(apiKey: string) { + super() + this.apiKey = apiKey + } + + async search(query: string, signal?: AbortSignal): Promise { + try { + const response = await this.fetch('https://kagi.com/api/v1/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: { + query, + }, + signal, + }) + + const items = (response.data?.search || []).map((result: { url: string; title: string; snippet?: string }) => ({ + title: result.title, + link: result.url, + snippet: result.snippet || '', + })) + + return { items } + } catch (error) { + console.error('Kagi search error:', error) + throw error + } + } + + async parseLink(url: string, signal?: AbortSignal): Promise { + const response = await this.fetch('https://kagi.com/api/v1/extract', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: { + pages: [{ url }], + }, + signal, + }) + + const result = response.data?.[0] + if (!result || result.error) { + const technical = result?.error || `Kagi extract API returned no results for ${url}` + throw ChatboxAIAPIError.fromCodeName(technical, 'parse_link_failed') ?? new Error(technical) + } + + let title = url + try { + const hostname = new URL(url).hostname + if (hostname) title = hostname + } catch {} + + return { + url, + title, + content: result.markdown || '', + } + } +} \ No newline at end of file diff --git a/src/renderer/packages/web-search/parse-link-consistency.test.ts b/src/renderer/packages/web-search/parse-link-consistency.test.ts index 8791ef631e..9a454a9126 100644 --- a/src/renderer/packages/web-search/parse-link-consistency.test.ts +++ b/src/renderer/packages/web-search/parse-link-consistency.test.ts @@ -4,6 +4,7 @@ import { BingNewsSearch } from '@/packages/web-search/bing-news' import { BochaSearch } from '@/packages/web-search/bocha' import { ChatboxSearch } from '@/packages/web-search/chatbox-search' import { PROVIDERS_WITH_PARSE_LINK } from '@/packages/web-search' +import { KagiSearch } from '@/packages/web-search/kagi' import { QueritSearch } from '@/packages/web-search/querit' import { TavilySearch } from '@/packages/web-search/tavily' @@ -32,6 +33,7 @@ describe('parse_link capability consistency', () => { { id: 'tavily', instance: new TavilySearch('stub-api-key') }, { id: 'bocha', instance: new BochaSearch('stub-api-key') }, { id: 'querit', instance: new QueritSearch('stub-api-key') }, + { id: 'kagi', instance: new KagiSearch('stub-api-key') }, ] it.each(providers)('$id: PROVIDERS_WITH_PARSE_LINK matches supportsParseLink flag', ({ id, instance }) => { diff --git a/src/renderer/routes/settings/web-search.tsx b/src/renderer/routes/settings/web-search.tsx index 938aa0a8bc..d04803e4eb 100644 --- a/src/renderer/routes/settings/web-search.tsx +++ b/src/renderer/routes/settings/web-search.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import { AdaptiveSelect } from '@/components/AdaptiveSelect' import { PROVIDERS_WITH_PARSE_LINK } from '@/packages/web-search' import { BochaSearch } from '@/packages/web-search/bocha' +import { KagiSearch } from '@/packages/web-search/kagi' import { QUERIT_SEARCH_URL } from '@/packages/web-search/querit' import platform from '@/platform' import { trackJkClickEvent } from '@/analytics/jk' @@ -93,6 +94,23 @@ export function RouteComponent() { } } + const [checkingKagi, setCheckingKagi] = useState(false) + const [kagiAvailable, setKagiAvailable] = useState() + const checkKagi = async () => { + if (extension.webSearch.kagiApiKey) { + setCheckingKagi(true) + setKagiAvailable(undefined) + try { + await new KagiSearch(extension.webSearch.kagiApiKey).search('Chatbox') + setKagiAvailable(true) + } catch (e) { + setKagiAvailable(false) + } finally { + setCheckingKagi(false) + } + } + } + return ( {t('Web Search')} @@ -105,6 +123,7 @@ export function RouteComponent() { { value: 'tavily', label: 'Tavily' }, { value: 'bocha', label: 'BoCha' }, { value: 'querit', label: 'Querit' }, + { value: 'kagi', label: 'Kagi' }, ]} value={extension.webSearch.provider} onChange={(e) => @@ -114,7 +133,7 @@ export function RouteComponent() { ...extension, webSearch: { ...extension.webSearch, - provider: e as 'build-in' | 'bing' | 'tavily' | 'bocha' | 'querit', + provider: e as 'build-in' | 'bing' | 'tavily' | 'bocha' | 'querit' | 'kagi', }, }, }) @@ -214,6 +233,62 @@ export function RouteComponent() { )} + {/* Kagi API Key */} + {extension.webSearch.provider === 'kagi' && ( + + {t('Kagi API Key')} + + { + setKagiAvailable(undefined) + setSettings({ + extension: { + ...extension, + webSearch: { + ...extension.webSearch, + kagiApiKey: e.currentTarget.value, + }, + }, + }) + }} + error={kagiAvailable === false} + /> + + + + {typeof kagiAvailable === 'boolean' ? ( + kagiAvailable ? ( + + {t('Connection successful!')} + + ) : ( + + {t('API key invalid!')} + + ) + ) : null} + + + )} {/* BoCha API Key */} {extension.webSearch.provider === 'bocha' && ( diff --git a/src/shared/models/errors.ts b/src/shared/models/errors.ts index 26b7d79036..2c3def386c 100644 --- a/src/shared/models/errors.ts +++ b/src/shared/models/errors.ts @@ -292,6 +292,12 @@ export class ChatboxAIAPIError extends BaseError { i18nKey: 'You have selected BoCha as the search provider, but an API key has not been entered yet. Please click here to open Settings and enter your API key, or choose a different search provider.', }, + kagi_api_key_required: { + name: 'kagi_api_key_required', + code: 20036, + i18nKey: + 'You have selected Kagi as the search provider, but an API key has not been entered yet. Please click here to open Settings and enter your API key, or choose a different search provider.', + }, parse_link_failed: { name: 'parse_link_failed', code: 20037, diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index a062b36e8a..d52e67cd47 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -226,12 +226,13 @@ const ShortcutSettingSchema = z.object({ const ExtensionSettingsSchema = z.object({ webSearch: z.object({ - provider: z.enum(['build-in', 'bing', 'tavily', 'bocha', 'querit']).catch('build-in'), + provider: z.enum(['build-in', 'bing', 'tavily', 'bocha', 'querit', 'kagi']).catch('build-in'), tavilyApiKey: z.string().optional(), bochaApiKey: z.string().optional(), queritApiKey: z.string().optional(), queritMaxResults: z.number().optional(), queritTimeRange: z.string().nullable().optional(), + kagiApiKey: z.string().optional(), }), knowledgeBase: z .object({ From ac9e73b875f97e8ed0c085571672057ddaff4c61 Mon Sep 17 00:00:00 2001 From: Raphael Walker Date: Thu, 11 Jun 2026 17:15:29 +0200 Subject: [PATCH 2/2] add kagi docs --- docs/technical/tools-and-integrations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/technical/tools-and-integrations.md b/docs/technical/tools-and-integrations.md index fbbcd7ac81..6f81274ebb 100644 --- a/docs/technical/tools-and-integrations.md +++ b/docs/technical/tools-and-integrations.md @@ -69,6 +69,7 @@ Web Search 采用抽象基类模式(`src/renderer/packages/web-search/base.ts` | Tavily | `tavily.ts` | ✓ | ✓(调用 `/extract`) | 高质量 AI 搜索,需用户自备 API Key | | BoCha | `bocha.ts` | ✓ | ✗ | 国内搜索 API | | Querit | `querit.ts` | ✓ | ✗ | 多源聚合搜索 | +| Kagi | `kagi.ts` | ✓ | ✓(调用 `/extract`) | 高质量搜索与内容提取,需用户自备 API Key | 每个供应商通过 `supportsParseLink` 实例标志声明自己是否实现了 `parseLink`。基类默认返回 `false`,需要的子类用 `override supportsParseLink = true` 显式声明。 @@ -134,6 +135,7 @@ Web Search 采用抽象基类模式(`src/renderer/packages/web-search/base.ts` |--------|---------|---------| | `build-in` (Chatbox AI) | 检查 `licenseKey` → 调用 `remote.parseUserLinkPro` | 缺 license 抛 `chatbox_search_license_key_required`(后端不限制 tier,任意 license 均可调用) | | `tavily` | `getParseLinkProvider().parseLink()` → Tavily `/extract` API | 缺 API key 抛 `tavily_api_key_required`;提取空抛 `parse_link_failed` | +| `kagi` | `getParseLinkProvider().parseLink()` → Kagi `/extract` API | 缺 API key 抛 `kagi_api_key_required`;提取空抛 `parse_link_failed` | | 其他(`bing` / `bocha` / `querit`) | 不会注入 `parse_link`,模型看不到此工具 | — | 错误抛出采用 AI/用户双层结构:`Error.message`(传给 `ChatboxAIAPIError` 构造器的第一参数)携带技术原因供 AI 推理(例如 "Tavily extract API returned no results for {url}"),`detail.i18nKey` 则给用户渲染本地化的友好提示。