mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 18:39:06 +08:00
Merge branch 'main' into feat/agents-new
This commit is contained in:
commit
1bf63865a8
@ -80,6 +80,7 @@
|
||||
"express": "^5.1.0",
|
||||
"express-validator": "^7.2.1",
|
||||
"faiss-node": "^0.5.1",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
@ -99,6 +100,7 @@
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.0",
|
||||
"@ai-sdk/google-vertex": "^3.0.25",
|
||||
"@ai-sdk/mistral": "^2.0.0",
|
||||
"@ai-sdk/perplexity": "^2.0.8",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.41.0",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
|
||||
@ -38,6 +38,7 @@ export enum IpcChannel {
|
||||
App_GetDiskInfo = 'app:get-disk-info',
|
||||
App_SetFullScreen = 'app:set-full-screen',
|
||||
App_IsFullScreen = 'app:is-full-screen',
|
||||
App_GetSystemFonts = 'app:get-system-fonts',
|
||||
|
||||
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
|
||||
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
|
||||
|
||||
@ -14,6 +14,7 @@ import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import checkDiskSpace from 'check-disk-space'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import fontList from 'font-list'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
@ -219,6 +220,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
return mainWindow.isFullScreen()
|
||||
})
|
||||
|
||||
// Get System Fonts
|
||||
ipcMain.handle(IpcChannel.App_GetSystemFonts, async () => {
|
||||
try {
|
||||
const fonts = await fontList.getFonts()
|
||||
return fonts.map((font: string) => font.replace(/^"(.*)"$/, '$1')).filter((font: string) => font.length > 0)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get system fonts:', error as Error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
|
||||
@ -84,6 +84,7 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
|
||||
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
|
||||
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
|
||||
getSystemFonts: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts),
|
||||
mac: {
|
||||
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
||||
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
||||
import type { TextStreamPart, ToolSet } from 'ai'
|
||||
|
||||
import { ToolCallChunkHandler } from './handleToolCallChunk'
|
||||
@ -29,13 +30,18 @@ export interface CherryStudioChunk {
|
||||
export class AiSdkToChunkAdapter {
|
||||
toolCallHandler: ToolCallChunkHandler
|
||||
private accumulate: boolean | undefined
|
||||
private isFirstChunk = true
|
||||
private enableWebSearch: boolean = false
|
||||
|
||||
constructor(
|
||||
private onChunk: (chunk: Chunk) => void,
|
||||
mcpTools: MCPTool[] = [],
|
||||
accumulate?: boolean
|
||||
accumulate?: boolean,
|
||||
enableWebSearch?: boolean
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -65,11 +71,24 @@ export class AiSdkToChunkAdapter {
|
||||
webSearchResults: [],
|
||||
reasoningId: ''
|
||||
}
|
||||
// Reset link converter state at the start of stream
|
||||
this.isFirstChunk = true
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
// Flush any remaining content from link converter buffer if web search is enabled
|
||||
if (this.enableWebSearch) {
|
||||
const remainingText = flushLinkConverterBuffer()
|
||||
if (remainingText) {
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: remainingText
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@ -87,7 +106,7 @@ export class AiSdkToChunkAdapter {
|
||||
*/
|
||||
private convertAndEmitChunk(
|
||||
chunk: TextStreamPart<any>,
|
||||
final: { text: string; reasoningContent: string; webSearchResults: any[]; reasoningId: string }
|
||||
final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string }
|
||||
) {
|
||||
logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
|
||||
switch (chunk.type) {
|
||||
@ -97,17 +116,44 @@ export class AiSdkToChunkAdapter {
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
break
|
||||
case 'text-delta':
|
||||
if (this.accumulate) {
|
||||
final.text += chunk.text || ''
|
||||
case 'text-delta': {
|
||||
const processedText = chunk.text || ''
|
||||
let finalText: string
|
||||
|
||||
// Only apply link conversion if web search is enabled
|
||||
if (this.enableWebSearch) {
|
||||
const result = convertLinks(processedText, this.isFirstChunk)
|
||||
|
||||
if (this.isFirstChunk) {
|
||||
this.isFirstChunk = false
|
||||
}
|
||||
|
||||
// Handle buffered content
|
||||
if (result.hasBufferedContent) {
|
||||
finalText = result.text
|
||||
} else {
|
||||
finalText = result.text || processedText
|
||||
}
|
||||
} else {
|
||||
final.text = chunk.text || ''
|
||||
// Without web search, just use the original text
|
||||
finalText = processedText
|
||||
}
|
||||
|
||||
if (this.accumulate) {
|
||||
final.text += finalText
|
||||
} else {
|
||||
final.text = finalText
|
||||
}
|
||||
|
||||
// Only emit chunk if there's text to send
|
||||
if (finalText) {
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: this.accumulate ? final.text : finalText
|
||||
})
|
||||
}
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: final.text || ''
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'text-end':
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
@ -200,7 +246,7 @@ export class AiSdkToChunkAdapter {
|
||||
[WebSearchSource.ANTHROPIC]: WebSearchSource.ANTHROPIC,
|
||||
[WebSearchSource.OPENROUTER]: WebSearchSource.OPENROUTER,
|
||||
[WebSearchSource.GEMINI]: WebSearchSource.GEMINI,
|
||||
[WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY,
|
||||
// [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY,
|
||||
[WebSearchSource.QWEN]: WebSearchSource.QWEN,
|
||||
[WebSearchSource.HUNYUAN]: WebSearchSource.HUNYUAN,
|
||||
[WebSearchSource.ZHIPU]: WebSearchSource.ZHIPU,
|
||||
@ -268,18 +314,9 @@ export class AiSdkToChunkAdapter {
|
||||
// === 源和文件相关事件 ===
|
||||
case 'source':
|
||||
if (chunk.sourceType === 'url') {
|
||||
// if (final.webSearchResults.length === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { sourceType: _, ...rest } = chunk
|
||||
final.webSearchResults.push(rest)
|
||||
// }
|
||||
// this.onChunk({
|
||||
// type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
|
||||
// llm_web_search: {
|
||||
// source: WebSearchSource.AISDK,
|
||||
// results: final.webSearchResults
|
||||
// }
|
||||
// })
|
||||
}
|
||||
break
|
||||
case 'file':
|
||||
|
||||
@ -284,7 +284,7 @@ export default class ModernAiProvider {
|
||||
// 创建带有中间件的执行器
|
||||
if (config.onChunk) {
|
||||
const accumulate = this.model!.supported_text_delta !== false // true and undefined
|
||||
const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate)
|
||||
const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate, config.enableWebSearch)
|
||||
|
||||
const streamResult = await executor.streamText({
|
||||
...params,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { flushLinkConverterBuffer, smartLinkConverter } from '@renderer/utils/linkConverter'
|
||||
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
import { CompletionsContext, CompletionsMiddleware } from '../types'
|
||||
@ -28,8 +28,6 @@ export const WebSearchMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
// 调用下游中间件
|
||||
const result = await next(ctx, params)
|
||||
|
||||
const model = params.assistant?.model!
|
||||
let isFirstChunk = true
|
||||
|
||||
// 响应后处理:记录Web搜索事件
|
||||
@ -42,15 +40,9 @@ export const WebSearchMiddleware: CompletionsMiddleware =
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
const providerType = model.provider || 'openai'
|
||||
// 使用当前可用的Web搜索结果进行链接转换
|
||||
const text = chunk.text
|
||||
const result = smartLinkConverter(
|
||||
text,
|
||||
providerType,
|
||||
isFirstChunk,
|
||||
ctx._internal.webSearchState!.results
|
||||
)
|
||||
const result = convertLinks(text, isFirstChunk)
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ export interface AiSdkMiddlewareConfig {
|
||||
isSupportedToolUse: boolean
|
||||
// image generation endpoint
|
||||
isImageGenerationEndpoint: boolean
|
||||
// 是否开启内置搜索
|
||||
enableWebSearch: boolean
|
||||
enableGenerateImage: boolean
|
||||
enableUrlContext: boolean
|
||||
|
||||
@ -15,7 +15,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import store from '@renderer/store'
|
||||
import type { Model, Provider } from '@renderer/types'
|
||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { cloneDeep, isEmpty } from 'lodash'
|
||||
|
||||
@ -61,14 +61,16 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
// return createVertexProvider(provider)
|
||||
// }
|
||||
|
||||
if (provider.id === 'aihubmix') {
|
||||
return aihubmixProviderCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'newapi') {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'vertexai') {
|
||||
return vertexAnthropicProviderCreator(model, provider)
|
||||
if (isSystemProvider(provider)) {
|
||||
if (provider.id === 'aihubmix') {
|
||||
return aihubmixProviderCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'new-api') {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'vertexai') {
|
||||
return vertexAnthropicProviderCreator(model, provider)
|
||||
}
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
@ -39,6 +39,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
||||
creatorFunctionName: 'createAmazonBedrock',
|
||||
supportsImageGeneration: true,
|
||||
aliases: ['aws-bedrock']
|
||||
},
|
||||
{
|
||||
id: 'perplexity',
|
||||
name: 'Perplexity',
|
||||
import: () => import('@ai-sdk/perplexity'),
|
||||
creatorFunctionName: 'createPerplexity',
|
||||
supportsImageGeneration: false,
|
||||
aliases: ['perplexity']
|
||||
}
|
||||
] as const
|
||||
|
||||
|
||||
@ -1,23 +1,24 @@
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
var(--user-font-family), Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
--code-font-family: var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
/* Windows系统专用字体配置 */
|
||||
body[os='windows'] {
|
||||
--font-family:
|
||||
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
var(--user-font-family), 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
|
||||
Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family:
|
||||
'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI', Courier, monospace;
|
||||
var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI',
|
||||
Courier, monospace;
|
||||
}
|
||||
|
||||
@ -15,6 +15,10 @@ export default function useUserTheme() {
|
||||
document.body.style.setProperty('--primary', colorPrimary.toString())
|
||||
document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString())
|
||||
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())
|
||||
|
||||
// Set font family CSS variables
|
||||
document.documentElement.style.setProperty('--user-font-family', `'${theme.userFontFamily}'`)
|
||||
document.documentElement.style.setProperty('--user-code-font-family', `'${theme.userCodeFontFamily}'`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -3135,6 +3135,13 @@
|
||||
"placeholder": "/* Put custom CSS here */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "Code Font",
|
||||
"default": "Default",
|
||||
"global": "Global Font",
|
||||
"select": "Select Font",
|
||||
"title": "Font Settings"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "Navbar Position",
|
||||
|
||||
@ -3138,6 +3138,13 @@
|
||||
"placeholder": "/* 这里写自定义 CSS */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "代码字体",
|
||||
"default": "默认",
|
||||
"global": "全局字体",
|
||||
"select": "选择字体",
|
||||
"title": "字体设置"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "导航栏位置",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* 這裡寫自訂 CSS */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "程式碼字型",
|
||||
"default": "預設",
|
||||
"global": "全域字型",
|
||||
"select": "選擇字體",
|
||||
"title": "字型設定"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "導航欄位置",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* Γράψτε εδώ την προσαρμοστική CSS */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "γραμματοσειρά κώδικα",
|
||||
"default": "προεπιλογή",
|
||||
"global": "Γενική γραμματοσειρά",
|
||||
"select": "Επιλέξτε γραμματοσειρά",
|
||||
"title": "Ρύθμιση γραμματοσειράς"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "Θέση Γραμμής Πλοήγησης",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* Escribe tu CSS personalizado aquí */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "fuente de código",
|
||||
"default": "predeterminado",
|
||||
"global": "Fuente global",
|
||||
"select": "Seleccionar fuente",
|
||||
"title": "Configuración de fuente"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "Posición de la barra de navegación",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* Écrire votre CSS personnalisé ici */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "police de code",
|
||||
"default": "Par défaut",
|
||||
"global": "Police de caractère globale",
|
||||
"select": "Sélectionner la police",
|
||||
"title": "Paramètres de police"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "Position de la barre de navigation",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* ここにカスタムCSSを入力 */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "コードフォント",
|
||||
"default": "デフォルト",
|
||||
"global": "グローバルフォント",
|
||||
"select": "フォントを選択",
|
||||
"title": "フォント設定"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "ナビゲーションバー位置",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* Escreva seu CSS personalizado aqui */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "fonte de código",
|
||||
"default": "padrão",
|
||||
"global": "Fonte global",
|
||||
"select": "Selecionar fonte",
|
||||
"title": "Configuração de fonte"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "Posição da Barra de Navegação",
|
||||
|
||||
@ -3116,6 +3116,13 @@
|
||||
"placeholder": "/* Здесь введите пользовательский CSS */"
|
||||
}
|
||||
},
|
||||
"font": {
|
||||
"code": "шрифт кода",
|
||||
"default": "по умолчанию",
|
||||
"global": "Глобальный шрифт",
|
||||
"select": "Выбрать шрифт",
|
||||
"title": "Настройки шрифта"
|
||||
},
|
||||
"navbar": {
|
||||
"position": {
|
||||
"label": "Положение навигации",
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
setSidebarIcons
|
||||
} from '@renderer/store/settings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { Button, ColorPicker, Segmented, Switch } from 'antd'
|
||||
import { Button, ColorPicker, Segmented, Select, Switch } from 'antd'
|
||||
import { Minus, Monitor, Moon, Plus, Sun } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -78,6 +78,7 @@ const DisplaySettings: FC = () => {
|
||||
|
||||
const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
|
||||
const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
|
||||
const [fontList, setFontList] = useState<string[]>([])
|
||||
|
||||
const handleWindowStyleChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
@ -136,6 +137,11 @@ const DisplaySettings: FC = () => {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化获取所有系统字体
|
||||
window.api.getSystemFonts().then((fonts: string[]) => {
|
||||
setFontList(fonts)
|
||||
})
|
||||
|
||||
// 初始化获取当前缩放值
|
||||
window.api.handleZoomFactor(0).then((factor) => {
|
||||
setCurrentZoom(factor)
|
||||
@ -160,6 +166,26 @@ const DisplaySettings: FC = () => {
|
||||
setCurrentZoom(zoomFactor)
|
||||
}
|
||||
|
||||
const handleUserFontChange = useCallback(
|
||||
(value: string) => {
|
||||
setUserTheme({
|
||||
...userTheme,
|
||||
userFontFamily: value
|
||||
})
|
||||
},
|
||||
[setUserTheme, userTheme]
|
||||
)
|
||||
|
||||
const handleUserCodeFontChange = useCallback(
|
||||
(value: string) => {
|
||||
setUserTheme({
|
||||
...userTheme,
|
||||
userCodeFontFamily: value
|
||||
})
|
||||
},
|
||||
[setUserTheme, userTheme]
|
||||
)
|
||||
|
||||
const assistantIconTypeOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'model', label: t('settings.assistant.icon.type.model') },
|
||||
@ -194,6 +220,7 @@ const DisplaySettings: FC = () => {
|
||||
))}
|
||||
</HStack>
|
||||
<ColorPicker
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
className="color-picker"
|
||||
value={userTheme.colorPrimary}
|
||||
onChange={(color) => handleColorPrimaryChange(color.toHexString())}
|
||||
@ -255,6 +282,75 @@ const DisplaySettings: FC = () => {
|
||||
</ZoomButtonGroup>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle style={{ justifyContent: 'flex-start', gap: 5 }}>
|
||||
{t('settings.display.font.title')} <TextBadge text="New" />
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.display.font.global')}</SettingRowTitle>
|
||||
<SelectRow>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
placeholder={t('settings.display.font.select')}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span style={{ fontFamily: 'Ubuntu, -apple-system, system-ui, Arial, sans-serif' }}>
|
||||
{t('settings.display.font.default')}
|
||||
</span>
|
||||
),
|
||||
value: ''
|
||||
},
|
||||
...fontList.map((font) => ({ label: <span style={{ fontFamily: font }}>{font}</span>, value: font }))
|
||||
]}
|
||||
value={userTheme.userFontFamily || ''}
|
||||
onChange={(font) => handleUserFontChange(font)}
|
||||
showSearch
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleUserFontChange('')}
|
||||
style={{ marginLeft: 8 }}
|
||||
icon={<ResetIcon size="14" />}
|
||||
color="default"
|
||||
variant="text"
|
||||
/>
|
||||
</SelectRow>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.display.font.code')}</SettingRowTitle>
|
||||
<SelectRow>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
placeholder={t('settings.display.font.select')}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span style={{ fontFamily: 'Ubuntu, -apple-system, system-ui, Arial, sans-serif' }}>
|
||||
{t('settings.display.font.default')}
|
||||
</span>
|
||||
),
|
||||
value: ''
|
||||
},
|
||||
...fontList.map((font) => ({ label: <span style={{ fontFamily: font }}>{font}</span>, value: font }))
|
||||
]}
|
||||
value={userTheme.userCodeFontFamily || ''}
|
||||
onChange={(font) => handleUserCodeFontChange(font)}
|
||||
showSearch
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleUserCodeFontChange('')}
|
||||
style={{ marginLeft: 8 }}
|
||||
icon={<ResetIcon size="14" />}
|
||||
color="default"
|
||||
variant="text"
|
||||
/>
|
||||
</SelectRow>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.topic.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
@ -379,4 +475,11 @@ const ZoomValue = styled.span`
|
||||
margin: 0 5px;
|
||||
`
|
||||
|
||||
const SelectRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 300px;
|
||||
`
|
||||
|
||||
export default DisplaySettings
|
||||
|
||||
@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 153,
|
||||
version: 154,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
|
||||
import type { GroundingMetadata } from '@google/genai'
|
||||
import { createEntityAdapter, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import { Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
|
||||
import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
|
||||
import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockType } from '@renderer/types/newMessage'
|
||||
import type OpenAI from 'openai'
|
||||
|
||||
import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出
|
||||
|
||||
// Create a simplified type for the entity adapter to avoid circular type issues
|
||||
type MessageBlockEntity = MessageBlock
|
||||
|
||||
// 1. 创建实体适配器 (Entity Adapter)
|
||||
// 我们使用块的 `id` 作为唯一标识符。
|
||||
const messageBlocksAdapter = createEntityAdapter<MessageBlock>()
|
||||
const messageBlocksAdapter = createEntityAdapter<MessageBlockEntity>()
|
||||
|
||||
// 2. 使用适配器定义初始状态 (Initial State)
|
||||
// 如果需要,可以在规范化实体的旁边添加其他状态属性。
|
||||
@ -20,6 +23,7 @@ const initialState = messageBlocksAdapter.getInitialState({
|
||||
})
|
||||
|
||||
// 3. 创建 Slice
|
||||
// @ts-ignore ignore
|
||||
export const messageBlocksSlice = createSlice({
|
||||
name: 'messageBlocks',
|
||||
initialState,
|
||||
@ -76,8 +80,13 @@ export const messageBlocksSelectors = messageBlocksAdapter.getSelectors<RootStat
|
||||
// --- Selector Integration --- START
|
||||
|
||||
// Selector to get the raw block entity by ID
|
||||
const selectBlockEntityById = (state: RootState, blockId: string | undefined) =>
|
||||
blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined // Use adapter selector
|
||||
const selectBlockEntityById = (state: RootState, blockId: string | undefined): MessageBlock | undefined => {
|
||||
const entity = blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined
|
||||
if (!entity) return undefined
|
||||
|
||||
// Convert back to full MessageBlock type
|
||||
return entity
|
||||
}
|
||||
|
||||
// --- Centralized Citation Formatting Logic ---
|
||||
export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => {
|
||||
@ -173,13 +182,16 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined
|
||||
case WebSearchSource.GROK:
|
||||
case WebSearchSource.OPENROUTER:
|
||||
formattedCitations =
|
||||
(block.response.results as any[])?.map((url, index) => {
|
||||
(block.response.results as AISDKWebSearchResult[])?.map((result, index) => {
|
||||
const url = result.url
|
||||
try {
|
||||
const hostname = new URL(url).hostname
|
||||
const hostname = new URL(result.url).hostname
|
||||
const content = result.providerMetadata && result.providerMetadata['openrouter']?.content
|
||||
return {
|
||||
number: index + 1,
|
||||
url,
|
||||
hostname,
|
||||
title: result.title || hostname,
|
||||
content: content as string,
|
||||
showFavicon: true,
|
||||
type: 'websearch'
|
||||
}
|
||||
@ -218,10 +230,12 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined
|
||||
break
|
||||
case WebSearchSource.AISDK:
|
||||
formattedCitations =
|
||||
(block.response.results as any[])?.map((result, index) => ({
|
||||
(block.response.results && (block.response.results as AISDKWebSearchResult[]))?.map((result, index) => ({
|
||||
number: index + 1,
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
title: result.title || new URL(result.url).hostname,
|
||||
showFavicon: true,
|
||||
type: 'websearch',
|
||||
providerMetadata: result?.providerMetadata
|
||||
})) || []
|
||||
break
|
||||
|
||||
@ -2451,6 +2451,18 @@ const migrateConfig = {
|
||||
logger.error('migrate 153 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'154': (state: RootState) => {
|
||||
try {
|
||||
if (state.settings.userTheme) {
|
||||
state.settings.userTheme.userFontFamily = settingsInitialState.userTheme.userFontFamily
|
||||
state.settings.userTheme.userCodeFontFamily = settingsInitialState.userTheme.userCodeFontFamily
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 154 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,8 @@ export type AssistantIconType = 'model' | 'emoji' | 'none'
|
||||
|
||||
export type UserTheme = {
|
||||
colorPrimary: string
|
||||
userFontFamily: string
|
||||
userCodeFontFamily: string
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
@ -243,7 +245,9 @@ export const initialState: SettingsState = {
|
||||
tray: true,
|
||||
theme: ThemeMode.system,
|
||||
userTheme: {
|
||||
colorPrimary: '#00b96b'
|
||||
colorPrimary: '#00b96b',
|
||||
userFontFamily: '',
|
||||
userCodeFontFamily: ''
|
||||
},
|
||||
windowStyle: isMac ? 'transparent' : 'opaque',
|
||||
fontSize: 14,
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
resetAssistantMessage
|
||||
} from '@renderer/utils/messageUtils/create'
|
||||
import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue'
|
||||
import { defaultAppHeaders } from '@shared/utils'
|
||||
import { t } from 'i18next'
|
||||
import { isEmpty, throttle } from 'lodash'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
@ -369,7 +370,8 @@ const fetchAndProcessAssistantResponseImpl = async (
|
||||
topicId,
|
||||
options: {
|
||||
signal: abortController.signal,
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
headers: defaultAppHeaders()
|
||||
}
|
||||
},
|
||||
streamProcessorCallbacks
|
||||
@ -1073,7 +1075,7 @@ export const cloneMessagesToNewTopicThunk =
|
||||
const oldBlock = state.messageBlocks.entities[oldBlockId]
|
||||
if (oldBlock) {
|
||||
const newBlockId = uuid()
|
||||
const newBlock: MessageBlock = {
|
||||
const newBlock = {
|
||||
...oldBlock,
|
||||
id: newBlockId,
|
||||
messageId: newMsgId // Link block to the NEW message ID
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { LanguageModelV2Source } from '@ai-sdk/provider'
|
||||
import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
|
||||
import type { GenerateImagesConfig, GroundingMetadata, PersonGeneration } from '@google/genai'
|
||||
import type OpenAI from 'openai'
|
||||
@ -727,12 +728,15 @@ export type WebSearchProviderResponse = {
|
||||
results: WebSearchProviderResult[]
|
||||
}
|
||||
|
||||
export type AISDKWebSearchResult = Omit<Extract<LanguageModelV2Source, { sourceType: 'url' }>, 'sourceType'>
|
||||
|
||||
export type WebSearchResults =
|
||||
| WebSearchProviderResponse
|
||||
| GroundingMetadata
|
||||
| OpenAI.Chat.Completions.ChatCompletionMessage.Annotation.URLCitation[]
|
||||
| OpenAI.Responses.ResponseOutputText.URLCitation[]
|
||||
| WebSearchResultBlock[]
|
||||
| AISDKWebSearchResult[]
|
||||
| any[]
|
||||
|
||||
export enum WebSearchSource {
|
||||
|
||||
@ -3,91 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cleanLinkCommas,
|
||||
completeLinks,
|
||||
completionPerplexityLinks,
|
||||
convertLinks,
|
||||
convertLinksToHunyuan,
|
||||
convertLinksToOpenRouter,
|
||||
convertLinksToZhipu,
|
||||
extractUrlsFromMarkdown,
|
||||
flushLinkConverterBuffer
|
||||
} from '../linkConverter'
|
||||
|
||||
describe('linkConverter', () => {
|
||||
describe('convertLinksToZhipu', () => {
|
||||
it('should correctly convert complete [ref_N] format', () => {
|
||||
const input = '这里有一个参考文献 [ref_1] 和另一个 [ref_2]'
|
||||
const result = convertLinksToZhipu(input, true)
|
||||
expect(result).toBe('这里有一个参考文献 [<sup>1</sup>]() 和另一个 [<sup>2</sup>]()')
|
||||
})
|
||||
|
||||
it('should handle chunked input and preserve incomplete link patterns', () => {
|
||||
// 第一个块包含未完成的模式
|
||||
const chunk1 = '这是第一部分 [ref'
|
||||
const result1 = convertLinksToZhipu(chunk1, true)
|
||||
expect(result1).toBe('这是第一部分 ')
|
||||
|
||||
// 第二个块完成该模式
|
||||
const chunk2 = '_1] 这是剩下的部分'
|
||||
const result2 = convertLinksToZhipu(chunk2, false)
|
||||
expect(result2).toBe('[<sup>1</sup>]() 这是剩下的部分')
|
||||
})
|
||||
|
||||
it('should clear buffer when resetting counter', () => {
|
||||
// 先进行一次转换不重置
|
||||
const input1 = '第一次输入 [ref_1]'
|
||||
convertLinksToZhipu(input1, false)
|
||||
|
||||
// 然后重置并进行新的转换
|
||||
const input2 = '新的输入 [ref_2]'
|
||||
const result = convertLinksToZhipu(input2, true)
|
||||
expect(result).toBe('新的输入 [<sup>2</sup>]()')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertLinksToHunyuan', () => {
|
||||
it('should correctly convert [N](@ref) format to links with URLs', () => {
|
||||
const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }]
|
||||
const input = '这里有单个引用 [1](@ref) 和多个引用 [2](@ref)'
|
||||
const result = convertLinksToHunyuan(input, webSearch, true)
|
||||
expect(result).toBe(
|
||||
'这里有单个引用 [<sup>1</sup>](https://example.com/1) 和多个引用 [<sup>2</sup>](https://example.com/2)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should correctly handle comma-separated multiple references', () => {
|
||||
const webSearch = [
|
||||
{ url: 'https://example.com/1' },
|
||||
{ url: 'https://example.com/2' },
|
||||
{ url: 'https://example.com/3' }
|
||||
]
|
||||
const input = '这里有多个引用 [1, 2, 3](@ref)'
|
||||
const result = convertLinksToHunyuan(input, webSearch, true)
|
||||
expect(result).toBe(
|
||||
'这里有多个引用 [<sup>1</sup>](https://example.com/1)[<sup>2</sup>](https://example.com/2)[<sup>3</sup>](https://example.com/3)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle non-existent reference indices', () => {
|
||||
const webSearch = [{ url: 'https://example.com/1' }]
|
||||
const input = '这里有一个超出范围的引用 [2](@ref)'
|
||||
const result = convertLinksToHunyuan(input, webSearch, true)
|
||||
expect(result).toBe('这里有一个超出范围的引用 [<sup>2</sup>](@ref)')
|
||||
})
|
||||
|
||||
it('should handle incomplete reference formats in chunked input', () => {
|
||||
const webSearch = [{ url: 'https://example.com/1' }]
|
||||
// 第一个块包含未完成的模式
|
||||
const chunk1 = '这是第一部分 ['
|
||||
const result1 = convertLinksToHunyuan(chunk1, webSearch, true)
|
||||
expect(result1).toBe('这是第一部分 ')
|
||||
|
||||
// 第二个块完成该模式
|
||||
const chunk2 = '1](@ref) 这是剩下的部分'
|
||||
const result2 = convertLinksToHunyuan(chunk2, webSearch, false)
|
||||
expect(result2).toBe('[<sup>1</sup>](https://example.com/1) 这是剩下的部分')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertLinks', () => {
|
||||
it('should convert number links to numbered links', () => {
|
||||
const input = '参考 [1](https://example.com/1) 和 [2](https://example.com/2)'
|
||||
@ -226,8 +147,10 @@ describe('linkConverter', () => {
|
||||
it('should handle real links split across small chunks with proper buffering', () => {
|
||||
// 模拟真实链接被分割成小chunks的情况 - 更现实的分割方式
|
||||
const chunks = [
|
||||
'Please visit [example.com](', // 不完整链接
|
||||
'https://example.com) for details' // 完成链接
|
||||
'Please visit [example.',
|
||||
'com](', // 不完整链接'
|
||||
'https://exa',
|
||||
'mple.com) for details' // 完成链接'
|
||||
]
|
||||
|
||||
let accumulatedText = ''
|
||||
@ -235,14 +158,24 @@ describe('linkConverter', () => {
|
||||
// 第一个chunk:包含不完整链接 [text](
|
||||
const result1 = convertLinks(chunks[0], true)
|
||||
expect(result1.text).toBe('Please visit ') // 只返回安全部分
|
||||
expect(result1.hasBufferedContent).toBe(true) // [example.com]( 被缓冲
|
||||
expect(result1.hasBufferedContent).toBe(true) //
|
||||
accumulatedText += result1.text
|
||||
|
||||
// 第二个chunk:完成链接
|
||||
// 第二个chunk
|
||||
const result2 = convertLinks(chunks[1], false)
|
||||
expect(result2.text).toBe('[<sup>1</sup>](https://example.com) for details') // 完整链接 + 剩余文本
|
||||
expect(result2.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result2.text
|
||||
expect(result2.text).toBe('')
|
||||
expect(result2.hasBufferedContent).toBe(true)
|
||||
// 第三个chunk
|
||||
const result3 = convertLinks(chunks[2], false)
|
||||
expect(result3.text).toBe('')
|
||||
expect(result3.hasBufferedContent).toBe(true)
|
||||
accumulatedText += result3.text
|
||||
|
||||
// 第四个chunk
|
||||
const result4 = convertLinks(chunks[3], false)
|
||||
expect(result4.text).toBe('[<sup>1</sup>](https://example.com) for details')
|
||||
expect(result4.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result4.text
|
||||
|
||||
// 验证最终结果
|
||||
expect(accumulatedText).toBe('Please visit [<sup>1</sup>](https://example.com) for details')
|
||||
@ -293,32 +226,6 @@ describe('linkConverter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertLinksToOpenRouter', () => {
|
||||
it('should only convert links with domain-like text', () => {
|
||||
const input = '网站 [example.com](https://example.com) 和 [点击这里](https://other.com)'
|
||||
const result = convertLinksToOpenRouter(input, true)
|
||||
expect(result).toBe('网站 [<sup>1</sup>](https://example.com) 和 [点击这里](https://other.com)')
|
||||
})
|
||||
|
||||
it('should use the same counter for duplicate URLs', () => {
|
||||
const input = '两个相同的链接 [example.com](https://example.com) 和 [example.org](https://example.com)'
|
||||
const result = convertLinksToOpenRouter(input, true)
|
||||
expect(result).toBe('两个相同的链接 [<sup>1</sup>](https://example.com) 和 [<sup>1</sup>](https://example.com)')
|
||||
})
|
||||
|
||||
it('should handle incomplete links in chunked input', () => {
|
||||
// 第一个块包含未完成的链接
|
||||
const chunk1 = '这是域名链接 ['
|
||||
const result1 = convertLinksToOpenRouter(chunk1, true)
|
||||
expect(result1).toBe('这是域名链接 ')
|
||||
|
||||
// 第二个块完成链接
|
||||
const chunk2 = 'example.com](https://example.com)'
|
||||
const result2 = convertLinksToOpenRouter(chunk2, false)
|
||||
expect(result2).toBe('[<sup>1</sup>](https://example.com)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeLinks', () => {
|
||||
it('should complete empty links with webSearch data', () => {
|
||||
const webSearch = [{ link: 'https://example.com/1' }, { link: 'https://example.com/2' }]
|
||||
@ -383,13 +290,4 @@ describe('linkConverter', () => {
|
||||
expect(result).toBe('[链接1](https://example.com)[链接2](https://other.com)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionPerplexityLinks', () => {
|
||||
it('should complete links with webSearch data', () => {
|
||||
const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }]
|
||||
const input = '参考 [1] 和 [2]'
|
||||
const result = completionPerplexityLinks(input, webSearch)
|
||||
expect(result).toBe('参考 [1](https://example.com/1) 和 [2](https://example.com/2)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { WebSearchResponse, WebSearchSource } from '@renderer/types'
|
||||
|
||||
// Counter for numbering links
|
||||
let linkCounter = 1
|
||||
// Buffer to hold incomplete link fragments across chunks
|
||||
@ -17,109 +15,6 @@ function isHost(text: string): boolean {
|
||||
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 {string} text The current chunk of text to process
|
||||
* @param {boolean} resetCounter Whether to reset the counter and buffer
|
||||
* @returns {string} Processed text with complete links converted
|
||||
*/
|
||||
export function convertLinksToZhipu(text: string, resetCounter: boolean = 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>]()`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Markdown links in the text to numbered links based on the rules:
|
||||
* [N](@ref) -> [<sup>N</sup>]()
|
||||
* [N,M,...](@ref) -> [<sup>N</sup>]() [<sup>M</sup>]() ...
|
||||
* @param {string} text The current chunk of text to process
|
||||
* @param {any[]} webSearch webSearch results
|
||||
* @param {boolean} resetCounter Whether to reset the counter and buffer
|
||||
* @returns {string} Processed text with complete links converted
|
||||
*/
|
||||
export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter: boolean = 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)
|
||||
@ -171,13 +66,21 @@ export function convertLinks(
|
||||
break
|
||||
}
|
||||
|
||||
// 检查是否是完整的链接但需要验证
|
||||
// 检查是否是完整的链接
|
||||
const completeLink = /^\[([^\]]+)\]\(([^)]+)\)/.test(substring)
|
||||
if (completeLink) {
|
||||
// 如果是完整链接,继续处理,不设置safePoint
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是不完整的 [ 开始但还没有闭合的 ]
|
||||
// 例如 [example. 这种情况
|
||||
const incompleteBracket = /^\[[^\]]*$/.test(substring)
|
||||
if (incompleteBracket) {
|
||||
safePoint = i
|
||||
break
|
||||
}
|
||||
|
||||
// 如果不是潜在的链接格式,继续检查
|
||||
}
|
||||
}
|
||||
@ -263,65 +166,6 @@ export function convertLinks(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Markdown links in the text to numbered links based on the rules:
|
||||
* 1. [host](url) -> [cnt](url)
|
||||
*
|
||||
* @param {string} text The current chunk of text to process
|
||||
* @param {boolean} resetCounter Whether to reset the counter and buffer
|
||||
* @returns {string} 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 {string} text 原始文本
|
||||
@ -341,25 +185,6 @@ export function completeLinks(text: string, webSearch: any[]): string {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据webSearch结果补全链接,将[num]转换为[num](webSearch[num-1].url)
|
||||
* @param {string} text 原始文本
|
||||
* @param {any[]} webSearch webSearch结果
|
||||
* @returns {string} 补全后的文本
|
||||
*/
|
||||
export function completionPerplexityLinks(text: string, webSearch: any[]): string {
|
||||
return text.replace(/\[(\d+)\]/g, (match, numStr) => {
|
||||
const num = parseInt(numStr)
|
||||
const index = num - 1
|
||||
// 检查 webSearch 数组中是否存在对应的 URL
|
||||
if (index >= 0 && index < webSearch.length && webSearch[index].url) {
|
||||
return `[${num}](${webSearch[index].url})`
|
||||
}
|
||||
// 如果没有找到对应的 URL,保持原样
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Markdown文本中提取所有URL
|
||||
* 支持以下格式:
|
||||
@ -412,118 +237,6 @@ export function cleanLinkCommas(text: string): string {
|
||||
return text.replace(/\]\(([^)]+)\)\s*,\s*\[/g, ']($1)[')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本中识别各种格式的Web搜索引用占位符
|
||||
* 支持的格式包括:[1], [ref_1], [1](@ref), [1,2,3](@ref) 等
|
||||
* @param {string} text 要分析的文本
|
||||
* @returns {Array} 识别到的引用信息数组
|
||||
*/
|
||||
export function extractWebSearchReferences(text: string): Array<{
|
||||
match: string
|
||||
placeholder: string
|
||||
numbers: number[]
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}> {
|
||||
const references: Array<{
|
||||
match: string
|
||||
placeholder: string
|
||||
numbers: number[]
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}> = []
|
||||
|
||||
// 匹配各种引用格式的正则表达式
|
||||
const patterns = [
|
||||
// [1], [2], [3] - 简单数字引用
|
||||
{ regex: /\[(\d+)\]/g, type: 'simple' },
|
||||
// [ref_1], [ref_2] - Zhipu格式
|
||||
{ regex: /\[ref_(\d+)\]/g, type: 'zhipu' },
|
||||
// [1](@ref), [2](@ref) - Hunyuan单个引用格式
|
||||
{ regex: /\[(\d+)\]\(@ref\)/g, type: 'hunyuan_single' },
|
||||
// [1,2,3](@ref) - Hunyuan多个引用格式
|
||||
{ regex: /\[([\d,\s]+)\]\(@ref\)/g, type: 'hunyuan_multiple' }
|
||||
]
|
||||
|
||||
patterns.forEach(({ regex, type }) => {
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
let numbers: number[] = []
|
||||
|
||||
if (type === 'hunyuan_multiple') {
|
||||
// 解析逗号分隔的数字
|
||||
numbers = match[1]
|
||||
.split(',')
|
||||
.map((num) => parseInt(num.trim()))
|
||||
.filter((num) => !isNaN(num))
|
||||
} else {
|
||||
// 单个数字
|
||||
numbers = [parseInt(match[1])]
|
||||
}
|
||||
|
||||
references.push({
|
||||
match: match[0],
|
||||
placeholder: match[0],
|
||||
numbers: numbers,
|
||||
startIndex: match.index!,
|
||||
endIndex: match.index! + match[0].length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 按位置排序
|
||||
return references.sort((a, b) => a.startIndex - b.startIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能链接转换器 - 根据文本中的引用模式和Web搜索结果自动选择合适的转换策略
|
||||
* @param {string} text 当前文本块
|
||||
* @param {any[]} webSearchResults Web搜索结果数组
|
||||
* @param {string} providerType Provider类型 ('openai', 'zhipu', 'hunyuan', 'openrouter', etc.)
|
||||
* @param {boolean} resetCounter 是否重置计数器
|
||||
* @returns {{text: string, hasBufferedContent: boolean}} 转换后的文本和是否有内容被缓冲
|
||||
*/
|
||||
export function smartLinkConverter(
|
||||
text: string,
|
||||
providerType: string = 'openai',
|
||||
resetCounter: boolean = false,
|
||||
webSearchResults?: WebSearchResponse
|
||||
): { text: string; hasBufferedContent: boolean } {
|
||||
if (webSearchResults) {
|
||||
const webSearch = webSearchResults.results
|
||||
switch (webSearchResults.source) {
|
||||
case WebSearchSource.PERPLEXITY: {
|
||||
text = completionPerplexityLinks(text, webSearch as any[])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 检测文本中的引用模式
|
||||
const references = extractWebSearchReferences(text)
|
||||
|
||||
if (references.length === 0) {
|
||||
// 如果没有特定的引用模式,使用通用转换
|
||||
return convertLinks(text, resetCounter)
|
||||
}
|
||||
|
||||
// 根据检测到的引用模式选择合适的转换器
|
||||
const hasZhipuPattern = references.some((ref) => ref.placeholder.includes('ref_'))
|
||||
|
||||
if (hasZhipuPattern) {
|
||||
return {
|
||||
text: convertLinksToZhipu(text, resetCounter),
|
||||
hasBufferedContent: false
|
||||
}
|
||||
} else if (providerType === 'openrouter') {
|
||||
return {
|
||||
text: convertLinksToOpenRouter(text, resetCounter),
|
||||
hasBufferedContent: false
|
||||
}
|
||||
} else {
|
||||
return convertLinks(text, resetCounter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制返回buffer中的所有内容,用于流结束时清空缓冲区
|
||||
* @returns {string} buffer中剩余的所有内容
|
||||
|
||||
21
yarn.lock
21
yarn.lock
@ -239,6 +239,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/perplexity@npm:^2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@ai-sdk/perplexity@npm:2.0.8"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.8"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
checksum: 10c0/acfd6c09c4c0ef5af7eeec6e8bc20b90b24d1d3fc2bc8ee9de4e40770fc0c17ca2c8db8f0248ff07264b71e5aa65f64d37a165db2f43fee84c1b3513cb97983c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/provider-utils@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@ai-sdk/provider-utils@npm:3.0.3"
|
||||
@ -13386,6 +13398,7 @@ __metadata:
|
||||
"@ai-sdk/amazon-bedrock": "npm:^3.0.0"
|
||||
"@ai-sdk/google-vertex": "npm:^3.0.25"
|
||||
"@ai-sdk/mistral": "npm:^2.0.0"
|
||||
"@ai-sdk/perplexity": "npm:^2.0.8"
|
||||
"@ant-design/v5-patch-for-react-19": "npm:^1.0.3"
|
||||
"@anthropic-ai/sdk": "npm:^0.41.0"
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch"
|
||||
@ -13551,6 +13564,7 @@ __metadata:
|
||||
fast-diff: "npm:^1.3.0"
|
||||
fast-xml-parser: "npm:^5.2.0"
|
||||
fetch-socks: "npm:1.3.2"
|
||||
font-list: "npm:^2.0.0"
|
||||
framer-motion: "npm:^12.23.12"
|
||||
franc-min: "npm:^6.2.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
@ -18692,6 +18706,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"font-list@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "font-list@npm:2.0.0"
|
||||
checksum: 10c0/9fc8600fa40a5d079982505ea101e49b21260a36f33167ac993fd7b26cec8372a16017c00d6fb404e259600ce8d588830167c9141c2df7dedb0fedd5953905f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"foreground-child@npm:^3.1.0":
|
||||
version: 3.3.1
|
||||
resolution: "foreground-child@npm:3.3.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user