Compare commits
2 Commits
chore/libs
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d6fbe8f4 | ||
|
|
ff378ca567 |
@@ -24,8 +24,10 @@ import { generateText } from 'ai'
|
|||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
|
|
||||||
import { MemoryProcessor } from '../../services/MemoryProcessor'
|
import { MemoryProcessor } from '../../services/MemoryProcessor'
|
||||||
|
import { exaSearchTool } from '../tools/ExaSearchTool'
|
||||||
import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
|
import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
|
||||||
import { memorySearchTool } from '../tools/MemorySearchTool'
|
import { memorySearchTool } from '../tools/MemorySearchTool'
|
||||||
|
import { tavilySearchTool } from '../tools/TavilySearchTool'
|
||||||
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
|
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
|
||||||
|
|
||||||
const logger = loggerService.withContext('SearchOrchestrationPlugin')
|
const logger = loggerService.withContext('SearchOrchestrationPlugin')
|
||||||
@@ -316,13 +318,28 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
|
|||||||
const needsSearch = analysisResult.websearch.question && analysisResult.websearch.question[0] !== 'not_needed'
|
const needsSearch = analysisResult.websearch.question && analysisResult.websearch.question[0] !== 'not_needed'
|
||||||
|
|
||||||
if (needsSearch) {
|
if (needsSearch) {
|
||||||
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
|
// 根据 Provider ID 动态选择工具
|
||||||
// logger.info('🌐 Adding web search tool with pre-extracted keywords')
|
switch (assistant.webSearchProviderId) {
|
||||||
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
|
case 'exa':
|
||||||
assistant.webSearchProviderId,
|
logger.info('🌐 Adding Exa search tool (provider-specific)')
|
||||||
analysisResult.websearch,
|
// Exa 工具直接接受单个查询字符串,使用第一个问题或合并所有问题
|
||||||
context.requestId
|
params.tools['builtin_exa_search'] = exaSearchTool(context.requestId)
|
||||||
)
|
break
|
||||||
|
case 'tavily':
|
||||||
|
logger.info('🌐 Adding Tavily search tool (provider-specific)')
|
||||||
|
// Tavily 工具直接接受单个查询字符串
|
||||||
|
params.tools['builtin_tavily_search'] = tavilySearchTool(context.requestId)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.info('🌐 Adding web search tool with pre-extracted keywords')
|
||||||
|
// 其他 Provider 使用通用的 WebSearchTool
|
||||||
|
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
|
||||||
|
assistant.webSearchProviderId,
|
||||||
|
analysisResult.websearch,
|
||||||
|
context.requestId
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
166
src/renderer/src/aiCore/tools/ExaSearchTool.ts
Normal file
166
src/renderer/src/aiCore/tools/ExaSearchTool.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||||
|
import WebSearchService from '@renderer/services/WebSearchService'
|
||||||
|
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
|
||||||
|
import { ExtractResults } from '@renderer/utils/extract'
|
||||||
|
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ExaSearchTool')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exa 专用搜索工具 - 暴露 Exa 的高级搜索能力给 LLM
|
||||||
|
* 支持 Neural Search、Category Filtering、Date Range 等功能
|
||||||
|
*/
|
||||||
|
export const exaSearchTool = (requestId: string) => {
|
||||||
|
const webSearchProvider = WebSearchService.getWebSearchProvider('exa')
|
||||||
|
|
||||||
|
if (!webSearchProvider) {
|
||||||
|
throw new Error('Exa provider not found or not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
name: 'builtin_exa_search',
|
||||||
|
description: `Advanced AI-powered search using Exa.ai with neural understanding and filtering capabilities.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Neural Search: AI-powered semantic search that understands intent
|
||||||
|
- Search Type: Choose between neural (AI), keyword (traditional), or auto mode
|
||||||
|
- Category Filter: Focus on specific content types (company, research paper, news, etc.)
|
||||||
|
- Date Range: Filter by publication date
|
||||||
|
- Auto-prompt: Let Exa optimize your query automatically
|
||||||
|
|
||||||
|
Best for: Research, finding specific types of content, semantic search, and understanding complex queries.`,
|
||||||
|
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe('The search query - be specific and clear'),
|
||||||
|
numResults: z.number().min(1).max(20).optional().describe('Number of results to return (1-20, default: 5)'),
|
||||||
|
type: z
|
||||||
|
.enum(['neural', 'keyword', 'auto', 'fast'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Search type: neural (embeddings-based), keyword (Google-like SERP), auto (default, intelligently combines both), or fast (streamlined versions)'
|
||||||
|
),
|
||||||
|
category: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Filter by content category: company, research paper, news, github, tweet, movie, song, personal site, pdf, etc.'
|
||||||
|
),
|
||||||
|
startPublishedDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Start date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||||
|
endPublishedDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('End date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||||
|
startCrawlDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Start date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||||
|
endCrawlDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('End date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||||
|
useAutoprompt: z.boolean().optional().describe('Let Exa optimize your query automatically (recommended: true)')
|
||||||
|
}),
|
||||||
|
|
||||||
|
execute: async (params, { abortSignal }) => {
|
||||||
|
// 构建 provider 特定参数(排除 query 和 numResults,这些由系统控制)
|
||||||
|
const providerParams: ProviderSpecificParams = {
|
||||||
|
exa: {
|
||||||
|
type: params.type,
|
||||||
|
category: params.category,
|
||||||
|
startPublishedDate: params.startPublishedDate,
|
||||||
|
endPublishedDate: params.endPublishedDate,
|
||||||
|
startCrawlDate: params.startCrawlDate,
|
||||||
|
endCrawlDate: params.endCrawlDate,
|
||||||
|
useAutoprompt: params.useAutoprompt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 构建 ExtractResults 结构
|
||||||
|
const extractResults: ExtractResults = {
|
||||||
|
websearch: {
|
||||||
|
question: [params.query]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一调用 processWebsearch - 保留所有中间件(时间戳、黑名单、tracing、压缩)
|
||||||
|
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
|
||||||
|
webSearchProvider,
|
||||||
|
extractResults,
|
||||||
|
requestId,
|
||||||
|
abortSignal,
|
||||||
|
providerParams
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`Exa search completed: ${finalResults.results.length} results for "${params.query}"`)
|
||||||
|
|
||||||
|
return finalResults
|
||||||
|
},
|
||||||
|
|
||||||
|
toModelOutput: (results) => {
|
||||||
|
let summary = 'No search results found.'
|
||||||
|
if (results.query && results.results.length > 0) {
|
||||||
|
summary = `Found ${results.results.length} relevant sources using Exa AI search. Use [number] format to cite specific information.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const citationData = results.results.map((result, index) => {
|
||||||
|
const citation: any = {
|
||||||
|
number: index + 1,
|
||||||
|
title: result.title,
|
||||||
|
content: result.content,
|
||||||
|
url: result.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 Exa 特有的元数据
|
||||||
|
if ('favicon' in result && result.favicon) {
|
||||||
|
citation.favicon = result.favicon
|
||||||
|
}
|
||||||
|
if ('author' in result && result.author) {
|
||||||
|
citation.author = result.author
|
||||||
|
}
|
||||||
|
if ('publishedDate' in result && result.publishedDate) {
|
||||||
|
citation.publishedDate = result.publishedDate
|
||||||
|
}
|
||||||
|
if ('score' in result && result.score !== undefined) {
|
||||||
|
citation.score = result.score
|
||||||
|
}
|
||||||
|
if ('highlights' in result && result.highlights) {
|
||||||
|
citation.highlights = result.highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
return citation
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用 REFERENCE_PROMPT 格式化引用
|
||||||
|
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
|
||||||
|
const fullInstructions = REFERENCE_PROMPT.replace(
|
||||||
|
'{question}',
|
||||||
|
"Based on the Exa search results, please answer the user's question with proper citations."
|
||||||
|
).replace('{references}', referenceContent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'content',
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Exa AI Search: Neural search with semantic understanding and rich metadata (author, publish date, highlights).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: summary
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: fullInstructions
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExaSearchToolOutput = InferToolOutput<ReturnType<typeof exaSearchTool>>
|
||||||
|
export type ExaSearchToolInput = InferToolInput<ReturnType<typeof exaSearchTool>>
|
||||||
161
src/renderer/src/aiCore/tools/TavilySearchTool.ts
Normal file
161
src/renderer/src/aiCore/tools/TavilySearchTool.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||||
|
import WebSearchService from '@renderer/services/WebSearchService'
|
||||||
|
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
|
||||||
|
import { ExtractResults } from '@renderer/utils/extract'
|
||||||
|
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('TavilySearchTool')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tavily 专用搜索工具 - 暴露 Tavily 的高级搜索能力给 LLM
|
||||||
|
* 支持 AI-powered answers、Search depth control、Topic filtering 等功能
|
||||||
|
*/
|
||||||
|
export const tavilySearchTool = (requestId: string) => {
|
||||||
|
const webSearchProvider = WebSearchService.getWebSearchProvider('tavily')
|
||||||
|
|
||||||
|
if (!webSearchProvider) {
|
||||||
|
throw new Error('Tavily provider not found or not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
name: 'builtin_tavily_search',
|
||||||
|
description: `AI-powered search using Tavily with direct answers and comprehensive content extraction.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Direct AI Answer: Get a concise, factual answer extracted from search results
|
||||||
|
- Search Depth: Choose between basic (fast) or advanced (comprehensive) search
|
||||||
|
- Topic Focus: Filter by general, news, or finance topics
|
||||||
|
- Full Content: Access complete webpage content, not just snippets
|
||||||
|
- Rich Media: Optionally include relevant images from search results
|
||||||
|
|
||||||
|
Best for: Quick factual answers, news monitoring, financial research, and comprehensive content analysis.`,
|
||||||
|
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe('The search query - be specific and clear'),
|
||||||
|
maxResults: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(20)
|
||||||
|
.optional()
|
||||||
|
.describe('Maximum number of results to return (1-20, default: 5)'),
|
||||||
|
topic: z
|
||||||
|
.enum(['general', 'news', 'finance'])
|
||||||
|
.optional()
|
||||||
|
.describe('Topic filter: general (default), news (latest news), or finance (financial/market data)'),
|
||||||
|
searchDepth: z
|
||||||
|
.enum(['basic', 'advanced'])
|
||||||
|
.optional()
|
||||||
|
.describe('Search depth: basic (faster, top results) or advanced (slower, more comprehensive)'),
|
||||||
|
includeAnswer: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Include AI-generated direct answer extracted from results (default: true)'),
|
||||||
|
includeRawContent: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Include full webpage content instead of just snippets (default: true)'),
|
||||||
|
includeImages: z.boolean().optional().describe('Include relevant images from search results (default: false)')
|
||||||
|
}),
|
||||||
|
|
||||||
|
execute: async (params, { abortSignal }) => {
|
||||||
|
try {
|
||||||
|
// 构建 provider 特定参数
|
||||||
|
const providerParams: ProviderSpecificParams = {
|
||||||
|
tavily: {
|
||||||
|
topic: params.topic,
|
||||||
|
searchDepth: params.searchDepth,
|
||||||
|
includeAnswer: params.includeAnswer,
|
||||||
|
includeRawContent: params.includeRawContent,
|
||||||
|
includeImages: params.includeImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 ExtractResults 结构
|
||||||
|
const extractResults: ExtractResults = {
|
||||||
|
websearch: {
|
||||||
|
question: [params.query]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一调用 processWebsearch - 保留所有中间件(时间戳、黑名单、tracing、压缩)
|
||||||
|
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
|
||||||
|
webSearchProvider,
|
||||||
|
extractResults,
|
||||||
|
requestId,
|
||||||
|
abortSignal,
|
||||||
|
providerParams
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`Tavily search completed: ${finalResults.results.length} results for "${params.query}"`)
|
||||||
|
|
||||||
|
return finalResults
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
logger.info('Tavily search aborted')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
logger.error('Tavily search failed:', error as Error)
|
||||||
|
throw new Error(`Tavily search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toModelOutput: (results) => {
|
||||||
|
let summary = 'No search results found.'
|
||||||
|
if (results.query && results.results.length > 0) {
|
||||||
|
summary = `Found ${results.results.length} relevant sources using Tavily AI search. Use [number] format to cite specific information.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const citationData = results.results.map((result, index) => {
|
||||||
|
const citation: any = {
|
||||||
|
number: index + 1,
|
||||||
|
title: result.title,
|
||||||
|
content: result.content,
|
||||||
|
url: result.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 Tavily 特有的元数据
|
||||||
|
if ('answer' in result && result.answer) {
|
||||||
|
citation.answer = result.answer // Tavily 的直接答案
|
||||||
|
}
|
||||||
|
if ('images' in result && result.images && result.images.length > 0) {
|
||||||
|
citation.images = result.images // Tavily 的图片
|
||||||
|
}
|
||||||
|
if ('score' in result && result.score !== undefined) {
|
||||||
|
citation.score = result.score
|
||||||
|
}
|
||||||
|
|
||||||
|
return citation
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用 REFERENCE_PROMPT 格式化引用
|
||||||
|
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
|
||||||
|
const fullInstructions = REFERENCE_PROMPT.replace(
|
||||||
|
'{question}',
|
||||||
|
"Based on the Tavily search results, please answer the user's question with proper citations."
|
||||||
|
).replace('{references}', referenceContent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'content',
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Tavily AI Search: AI-powered with direct answers, full content extraction, and optional image results.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: summary
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: fullInstructions
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TavilySearchToolOutput = InferToolOutput<ReturnType<typeof tavilySearchTool>>
|
||||||
|
export type TavilySearchToolInput = InferToolInput<ReturnType<typeof tavilySearchTool>>
|
||||||
@@ -40,7 +40,7 @@ You can use this tool as-is to search with the prepared queries, or provide addi
|
|||||||
.describe('Optional additional context, keywords, or specific focus to enhance the search')
|
.describe('Optional additional context, keywords, or specific focus to enhance the search')
|
||||||
}),
|
}),
|
||||||
|
|
||||||
execute: async ({ additionalContext }) => {
|
execute: async ({ additionalContext }, { abortSignal }) => {
|
||||||
let finalQueries = [...extractedKeywords.question]
|
let finalQueries = [...extractedKeywords.question]
|
||||||
|
|
||||||
if (additionalContext?.trim()) {
|
if (additionalContext?.trim()) {
|
||||||
@@ -67,7 +67,15 @@ You can use this tool as-is to search with the prepared queries, or provide addi
|
|||||||
links: extractedKeywords.links
|
links: extractedKeywords.links
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
|
// abortSignal?.addEventListener('abort', () => {
|
||||||
|
// console.log('tool_call_abortSignal', abortSignal?.aborted)
|
||||||
|
// })
|
||||||
|
searchResults = await WebSearchService.processWebsearch(
|
||||||
|
webSearchProvider!,
|
||||||
|
extractResults,
|
||||||
|
requestId,
|
||||||
|
abortSignal
|
||||||
|
)
|
||||||
|
|
||||||
return searchResults
|
return searchResults
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { NormalToolResponse } from '@renderer/types'
|
import { NormalToolResponse } from '@renderer/types'
|
||||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
import { MessageBlockStatus, ToolMessageBlock } from '@renderer/types/newMessage'
|
||||||
|
import { TFunction } from 'i18next'
|
||||||
|
import { Pause } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { MessageAgentTools } from './MessageAgentTools'
|
import { MessageAgentTools } from './MessageAgentTools'
|
||||||
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
|
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
|
||||||
@@ -35,14 +38,28 @@ const isAgentTool = (toolName: string) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
|
const ChooseTool = (
|
||||||
|
toolResponse: NormalToolResponse,
|
||||||
|
status: MessageBlockStatus,
|
||||||
|
t: TFunction
|
||||||
|
): React.ReactNode | null => {
|
||||||
let toolName = toolResponse.tool.name
|
let toolName = toolResponse.tool.name
|
||||||
const toolType = toolResponse.tool.type
|
const toolType = toolResponse.tool.type
|
||||||
if (toolName.startsWith(prefix)) {
|
if (toolName.startsWith(prefix)) {
|
||||||
toolName = toolName.slice(prefix.length)
|
toolName = toolName.slice(prefix.length)
|
||||||
|
if (status === MessageBlockStatus.PAUSED) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
<span>{t('message.tools.aborted')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'web_search':
|
case 'web_search':
|
||||||
case 'web_search_preview':
|
case 'web_search_preview':
|
||||||
|
case 'exa_search':
|
||||||
|
case 'tavily_search':
|
||||||
return toolType === 'provider' ? null : <MessageWebSearchToolTitle toolResponse={toolResponse} />
|
return toolType === 'provider' ? null : <MessageWebSearchToolTitle toolResponse={toolResponse} />
|
||||||
case 'knowledge_search':
|
case 'knowledge_search':
|
||||||
return <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />
|
return <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />
|
||||||
@@ -58,12 +75,13 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageTool({ block }: Props) {
|
export default function MessageTool({ block }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
||||||
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
|
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
|
||||||
|
|
||||||
if (!toolResponse) return null
|
if (!toolResponse) return null
|
||||||
|
|
||||||
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse)
|
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse, block.status, t)
|
||||||
|
|
||||||
if (!toolRenderer) return null
|
if (!toolRenderer) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ExaSearchToolInput, ExaSearchToolOutput } from '@renderer/aiCore/tools/ExaSearchTool'
|
||||||
|
import { TavilySearchToolInput, TavilySearchToolOutput } from '@renderer/aiCore/tools/TavilySearchTool'
|
||||||
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
||||||
import Spinner from '@renderer/components/Spinner'
|
import Spinner from '@renderer/components/Spinner'
|
||||||
import { NormalToolResponse } from '@renderer/types'
|
import { NormalToolResponse } from '@renderer/types'
|
||||||
@@ -8,17 +10,31 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
|
// 联合类型 - 支持多种搜索工具
|
||||||
|
type SearchToolInput = WebSearchToolInput | ExaSearchToolInput | TavilySearchToolInput
|
||||||
|
type SearchToolOutput = WebSearchToolOutput | ExaSearchToolOutput | TavilySearchToolOutput
|
||||||
|
|
||||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toolInput = toolResponse.arguments as WebSearchToolInput
|
const toolInput = toolResponse.arguments as SearchToolInput
|
||||||
const toolOutput = toolResponse.response as WebSearchToolOutput
|
const toolOutput = toolResponse.response as SearchToolOutput
|
||||||
|
// 根据不同的工具类型获取查询内容
|
||||||
|
const getQueryText = () => {
|
||||||
|
if ('additionalContext' in toolInput) {
|
||||||
|
return toolInput.additionalContext ?? ''
|
||||||
|
}
|
||||||
|
if ('query' in toolInput) {
|
||||||
|
return toolInput.query ?? ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
return toolResponse.status !== 'done' ? (
|
return toolResponse.status !== 'done' ? (
|
||||||
<Spinner
|
<Spinner
|
||||||
text={
|
text={
|
||||||
<PrepareToolWrapper>
|
<PrepareToolWrapper>
|
||||||
{t('message.searching')}
|
{t('message.searching')}
|
||||||
<span>{toolInput?.additionalContext ?? ''}</span>
|
<span>{getQueryText()}</span>
|
||||||
</PrepareToolWrapper>
|
</PrepareToolWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
|
|
||||||
export default abstract class BaseWebSearchProvider {
|
export default abstract class BaseWebSearchProvider {
|
||||||
// @ts-ignore this
|
// @ts-ignore this
|
||||||
@@ -16,7 +16,8 @@ export default abstract class BaseWebSearchProvider {
|
|||||||
abstract search(
|
abstract search(
|
||||||
query: string,
|
query: string,
|
||||||
websearch: WebSearchState,
|
websearch: WebSearchState,
|
||||||
httpOptions?: RequestInit
|
httpOptions?: RequestInit,
|
||||||
|
providerParams?: ProviderSpecificParams
|
||||||
): Promise<WebSearchProviderResponse>
|
): Promise<WebSearchProviderResponse>
|
||||||
|
|
||||||
public getApiHost() {
|
public getApiHost() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
import { BochaSearchParams, BochaSearchResponse } from '@renderer/utils/bocha'
|
import { BochaSearchParams, BochaSearchResponse } from '@renderer/utils/bocha'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
@@ -18,7 +18,12 @@ export default class BochaProvider extends BaseWebSearchProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
public async search(
|
||||||
|
query: string,
|
||||||
|
websearch: WebSearchState,
|
||||||
|
httpOptions?: RequestInit,
|
||||||
|
_providerParams?: ProviderSpecificParams
|
||||||
|
): Promise<WebSearchProviderResponse> {
|
||||||
try {
|
try {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
throw new Error('Search query cannot be empty')
|
throw new Error('Search query cannot be empty')
|
||||||
@@ -44,7 +49,8 @@ export default class BochaProvider extends BaseWebSearchProvider {
|
|||||||
headers: {
|
headers: {
|
||||||
...this.defaultHeaders(),
|
...this.defaultHeaders(),
|
||||||
...headers
|
...headers
|
||||||
}
|
},
|
||||||
|
signal: httpOptions?.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { WebSearchProviderResponse } from '@renderer/types'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
|
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
|
|
||||||
export default class DefaultProvider extends BaseWebSearchProvider {
|
export default class DefaultProvider extends BaseWebSearchProvider {
|
||||||
search(): Promise<WebSearchProviderResponse> {
|
search(
|
||||||
|
_query: string,
|
||||||
|
_websearch: WebSearchState,
|
||||||
|
_httpOptions?: RequestInit,
|
||||||
|
_providerParams?: ProviderSpecificParams
|
||||||
|
): Promise<WebSearchProviderResponse> {
|
||||||
throw new Error('Method not implemented.')
|
throw new Error('Method not implemented.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
import { ExaClient } from '@agentic/exa'
|
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import {
|
||||||
|
ExaSearchResult as ExaSearchResultType,
|
||||||
|
ProviderSpecificParams,
|
||||||
|
WebSearchProvider,
|
||||||
|
WebSearchProviderResponse
|
||||||
|
} from '@renderer/types'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ExaProvider')
|
const logger = loggerService.withContext('ExaProvider')
|
||||||
export default class ExaProvider extends BaseWebSearchProvider {
|
|
||||||
private exa: ExaClient
|
|
||||||
|
|
||||||
|
interface ExaSearchRequest {
|
||||||
|
query: string
|
||||||
|
numResults: number
|
||||||
|
contents?: {
|
||||||
|
text?: boolean
|
||||||
|
highlights?: boolean
|
||||||
|
summary?: boolean
|
||||||
|
}
|
||||||
|
useAutoprompt?: boolean
|
||||||
|
category?: string
|
||||||
|
type?: 'keyword' | 'neural' | 'auto' | 'fast'
|
||||||
|
startPublishedDate?: string
|
||||||
|
endPublishedDate?: string
|
||||||
|
startCrawlDate?: string
|
||||||
|
endCrawlDate?: string
|
||||||
|
includeDomains?: string[]
|
||||||
|
excludeDomains?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExaSearchResult {
|
||||||
|
title: string | null
|
||||||
|
url: string | null
|
||||||
|
text?: string | null
|
||||||
|
author?: string | null
|
||||||
|
score?: number
|
||||||
|
publishedDate?: string | null
|
||||||
|
favicon?: string | null
|
||||||
|
highlights?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExaSearchResponse {
|
||||||
|
autopromptString?: string
|
||||||
|
results: ExaSearchResult[]
|
||||||
|
resolvedSearchType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ExaProvider extends BaseWebSearchProvider {
|
||||||
constructor(provider: WebSearchProvider) {
|
constructor(provider: WebSearchProvider) {
|
||||||
super(provider)
|
super(provider)
|
||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
@@ -17,34 +56,138 @@ export default class ExaProvider extends BaseWebSearchProvider {
|
|||||||
if (!this.apiHost) {
|
if (!this.apiHost) {
|
||||||
throw new Error('API host is required for Exa provider')
|
throw new Error('API host is required for Exa provider')
|
||||||
}
|
}
|
||||||
this.exa = new ExaClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
/**
|
||||||
|
* 统一的搜索方法 - 根据 providerParams 决定是否使用高级参数
|
||||||
|
*/
|
||||||
|
public async search(
|
||||||
|
query: string,
|
||||||
|
websearch: WebSearchState,
|
||||||
|
httpOptions?: RequestInit,
|
||||||
|
providerParams?: ProviderSpecificParams
|
||||||
|
): Promise<WebSearchProviderResponse> {
|
||||||
|
// 如果提供了 Exa 特定参数,使用高级搜索
|
||||||
|
if (providerParams?.exa) {
|
||||||
|
return this.searchWithParams({
|
||||||
|
query,
|
||||||
|
numResults: websearch.maxResults,
|
||||||
|
...providerParams.exa, // 展开高级参数
|
||||||
|
signal: httpOptions?.signal ?? undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用默认参数
|
||||||
|
return this.searchWithParams({
|
||||||
|
query,
|
||||||
|
numResults: websearch.maxResults,
|
||||||
|
useAutoprompt: true,
|
||||||
|
signal: httpOptions?.signal ?? undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用完整参数进行搜索(支持 Exa 的所有高级功能)
|
||||||
|
*/
|
||||||
|
public async searchWithParams(params: {
|
||||||
|
query: string
|
||||||
|
numResults?: number
|
||||||
|
type?: 'keyword' | 'neural' | 'auto' | 'fast'
|
||||||
|
category?: string
|
||||||
|
startPublishedDate?: string
|
||||||
|
endPublishedDate?: string
|
||||||
|
startCrawlDate?: string
|
||||||
|
endCrawlDate?: string
|
||||||
|
useAutoprompt?: boolean
|
||||||
|
includeDomains?: string[]
|
||||||
|
excludeDomains?: string[]
|
||||||
|
signal?: AbortSignal
|
||||||
|
}): Promise<WebSearchProviderResponse> {
|
||||||
try {
|
try {
|
||||||
if (!query.trim()) {
|
if (!params.query.trim()) {
|
||||||
throw new Error('Search query cannot be empty')
|
throw new Error('Search query cannot be empty')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.exa.search({
|
const requestBody: ExaSearchRequest = {
|
||||||
query,
|
query: params.query,
|
||||||
numResults: Math.max(1, websearch.maxResults),
|
numResults: Math.max(1, params.numResults || 5),
|
||||||
contents: {
|
contents: {
|
||||||
text: true
|
text: true,
|
||||||
}
|
highlights: true // 获取高亮片段
|
||||||
|
},
|
||||||
|
useAutoprompt: params.useAutoprompt ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加可选参数
|
||||||
|
if (params.type) {
|
||||||
|
requestBody.type = params.type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.category) {
|
||||||
|
requestBody.category = params.category
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.startPublishedDate) {
|
||||||
|
requestBody.startPublishedDate = params.startPublishedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.endPublishedDate) {
|
||||||
|
requestBody.endPublishedDate = params.endPublishedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.startCrawlDate) {
|
||||||
|
requestBody.startCrawlDate = params.startCrawlDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.endCrawlDate) {
|
||||||
|
requestBody.endCrawlDate = params.endCrawlDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.includeDomains && params.includeDomains.length > 0) {
|
||||||
|
requestBody.includeDomains = params.includeDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.excludeDomains && params.excludeDomains.length > 0) {
|
||||||
|
requestBody.excludeDomains = params.excludeDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.apiHost}/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-api-key': this.apiKey!,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: params.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`Exa API error (${response.status}): ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ExaSearchResponse = await response.json()
|
||||||
|
|
||||||
|
// 返回完整的 Exa 结果(包含 favicon、author、score 等字段)
|
||||||
return {
|
return {
|
||||||
query: response.autopromptString,
|
query: data.autopromptString || params.query,
|
||||||
results: response.results.slice(0, websearch.maxResults).map((result) => {
|
results: data.results.slice(0, params.numResults || 5).map(
|
||||||
return {
|
(result): ExaSearchResultType => ({
|
||||||
title: result.title || 'No title',
|
title: result.title || 'No title',
|
||||||
content: result.text || '',
|
content: result.text || '',
|
||||||
url: result.url || ''
|
url: result.url || '',
|
||||||
}
|
favicon: result.favicon || undefined,
|
||||||
})
|
publishedDate: result.publishedDate || undefined,
|
||||||
|
author: result.author || undefined,
|
||||||
|
score: result.score,
|
||||||
|
highlights: result.highlights
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
logger.error('Exa search failed:', error as Error)
|
logger.error('Exa search failed:', error as Error)
|
||||||
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { loggerService } from '@logger'
|
|||||||
import { nanoid } from '@reduxjs/toolkit'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse, WebSearchProviderResult } from '@renderer/types'
|
import {
|
||||||
|
ProviderSpecificParams,
|
||||||
|
WebSearchProvider,
|
||||||
|
WebSearchProviderResponse,
|
||||||
|
WebSearchProviderResult
|
||||||
|
} from '@renderer/types'
|
||||||
import { createAbortPromise } from '@renderer/utils/abortController'
|
import { createAbortPromise } from '@renderer/utils/abortController'
|
||||||
import { isAbortError } from '@renderer/utils/error'
|
import { isAbortError } from '@renderer/utils/error'
|
||||||
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
|
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
|
||||||
@@ -27,7 +32,8 @@ export default class LocalSearchProvider extends BaseWebSearchProvider {
|
|||||||
public async search(
|
public async search(
|
||||||
query: string,
|
query: string,
|
||||||
websearch: WebSearchState,
|
websearch: WebSearchState,
|
||||||
httpOptions?: RequestInit
|
httpOptions?: RequestInit,
|
||||||
|
_providerParams?: ProviderSpecificParams
|
||||||
): Promise<WebSearchProviderResponse> {
|
): Promise<WebSearchProviderResponse> {
|
||||||
const uid = nanoid()
|
const uid = nanoid()
|
||||||
const language = store.getState().settings.language
|
const language = store.getState().settings.language
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SearxngClient } from '@agentic/searxng'
|
import { SearxngClient } from '@agentic/searxng'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
|
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import ky from 'ky'
|
import ky from 'ky'
|
||||||
@@ -95,7 +95,12 @@ export default class SearxngProvider extends BaseWebSearchProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
public async search(
|
||||||
|
query: string,
|
||||||
|
websearch: WebSearchState,
|
||||||
|
httpOptions?: RequestInit,
|
||||||
|
_providerParams?: ProviderSpecificParams
|
||||||
|
): Promise<WebSearchProviderResponse> {
|
||||||
try {
|
try {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
throw new Error('Search query cannot be empty')
|
throw new Error('Search query cannot be empty')
|
||||||
@@ -124,7 +129,7 @@ export default class SearxngProvider extends BaseWebSearchProvider {
|
|||||||
// Fetch content for each URL concurrently
|
// Fetch content for each URL concurrently
|
||||||
const fetchPromises = validItems.map(async (item) => {
|
const fetchPromises = validItems.map(async (item) => {
|
||||||
// Logger.log(`Fetching content for ${item.url}...`)
|
// Logger.log(`Fetching content for ${item.url}...`)
|
||||||
return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser)
|
return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser, httpOptions)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wait for all fetches to complete
|
// Wait for all fetches to complete
|
||||||
|
|||||||
@@ -1,14 +1,45 @@
|
|||||||
import { TavilyClient } from '@agentic/tavily'
|
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import {
|
||||||
|
ProviderSpecificParams,
|
||||||
|
TavilySearchResult as TavilySearchResultType,
|
||||||
|
WebSearchProvider,
|
||||||
|
WebSearchProviderResponse
|
||||||
|
} from '@renderer/types'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
|
|
||||||
const logger = loggerService.withContext('TavilyProvider')
|
const logger = loggerService.withContext('TavilyProvider')
|
||||||
export default class TavilyProvider extends BaseWebSearchProvider {
|
|
||||||
private tvly: TavilyClient
|
|
||||||
|
|
||||||
|
interface TavilySearchRequest {
|
||||||
|
query: string
|
||||||
|
max_results?: number
|
||||||
|
topic?: 'general' | 'news' | 'finance'
|
||||||
|
search_depth?: 'basic' | 'advanced'
|
||||||
|
include_answer?: boolean
|
||||||
|
include_raw_content?: boolean
|
||||||
|
include_images?: boolean
|
||||||
|
include_domains?: string[]
|
||||||
|
exclude_domains?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TavilySearchResult {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
content: string
|
||||||
|
raw_content?: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TavilySearchResponse {
|
||||||
|
query: string
|
||||||
|
results: TavilySearchResult[]
|
||||||
|
answer?: string
|
||||||
|
images?: string[]
|
||||||
|
response_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TavilyProvider extends BaseWebSearchProvider {
|
||||||
constructor(provider: WebSearchProvider) {
|
constructor(provider: WebSearchProvider) {
|
||||||
super(provider)
|
super(provider)
|
||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
@@ -17,30 +48,119 @@ export default class TavilyProvider extends BaseWebSearchProvider {
|
|||||||
if (!this.apiHost) {
|
if (!this.apiHost) {
|
||||||
throw new Error('API host is required for Tavily provider')
|
throw new Error('API host is required for Tavily provider')
|
||||||
}
|
}
|
||||||
this.tvly = new TavilyClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
/**
|
||||||
|
* 统一的搜索方法 - 根据 providerParams 决定是否使用高级参数
|
||||||
|
*/
|
||||||
|
public async search(
|
||||||
|
query: string,
|
||||||
|
websearch: WebSearchState,
|
||||||
|
httpOptions?: RequestInit,
|
||||||
|
providerParams?: ProviderSpecificParams
|
||||||
|
): Promise<WebSearchProviderResponse> {
|
||||||
|
// 如果提供了 Tavily 特定参数,使用高级搜索
|
||||||
|
if (providerParams?.tavily) {
|
||||||
|
return this.searchWithParams({
|
||||||
|
query,
|
||||||
|
maxResults: websearch.maxResults,
|
||||||
|
...providerParams.tavily, // 展开高级参数
|
||||||
|
signal: httpOptions?.signal ?? undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用默认参数
|
||||||
|
return this.searchWithParams({
|
||||||
|
query,
|
||||||
|
maxResults: websearch.maxResults,
|
||||||
|
includeRawContent: true,
|
||||||
|
signal: httpOptions?.signal ?? undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用完整参数进行搜索(支持 Tavily 的所有高级功能)
|
||||||
|
*/
|
||||||
|
public async searchWithParams(params: {
|
||||||
|
query: string
|
||||||
|
maxResults?: number
|
||||||
|
topic?: 'general' | 'news' | 'finance'
|
||||||
|
searchDepth?: 'basic' | 'advanced'
|
||||||
|
includeAnswer?: boolean
|
||||||
|
includeRawContent?: boolean
|
||||||
|
includeImages?: boolean
|
||||||
|
includeDomains?: string[]
|
||||||
|
excludeDomains?: string[]
|
||||||
|
signal?: AbortSignal
|
||||||
|
}): Promise<WebSearchProviderResponse> {
|
||||||
try {
|
try {
|
||||||
if (!query.trim()) {
|
if (!params.query.trim()) {
|
||||||
throw new Error('Search query cannot be empty')
|
throw new Error('Search query cannot be empty')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.tvly.search({
|
const requestBody: TavilySearchRequest = {
|
||||||
query,
|
query: params.query,
|
||||||
max_results: Math.max(1, websearch.maxResults)
|
max_results: Math.max(1, params.maxResults || 5),
|
||||||
|
include_raw_content: params.includeRawContent ?? true,
|
||||||
|
include_answer: params.includeAnswer ?? true,
|
||||||
|
include_images: params.includeImages ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加可选参数
|
||||||
|
if (params.topic) {
|
||||||
|
requestBody.topic = params.topic
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.searchDepth) {
|
||||||
|
requestBody.search_depth = params.searchDepth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.includeDomains && params.includeDomains.length > 0) {
|
||||||
|
requestBody.include_domains = params.includeDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.excludeDomains && params.excludeDomains.length > 0) {
|
||||||
|
requestBody.exclude_domains = params.excludeDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.apiHost}/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...requestBody,
|
||||||
|
api_key: this.apiKey
|
||||||
|
}),
|
||||||
|
signal: params.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`Tavily API error (${response.status}): ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TavilySearchResponse = await response.json()
|
||||||
|
|
||||||
|
// 返回完整的 Tavily 结果(包含 answer、images 等字段)
|
||||||
return {
|
return {
|
||||||
query: result.query,
|
query: data.query,
|
||||||
results: result.results.slice(0, websearch.maxResults).map((result) => {
|
results: data.results.slice(0, params.maxResults || 5).map(
|
||||||
return {
|
(item): TavilySearchResultType => ({
|
||||||
title: result.title || 'No title',
|
title: item.title || 'No title',
|
||||||
content: result.content || '',
|
content: item.raw_content || item.content || '',
|
||||||
url: result.url || ''
|
url: item.url || '',
|
||||||
}
|
rawContent: item.raw_content,
|
||||||
})
|
score: item.score,
|
||||||
|
answer: data.answer, // Tavily 的直接答案
|
||||||
|
images: data.images // Tavily 的图片
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
logger.error('Tavily search failed:', error as Error)
|
logger.error('Tavily search failed:', error as Error)
|
||||||
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { WebSearchState } from '@renderer/store/websearch'
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
|
|
||||||
@@ -43,7 +43,12 @@ export default class ZhipuProvider extends BaseWebSearchProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
public async search(
|
||||||
|
query: string,
|
||||||
|
websearch: WebSearchState,
|
||||||
|
httpOptions?: RequestInit,
|
||||||
|
_providerParams?: ProviderSpecificParams
|
||||||
|
): Promise<WebSearchProviderResponse> {
|
||||||
try {
|
try {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
throw new Error('Search query cannot be empty')
|
throw new Error('Search query cannot be empty')
|
||||||
@@ -62,7 +67,8 @@ export default class ZhipuProvider extends BaseWebSearchProvider {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.defaultHeaders()
|
...this.defaultHeaders()
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: httpOptions?.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { withSpanResult } from '@renderer/services/SpanManagerService'
|
import { withSpanResult } from '@renderer/services/SpanManagerService'
|
||||||
import type { WebSearchState } from '@renderer/store/websearch'
|
import type { WebSearchState } from '@renderer/store/websearch'
|
||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
import { filterResultWithBlacklist } from '@renderer/utils/blacklistMatchPattern'
|
import { filterResultWithBlacklist } from '@renderer/utils/blacklistMatchPattern'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
@@ -24,10 +24,11 @@ export default class WebSearchEngineProvider {
|
|||||||
public async search(
|
public async search(
|
||||||
query: string,
|
query: string,
|
||||||
websearch: WebSearchState,
|
websearch: WebSearchState,
|
||||||
httpOptions?: RequestInit
|
httpOptions?: RequestInit,
|
||||||
|
providerParams?: ProviderSpecificParams
|
||||||
): Promise<WebSearchProviderResponse> {
|
): Promise<WebSearchProviderResponse> {
|
||||||
const callSearch = async ({ query, websearch }) => {
|
const callSearch = async ({ query, websearch, providerParams }) => {
|
||||||
return await this.sdk.search(query, websearch, httpOptions)
|
return await this.sdk.search(query, websearch, httpOptions, providerParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
const traceParams = {
|
const traceParams = {
|
||||||
@@ -38,7 +39,7 @@ export default class WebSearchEngineProvider {
|
|||||||
modelName: this.modelName
|
modelName: this.modelName
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withSpanResult(callSearch, traceParams, { query, websearch })
|
const result = await withSpanResult(callSearch, traceParams, { query, websearch, providerParams })
|
||||||
|
|
||||||
return await filterResultWithBlacklist(result, websearch)
|
return await filterResultWithBlacklist(result, websearch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeReference,
|
KnowledgeReference,
|
||||||
|
ProviderSpecificParams,
|
||||||
WebSearchProvider,
|
WebSearchProvider,
|
||||||
WebSearchProviderResponse,
|
WebSearchProviderResponse,
|
||||||
WebSearchProviderResult,
|
WebSearchProviderResult,
|
||||||
@@ -161,13 +162,17 @@ class WebSearchService {
|
|||||||
* @public
|
* @public
|
||||||
* @param provider 搜索提供商
|
* @param provider 搜索提供商
|
||||||
* @param query 搜索查询
|
* @param query 搜索查询
|
||||||
|
* @param httpOptions HTTP选项(包含signal等)
|
||||||
|
* @param spanId Span ID用于追踪
|
||||||
|
* @param providerParams Provider特定参数(如Exa的category、Tavily的searchDepth等)
|
||||||
* @returns 搜索响应
|
* @returns 搜索响应
|
||||||
*/
|
*/
|
||||||
public async search(
|
public async search(
|
||||||
provider: WebSearchProvider,
|
provider: WebSearchProvider,
|
||||||
query: string,
|
query: string,
|
||||||
httpOptions?: RequestInit,
|
httpOptions?: RequestInit,
|
||||||
spanId?: string
|
spanId?: string,
|
||||||
|
providerParams?: ProviderSpecificParams
|
||||||
): Promise<WebSearchProviderResponse> {
|
): Promise<WebSearchProviderResponse> {
|
||||||
const websearch = this.getWebSearchState()
|
const websearch = this.getWebSearchState()
|
||||||
const webSearchEngine = new WebSearchEngineProvider(provider, spanId)
|
const webSearchEngine = new WebSearchEngineProvider(provider, spanId)
|
||||||
@@ -178,7 +183,7 @@ class WebSearchService {
|
|||||||
formattedQuery = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}`
|
formattedQuery = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return await webSearchEngine.search(formattedQuery, websearch, httpOptions)
|
return await webSearchEngine.search(formattedQuery, websearch, httpOptions, providerParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -424,13 +429,17 @@ class WebSearchService {
|
|||||||
* @param webSearchProvider - 要使用的网络搜索提供商
|
* @param webSearchProvider - 要使用的网络搜索提供商
|
||||||
* @param extractResults - 包含搜索问题和链接的提取结果对象
|
* @param extractResults - 包含搜索问题和链接的提取结果对象
|
||||||
* @param requestId - 唯一的请求标识符,用于状态跟踪和资源管理
|
* @param requestId - 唯一的请求标识符,用于状态跟踪和资源管理
|
||||||
|
* @param externalSignal - 可选的 AbortSignal 用于取消请求
|
||||||
|
* @param providerParams - 可选的 Provider 特定参数(如 Exa 的 category、Tavily 的 searchDepth 等)
|
||||||
*
|
*
|
||||||
* @returns 包含搜索结果的响应对象
|
* @returns 包含搜索结果的响应对象
|
||||||
*/
|
*/
|
||||||
public async processWebsearch(
|
public async processWebsearch(
|
||||||
webSearchProvider: WebSearchProvider,
|
webSearchProvider: WebSearchProvider,
|
||||||
extractResults: ExtractResults,
|
extractResults: ExtractResults,
|
||||||
requestId: string
|
requestId: string,
|
||||||
|
externalSignal?: AbortSignal,
|
||||||
|
providerParams?: ProviderSpecificParams
|
||||||
): Promise<WebSearchProviderResponse> {
|
): Promise<WebSearchProviderResponse> {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
await this.setWebSearchStatus(requestId, { phase: 'default' })
|
await this.setWebSearchStatus(requestId, { phase: 'default' })
|
||||||
@@ -441,8 +450,8 @@ class WebSearchService {
|
|||||||
return { results: [] }
|
return { results: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用请求特定的signal,如果没有则回退到全局signal
|
// 优先使用外部传入的signal,其次是请求特定的signal,最后回退到全局signal
|
||||||
const signal = this.getRequestState(requestId).signal || this.signal
|
const signal = externalSignal || this.getRequestState(requestId).signal || this.signal
|
||||||
|
|
||||||
const span = webSearchProvider.topicId
|
const span = webSearchProvider.topicId
|
||||||
? addSpan({
|
? addSpan({
|
||||||
@@ -473,8 +482,9 @@ class WebSearchService {
|
|||||||
return { query: 'summaries', results: contents }
|
return { query: 'summaries', results: contents }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
const searchPromises = questions.map((q) =>
|
const searchPromises = questions.map((q) =>
|
||||||
this.search(webSearchProvider, q, { signal }, span?.spanContext().spanId)
|
this.search(webSearchProvider, q, { signal }, span?.spanContext().spanId, providerParams)
|
||||||
)
|
)
|
||||||
const searchResults = await Promise.allSettled(searchPromises)
|
const searchResults = await Promise.allSettled(searchPromises)
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
|
|||||||
}
|
}
|
||||||
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL, true)
|
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL, true)
|
||||||
// Handle citation block creation for web search results
|
// Handle citation block creation for web search results
|
||||||
if (toolResponse.tool.name === 'builtin_web_search' && toolResponse.response) {
|
const webSearchTools = ['builtin_web_search', 'builtin_exa_search', 'builtin_tavily_search']
|
||||||
|
if (webSearchTools.includes(toolResponse.tool.name) && toolResponse.response) {
|
||||||
const citationBlock = createCitationBlock(
|
const citationBlock = createCitationBlock(
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createEntityAdapter, createSelector, createSlice, type PayloadAction }
|
|||||||
import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
|
import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
|
||||||
import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage'
|
import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { MessageBlockType } from '@renderer/types/newMessage'
|
import { MessageBlockType } from '@renderer/types/newMessage'
|
||||||
|
import { adaptSearchResultsToCitations } from '@renderer/utils/searchResultAdapters'
|
||||||
import type OpenAI from 'openai'
|
import type OpenAI from 'openai'
|
||||||
|
|
||||||
import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出
|
import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出
|
||||||
@@ -217,17 +218,12 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined
|
|||||||
type: 'websearch'
|
type: 'websearch'
|
||||||
})) || []
|
})) || []
|
||||||
break
|
break
|
||||||
case WebSearchSource.WEBSEARCH:
|
case WebSearchSource.WEBSEARCH: {
|
||||||
formattedCitations =
|
const results = (block.response.results as WebSearchProviderResponse)?.results || []
|
||||||
(block.response.results as WebSearchProviderResponse)?.results?.map((result, index) => ({
|
// 使用适配器统一转换,自动处理 Provider 特定字段(如 Exa 的 favicon、Tavily 的 answer 等)
|
||||||
number: index + 1,
|
formattedCitations = adaptSearchResultsToCitations(results)
|
||||||
url: result.url,
|
|
||||||
title: result.title,
|
|
||||||
content: result.content,
|
|
||||||
showFavicon: true,
|
|
||||||
type: 'websearch'
|
|
||||||
})) || []
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case WebSearchSource.AISDK:
|
case WebSearchSource.AISDK:
|
||||||
formattedCitations =
|
formattedCitations =
|
||||||
(block.response?.results as AISDKWebSearchResult[])?.map((result, index) => ({
|
(block.response?.results as AISDKWebSearchResult[])?.map((result, index) => ({
|
||||||
|
|||||||
@@ -575,17 +575,63 @@ export type WebSearchProvider = {
|
|||||||
modelName?: string
|
modelName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebSearchProviderResult = {
|
// 基础搜索结果(所有 Provider 必须实现)
|
||||||
|
export interface BaseSearchResult {
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exa Provider 特定扩展
|
||||||
|
export interface ExaSearchResult extends BaseSearchResult {
|
||||||
|
favicon?: string
|
||||||
|
publishedDate?: string
|
||||||
|
author?: string
|
||||||
|
score?: number
|
||||||
|
highlights?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tavily Provider 特定扩展
|
||||||
|
export interface TavilySearchResult extends BaseSearchResult {
|
||||||
|
answer?: string // Tavily 的 AI 直接答案
|
||||||
|
images?: string[]
|
||||||
|
rawContent?: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 联合类型 - 向后兼容
|
||||||
|
export type WebSearchProviderResult = BaseSearchResult | ExaSearchResult | TavilySearchResult
|
||||||
|
|
||||||
export type WebSearchProviderResponse = {
|
export type WebSearchProviderResponse = {
|
||||||
query?: string
|
query?: string
|
||||||
results: WebSearchProviderResult[]
|
results: WebSearchProviderResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider 特定参数类型
|
||||||
|
export interface ExaSearchParams {
|
||||||
|
type?: 'neural' | 'keyword' | 'auto' | 'fast'
|
||||||
|
category?: string
|
||||||
|
startPublishedDate?: string
|
||||||
|
endPublishedDate?: string
|
||||||
|
startCrawlDate?: string
|
||||||
|
endCrawlDate?: string
|
||||||
|
useAutoprompt?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TavilySearchParams {
|
||||||
|
topic?: 'general' | 'news' | 'finance'
|
||||||
|
searchDepth?: 'basic' | 'advanced'
|
||||||
|
includeAnswer?: boolean
|
||||||
|
includeRawContent?: boolean
|
||||||
|
includeImages?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 联合类型 - 支持不同 Provider 的特定参数
|
||||||
|
export interface ProviderSpecificParams {
|
||||||
|
exa?: ExaSearchParams
|
||||||
|
tavily?: TavilySearchParams
|
||||||
|
}
|
||||||
|
|
||||||
export type AISDKWebSearchResult = Omit<Extract<LanguageModelV2Source, { sourceType: 'url' }>, 'sourceType'>
|
export type AISDKWebSearchResult = Omit<Extract<LanguageModelV2Source, { sourceType: 'url' }>, 'sourceType'>
|
||||||
|
|
||||||
export type WebSearchResults =
|
export type WebSearchResults =
|
||||||
@@ -813,6 +859,7 @@ export interface Citation {
|
|||||||
hostname?: string
|
hostname?: string
|
||||||
content?: string
|
content?: string
|
||||||
showFavicon?: boolean
|
showFavicon?: boolean
|
||||||
|
favicon?: string // 新增:直接的 favicon URL(来自 Provider)
|
||||||
type?: string
|
type?: string
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/renderer/src/utils/searchResultAdapters.ts
Normal file
77
src/renderer/src/utils/searchResultAdapters.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 搜索结果适配器
|
||||||
|
* 将不同 Provider 的搜索结果统一转换为 Citation 格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Citation, WebSearchProviderResult } from '@renderer/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 WebSearchProviderResult 转换为 Citation
|
||||||
|
* 自动识别并处理不同 Provider 的额外字段
|
||||||
|
*
|
||||||
|
* @param result - 搜索结果(可能包含 Provider 特定字段)
|
||||||
|
* @param index - 结果序号(从0开始)
|
||||||
|
* @returns Citation 对象
|
||||||
|
*/
|
||||||
|
export function adaptSearchResultToCitation(result: WebSearchProviderResult, index: number): Citation {
|
||||||
|
// 基础字段(所有 Provider 都有)
|
||||||
|
const citation: Citation = {
|
||||||
|
number: index + 1,
|
||||||
|
url: result.url,
|
||||||
|
title: result.title,
|
||||||
|
content: result.content,
|
||||||
|
showFavicon: true,
|
||||||
|
type: 'websearch'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exa Provider 特定字段
|
||||||
|
if ('favicon' in result && result.favicon) {
|
||||||
|
citation.favicon = result.favicon
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集元数据
|
||||||
|
const metadata: Record<string, any> = {}
|
||||||
|
|
||||||
|
// Exa 元数据
|
||||||
|
if ('publishedDate' in result && result.publishedDate) {
|
||||||
|
metadata.publishedDate = result.publishedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('author' in result && result.author) {
|
||||||
|
metadata.author = result.author
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('score' in result && result.score !== undefined) {
|
||||||
|
metadata.score = result.score
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('highlights' in result && result.highlights && result.highlights.length > 0) {
|
||||||
|
metadata.highlights = result.highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tavily 元数据
|
||||||
|
if ('answer' in result && result.answer) {
|
||||||
|
metadata.answer = result.answer
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('images' in result && result.images && result.images.length > 0) {
|
||||||
|
metadata.images = result.images
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在有元数据时添加
|
||||||
|
if (Object.keys(metadata).length > 0) {
|
||||||
|
citation.metadata = metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
return citation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量转换搜索结果为 Citations
|
||||||
|
*
|
||||||
|
* @param results - 搜索结果数组
|
||||||
|
* @returns Citation 数组
|
||||||
|
*/
|
||||||
|
export function adaptSearchResultsToCitations(results: WebSearchProviderResult[]): Citation[] {
|
||||||
|
return results.map((result, index) => adaptSearchResultToCitation(result, index))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user