diff --git a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx index c4a13c380b..9e613b288b 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx @@ -12,14 +12,14 @@ import CitationsList from '../CitationsList' function CitationBlock({ block }: { block: CitationMessageBlock }) { const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id)) + const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI const hasCitations = useMemo(() => { - const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI return ( (formattedCitations && formattedCitations.length > 0) || hasGeminiBlock || (block.knowledge && block.knowledge.length > 0) ) - }, [formattedCitations, block.response, block.knowledge]) + }, [formattedCitations, block.knowledge, hasGeminiBlock]) if (block.status === MessageBlockStatus.PROCESSING) { return @@ -29,12 +29,10 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) { return null } - const isGemini = block.response?.source === WebSearchSource.GEMINI - return ( <> {block.status === MessageBlockStatus.SUCCESS && - (isGemini ? ( + (hasGeminiBlock ? ( <> = ({ block, citationBlockId, role, mentions return content } - // FIXME:性能问题,需要优化 - // Replace all citation numbers in the content with formatted citations - formattedCitations.forEach((citation) => { - const citationNum = citation.number - const supData = { - id: citationNum, - url: citation.url, - title: citation.title || citation.hostname || '', - content: citation.content?.substring(0, 200) - } - const citationJson = encodeHTML(JSON.stringify(supData)) - const citationTag = `[${citationNum}](${citation.url})` + switch (block.citationReferences[0].citationBlockSource) { + case WebSearchSource.OPENAI_COMPATIBLE: + case WebSearchSource.OPENAI: { + formattedCitations.forEach((citation) => { + const citationNum = citation.number + const supData = { + id: citationNum, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const citationJson = encodeHTML(JSON.stringify(supData)) - // Replace all occurrences of [citationNum] with the formatted citation - const regex = new RegExp(`\\[${citationNum}\\]`, 'g') - content = content.replace(regex, citationTag) - }) + // Handle[N](url) + const preFormattedRegex = new RegExp(`\\[${citationNum}\\]\\(.*?\\)`, 'g') + + const citationTag = `[${citationNum}](${citation.url})` + + content = content.replace(preFormattedRegex, citationTag) + }) + break + } + case WebSearchSource.GEMINI: { + // First pass: Add basic citation marks using metadata + let processedContent = content + const firstCitation = formattedCitations[0] + if (firstCitation?.metadata) { + console.log('groundingSupport, ', firstCitation.metadata) + firstCitation.metadata.forEach((support: GroundingSupport) => { + const citationNums = support.groundingChunkIndices! + + if (support.segment) { + const text = support.segment.text! + // 生成引用标记 + const basicTag = citationNums + .map((citationNum) => { + const citation = formattedCitations.find((c) => c.number === citationNum + 1) + return citation ? `[${citationNum + 1}](${citation.url})` : '' + }) + .join('') + + // 在文本后面添加引用标记,而不是替换 + if (text && basicTag) { + processedContent = processedContent.replace(text, `${text}${basicTag}`) + } + } + }) + content = processedContent + } + // Second pass: Replace basic citations with full citation data + formattedCitations.forEach((citation) => { + const citationNum = citation.number + const supData = { + id: citationNum, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const citationJson = encodeHTML(JSON.stringify(supData)) + + // Replace basic citation with full citation including data + const basicCitationRegex = new RegExp(`\\[${citationNum}\\]\\(${citation.url}\\)`, 'g') + const fullCitationTag = `[${citationNum}](${citation.url})` + content = content.replace(basicCitationRegex, fullCitationTag) + }) + break + } + default: { + // FIXME:性能问题,需要优化 + // Replace all citation numbers and pre-formatted links with formatted citations + formattedCitations.forEach((citation) => { + const citationNum = citation.number + const supData = { + id: citationNum, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const citationJson = encodeHTML(JSON.stringify(supData)) + + // Handle both plain references [N] and pre-formatted links [N](url) + const plainRefRegex = new RegExp(`\\[${citationNum}\\]`, 'g') + + const citationTag = `[${citationNum}](${citation.url})` + + content = content.replace(plainRefRegex, citationTag) + }) + } + } return content }, [block.content, block.citationReferences, citationBlockId, formattedCitations]) diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 47c0afcc24..14f0b2b36b 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -16,6 +16,7 @@ export interface Citation { content?: string showFavicon?: boolean type?: string + metadata?: Record } interface CitationsListProps { diff --git a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts index dce83cb6dc..c21a32bec0 100644 --- a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts @@ -331,7 +331,7 @@ export default class OpenAICompatibleProvider extends OpenAIProvider { const model = assistant.model || defaultModel const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant) - const isEnabledWebSearch = assistant.enableWebSearch || !!assistant.webSearchProviderId + const isEnabledBultinWebSearch = assistant.enableWebSearch messages = addImageFileToContents(messages) const enableReasoning = ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && @@ -597,7 +597,7 @@ export default class OpenAICompatibleProvider extends OpenAIProvider { } } if ( - isEnabledWebSearch && + isEnabledBultinWebSearch && isZhipuModel(model) && finishReason === 'stop' && originalFinishRawChunk?.web_search @@ -611,7 +611,7 @@ export default class OpenAICompatibleProvider extends OpenAIProvider { } as LLMWebSearchCompleteChunk) } if ( - isEnabledWebSearch && + isEnabledBultinWebSearch && isHunyuanSearchModel(model) && originalFinishRawChunk?.search_info?.search_results ) { diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index b1fc7a6c09..8536ac96f4 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -287,7 +287,7 @@ export default class OpenAIProvider extends BaseProvider { const model = assistant.model || defaultModel const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant) - const isEnabledWebSearch = assistant.enableWebSearch || !!assistant.webSearchProviderId + const isEnabledBuiltinWebSearch = assistant.enableWebSearch onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) // 退回到 OpenAI 兼容模式 if (isOpenAIWebSearch(model)) { @@ -342,7 +342,7 @@ export default class OpenAIProvider extends BaseProvider { const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason if (delta?.content) { - if (delta?.annotations) { + if (isOpenAIWebSearch(model)) { delta.content = convertLinks(delta.content || '', isFirstChunk) } if (isFirstChunk) { @@ -388,7 +388,10 @@ export default class OpenAIProvider extends BaseProvider { return } const tools: OpenAI.Responses.Tool[] = [] - if (isEnabledWebSearch) { + const toolChoices: OpenAI.Responses.ToolChoiceTypes = { + type: 'web_search_preview' + } + if (isEnabledBuiltinWebSearch) { tools.push({ type: 'web_search_preview' }) @@ -558,17 +561,22 @@ export default class OpenAIProvider extends BaseProvider { thinking_millsec: new Date().getTime() - time_first_token_millsec }) break - case 'response.output_text.delta': + case 'response.output_text.delta': { + let delta = chunk.delta + if (isEnabledBuiltinWebSearch) { + delta = convertLinks(delta) + } onChunk({ type: ChunkType.TEXT_DELTA, - text: chunk.delta + text: delta }) - content += chunk.delta + content += delta break + } case 'response.output_text.done': onChunk({ type: ChunkType.TEXT_COMPLETE, - text: chunk.text + text: content }) break case 'response.content_part.done': @@ -633,6 +641,7 @@ export default class OpenAIProvider extends BaseProvider { max_output_tokens: maxTokens, stream: streamOutput, tools: tools.length > 0 ? tools : undefined, + tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined, service_tier: this.getServiceTier(model), ...this.getResponseReasoningEffort(assistant, model), ...this.getCustomParameters(assistant) diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 4814da8306..33c00200da 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -85,19 +85,22 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita if (!block) return [] let formattedCitations: Citation[] = [] - // 1. Handle Web Search Responses (Non-Gemini) + // 1. Handle Web Search Responses if (block.response) { switch (block.response.source) { - case WebSearchSource.GEMINI: + case WebSearchSource.GEMINI: { + const groundingMetadata = block.response.results as GroundingMetadata formattedCitations = - (block.response?.results as GroundingMetadata)?.groundingChunks?.map((chunk, index) => ({ + groundingMetadata?.groundingChunks?.map((chunk, index) => ({ number: index + 1, url: chunk?.web?.uri || '', title: chunk?.web?.title, - showFavicon: false, + showFavicon: true, + metadata: groundingMetadata.groundingSupports, type: 'websearch' })) || [] break + } case WebSearchSource.OPENAI: formattedCitations = (block.response.results as OpenAI.Responses.ResponseOutputText.URLCitation[])?.map((result, index) => { @@ -140,7 +143,7 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita case WebSearchSource.ANTHROPIC: formattedCitations = (block.response.results as Array)?.map((result, index) => { - const {url} = result + const { url } = result let hostname: string | undefined try { hostname = new URL(url).hostname diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index ac285298b7..80fea11787 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -470,7 +470,6 @@ const fetchAndProcessAssistantResponseImpl = async ( saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) }, onExternalToolComplete: (externalToolResult: ExternalToolResult) => { - console.warn('onExternalToolComplete received.', externalToolResult) if (citationBlockId) { const changes: Partial = { response: externalToolResult.webSearch, @@ -505,16 +504,21 @@ const fetchAndProcessAssistantResponseImpl = async ( ) citationBlockId = citationBlock.id handleBlockTransition(citationBlock, MessageBlockType.CITATION) - if (mainTextBlockId) { - const state = getState() - const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] - if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { - const currentRefs = existingMainTextBlock.citationReferences || [] - if (!currentRefs.some((ref) => ref.citationBlockId === citationBlockId)) { - const mainTextChanges = { citationReferences: [...currentRefs, { citationBlockId }] } - dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + } + if (mainTextBlockId) { + const state = getState() + const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] + if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { + const currentRefs = existingMainTextBlock.citationReferences || [] + if (!currentRefs.some((ref) => ref.citationBlockId === citationBlockId)) { + const mainTextChanges = { + citationReferences: [ + ...currentRefs, + { citationBlockId, citationBlockSource: llmWebSearchResult.source } + ] } + dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) + saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) } } } diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index dd453dcbc5..6e9c7450cb 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -11,7 +11,8 @@ import type { Model, Topic, Usage, - WebSearchResponse + WebSearchResponse, + WebSearchSource } from '.' // MessageBlock 类型枚举 - 根据实际API返回特性优化 @@ -63,6 +64,7 @@ export interface MainTextMessageBlock extends BaseMessageBlock { // Citation references citationReferences?: { citationBlockId?: string + citationBlockSource?: WebSearchSource }[] } diff --git a/src/renderer/src/utils/__tests__/linkConverter.test.ts b/src/renderer/src/utils/__tests__/linkConverter.test.ts index 48d4614095..eaecc3ca1f 100644 --- a/src/renderer/src/utils/__tests__/linkConverter.test.ts +++ b/src/renderer/src/utils/__tests__/linkConverter.test.ts @@ -99,12 +99,6 @@ describe('linkConverter', () => { expect(result).toBe('这里有链接 [1](https://example.com)') }) - it('should preserve non-domain link text', () => { - const input = '点击[这里](https://example.com)查看更多' - const result = convertLinks(input, true) - expect(result).toBe('点击这里[1](https://example.com)查看更多') - }) - it('should use the same counter for duplicate URLs', () => { const input = '第一个链接 [example.com](https://example.com) 和第二个相同链接 [subdomain.example.com](https://example.com)' @@ -113,24 +107,6 @@ describe('linkConverter', () => { '第一个链接 [1](https://example.com) 和第二个相同链接 [1](https://example.com)' ) }) - - it('should correctly convert links in Zhipu mode', () => { - const input = '这里是引用 [ref_1]' - const result = convertLinks(input, true, true) - expect(result).toBe('这里是引用 [1]()') - }) - - it('should handle incomplete links in chunked input', () => { - // 第一个块包含未完成的链接 - const chunk1 = '这是链接 [' - const result1 = convertLinks(chunk1, true) - expect(result1).toBe('这是链接 ') - - // 第二个块完成链接 - const chunk2 = 'example.com](https://example.com)' - const result2 = convertLinks(chunk2, false) - expect(result2).toBe('[1](https://example.com)') - }) }) describe('convertLinksToOpenRouter', () => { diff --git a/src/renderer/src/utils/fetch.ts b/src/renderer/src/utils/fetch.ts index ff88973f31..115aa34171 100644 --- a/src/renderer/src/utils/fetch.ts +++ b/src/renderer/src/utils/fetch.ts @@ -126,3 +126,20 @@ export async function fetchWebContent( } } } + +export async function fetchRedirectUrl(url: string) { + try { + const response = await fetch(url, { + method: 'HEAD', + redirect: 'follow', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }) + return response.url + } catch (e) { + console.error(`Failed to fetch redirect url: ${e}`) + return url + } +} diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index cc98c35c85..ac45cf96a3 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -113,6 +113,7 @@ export function convertLinksToHunyuan(text: string, webSearch: any[], resetCount * Converts Markdown links in the text to numbered links based on the rules: * 1. ([host](url)) -> [cnt](url) * 2. [host](url) -> [cnt](url) + * 3. [any text except url](url)-> any text [cnt](url) * * @param text The current chunk of text to process * @param resetCounter Whether to reset the counter and buffer @@ -131,7 +132,6 @@ export function convertLinks(text: string, resetCounter = false): string { // Find the safe point - the position after which we might have incomplete patterns let safePoint = buffer.length - // Check for potentially incomplete patterns from the end for (let i = buffer.length - 1; i >= 0; i--) { if (buffer[i] === '(') { @@ -198,6 +198,7 @@ export function convertLinks(text: string, resetCounter = false): string { if (match) { // Found complete regular link + const linkText = match[1] const url = match[2] // Check if this URL has been seen before @@ -209,7 +210,13 @@ export function convertLinks(text: string, resetCounter = false): string { urlToCounterMap.set(url, counter) } - result += `[${counter}](${url})` + // Rule 3: If the link text is not a URL/host, keep the text and add the numbered link + if (!isHost(linkText)) { + result += `${linkText} [${counter}](${url})` + } else { + // Rule 2: If the link text is a URL/host, replace with numbered link + result += `[${counter}](${url})` + } position += match[0].length continue @@ -317,7 +324,7 @@ export function extractUrlsFromMarkdown(text: string): string[] { // 匹配所有Markdown链接格式 const linkPattern = /\[(?:[^[\]]*)\]\(([^()]+)\)/g - let match + let match: RegExpExecArray | null while ((match = linkPattern.exec(text)) !== null) { const url = match[1].trim()