feat(UI, OpenAI): support OpenAI-4o-web-search add support for web search citations (#3524)

* feat(UI, OpenAI): support  OpenAI 4o web search add support for web search citations

- refactor: Introduced a new CitationsList component to display citations in MessageContent.
- feat: Enhanced message handling to support web search results and annotations from OpenAI.
- refactor: Removed the deprecated MessageSearchResults component for cleaner code structure.
- refactor: Added utility functions for link conversion and URL extraction from Markdown.

* chore: remove debug logging from ProxyManager

* revert(OpenAIProvider): streamline reasoning check for stream output handling

* chore(OpenAIProvider): correct placement of webSearch in response object

* fix(patches): update OpenAI package version and remove patch references

- Integrated dayjs for dynamic date formatting in prompts.ts.

* feat(Citation, Favicon): enhance OpenAI web search support and citation handling

- Improved FallbackFavicon component to cache failed favicon URLs.
- Support all web search citation preview
- Added support for Hunyuan search model in OpenAIProvider and ApiService.

* refactor(provider/AI): move additional search parameters to AI Provider
This commit is contained in:
SuYao 2025-04-06 09:11:59 +08:00 committed by GitHub
parent e02c967f5b
commit f2ca56a088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 894 additions and 253 deletions

View File

@ -1,8 +1,8 @@
diff --git a/core.js b/core.js diff --git a/core.js b/core.js
index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c14d6e11b 100644 index ebb071d31cd5a14792b62814df072c5971e83300..31e1062d4a7f2422ffec79cf96a35dbb69fe89cb 100644
--- a/core.js --- a/core.js
+++ b/core.js +++ b/core.js
@@ -156,7 +156,7 @@ class APIClient { @@ -157,7 +157,7 @@ class APIClient {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(), 'User-Agent': this.getUserAgent(),
@ -12,10 +12,10 @@ index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c
}; };
} }
diff --git a/core.mjs b/core.mjs diff --git a/core.mjs b/core.mjs
index fcef58eb502664c41a77483a00db8adaf29b2817..18c5d6ed4be86b3640931277bdc27700006764d7 100644 index 9c1a0264dcd73a85de1cf81df4efab9ce9ee2ab7..33f9f1f237f2eb2667a05dae1a7e3dc916f6bfff 100644
--- a/core.mjs --- a/core.mjs
+++ b/core.mjs +++ b/core.mjs
@@ -149,7 +149,7 @@ export class APIClient { @@ -150,7 +150,7 @@ export class APIClient {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(), 'User-Agent': this.getUserAgent(),

View File

@ -156,7 +156,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.4", "mime": "^4.0.4",
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch", "openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"p-queue": "^8.1.0", "p-queue": "^8.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5", "rc-virtual-list": "^3.18.5",
@ -193,7 +193,7 @@
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", "@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch", "openai@npm:^4.77.0": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch" "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
}, },
"packageManager": "yarn@4.6.0", "packageManager": "yarn@4.6.0",

View File

