Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/technical/tools-and-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 显式声明。

Expand Down Expand Up @@ -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` 则给用户渲染本地化的友好提示。
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/packages/web-search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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}`)
}
Expand Down Expand Up @@ -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<string> = new Set(['build-in', 'tavily'])
export const PROVIDERS_WITH_PARSE_LINK: ReadonlySet<string> = new Set(['build-in', 'tavily', 'kagi'])

/**
* Returns the first configured search provider that supports parseLink.
Expand Down
73 changes: 73 additions & 0 deletions src/renderer/packages/web-search/kagi.ts
Original file line number Diff line number Diff line change
@@ -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<SearchResult> {
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<ParseLinkResult> {
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 || '',
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 }) => {
Expand Down
77 changes: 76 additions & 1 deletion src/renderer/routes/settings/web-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -93,6 +94,23 @@ export function RouteComponent() {
}
}

const [checkingKagi, setCheckingKagi] = useState(false)
const [kagiAvailable, setKagiAvailable] = useState<boolean>()
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 (
<Stack p="md" gap="xxl">
<Title order={5}>{t('Web Search')}</Title>
Expand All @@ -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) =>
Expand All @@ -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',
},
},
})
Expand Down Expand Up @@ -214,6 +233,62 @@ export function RouteComponent() {
</Button>
</Stack>
)}
{/* Kagi API Key */}
{extension.webSearch.provider === 'kagi' && (
<Stack gap="xs">
<Text fw="600">{t('Kagi API Key')}</Text>
<Flex align="center" gap="xs">
<PasswordInput
flex={1}
maw={320}
value={extension.webSearch.kagiApiKey}
onChange={(e) => {
setKagiAvailable(undefined)
setSettings({
extension: {
...extension,
webSearch: {
...extension.webSearch,
kagiApiKey: e.currentTarget.value,
},
},
})
}}
error={kagiAvailable === false}
/>
<Button
color="blue"
variant="light"
onClick={checkKagi}
loading={checkingKagi}
disabled={!extension.webSearch.kagiApiKey?.trim()}
>
{t('Check')}
</Button>
</Flex>

{typeof kagiAvailable === 'boolean' ? (
kagiAvailable ? (
<Text size="xs" c="chatbox-success">
{t('Connection successful!')}
</Text>
) : (
<Text size="xs" c="chatbox-error">
{t('API key invalid!')}
</Text>
)
) : null}
<Button
variant="transparent"
size="compact-xs"
px={0}
className="self-start"
onClick={() => platform.openLink('https://kagi.com/api/keys')}
>
{t('Get API Key')}
</Button>
</Stack>
)}
{/* BoCha API Key */}
{extension.webSearch.provider === 'bocha' && (
<Stack gap="xs">
Expand Down
6 changes: 6 additions & 0 deletions src/shared/models/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OpenExtensionSettingButton>click here to open Settings</OpenExtensionSettingButton> 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 <OpenExtensionSettingButton>click here to open Settings</OpenExtensionSettingButton> and enter your API key, or choose a different search provider.',
},
parse_link_failed: {
name: 'parse_link_failed',
code: 20037,
Expand Down
3 changes: 2 additions & 1 deletion src/shared/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down