@ -1,6 +1,37 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
// 记录失败的URL的缓存键前缀
const FAILED_FAVICON_CACHE_PREFIX = 'failed_favicon_'
// 失败URL的缓存时间 (24小时)
const FAILED_FAVICON_CACHE_DURATION = 24 * 60 * 60 * 1000
// 检查URL是否在失败缓存中
const isUrlFailedRecently = (url: string): boolean => {
const cacheKey = `${FAILED_FAVICON_CACHE_PREFIX}${url}`
const cachedTimestamp = localStorage.getItem(cacheKey)
if (!cachedTimestamp) return false
const timestamp = parseInt(cachedTimestamp, 10)
const now = Date.now()
// 如果时间戳在缓存期内则认为URL仍处于失败状态
if (now - timestamp < FAILED_FAVICON_CACHE_DURATION) {
return true
}
// 清除过期的缓存
localStorage.removeItem(cacheKey)
return false
}
// 记录失败的URL到缓存
const markUrlAsFailed = (url: string): void => {
const cacheKey = `${FAILED_FAVICON_CACHE_PREFIX}${url}`
localStorage.setItem(cacheKey, Date.now().toString())
}
// FallbackFavicon component that tries multiple favicon sources // FallbackFavicon component that tries multiple favicon sources
interface FallbackFaviconProps { interface FallbackFaviconProps {
hostname: string hostname: string
@ -22,20 +53,27 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
// Generate all possible favicon URLs // Generate all possible favicon URLs
const faviconUrls = [ const faviconUrls = [
`https://favicon.splitbee.io/?url=${hostname}`,
`https://${hostname}/favicon.ico`,
`https://icon.horse/icon/${hostname}`, `https://icon.horse/icon/${hostname}`,
`https://favicon.cccyun.cc/${hostname}`, `https://favicon.splitbee.io/?url=${hostname}`,
`https://favicon.im/${hostname}`, `https://favicon.im/${hostname}`,
`https://www.google.com/s2/favicons?domain=${hostname}` `https://${hostname}/favicon.ico`
] ]
// 过滤掉最近已失败的URL
const validFaviconUrls = faviconUrls.filter((url) => !isUrlFailedRecently(url))
// 如果所有URL都被缓存为失败使用第一个URL
if (validFaviconUrls.length === 0) {
setFaviconState({ status: 'loaded', src: faviconUrls[0] })
return
}
// Main controller to abort all requests when needed // Main controller to abort all requests when needed
const controller = new AbortController() const controller = new AbortController()
const { signal } = controller const { signal } = controller
// Create a promise for each favicon URL // Create a promise for each favicon URL
const faviconPromises = faviconUrls.map((url) => const faviconPromises = validFaviconUrls.map((url) =>
fetch(url, { fetch(url, {
method: 'HEAD', method: 'HEAD',
signal, signal,
@ -45,6 +83,10 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
if (response.ok) { if (response.ok) {
return url return url
} }
// 记录4xx或5xx失败
if (response.status >= 400) {
markUrlAsFailed(url)
}
throw new Error(`Failed to fetch ${url}`) throw new Error(`Failed to fetch ${url}`)
}) })
.catch((error) => { .catch((error) => {
@ -89,6 +131,10 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
}, [hostname]) // Only depend on hostname }, [hostname]) // Only depend on hostname
const handleError = () => { const handleError = () => {
if (faviconState.status === 'loaded') {
// 记录图片加载失败的URL
markUrlAsFailed(faviconState.src)
}
setFaviconState({ status: 'failed' }) setFaviconState({ status: 'failed' })
} }

View File

@ -133,6 +133,7 @@ import { getProviderByModel } from '@renderer/services/AssistantService'
import { Assistant, Model } from '@renderer/types' import { Assistant, Model } from '@renderer/types'
import OpenAI from 'openai' import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
import { getWebSearchTools } from './tools' import { getWebSearchTools } from './tools'
// Vision models // Vision models
@ -2148,6 +2149,9 @@ export function isVisionModel(model: Model): boolean {
export function isOpenAIoSeries(model: Model): boolean { export function isOpenAIoSeries(model: Model): boolean {
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3') return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
} }
export function isOpenAIWebSearch(model: Model): boolean {
return model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview')
}
export function isSupportedResoningEffortModel(model?: Model): boolean { export function isSupportedResoningEffortModel(model?: Model): boolean {
if (!model) { if (!model) {
@ -2212,7 +2216,7 @@ export function isWebSearchModel(model: Model): boolean {
} }
if (provider?.type === 'openai') { if (provider?.type === 'openai') {
if (GEMINI_SEARCH_MODELS.includes(model?.id)) { if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) {
return true return true
} }
} }
@ -2270,7 +2274,7 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
const webSearchTools = getWebSearchTools(model) const webSearchTools = getWebSearchTools(model)
if (model.provider === 'hunyuan') { if (model.provider === 'hunyuan') {
return { enable_enhancement: true } return { enable_enhancement: true, citation: true, search_info: true }
} }
if (model.provider === 'dashscope') { if (model.provider === 'dashscope') {
@ -2284,10 +2288,14 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
if (model.provider === 'openrouter') { if (model.provider === 'openrouter') {
return { return {
plugins: [{ id: 'web' }] plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }]
} }
} }
if (isOpenAIWebSearch(model)) {
return {}
}
return { return {
tools: webSearchTools tools: webSearchTools
} }
@ -2308,3 +2316,23 @@ export function isGemmaModel(model?: Model): boolean {
return model.id.includes('gemma-') || model.group === 'Gemma' return model.id.includes('gemma-') || model.group === 'Gemma'
} }
export function isZhipuModel(model?: Model): boolean {
if (!model) {
return false
}
return model.provider === 'zhipu'
}
export function isHunyuanSearchModel(model?: Model): boolean {
if (!model) {
return false
}
if (model.provider === 'hunyuan') {
return model.id !== 'hunyuan-lite'
}
return false
}

View File

@ -1,3 +1,5 @@
import dayjs from 'dayjs'
export const AGENT_PROMPT = ` export const AGENT_PROMPT = `
You are a Prompt Generator. You will integrate user input information into a structured Prompt using Markdown syntax. Please do not use code blocks for output, display directly! You are a Prompt Generator. You will integrate user input information into a structured Prompt using Markdown syntax. Please do not use code blocks for output, display directly!
@ -109,3 +111,20 @@ export const FOOTNOTE_PROMPT = `Please answer the question based on the referenc
{references} {references}
` `
export const WEB_SEARCH_PROMPT_FOR_ZHIPU = `
#
{search_result}
# 当前日期: ${dayjs().format('YYYY-MM-DD')}
#
使[ref_序号](url)markdown链接形式来标明参考信息来源
`
export const WEB_SEARCH_PROMPT_FOR_OPENROUTER = `
A web search was conducted on \`${dayjs().format('YYYY-MM-DD')}\`. Incorporate the following web search results into your response.
IMPORTANT: Cite them using markdown links named using the domain of the source.
Example: [nytimes.com](https://nytimes.com/some-page).
If have multiple citations, please directly list them like this:
[www.nytimes.com](https://nytimes.com/some-page)[www.bbc.com](https://bbc.com/some-page)
`

View File

@ -1,12 +1,17 @@
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { ChatCompletionTool } from 'openai/resources' import { ChatCompletionTool } from 'openai/resources'
import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts'
export function getWebSearchTools(model: Model): ChatCompletionTool[] { export function getWebSearchTools(model: Model): ChatCompletionTool[] {
if (model?.provider === 'zhipu') { if (model?.provider === 'zhipu') {
if (model.id === 'glm-4-alltools') { if (model.id === 'glm-4-alltools') {
return [ return [
{ {
type: 'web_browser' type: 'web_browser',
web_browser: {
browser: 'auto'
}
} as unknown as ChatCompletionTool } as unknown as ChatCompletionTool
] ]
} }
@ -15,7 +20,8 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] {
type: 'web_search', type: 'web_search',
web_search: { web_search: {
enable: true, enable: true,
search_result: true search_result: true,
search_prompt: WEB_SEARCH_PROMPT_FOR_ZHIPU
} }
} as unknown as ChatCompletionTool } as unknown as ChatCompletionTool
] ]

View File

@ -27,19 +27,11 @@ const ALLOWED_ELEMENTS =
interface Props { interface Props {
message: Message message: Message
citationsData?: Map<
string,
{
url: string
title?: string
content?: string
}
>
} }
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly] const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
const disallowedElements = ['iframe'] const disallowedElements = ['iframe']
const Markdown: FC<Props> = ({ message, citationsData }) => { const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { renderInputMessageAsMarkdown, mathEngine } = useSettings() const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
@ -60,8 +52,34 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
const components = useMemo(() => { const components = useMemo(() => {
const baseComponents = { const baseComponents = {
a: (props: any) => { a: (props: any) => {
if (props.href && citationsData?.has(props.href)) { // 更彻底的查找方法,递归搜索所有子元素
return <Link {...props} citationData={citationsData.get(props.href)} /> const findCitationInChildren = (children) => {
if (!children) return null
// 直接搜索子元素
for (const child of Array.isArray(children) ? children : [children]) {
if (typeof child === 'object' && child?.props?.['data-citation']) {
return child.props['data-citation']
}
// 递归查找更深层次
if (typeof child === 'object' && child?.props?.children) {
const found = findCitationInChildren(child.props.children)
if (found) return found
}
}
return null
}
// 然后在组件中使用
const citationData = findCitationInChildren(props.children)
if (citationData) {
try {
return <Link {...props} citationData={JSON.parse(citationData)} />
} catch (e) {
console.error('Failed to parse citation data', e)
}
} }
return <Link {...props} /> return <Link {...props} />
}, },
@ -70,7 +88,7 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} /> pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
} as Partial<Components> } as Partial<Components>
return baseComponents return baseComponents
}, [citationsData]) }, [messageContent])
if (message.role === 'user' && !renderInputMessageAsMarkdown) { if (message.role === 'user' && !renderInputMessageAsMarkdown) {
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p> return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>

View File

@ -0,0 +1,81 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { HStack } from '@renderer/components/Layout'
import React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Citation {
number: number
url: string
title?: string
hostname?: string
showFavicon?: boolean
}
interface CitationsListProps {
citations: Citation[]
}
const CitationsList: React.FC<CitationsListProps> = ({ citations }) => {
const { t } = useTranslation()
if (!citations || citations.length === 0) return null
return (
<CitationsContainer className="footnotes">
<CitationsTitle>
{t('message.citations')}
<InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} />
</CitationsTitle>
{citations.map((citation) => (
<HStack key={citation.url || citation.number} style={{ alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-2)' }}>{citation.number}.</span>
{citation.showFavicon && citation.url && (
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
)}
<CitationLink href={citation.url} target="_blank" rel="noopener noreferrer">
{citation.title ? citation.title : <span className="hostname">{citation.hostname}</span>}
</CitationLink>
</HStack>
))}
</CitationsContainer>
)
}
const CitationsContainer = styled.div`
background-color: rgb(242, 247, 253);
border-radius: 4px;
padding: 8px 12px;
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
body[theme-mode='dark'] & {
background-color: rgba(255, 255, 255, 0.05);
}
`
const CitationsTitle = styled.div`
font-weight: 500;
margin-bottom: 4px;
color: var(--color-text-1);
`
const CitationLink = styled.a`
font-size: 14px;
line-height: 1.6;
text-decoration: none;
color: var(--color-text-1);
.hostname {
color: var(--color-link);
}
&:hover {
text-decoration: underline;
}
`
export default CitationsList

View File

@ -1,6 +1,5 @@
import { InfoCircleOutlined, SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons' import { SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import Favicon from '@renderer/components/Icons/FallbackFavicon' import { isOpenAIWebSearch } from '@renderer/config/models'
import { HStack } from '@renderer/components/Layout'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils' import { getBriefInfo } from '@renderer/utils'
@ -14,10 +13,10 @@ import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components' import styled from 'styled-components'
import Markdown from '../Markdown/Markdown' import Markdown from '../Markdown/Markdown'
import CitationsList from './CitationsList'
import MessageAttachments from './MessageAttachments' import MessageAttachments from './MessageAttachments'
import MessageError from './MessageError' import MessageError from './MessageError'
import MessageImage from './MessageImage' import MessageImage from './MessageImage'
import MessageSearchResults from './MessageSearchResults'
import MessageThought from './MessageThought' import MessageThought from './MessageThought'
import MessageTools from './MessageTools' import MessageTools from './MessageTools'
@ -29,6 +28,7 @@ interface Props {
const MessageContent: React.FC<Props> = ({ message: _message, model }) => { const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation() const { t } = useTranslation()
const message = withMessageThought(clone(_message)) const message = withMessageThought(clone(_message))
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
// HTML实体编码辅助函数 // HTML实体编码辅助函数
const encodeHTML = (str: string) => { const encodeHTML = (str: string) => {
@ -44,39 +44,95 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
}) })
} }
// Format citations for display
const formattedCitations = useMemo(() => {
if (!message.metadata?.citations?.length && !message.metadata?.annotations?.length) return null
let citations: any[] = []
if (model && isOpenAIWebSearch(model)) {
citations =
message.metadata.annotations?.map((url, index) => {
return { number: index + 1, url: url.url_citation?.url, hostname: url.url_citation.title }
}) || []
} else {
citations =
message.metadata?.citations?.map((url, index) => {
try {
const hostname = new URL(url).hostname
return { number: index + 1, url, hostname }
} catch {
return { number: index + 1, url, hostname: url }
}
}) || []
}
// Deduplicate by URL
const urlSet = new Set()
return citations
.filter((citation) => {
if (!citation.url || urlSet.has(citation.url)) return false
urlSet.add(citation.url)
return true
})
.map((citation, index) => ({
...citation,
number: index + 1 // Renumber citations sequentially after deduplication
}))
}, [message.metadata?.citations, message.metadata?.annotations, model])
// 获取引用数据 // 获取引用数据
const citationsData = useMemo(() => { const citationsData = useMemo(() => {
const searchResults = message?.metadata?.webSearch?.results || [] const searchResults =
const citationsUrls = message?.metadata?.citations || [] message?.metadata?.webSearch?.results ||
message?.metadata?.webSearchInfo ||
message?.metadata?.groundingMetadata?.groundingChunks.map((chunk) => chunk.web) ||
message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ||
[]
const citationsUrls = formattedCitations || []
// 合并引用数据 // 合并引用数据
const data = new Map() const data = new Map()
// 添加webSearch结果 // 添加webSearch结果
searchResults.forEach((result) => { searchResults.forEach((result) => {
data.set(result.url, { data.set(result.url || result.uri || result.link, {
url: result.url, url: result.url || result.uri || result.link,
title: result.title, title: result.title || result.hostname,
content: result.content content: result.content
}) })
}) })
// 添加citations // 添加citations
citationsUrls.forEach((url) => { citationsUrls.forEach((result) => {
if (!data.has(url)) { if (!data.has(result.url)) {
data.set(url, { data.set(result.url, {
url: url url: result.url,
// 如果没有title和content将在CitationTooltip中显示hostname title: result.title || result.hostname || undefined,
content: result.content || undefined
}) })
} }
}) })
return data return data
}, [message.metadata?.citations, message.metadata?.webSearch?.results]) }, [
formattedCitations,
message?.metadata?.annotations,
message?.metadata?.groundingMetadata?.groundingChunks,
message?.metadata?.webSearch?.results,
message?.metadata?.webSearchInfo
])
// Process content to make citation numbers clickable // Process content to make citation numbers clickable
const processedContent = useMemo(() => { const processedContent = useMemo(() => {
if (!(message.metadata?.citations || message.metadata?.webSearch)) { if (
!(
message.metadata?.citations ||
message.metadata?.webSearch ||
message.metadata?.webSearchInfo ||
message.metadata?.annotations
)
) {
return message.content return message.content
} }
@ -88,6 +144,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
// Convert [n] format to superscript numbers and make them clickable // Convert [n] format to superscript numbers and make them clickable
// Use <sup> tag for superscript and make it a link with citation data // Use <sup> tag for superscript and make it a link with citation data
if (message.metadata?.webSearch) {
content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => { content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => {
const num = num1 || num2 const num = num1 || num2
const index = parseInt(num) - 1 const index = parseInt(num) - 1
@ -98,23 +155,21 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
} }
return match return match
}) })
} else {
return content content = content.replace(/\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => {
}, [message.content, message.metadata, citationsData]) const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null
return `[<sup data-citation='${citationData}'>${num}</sup>](${url})`
// Format citations for display
const formattedCitations = useMemo(() => {
if (!message.metadata?.citations?.length) return null
return message.metadata.citations.map((url, index) => {
try {
const hostname = new URL(url).hostname
return { number: index + 1, url, hostname }
} catch {
return { number: index + 1, url, hostname: url }
}
}) })
}, [message.metadata?.citations]) }
return content
}, [
message.metadata?.citations,
message.metadata?.webSearch,
message.metadata?.webSearchInfo,
message.metadata?.annotations,
message.content,
citationsData
])
if (message.status === 'sending') { if (message.status === 'sending') {
return ( return (
@ -150,7 +205,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
</Flex> </Flex>
<MessageThought message={message} /> <MessageThought message={message} />
<MessageTools message={message} /> <MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent }} citationsData={citationsData} /> <Markdown message={{ ...message, content: processedContent }} />
{message.metadata?.generateImage && <MessageImage message={message} />} {message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && ( {message.translatedContent && (
<Fragment> <Fragment>
@ -164,36 +219,54 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
)} )}
</Fragment> </Fragment>
)} )}
<MessageSearchResults message={message} /> {message?.metadata?.groundingMetadata && message.status == 'success' && (
<>
<CitationsList
citations={message.metadata.groundingMetadata.groundingChunks.map((chunk, index) => ({
number: index + 1,
url: chunk.web?.uri,
title: chunk.web?.title,
showFavicon: false
}))}
/>
<SearchEntryPoint
dangerouslySetInnerHTML={{
__html: message.metadata.groundingMetadata.searchEntryPoint?.renderedContent
?.replace(/@media \(prefers-color-scheme: light\)/g, 'body[theme-mode="light"]')
.replace(/@media \(prefers-color-scheme: dark\)/g, 'body[theme-mode="dark"]')
}}
/>
</>
)}
{formattedCitations && ( {formattedCitations && (
<CitationsContainer> <CitationsList
<CitationsTitle> citations={formattedCitations.map((citation) => ({
{t('message.citations')} number: citation.number,
<InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} /> url: citation.url,
</CitationsTitle> hostname: citation.hostname,
{formattedCitations.map(({ number, url, hostname }) => ( showFavicon: isWebCitation
<CitationLink key={number} href={url} target="_blank" rel="noopener noreferrer"> }))}
{number}. <span className="hostname">{hostname}</span> />
</CitationLink>
))}
</CitationsContainer>
)} )}
{message?.metadata?.webSearch && message.status === 'success' && ( {message?.metadata?.webSearch && message.status === 'success' && (
<CitationsContainer className="footnotes"> <CitationsList
<CitationsTitle> citations={message.metadata.webSearch.results.map((result, index) => ({
{t('message.citations')} number: index + 1,
<InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} /> url: result.url,
</CitationsTitle> title: result.title,
{message.metadata.webSearch.results.map((result, index) => ( showFavicon: true
<HStack key={result.url} style={{ alignItems: 'center', gap: 8 }}> }))}
<span style={{ fontSize: 13, color: 'var(--color-text-2)' }}>{index + 1}.</span> />
<Favicon hostname={new URL(result.url).hostname} alt={result.title} /> )}
<CitationLink href={result.url} target="_blank" rel="noopener noreferrer"> {message?.metadata?.webSearchInfo && message.status === 'success' && (
{result.title} <CitationsList
</CitationLink> citations={message.metadata.webSearchInfo.map((result, index) => ({
</HStack> number: index + 1,
))} url: result.link || result.url,
</CitationsContainer> title: result.title,
showFavicon: true
}))}
/>
)} )}
<MessageAttachments message={message} /> <MessageAttachments message={message} />
</Fragment> </Fragment>
@ -224,41 +297,6 @@ const MentionTag = styled.span`
color: var(--color-link); color: var(--color-link);
` `
const CitationsContainer = styled.div`
background-color: rgb(242, 247, 253);
border-radius: 4px;
padding: 8px 12px;
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
body[theme-mode='dark'] & {
background-color: rgba(255, 255, 255, 0.05);
}
`
const CitationsTitle = styled.div`
font-weight: 500;
margin-bottom: 4px;
color: var(--color-text-1);
`
const CitationLink = styled.a`
font-size: 14px;
line-height: 1.6;
text-decoration: none;
color: var(--color-text-1);
.hostname {
color: var(--color-link);
}
&:hover {
text-decoration: underline;
}
`
const SearchingText = styled.div` const SearchingText = styled.div`
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
@ -266,4 +304,8 @@ const SearchingText = styled.div`
color: var(--color-text-1); color: var(--color-text-1);
` `
const SearchEntryPoint = styled.div`
margin: 10px 2px;
`
export default React.memo(MessageContent) export default React.memo(MessageContent)

View File

@ -1,95 +0,0 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { Message } from '@renderer/types'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
message: Message
}
const MessageSearchResults: FC<Props> = ({ message }) => {
const { t } = useTranslation()
if (!message.metadata?.groundingMetadata) {
return null
}
const { groundingChunks, searchEntryPoint } = message.metadata.groundingMetadata
if (!groundingChunks) {
return null
}
let searchEntryContent = searchEntryPoint?.renderedContent
searchEntryContent = searchEntryContent?.replace(
/@media \(prefers-color-scheme: light\)/g,
'body[theme-mode="light"]'
)
searchEntryContent = searchEntryContent?.replace(/@media \(prefers-color-scheme: dark\)/g, 'body[theme-mode="dark"]')
return (
<>
<Container className="footnotes">
<TitleRow>
<Title>{t('common.footnotes')}</Title>
<InfoCircleOutlined />
</TitleRow>
<Sources>
{groundingChunks.map((chunk, index) => (
<SourceItem key={index}>
<Link href={chunk.web?.uri} target="_blank" rel="noopener noreferrer">
{chunk.web?.title}
</Link>
</SourceItem>
))}
</Sources>
</Container>
<SearchEntryPoint dangerouslySetInnerHTML={{ __html: searchEntryContent || '' }} />
</>
)
}
const Container = styled.div`
padding: 16px;
border-radius: 8px;
margin-bottom: 0;
`
const TitleRow = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-bottom: 10px;
`
const Title = styled.h4`
margin: 0 !important;
`
const Sources = styled.ol`
margin-top: 10px;
`
const SourceItem = styled.li`
margin-bottom: 5px;
`
const Link = styled.a`
margin-left: 5px;
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
`
const SearchEntryPoint = styled.div`
margin: 10px 2px;
`
export default MessageSearchResults

View File

@ -9,12 +9,12 @@ import type {
Message, Message,
Model, Model,
Provider, Provider,
Suggestion Suggestion,
WebSearchResponse
} from '@renderer/types' } from '@renderer/types'
import { delay, isJSON, parseJSON } from '@renderer/utils' import { delay, isJSON, parseJSON } from '@renderer/utils'
import { addAbortController, removeAbortController } from '@renderer/utils/abortController' import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
import { formatApiHost } from '@renderer/utils/api' import { formatApiHost } from '@renderer/utils/api'
import { TavilySearchResponse } from '@tavily/core'
import { t } from 'i18next' import { t } from 'i18next'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import type OpenAI from 'openai' import type OpenAI from 'openai'
@ -123,7 +123,7 @@ export default abstract class BaseProvider {
if (isEmpty(message.content)) { if (isEmpty(message.content)) {
return [] return []
} }
const webSearch: TavilySearchResponse = window.keyv.get(`web-search-${message.id}`) const webSearch: WebSearchResponse = window.keyv.get(`web-search-${message.id}`)
if (webSearch) { if (webSearch) {
return webSearch.results.map( return webSearch.results.map(

View File

@ -1,10 +1,13 @@
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { import {
getOpenAIWebSearchParams, getOpenAIWebSearchParams,
isHunyuanSearchModel,
isOpenAIoSeries, isOpenAIoSeries,
isOpenAIWebSearch,
isReasoningModel, isReasoningModel,
isSupportedModel, isSupportedModel,
isVisionModel isVisionModel,
isZhipuModel
} from '@renderer/config/models' } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings' import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
@ -185,7 +188,7 @@ export default class OpenAIProvider extends BaseProvider {
* @returns The temperature * @returns The temperature
*/ */
private getTemperature(assistant: Assistant, model: Model) { private getTemperature(assistant: Assistant, model: Model) {
return isReasoningModel(model) ? undefined : assistant?.settings?.temperature return isReasoningModel(model) || isOpenAIWebSearch(model) ? undefined : assistant?.settings?.temperature
} }
/** /**
@ -222,7 +225,7 @@ export default class OpenAIProvider extends BaseProvider {
* @returns The top P * @returns The top P
*/ */
private getTopP(assistant: Assistant, model: Model) { private getTopP(assistant: Assistant, model: Model) {
if (isReasoningModel(model)) return undefined if (isReasoningModel(model) || isOpenAIWebSearch(model)) return undefined
return assistant?.settings?.topP return assistant?.settings?.topP
} }
@ -433,6 +436,7 @@ export default class OpenAIProvider extends BaseProvider {
) as ChatCompletionMessageParam[] ) as ChatCompletionMessageParam[]
const toolResponses: MCPToolResponse[] = [] const toolResponses: MCPToolResponse[] = []
let firstChunk = true
const processStream = async (stream: any, idx: number) => { const processStream = async (stream: any, idx: number) => {
if (!isSupportStreamOutput()) { if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec const time_completion_millsec = new Date().getTime() - start_time_millsec
@ -498,6 +502,15 @@ export default class OpenAIProvider extends BaseProvider {
} }
} }
let webSearch: any[] | undefined = undefined
if (assistant.enableWebSearch && isZhipuModel(model) && finishReason === 'stop') {
webSearch = chunk?.web_search
}
if (firstChunk && assistant.enableWebSearch && isHunyuanSearchModel(model)) {
webSearch = chunk?.search_info?.search_results
firstChunk = true
}
if (finishReason === 'tool_calls' || (finishReason === 'stop' && Object.keys(final_tool_calls).length > 0)) { if (finishReason === 'tool_calls' || (finishReason === 'stop' && Object.keys(final_tool_calls).length > 0)) {
const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs) const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs)
console.log('start invoke tools', toolCalls) console.log('start invoke tools', toolCalls)
@ -603,6 +616,8 @@ export default class OpenAIProvider extends BaseProvider {
time_first_token_millsec, time_first_token_millsec,
time_thinking_millsec time_thinking_millsec
}, },
webSearch,
annotations: delta?.annotations,
citations, citations,
mcpToolResponse: toolResponses mcpToolResponse: toolResponses
}) })

View File

@ -20,7 +20,13 @@ export interface ChunkCallbackData {
reasoning_content?: string reasoning_content?: string
usage?: OpenAI.Completions.CompletionUsage usage?: OpenAI.Completions.CompletionUsage
metrics?: Metrics metrics?: Metrics
// Zhipu web search
webSearch?: any[]
// Gemini web search
search?: GroundingMetadata search?: GroundingMetadata
// Openai web search
annotations?: OpenAI.Chat.Completions.ChatCompletionMessage.Annotation[]
// Openrouter web search or Knowledge base
citations?: string[] citations?: string[]
mcpToolResponse?: MCPToolResponse[] mcpToolResponse?: MCPToolResponse[]
generateImage?: GenerateImageResponse generateImage?: GenerateImageResponse
@ -34,7 +40,9 @@ export interface CompletionsParams {
reasoning_content, reasoning_content,
usage, usage,
metrics, metrics,
webSearch,
search, search,
annotations,
citations, citations,
mcpToolResponse, mcpToolResponse,
generateImage generateImage

View File

@ -1,4 +1,9 @@
import { getOpenAIWebSearchParams } from '@renderer/config/models' import {
getOpenAIWebSearchParams,
isHunyuanSearchModel,
isOpenAIWebSearch,
isZhipuModel
} from '@renderer/config/models'
import { SEARCH_SUMMARY_PROMPT } from '@renderer/config/prompts' import { SEARCH_SUMMARY_PROMPT } from '@renderer/config/prompts'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
@ -6,6 +11,15 @@ import { setGenerating } from '@renderer/store/runtime'
import { Assistant, MCPTool, Message, Model, Provider, Suggestion } from '@renderer/types' import { Assistant, MCPTool, Message, Model, Provider, Suggestion } from '@renderer/types'
import { formatMessageError, isAbortError } from '@renderer/utils/error' import { formatMessageError, isAbortError } from '@renderer/utils/error'
import { withGenerateImage } from '@renderer/utils/formats' import { withGenerateImage } from '@renderer/utils/formats'
import {
cleanLinkCommas,
completeLinks,
convertLinks,
convertLinksToHunyuan,
convertLinksToOpenRouter,
convertLinksToZhipu,
extractUrlsFromMarkdown
} from '@renderer/utils/linkConverter'
import { cloneDeep, findLast, isEmpty } from 'lodash' import { cloneDeep, findLast, isEmpty } from 'lodash'
import AiProvider from '../providers/AiProvider' import AiProvider from '../providers/AiProvider'
@ -46,7 +60,7 @@ export async function fetchChatCompletion({
if (WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch && assistant.model) { if (WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch && assistant.model) {
const webSearchParams = getOpenAIWebSearchParams(assistant, assistant.model) const webSearchParams = getOpenAIWebSearchParams(assistant, assistant.model)
if (isEmpty(webSearchParams)) { if (isEmpty(webSearchParams) && !isOpenAIWebSearch(assistant.model)) {
const lastMessage = findLast(messages, (m) => m.role === 'user') const lastMessage = findLast(messages, (m) => m.role === 'user')
const lastAnswer = findLast(messages, (m) => m.role === 'assistant') const lastAnswer = findLast(messages, (m) => m.role === 'assistant')
const hasKnowledgeBase = !isEmpty(lastMessage?.knowledgeBaseIds) const hasKnowledgeBase = !isEmpty(lastMessage?.knowledgeBaseIds)
@ -115,7 +129,34 @@ export async function fetchChatCompletion({
messages: filterUsefulMessages(filterContextMessages(messages)), messages: filterUsefulMessages(filterContextMessages(messages)),
assistant, assistant,
onFilterMessages: (messages) => (_messages = messages), onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse, generateImage }) => { onChunk: ({
text,
reasoning_content,
usage,
metrics,
webSearch,
search,
annotations,
citations,
mcpToolResponse,
generateImage
}) => {
if (assistant.model) {
if (isOpenAIWebSearch(assistant.model)) {
text = convertLinks(text || '', isFirstChunk)
} else if (assistant.model.provider === 'openrouter' && assistant.enableWebSearch) {
text = convertLinksToOpenRouter(text || '', isFirstChunk)
} else if (assistant.enableWebSearch) {
if (isZhipuModel(assistant.model)) {
text = convertLinksToZhipu(text || '', isFirstChunk)
} else if (isHunyuanSearchModel(assistant.model)) {
text = convertLinksToHunyuan(text || '', webSearch || [], isFirstChunk)
}
}
}
if (isFirstChunk) {
isFirstChunk = false
}
message.content = message.content + text || '' message.content = message.content + text || ''
message.usage = usage message.usage = usage
message.metrics = metrics message.metrics = metrics
@ -124,10 +165,6 @@ export async function fetchChatCompletion({
message.reasoning_content = (message.reasoning_content || '') + reasoning_content message.reasoning_content = (message.reasoning_content || '') + reasoning_content
} }
if (search) {
message.metadata = { ...message.metadata, groundingMetadata: search }
}
if (mcpToolResponse) { if (mcpToolResponse) {
message.metadata = { ...message.metadata, mcpTools: cloneDeep(mcpToolResponse) } message.metadata = { ...message.metadata, mcpTools: cloneDeep(mcpToolResponse) }
} }
@ -143,12 +180,49 @@ export async function fetchChatCompletion({
} }
// Handle citations from Perplexity API // Handle citations from Perplexity API
if (isFirstChunk && citations) { if (citations) {
message.metadata = { message.metadata = {
...message.metadata, ...message.metadata,
citations citations
} }
isFirstChunk = false }
// Handle web search from Gemini
if (search) {
message.metadata = { ...message.metadata, groundingMetadata: search }
}
// Handle annotations from OpenAI
if (annotations) {
message.metadata = {
...message.metadata,
annotations: annotations
}
}
// Handle web search from Zhipu or Hunyuan
if (webSearch) {
message.metadata = {
...message.metadata,
webSearchInfo: webSearch
}
}
// Handle citations from Openrouter
if (assistant.model?.provider === 'openrouter' && assistant.enableWebSearch) {
const extractedUrls = extractUrlsFromMarkdown(message.content)
if (extractedUrls.length > 0) {
message.metadata = {
...message.metadata,
citations: extractedUrls
}
}
}
if (assistant.enableWebSearch) {
message.content = cleanLinkCommas(message.content)
if (webSearch && isZhipuModel(assistant.model)) {
message.content = completeLinks(message.content, webSearch)
}
} }
onResponse({ ...message, status: 'pending' }) onResponse({ ...message, status: 'pending' })

View File

@ -73,8 +73,12 @@ export type Message = {
metadata?: { metadata?: {
// Gemini // Gemini
groundingMetadata?: any groundingMetadata?: any
// Perplexity // Perplexity Or Openrouter
citations?: string[] citations?: string[]
// OpenAI
annotations?: OpenAI.Chat.Completions.ChatCompletionMessage.Annotation[]
// Zhipu or Hunyuan
webSearchInfo?: any[]
// Web search // Web search
webSearch?: WebSearchResponse webSearch?: WebSearchResponse
// MCP Tools // MCP Tools

View File

@ -0,0 +1,389 @@
// Counter for numbering links
let linkCounter = 1
// Buffer to hold incomplete link fragments across chunks
let buffer = ''
// Map to track URLs that have already been assigned numbers
let urlToCounterMap: Map<string, number> = new Map()
/**
* Determines if a string looks like a host/URL
* @param text The text to check
* @returns Boolean indicating if the text is likely a host
*/
function isHost(text: string): boolean {
// Basic check for URL-like patterns
return /^(https?:\/\/)?[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(text) || /^[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(text)
}
/**
* Converts Markdown links in the text to numbered links based on the rules:s
* [ref_N] -> [<sup>N</sup>]
* @param text The current chunk of text to process
* @param resetCounter Whether to reset the counter and buffer
* @returns Processed text with complete links converted
*/
export function convertLinksToZhipu(text: string, resetCounter = false): string {
if (resetCounter) {
linkCounter = 1
buffer = ''
}
// Append the new text to the buffer
buffer += text
let safePoint = buffer.length
// Check from the end for potentially incomplete [ref_N] patterns
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === '[') {
const substring = buffer.substring(i)
// Check if it's a complete [ref_N] pattern
const match = /^\[ref_\d+\]/.exec(substring)
if (!match) {
// Potentially incomplete [ref_N] pattern
safePoint = i
break
}
}
}
// Process the safe part of the buffer
const safeBuffer = buffer.substring(0, safePoint)
buffer = buffer.substring(safePoint)
// Replace all complete [ref_N] patterns
return safeBuffer.replace(/\[ref_(\d+)\]/g, (_, num) => {
return `[<sup>${num}</sup>]()`
})
}
export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter = false): string {
if (resetCounter) {
linkCounter = 1
buffer = ''
}
buffer += text
let safePoint = buffer.length
// Check from the end for potentially incomplete patterns
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === '[') {
const substring = buffer.substring(i)
// Check if it's a complete pattern - handles both [N](@ref) and [N,M,...](@ref)
const match = /^\[[\d,\s]+\]\(@ref\)/.exec(substring)
if (!match) {
// Potentially incomplete pattern
safePoint = i
break
}
}
}
// Process the safe part of the buffer
const safeBuffer = buffer.substring(0, safePoint)
buffer = buffer.substring(safePoint)
// Replace all complete patterns
return safeBuffer.replace(/\[([\d,\s]+)\]\(@ref\)/g, (_, numbers) => {
// Split the numbers string into individual numbers
const numArray = numbers
.split(',')
.map((num) => parseInt(num.trim()))
.filter((num) => !isNaN(num))
// Generate separate superscript links for each number
const links = numArray.map((num) => {
const index = num - 1
// Check if the index is valid in webSearch array
if (index >= 0 && index < webSearch.length && webSearch[index]?.url) {
return `[<sup>${num}</sup>](${webSearch[index].url})`
}
// If no matching URL found, keep the original reference format for this number
return `[<sup>${num}</sup>](@ref)`
})
// Join the separate links with spaces
return links.join('')
})
}
/**
* Converts Markdown links in the text to numbered links based on the rules:
* 1. ([host](url)) -> [cnt](url)
* 2. [host](url) -> [cnt](url)
* 3. [anytext except host](url) -> anytext[cnt](url)
*
* @param text The current chunk of text to process
* @param resetCounter Whether to reset the counter and buffer
* @param isZhipu Whether to use Zhipu format
* @returns Processed text with complete links converted
*/
export function convertLinks(text: string, resetCounter = false, isZhipu = false): string {
if (resetCounter) {
linkCounter = 1
buffer = ''
urlToCounterMap = new Map<string, number>()
}
// Append the new text to the buffer
buffer += text
// Find the safe point - the position after which we might have incomplete patterns
let safePoint = buffer.length
if (isZhipu) {
// Handle Zhipu mode - find safe point for [ref_N] patterns
let safePoint = buffer.length
// Check from the end for potentially incomplete [ref_N] patterns
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === '[') {
const substring = buffer.substring(i)
// Check if it's a complete [ref_N] pattern
const match = /^\[ref_\d+\]/.exec(substring)
if (!match) {
// Potentially incomplete [ref_N] pattern
safePoint = i
break
}
}
}
// Process the safe part of the buffer
const safeBuffer = buffer.substring(0, safePoint)
buffer = buffer.substring(safePoint)
// Replace all complete [ref_N] patterns
return safeBuffer.replace(/\[ref_(\d+)\]/g, (_, num) => {
return `[<sup>${num}</sup>]()`
})
}
// Check for potentially incomplete patterns from the end
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === '(') {
// Check if this could be the start of a parenthesized link
if (i + 1 < buffer.length && buffer[i + 1] === '[') {
// Verify if we have a complete parenthesized link
const substring = buffer.substring(i)
const match = /^\(\[([^\]]+)\]\(([^)]+)\)\)/.exec(substring)
if (!match) {
safePoint = i
break
}
}
} else if (buffer[i] === '[') {
// Check if this could be the start of a regular link
const substring = buffer.substring(i)
const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(substring)
if (!match) {
safePoint = i
break
}
}
}
// Extract the part of the buffer that we can safely process
const safeBuffer = buffer.substring(0, safePoint)
buffer = buffer.substring(safePoint)
// Process the safe buffer to handle complete links
let result = ''
let position = 0
while (position < safeBuffer.length) {
// Check for parenthesized link pattern: ([text](url))
if (position + 1 < safeBuffer.length && safeBuffer[position] === '(' && safeBuffer[position + 1] === '[') {
const substring = safeBuffer.substring(position)
const match = /^\(\[([^\]]+)\]\(([^)]+)\)\)/.exec(substring)
if (match) {
// Found complete parenthesized link
const url = match[2]
// Check if this URL has been seen before
let counter: number
if (urlToCounterMap.has(url)) {
counter = urlToCounterMap.get(url)!
} else {
counter = linkCounter++
urlToCounterMap.set(url, counter)
}
result += `[<sup>${counter}</sup>](${url})`
position += match[0].length
continue
}
}
// Check for regular link pattern: [text](url)
if (safeBuffer[position] === '[') {
const substring = safeBuffer.substring(position)
const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(substring)
if (match) {
// Found complete regular link
const linkText = match[1]
const url = match[2]
// Check if this URL has been seen before
let counter: number
if (urlToCounterMap.has(url)) {
counter = urlToCounterMap.get(url)!
} else {
counter = linkCounter++
urlToCounterMap.set(url, counter)
}
if (isHost(linkText)) {
result += `[<sup>${counter}</sup>](${url})`
} else {
result += `${linkText}[<sup>${counter}</sup>](${url})`
}
position += match[0].length
continue
}
}
// If no pattern matches at this position, add the character and move on
result += safeBuffer[position]
position++
}
return result
}
/**
* Converts Markdown links in the text to numbered links based on the rules:
* 1. [host](url) -> [cnt](url)
*
* @param text The current chunk of text to process
* @param resetCounter Whether to reset the counter and buffer
* @returns Processed text with complete links converted
*/
export function convertLinksToOpenRouter(text: string, resetCounter = false): string {
if (resetCounter) {
linkCounter = 1
buffer = ''
urlToCounterMap = new Map<string, number>()
}
// Append the new text to the buffer
buffer += text
// Find a safe point to process
let safePoint = buffer.length
// Check for potentially incomplete link patterns from the end
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === '[') {
const substring = buffer.substring(i)
const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(substring)
if (!match) {
safePoint = i
break
}
}
}
// Extract the part of the buffer that we can safely process
const safeBuffer = buffer.substring(0, safePoint)
buffer = buffer.substring(safePoint)
// Process the safe buffer to handle complete links
const result = safeBuffer.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
// Only convert link if the text looks like a host/URL
if (isHost(text)) {
// Check if this URL has been seen before
let counter: number
if (urlToCounterMap.has(url)) {
counter = urlToCounterMap.get(url)!
} else {
counter = linkCounter++
urlToCounterMap.set(url, counter)
}
return `[<sup>${counter}</sup>](${url})`
}
// Keep original link format if the text doesn't look like a host
return match
})
return result
}
/**
* webSearch结果补全链接[<sup>num</sup>]()[<sup>num</sup>](webSearch[num-1].url)
* @param text
* @param webSearch webSearch结果
* @returns
*/
export function completeLinks(text: string, webSearch: any[]): string {
// 使用正则表达式匹配形如 [<sup>num</sup>]() 的链接
return text.replace(/\[<sup>(\d+)<\/sup>\]\(\)/g, (match, num) => {
const index = parseInt(num) - 1
// 检查 webSearch 数组中是否存在对应的 URL
if (index >= 0 && index < webSearch.length && webSearch[index]?.link) {
return `[<sup>${num}</sup>](${webSearch[index].link})`
}
// 如果没有找到对应的 URL保持原样
return match
})
}
/**
* Markdown文本中提取所有URL
*
* 1. [text](url)
* 2. [<sup>num</sup>](url)
* 3. ([text](url))
*
* @param text Markdown格式的文本
* @returns URL数组
*/
export function extractUrlsFromMarkdown(text: string): string[] {
const urlSet = new Set<string>()
// 匹配所有Markdown链接格式
const linkPattern = /\[(?:[^[\]]*)\]\(([^()]+)\)/g
let match
while ((match = linkPattern.exec(text)) !== null) {
const url = match[1].trim()
if (isValidUrl(url)) {
urlSet.add(url)
}
}
return Array.from(urlSet)
}
/**
* URL
* @param url URL字符串
* @returns URL
*/
function isValidUrl(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}
/**
* Markdown
* : [text](url),[text](url) -> [text](url) [text](url)
* @param text Markdown
* @returns
*/
export function cleanLinkCommas(text: string): string {
// 匹配两个 Markdown 链接之间的逗号(可能包含空格)
return text.replace(/\]\([^)]+\)\s*,\s*\[/g, ']()[')
}

View File

@ -4003,7 +4003,7 @@ __metadata:
mime: "npm:^4.0.4" mime: "npm:^4.0.4"
npx-scope-finder: "npm:^1.2.0" npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.1.1" officeparser: "npm:^4.1.1"
openai: "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch" openai: "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch"
p-queue: "npm:^8.1.0" p-queue: "npm:^8.1.0"
prettier: "npm:^3.5.3" prettier: "npm:^3.5.3"
proxy-agent: "npm:^6.5.0" proxy-agent: "npm:^6.5.0"
@ -12180,9 +12180,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"openai@npm:4.77.3": "openai@npm:4.87.3":
version: 4.77.3 version: 4.87.3
resolution: "openai@npm:4.77.3" resolution: "openai@npm:4.87.3"
dependencies: dependencies:
"@types/node": "npm:^18.11.18" "@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4" "@types/node-fetch": "npm:^2.6.4"
@ -12192,13 +12192,16 @@ __metadata:
formdata-node: "npm:^4.3.2" formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7" node-fetch: "npm:^2.6.7"
peerDependencies: peerDependencies:
ws: ^8.18.0
zod: ^3.23.8 zod: ^3.23.8
peerDependenciesMeta: peerDependenciesMeta:
ws:
optional: true
zod: zod:
optional: true optional: true
bin: bin:
openai: bin/cli openai: bin/cli
checksum: 10c0/b90a4071cc1a8257339e3001377396226422519d168ae3c05b5abc662bbac2009c5ccd37f0112c431b0ce45d83e616305ee264846ddb2f2129f186faf9b5a8cc checksum: 10c0/e647456030f44b0c90cf35367676a7a2d8ed8a3cfa4bdd8785553519e1092699915e9a6a0c714b1f3ee59f6c116203422dc1d8f60ec2d7ba416dac0e343d0f62
languageName: node languageName: node
linkType: hard linkType: hard
@ -12227,9 +12230,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"openai@patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch": "openai@patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch":
version: 4.77.3 version: 4.87.3
resolution: "openai@patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch::version=4.77.3&hash=c5d42a" resolution: "openai@patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch::version=4.87.3&hash=7dcff7"
dependencies: dependencies:
"@types/node": "npm:^18.11.18" "@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4" "@types/node-fetch": "npm:^2.6.4"
@ -12239,13 +12242,16 @@ __metadata:
formdata-node: "npm:^4.3.2" formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7" node-fetch: "npm:^2.6.7"
peerDependencies: peerDependencies:
ws: ^8.18.0
zod: ^3.23.8 zod: ^3.23.8
peerDependenciesMeta: peerDependenciesMeta:
ws:
optional: true
zod: zod:
optional: true optional: true
bin: bin:
openai: bin/cli openai: bin/cli
checksum: 10c0/c3449d3d9945675d7debc4e3a68f58093400985e5275b29e4eb5610300ad3fa4589e527fda526ce770f9a945d7a1d03ffb33e34a3566f996a6947125aa761b1e checksum: 10c0/e23ddf28487ab0fdd72fb3c429500986651f1204cba5e778e1aa02ba5b382a2a68de8ca81d717d8d0fdbea985f07b0476b2e4a86d57bf71bf1d65aa141d7d7de
languageName: node languageName: node
linkType: hard linkType: hard