Merge branch 'main' into 1600822305-patch-2

# Conflicts:
#	src/main/ipc.ts
This commit is contained in:
kangfenmao 2025-04-10 13:52:07 +08:00
commit dba84bb04e
36 changed files with 918 additions and 149 deletions

View File

@ -66,6 +66,7 @@
"@electron/notarize": "^2.5.0", "@electron/notarize": "^2.5.0",
"@google/generative-ai": "^0.24.0", "@google/generative-ai": "^0.24.0",
"@langchain/community": "^0.3.36", "@langchain/community": "^0.3.36",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15", "@notionhq/client": "^2.2.15",
"@strongtz/win32-arm64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7",
"@tryfabric/martian": "^1.2.4", "@tryfabric/martian": "^1.2.4",

View File

@ -18,6 +18,10 @@ export enum IpcChannel {
App_InstallUvBinary = 'app:install-uv-binary', App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary', App_InstallBunBinary = 'app:install-bun-binary',
// ASR Server
Asr_StartServer = 'start-asr-server',
Asr_StopServer = 'stop-asr-server',
// Open // Open
Open_Path = 'open:path', Open_Path = 'open:path',
Open_Website = 'open:website', Open_Website = 'open:website',
@ -146,5 +150,10 @@ export enum IpcChannel {
MiniWindowReload = 'miniwindow-reload', MiniWindowReload = 'miniwindow-reload',
ReduxStateChange = 'redux-state-change', ReduxStateChange = 'redux-state-change',
ReduxStoreReady = 'redux-store-ready' ReduxStoreReady = 'redux-store-ready',
// Search Window
SearchWindow_Open = 'search-window:open',
SearchWindow_Close = 'search-window:close',
SearchWindow_OpenUrl = 'search-window:open-url'
} }

View File

@ -18,35 +18,48 @@ exports.default = async function (context) {
'node_modules' 'node_modules'
) )
removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64']) keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
} }
if (platform === 'linux') { if (platform === 'linux') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl'] const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch) keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
} }
if (platform === 'windows') { if (platform === 'windows') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
if (arch === Arch.arm64) { if (arch === Arch.arm64) {
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc']) keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc']) keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
} }
if (arch === Arch.x64) { if (arch === Arch.x64) {
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc']) keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
} }
} }
} }
function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) { /**
* 使用指定架构的 node_modules 文件
* @param {*} nodeModulesPath
* @param {*} packageName
* @param {*} arch
* @returns
*/
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
const modulePath = path.join(nodeModulesPath, packageName) const modulePath = path.join(nodeModulesPath, packageName)
if (!fs.existsSync(modulePath)) {
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
return
}
const dirs = fs.readdirSync(modulePath) const dirs = fs.readdirSync(modulePath)
dirs dirs
.filter((dir) => !arch.includes(dir)) .filter((dir) => !arch.includes(dir))
.forEach((dir) => { .forEach((dir) => {
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true }) fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
console.log(`Removed dir: ${dir}`, arch) console.log(`[After Pack] Removed dir: ${dir}`, arch)
}) })
} }

View File

@ -23,6 +23,7 @@ import mcpService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService' import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService' import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager' import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { searchService } from './services/SearchService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService' import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
@ -297,8 +298,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
NutstoreService.getDirectoryContents(token, path) NutstoreService.getDirectoryContents(token, path)
) )
// search window
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
await searchService.openSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
await searchService.closeSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
return await searchService.openUrlInSearchWindow(uid, url)
})
// 启动ASR服务器 // 启动ASR服务器
ipcMain.handle('start-asr-server', async () => { ipcMain.handle(IpcChannel.Asr_StartServer, async () => {
try { try {
if (asrServerProcess) { if (asrServerProcess) {
return { success: true, pid: asrServerProcess.pid } return { success: true, pid: asrServerProcess.pid }
@ -371,7 +383,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// 停止ASR服务器 // 停止ASR服务器
ipcMain.handle('stop-asr-server', async (_event, pid) => { ipcMain.handle(IpcChannel.Asr_StopServer, async (_event, pid) => {
try { try {
if (!asrServerProcess) { if (!asrServerProcess) {
return { success: true } return { success: true }

View File

@ -147,8 +147,12 @@ class McpService {
...getDefaultEnvironment(), ...getDefaultEnvironment(),
PATH: this.getEnhancedPath(process.env.PATH || ''), PATH: this.getEnhancedPath(process.env.PATH || ''),
...server.env ...server.env
} },
stderr: 'pipe'
}) })
transport.stderr?.on('data', (data) =>
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
)
} else { } else {
throw new Error('Either baseUrl or command must be provided') throw new Error('Either baseUrl or command must be provided')
} }

View File

@ -0,0 +1,82 @@
import { is } from '@electron-toolkit/utils'
import { BrowserWindow } from 'electron'
export class SearchService {
private static instance: SearchService | null = null
private searchWindows: Record<string, BrowserWindow> = {}
public static getInstance(): SearchService {
if (!SearchService.instance) {
SearchService.instance = new SearchService()
}
return SearchService.instance
}
constructor() {
// Initialize the service
}
private async createNewSearchWindow(uid: string): Promise<BrowserWindow> {
const newWindow = new BrowserWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
devTools: is.dev
}
})
newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
const headers = {
...details.requestHeaders,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
callback({ requestHeaders: headers })
})
this.searchWindows[uid] = newWindow
newWindow.on('closed', () => {
delete this.searchWindows[uid]
})
return newWindow
}
public async openSearchWindow(uid: string): Promise<void> {
await this.createNewSearchWindow(uid)
}
public async closeSearchWindow(uid: string): Promise<void> {
const window = this.searchWindows[uid]
if (window) {
window.close()
delete this.searchWindows[uid]
}
}
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
let window = this.searchWindows[uid]
if (window) {
await window.loadURL(url)
} else {
window = await this.createNewSearchWindow(uid)
await window.loadURL(url)
}
// Get the page content after loading the URL
// Wait for the page to fully load before getting the content
await new Promise<void>((resolve) => {
const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout
window.webContents.once('did-finish-load', () => {
clearTimeout(loadTimeout)
// Small delay to ensure JavaScript has executed
setTimeout(resolve, 500)
})
})
// Get the page content after ensuring it's fully loaded
const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML')
return content
}
}
export const searchService = SearchService.getInstance()

View File

@ -17,7 +17,6 @@ export class WindowService {
private mainWindow: BrowserWindow | null = null private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false private isPinnedMiniWindow: boolean = false
private wasFullScreen: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows //hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides //to restore the focus status when miniWindow hides
private wasMainWindowFocused: boolean = false private wasMainWindowFocused: boolean = false
@ -41,7 +40,8 @@ export class WindowService {
const mainWindowState = windowStateKeeper({ const mainWindowState = windowStateKeeper({
defaultWidth: 1080, defaultWidth: 1080,
defaultHeight: 670 defaultHeight: 670,
fullScreen: false
}) })
const theme = configManager.getTheme() const theme = configManager.getTheme()
@ -53,7 +53,7 @@ export class WindowService {
height: mainWindowState.height, height: mainWindowState.height,
minWidth: 1080, minWidth: 1080,
minHeight: 600, minHeight: 600,
show: false, // 初始不显示 show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
transparent: isMac, transparent: isMac,
vibrancy: 'sidebar', vibrancy: 'sidebar',
@ -138,12 +138,10 @@ export class WindowService {
// 处理全屏相关事件 // 处理全屏相关事件
mainWindow.on('enter-full-screen', () => { mainWindow.on('enter-full-screen', () => {
this.wasFullScreen = true
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true) mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
}) })
mainWindow.on('leave-full-screen', () => { mainWindow.on('leave-full-screen', () => {
this.wasFullScreen = false
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false) mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
}) })
@ -275,16 +273,6 @@ export class WindowService {
} }
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况 //上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
// 如果是Windows或Linux且处于全屏状态则退出应用
if (this.wasFullScreen) {
if (isWin || isLinux) {
return app.quit()
} else {
event.preventDefault()
mainWindow.setFullScreen(false)
return
}
}
event.preventDefault() event.preventDefault()
mainWindow.hide() mainWindow.hide()
@ -316,16 +304,29 @@ export class WindowService {
this.mainWindow.restore() this.mainWindow.restore()
return return
} }
//[macOS] Known Issue
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
// AppleScript may be a solution, but it's not worth
// [Linux] Known Issue /**
// setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland会导致窗口进入"假弹出"状态 * About setVisibleOnAllWorkspaces
// 因此在 Linux 环境下不执行这两行代码 *
* [macOS] Known Issue
* setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
* AppleScript may be a solution, but it's not worth
*
* [Linux] Known Issue
* setVisibleOnAllWorkspaces Linux KDE Wayland"假弹出"
* Linux
*/
if (!isLinux) { if (!isLinux) {
this.mainWindow.setVisibleOnAllWorkspaces(true) this.mainWindow.setVisibleOnAllWorkspaces(true)
} }
//[macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again
// So we need to set it to FALSE explicitly.
// althougle other platforms don't have the issue, but it's a good practice to do so
if (this.mainWindow.isFullScreen()) {
this.mainWindow.setFullScreen(false)
}
this.mainWindow.show() this.mainWindow.show()
this.mainWindow.focus() this.mainWindow.focus()
if (!isLinux) { if (!isLinux) {
@ -338,7 +339,9 @@ export class WindowService {
public toggleMainWindow() { public toggleMainWindow() {
// should not toggle main window when in full screen // should not toggle main window when in full screen
if (this.wasFullScreen) { // but if the main window is close to tray when it's in full screen, we can show it again
// (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained)
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
return return
} }
@ -392,7 +395,8 @@ export class WindowService {
//miniWindow should show in current desktop //miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
//make miniWindow always on top of fullscreen apps with level set //make miniWindow always on top of fullscreen apps with level set
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1) //[mac] level higher than 'floating' will cover the pinyin input method
this.miniWindow.setAlwaysOnTop(true, 'floating')
this.miniWindow.on('ready-to-show', () => { this.miniWindow.on('ready-to-show', () => {
if (isPreload) { if (isPreload) {

View File

@ -175,6 +175,11 @@ declare global {
decryptToken: (token: string) => Promise<{ username: string; access_token: string }> decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
getDirectoryContents: (token: string, path: string) => Promise<any> getDirectoryContents: (token: string, path: string) => Promise<any>
} }
searchService: {
openSearchWindow: (uid: string) => Promise<string>
closeSearchWindow: (uid: string) => Promise<string>
openUrlInSearchWindow: (uid: string, url: string) => Promise<string>
}
} }
} }
} }

View File

@ -169,6 +169,11 @@ const api = {
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token), decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
getDirectoryContents: (token: string, path: string) => getDirectoryContents: (token: string, path: string) =>
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path) ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
},
searchService: {
openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid),
closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid),
openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url)
} }
} }

View File

@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings' import { setUserName } from '@renderer/store/settings'
import { compressImage, isEmoji } from '@renderer/utils' import { compressImage, isEmoji } from '@renderer/utils'
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd' import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
import { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'

View File

@ -234,6 +234,10 @@ export function isFunctionCallingModel(model: Model): boolean {
return false return false
} }
if (model.provider === 'qiniu') {
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id)
}
if (['deepseek', 'anthropic'].includes(model.provider)) { if (['deepseek', 'anthropic'].includes(model.provider)) {
return true return true
} }
@ -500,12 +504,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'text-embedding-3-small', name: 'text-embedding-3-small',
group: '嵌入模型' group: '嵌入模型'
}, },
{
id: 'text-embedding-3-small',
provider: 'o3',
name: 'text-embedding-3-small',
group: '嵌入模型'
},
{ {
id: 'text-embedding-ada-002', id: 'text-embedding-ada-002',
provider: 'o3', provider: 'o3',
@ -2015,7 +2013,56 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Voyage Rerank V2' group: 'Voyage Rerank V2'
} }
], ],
qiniu: [] qiniu: [
{
id: 'deepseek-r1',
provider: 'qiniu',
name: 'DeepSeek R1',
group: 'DeepSeek'
},
{
id: 'deepseek-r1-search',
provider: 'qiniu',
name: 'DeepSeek R1 Search',
group: 'DeepSeek'
},
{
id: 'deepseek-r1-32b',
provider: 'qiniu',
name: 'DeepSeek R1 32B',
group: 'DeepSeek'
},
{
id: 'deepseek-v3',
provider: 'qiniu',
name: 'DeepSeek V3',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-search',
provider: 'qiniu',
name: 'DeepSeek V3 Search',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-tool',
provider: 'qiniu',
name: 'DeepSeek V3 Tool',
group: 'DeepSeek'
},
{
id: 'qwq-32b',
provider: 'qiniu',
name: 'QWQ 32B',
group: 'Qwen'
},
{
id: 'qwen2.5-72b-instruct',
provider: 'qiniu',
name: 'Qwen2.5 72B Instruct',
group: 'Qwen'
}
]
} }
export const TEXT_TO_IMAGES_MODELS = [ export const TEXT_TO_IMAGES_MODELS = [

View File

@ -31,5 +31,20 @@ export const WEB_SEARCH_PROVIDER_CONFIG = {
official: 'https://exa.ai', official: 'https://exa.ai',
apiKey: 'https://dashboard.exa.ai/api-keys' apiKey: 'https://dashboard.exa.ai/api-keys'
} }
},
'local-google': {
websites: {
official: 'https://www.google.com'
}
},
'local-bing': {
websites: {
official: 'https://www.bing.com'
}
},
'local-baidu': {
websites: {
official: 'https://www.baidu.com'
}
} }
} }

View File

@ -25,11 +25,20 @@ export const useDefaultWebSearchProvider = () => {
export const useWebSearchProviders = () => { export const useWebSearchProviders = () => {
const providers = useAppSelector((state) => state.websearch.providers) const providers = useAppSelector((state) => state.websearch.providers)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return { return {
providers, providers,
updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers)) updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers)),
addWebSearchProvider: (provider: WebSearchProvider) => {
// Check if provider exists
const exists = providers.some((p) => p.id === provider.id)
if (!exists) {
// Use the existing update action to add the new provider
dispatch(updateWebSearchProviders([...providers, provider]))
}
}
} }
} }
@ -37,6 +46,7 @@ export const useWebSearchProvider = (id: string) => {
const providers = useAppSelector((state) => state.websearch.providers) const providers = useAppSelector((state) => state.websearch.providers)
const provider = providers.find((provider) => provider.id === id) const provider = providers.find((provider) => provider.id === id)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
if (!provider) { if (!provider) {
throw new Error(`Web search provider with id ${id} not found`) throw new Error(`Web search provider with id ${id} not found`)
} }

View File

@ -1308,7 +1308,9 @@
}, },
"title": "Web Search", "title": "Web Search",
"overwrite": "Override search service", "overwrite": "Override search service",
"overwrite_tooltip": "Force use search service instead of LLM" "overwrite_tooltip": "Force use search service instead of LLM",
"apikey": "API key",
"free": "Free"
}, },
"quickPhrase": { "quickPhrase": {
"title": "Quick Phrases", "title": "Quick Phrases",

View File

@ -1307,7 +1307,9 @@
}, },
"title": "ウェブ検索", "title": "ウェブ検索",
"overwrite": "サービス検索を上書き", "overwrite": "サービス検索を上書き",
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する" "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
"apikey": "API キー",
"free": "無料"
}, },
"general.auto_check_update.title": "自動更新チェックを有効にする", "general.auto_check_update.title": "自動更新チェックを有効にする",
"quickPhrase": { "quickPhrase": {

View File

@ -1307,7 +1307,9 @@
}, },
"title": "Поиск в Интернете", "title": "Поиск в Интернете",
"overwrite": "Переопределить поставщика поиска", "overwrite": "Переопределить поставщика поиска",
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM" "overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
"apikey": "Ключ API",
"free": "Бесплатно"
}, },
"general.auto_check_update.title": "Включить автоматическую проверку обновлений", "general.auto_check_update.title": "Включить автоматическую проверку обновлений",
"quickPhrase": { "quickPhrase": {

View File

@ -1308,7 +1308,9 @@
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力", "description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
"title": "Tavily" "title": "Tavily"
}, },
"title": "网络搜索" "title": "网络搜索",
"apikey": "API 密钥",
"free": "免费"
}, },
"quickPhrase": { "quickPhrase": {
"title": "快捷短语", "title": "快捷短语",

View File

@ -1307,7 +1307,9 @@
}, },
"title": "網路搜尋", "title": "網路搜尋",
"overwrite": "覆蓋搜尋服務商", "overwrite": "覆蓋搜尋服務商",
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋" "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
"apikey": "API 金鑰",
"free": "免費"
}, },
"general.auto_check_update.title": "啟用自動更新檢查", "general.auto_check_update.title": "啟用自動更新檢查",
"quickPhrase": { "quickPhrase": {

View File

@ -1,23 +1,53 @@
import { Center, VStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import ImageStorage from '@renderer/services/ImageStorage'
import { Provider, ProviderType } from '@renderer/types' import { Provider, ProviderType } from '@renderer/types'
import { Divider, Form, Input, Modal, Select } from 'antd' import { compressImage } from '@renderer/utils'
import { useState } from 'react' import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props { interface Props {
provider?: Provider provider?: Provider
resolve: (result: { name: string; type: ProviderType }) => void resolve: (result: { name: string; type: ProviderType; logo?: string; logoFile?: File }) => void
} }
const PopupContainer: React.FC<Props> = ({ provider, resolve }) => { const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [name, setName] = useState(provider?.name || '') const [name, setName] = useState(provider?.name || '')
const [type, setType] = useState<ProviderType>(provider?.type || 'openai') const [type, setType] = useState<ProviderType>(provider?.type || 'openai')
const [logo, setLogo] = useState<string | null>(null)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const onOk = () => { useEffect(() => {
if (provider?.id) {
const loadLogo = async () => {
try {
const logoData = await ImageStorage.get(`provider-${provider.id}`)
if (logoData) {
setLogo(logoData)
}
} catch (error) {
console.error('Failed to load logo', error)
}
}
loadLogo()
}
}, [provider])
const onOk = async () => {
setOpen(false) setOpen(false)
resolve({ name, type })
// 返回结果,但不包含文件对象,因为文件已经直接保存到 ImageStorage
const result = {
name,
type,
logo: logo || undefined
}
resolve(result)
} }
const onCancel = () => { const onCancel = () => {
@ -26,11 +56,94 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
} }
const onClose = () => { const onClose = () => {
resolve({ name, type }) resolve({ name, type, logo: logo || undefined })
} }
const buttonDisabled = name.length === 0 const buttonDisabled = name.length === 0
const handleReset = async () => {
try {
setLogo(null)
if (provider?.id) {
await ImageStorage.set(`provider-${provider.id}`, '')
}
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}
const getInitials = () => {
return name.charAt(0).toUpperCase() || 'P'
}
const items = [
{
key: 'upload',
label: (
<div style={{ width: '100%', textAlign: 'center' }}>
<Upload
customRequest={() => {}}
accept="image/png, image/jpeg, image/gif"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => {
try {
const _file = file.originFileObj as File
let logoData: string | Blob
if (_file.type === 'image/gif') {
logoData = _file
} else {
logoData = await compressImage(_file)
}
if (provider?.id) {
if (logoData instanceof Blob && !(logoData instanceof File)) {
const fileFromBlob = new File([logoData], 'logo.png', { type: logoData.type })
await ImageStorage.set(`provider-${provider.id}`, fileFromBlob)
} else {
await ImageStorage.set(`provider-${provider.id}`, logoData)
}
const savedLogo = await ImageStorage.get(`provider-${provider.id}`)
setLogo(savedLogo)
} else {
// 临时保存在内存中,等创建 provider 后会在调用方保存
const tempUrl = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(logoData)
})
setLogo(tempUrl)
}
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}}>
{t('settings.general.image_upload')}
</Upload>
</div>
)
},
{
key: 'reset',
label: (
<div
style={{ width: '100%', textAlign: 'center' }}
onClick={(e) => {
e.stopPropagation()
handleReset()
}}>
{t('settings.general.avatar.reset')}
</div>
)
}
]
return ( return (
<Modal <Modal
open={open} open={open}
@ -43,6 +156,23 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
title={t('settings.provider.add.title')} title={t('settings.provider.add.title')}
okButtonProps={{ disabled: buttonDisabled }}> okButtonProps={{ disabled: buttonDisabled }}>
<Divider style={{ margin: '8px 0' }} /> <Divider style={{ margin: '8px 0' }} />
<Center mt="10px" mb="20px">
<VStack alignItems="center" gap="10px">
<Dropdown
menu={{ items }}
trigger={['click']}
open={dropdownOpen}
align={{ offset: [0, 4] }}
placement="bottom"
onOpenChange={(visible) => {
setDropdownOpen(visible)
}}>
{logo ? <ProviderLogo src={logo} /> : <ProviderInitialsLogo>{getInitials()}</ProviderInitialsLogo>}
</Dropdown>
</VStack>
</Center>
<Form layout="vertical" style={{ gap: 8 }}> <Form layout="vertical" style={{ gap: 8 }}>
<Form.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}> <Form.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}>
<Input <Input
@ -70,13 +200,46 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
) )
} }
const ProviderLogo = styled.img`
cursor: pointer;
width: 60px;
height: 60px;
border-radius: 12px;
object-fit: contain;
transition: opacity 0.3s ease;
background-color: var(--color-background-soft);
padding: 5px;
border: 0.5px solid var(--color-border);
&:hover {
opacity: 0.8;
}
`
const ProviderInitialsLogo = styled.div`
cursor: pointer;
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 500;
transition: opacity 0.3s ease;
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
&:hover {
opacity: 0.8;
}
`
export default class AddProviderPopup { export default class AddProviderPopup {
static topviewId = 0 static topviewId = 0
static hide() { static hide() {
TopView.hide('AddProviderPopup') TopView.hide('AddProviderPopup')
} }
static show(provider?: Provider) { static show(provider?: Provider) {
return new Promise<{ name: string; type: ProviderType }>((resolve) => { return new Promise<{ name: string; type: ProviderType; logo?: string; logoFile?: File }>((resolve) => {
TopView.show( TopView.show(
<PopupContainer <PopupContainer
provider={provider} provider={provider}

View File

@ -10,12 +10,12 @@ import {
SettingOutlined SettingOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse' import CustomCollapse from '@renderer/components/CustomCollapse'
import { HStack } from '@renderer/components/Layout'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { getModelLogo } from '@renderer/config/models' import { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers' import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant' import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem'
import { ModelCheckStatus } from '@renderer/services/HealthCheckService' import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants' import { setModel } from '@renderer/store/assistants'
@ -270,52 +270,48 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
const isChecking = modelStatus?.checking === true const isChecking = modelStatus?.checking === true
return ( return (
<FileItem <ListItem key={model.id}>
key={model.id} <HStack alignItems="center" gap={10} style={{ flex: 1 }}>
fileInfo={{ <Avatar src={getModelLogo(model.id)} style={{ width: 30, height: 30 }}>
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>, {model?.name?.[0]?.toUpperCase()}
name: ( </Avatar>
<ListItemName> <ListItemName>
<Tooltip <Tooltip
styles={{ styles={{
root: { root: {
width: 'auto', width: 'auto',
maxWidth: '500px' maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
} }
placement="top"> }}
<span>{model.name}</span> destroyTooltipOnHide
</Tooltip> title={
<ModelTagsWithLabel model={model} size={11} /> <Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
</ListItemName> {model.id}
), </Typography.Text>
ext: '.model', }
actions: ( placement="top">
<Flex gap={4} align="center"> <span>{model.name}</span>
{renderLatencyText(modelStatus)} </Tooltip>
{renderStatusIndicator(modelStatus)} <ModelTagsWithLabel model={model} size={11} />
<Button </ListItemName>
type="text" </HStack>
onClick={() => !isChecking && onEditModel(model)} <Flex gap={4} align="center">
disabled={isChecking} {renderLatencyText(modelStatus)}
icon={<SettingOutlined />} {renderStatusIndicator(modelStatus)}
/> <Button
<Button type="text"
type="text" onClick={() => !isChecking && onEditModel(model)}
onClick={() => !isChecking && removeModel(model)} disabled={isChecking}
disabled={isChecking} icon={<SettingOutlined />}
icon={<MinusOutlined />} />
/> <Button
</Flex> type="text"
) onClick={() => !isChecking && removeModel(model)}
}} disabled={isChecking}
/> icon={<MinusOutlined />}
/>
</Flex>
</ListItem>
) )
})} })}
</Flex> </Flex>
@ -357,6 +353,16 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
) )
} }
const ListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
`
const ListItemName = styled.div` const ListItemName = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -3,10 +3,11 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils' import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
import { FC, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -20,6 +21,28 @@ const ProvidersList: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [searchText, setSearchText] = useState<string>('') const [searchText, setSearchText] = useState<string>('')
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
useEffect(() => {
const loadAllLogos = async () => {
const logos: Record<string, string> = {}
for (const provider of providers) {
if (provider.id) {
try {
const logoData = await ImageStorage.get(`provider-${provider.id}`)
if (logoData) {
logos[provider.id] = logoData
}
} catch (error) {
console.error(`Failed to load logo for provider ${provider.id}`, error)
}
}
}
setProviderLogos(logos)
}
loadAllLogos()
}, [providers])
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
setDragging(false) setDragging(false)
@ -32,15 +55,15 @@ const ProvidersList: FC = () => {
} }
const onAddProvider = async () => { const onAddProvider = async () => {
const { name: prividerName, type } = await AddProviderPopup.show() const { name: providerName, type, logo } = await AddProviderPopup.show()
if (!prividerName.trim()) { if (!providerName.trim()) {
return return
} }
const provider = { const provider = {
id: uuid(), id: uuid(),
name: prividerName.trim(), name: providerName.trim(),
type, type,
apiKey: '', apiKey: '',
apiHost: '', apiHost: '',
@ -49,6 +72,21 @@ const ProvidersList: FC = () => {
isSystem: false isSystem: false
} as Provider } as Provider
let updatedLogos = { ...providerLogos }
if (logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
updatedLogos = {
...updatedLogos,
[provider.id]: logo
}
setProviderLogos(updatedLogos)
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('保存Provider Logo失败')
}
}
addProvider(provider) addProvider(provider)
setSelectedProvider(provider) setSelectedProvider(provider)
} }
@ -60,8 +98,36 @@ const ProvidersList: FC = () => {
key: 'edit', key: 'edit',
icon: <EditOutlined />, icon: <EditOutlined />,
async onClick() { async onClick() {
const { name, type } = await AddProviderPopup.show(provider) const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
name && updateProvider({ ...provider, name, type })
if (name) {
updateProvider({ ...provider, name, type })
if (provider.id) {
if (logoFile && logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
setProviderLogos((prev) => ({
...prev,
[provider.id]: logo
}))
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('更新Provider Logo失败')
}
} else if (logo === undefined && logoFile === undefined) {
try {
await ImageStorage.set(`provider-${provider.id}`, '')
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to reset logo', error)
}
}
}
}
} }
}, },
{ {
@ -76,7 +142,21 @@ const ProvidersList: FC = () => {
okButtonProps: { danger: true }, okButtonProps: { danger: true },
okText: t('common.delete'), okText: t('common.delete'),
centered: true, centered: true,
onOk: () => { onOk: async () => {
// 删除provider前先清理其logo
if (provider.id) {
try {
await ImageStorage.remove(`provider-${provider.id}`)
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to delete logo', error)
}
}
setSelectedProvider(providers.filter((p) => p.isSystem)[0]) setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider) removeProvider(provider)
} }
@ -96,17 +176,33 @@ const ProvidersList: FC = () => {
return menus return menus
} }
//will match the providers and the models that provider provides const getProviderAvatar = (provider: Provider) => {
if (provider.isSystem) {
return <ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
}
const customLogo = providerLogos[provider.id]
if (customLogo) {
return <ProviderLogo shape="square" src={customLogo} size={25} />
}
return (
<ProviderLogo
size={25}
shape="square"
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)
}
const filteredProviders = providers.filter((provider) => { const filteredProviders = providers.filter((provider) => {
// 获取 provider 的名称
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
// 检查 provider 的 id 和 name 是否匹配搜索条件
const isProviderMatch = const isProviderMatch =
provider.id.toLowerCase().includes(searchText.toLowerCase()) || provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
providerName.toLowerCase().includes(searchText.toLowerCase()) providerName.toLowerCase().includes(searchText.toLowerCase())
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
const isModelMatch = provider.models.some((model) => { const isModelMatch = provider.models.some((model) => {
return ( return (
model.id.toLowerCase().includes(searchText.toLowerCase()) || model.id.toLowerCase().includes(searchText.toLowerCase()) ||
@ -114,7 +210,6 @@ const ProvidersList: FC = () => {
) )
}) })
// 如果 provider 或 model 匹配,则保留该 provider
return isProviderMatch || isModelMatch return isProviderMatch || isModelMatch
}) })
@ -161,17 +256,7 @@ const ProvidersList: FC = () => {
key={JSON.stringify(provider)} key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''} className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}> onClick={() => setSelectedProvider(provider)}>
{provider.isSystem && ( {getProviderAvatar(provider)}
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
)}
{!provider.isSystem && (
<ProviderLogo
size={25}
shape="square"
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)}
<ProviderItemName className="text-nowrap"> <ProviderItemName className="text-nowrap">
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name} {provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
</ProviderItemName> </ProviderItemName>

View File

@ -117,7 +117,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingTitle> <SettingTitle>
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
<ProviderLogo shape="square" src={getWebSearchProviderLogo(provider.id)} size={16} /> <ProviderLogo shape="square" src={getWebSearchProviderLogo(provider.id)} size={16} />
<ProviderName> {provider.name}</ProviderName> <ProviderName> {provider.name}</ProviderName>
{officialWebsite && webSearchProviderConfig?.websites && ( {officialWebsite && webSearchProviderConfig?.websites && (
<Link target="_blank" href={webSearchProviderConfig.websites.official}> <Link target="_blank" href={webSearchProviderConfig.websites.official}>
@ -156,7 +155,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
</SettingHelpTextRow> </SettingHelpTextRow>
</> </>
)} )}
{hasObjectKey(provider, 'apiHost') && ( {hasObjectKey(provider, 'apiHost') && (
<> <>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}> <SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>

View File

@ -1,6 +1,7 @@
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
import { WebSearchProvider } from '@renderer/types' import { WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Select } from 'antd' import { Select } from 'antd'
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -17,6 +18,8 @@ const WebSearchSettings: FC = () => {
const [selectedProvider, setSelectedProvider] = useState<WebSearchProvider | undefined>(defaultProvider) const [selectedProvider, setSelectedProvider] = useState<WebSearchProvider | undefined>(defaultProvider)
const { theme: themeMode } = useTheme() const { theme: themeMode } = useTheme()
const isLocalProvider = selectedProvider?.id.startsWith('local')
function updateSelectedWebSearchProvider(providerId: string) { function updateSelectedWebSearchProvider(providerId: string) {
const provider = providers.find((p) => p.id === providerId) const provider = providers.find((p) => p.id === providerId)
if (!provider) { if (!provider) {
@ -39,14 +42,19 @@ const WebSearchSettings: FC = () => {
style={{ width: '200px' }} style={{ width: '200px' }}
onChange={(value: string) => updateSelectedWebSearchProvider(value)} onChange={(value: string) => updateSelectedWebSearchProvider(value)}
placeholder={t('settings.websearch.search_provider_placeholder')} placeholder={t('settings.websearch.search_provider_placeholder')}
options={providers.map((p) => ({ value: p.id, label: p.name }))} options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.websearch.apikey') : t('settings.websearch.free')})`
}))}
/> />
</div> </div>
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
<SettingGroup theme={themeMode}> {!isLocalProvider && (
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />} <SettingGroup theme={themeMode}>
</SettingGroup> {selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
</SettingGroup>
)}
<BasicSettings /> <BasicSettings />
<BlacklistSettings /> <BlacklistSettings />
</SettingContainer> </SettingContainer>

View File

@ -2,7 +2,7 @@ import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
export default abstract class BaseWebSearchProvider { export default abstract class BaseWebSearchProvider {
// @ts-ignore this // @ts-ignore this
private provider: WebSearchProvider protected provider: WebSearchProvider
protected apiKey: string protected apiKey: string
constructor(provider: WebSearchProvider) { constructor(provider: WebSearchProvider) {

View File

@ -0,0 +1,28 @@
import LocalSearchProvider, { SearchItem } from './LocalSearchProvider'
export default class LocalBaiduProvider extends LocalSearchProvider {
protected parseValidUrls(htmlContent: string): SearchItem[] {
const results: SearchItem[] = []
try {
// Parse HTML string into a DOM document
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, 'text/html')
const items = doc.querySelectorAll('#content_left .result h3')
items.forEach((item) => {
const node = item.querySelector('a')
if (node) {
results.push({
title: node.textContent || '',
url: node.href
})
}
})
} catch (error) {
console.error('Failed to parse Baidu search HTML:', error)
}
console.log('Parsed Baidu search results:', results)
return results
}
}

View File

@ -0,0 +1,27 @@
import LocalSearchProvider, { SearchItem } from './LocalSearchProvider'
export default class LocalBingProvider extends LocalSearchProvider {
protected parseValidUrls(htmlContent: string): SearchItem[] {
const results: SearchItem[] = []
try {
// Parse HTML string into a DOM document
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, 'text/html')
const items = doc.querySelectorAll('#b_results h2')
items.forEach((item) => {
const node = item.querySelector('a')
if (node) {
results.push({
title: node.textContent || '',
url: node.href
})
}
})
} catch (error) {
console.error('Failed to parse Bing search HTML:', error)
}
return results
}
}

View File

@ -0,0 +1,28 @@
import LocalSearchProvider, { SearchItem } from './LocalSearchProvider'
export default class LocalGoogleProvider extends LocalSearchProvider {
protected parseValidUrls(htmlContent: string): SearchItem[] {
const results: SearchItem[] = []
try {
// Parse HTML string into a DOM document
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, 'text/html')
const items = doc.querySelectorAll('#search .MjjYud')
items.forEach((item) => {
const title = item.querySelector('h3')
const link = item.querySelector('a')
if (title && link) {
results.push({
title: title.textContent || '',
url: link.href
})
}
})
} catch (error) {
console.error('Failed to parse Google search HTML:', error)
}
return results
}
}

View File

@ -0,0 +1,132 @@
import { Readability } from '@mozilla/readability'
import { nanoid } from '@reduxjs/toolkit'
import { WebSearchProvider, WebSearchResponse, WebSearchResult } from '@renderer/types'
import TurndownService from 'turndown'
import BaseWebSearchProvider from './BaseWebSearchProvider'
export interface SearchItem {
title: string
url: string
}
const noContent = 'No content found'
export default class LocalSearchProvider extends BaseWebSearchProvider {
private turndownService: TurndownService = new TurndownService()
constructor(provider: WebSearchProvider) {
if (!provider || !provider.url) {
throw new Error('Provider URL is required')
}
super(provider)
}
public async search(
query: string,
maxResults: number = 15,
excludeDomains: string[] = []
): Promise<WebSearchResponse> {
const uid = nanoid()
try {
if (!query.trim()) {
throw new Error('Search query cannot be empty')
}
if (!this.provider.url) {
throw new Error('Provider URL is required')
}
const cleanedQuery = query.split('\r\n')[1] ?? query
const url = this.provider.url.replace('%s', encodeURIComponent(cleanedQuery))
const content = await window.api.searchService.openUrlInSearchWindow(uid, url)
// Parse the content to extract URLs and metadata
const searchItems = this.parseValidUrls(content).slice(0, maxResults)
console.log('Total search items:', searchItems)
const validItems = searchItems
.filter(
(item) =>
(item.url.startsWith('http') || item.url.startsWith('https')) &&
excludeDomains.includes(new URL(item.url).host) === false
)
.slice(0, maxResults)
// console.log('Valid search items:', validItems)
// Fetch content for each URL concurrently
const fetchPromises = validItems.map(async (item) => {
// console.log(`Fetching content for ${item.url}...`)
const result = await this.fetchPageContent(item.url, this.provider.usingBrowser)
if (
this.provider.contentLimit &&
this.provider.contentLimit != -1 &&
result.content.length > this.provider.contentLimit
) {
result.content = result.content.slice(0, this.provider.contentLimit) + '...'
}
return result
})
// Wait for all fetches to complete
const results: WebSearchResult[] = await Promise.all(fetchPromises)
return {
query: query,
results: results.filter((result) => result.content != noContent)
}
} catch (error) {
console.error('Local search failed:', error)
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
await window.api.searchService.closeSearchWindow(uid)
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected parseValidUrls(_htmlContent: string): SearchItem[] {
throw new Error('Not implemented')
}
private async fetchPageContent(url: string, usingBrowser: boolean = false): Promise<WebSearchResult> {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
let html: string
if (usingBrowser) {
html = await window.api.searchService.openUrlInSearchWindow(`search-window-${nanoid()}`, url)
} else {
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
html = await response.text()
}
clearTimeout(timeoutId) // Clear the timeout if fetch completes successfully
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const article = new Readability(doc).parse()
// console.log('Parsed article:', article)
const markdown = this.turndownService.turndown(article?.content || '')
return {
title: article?.title || url,
url: url,
content: markdown || noContent
}
} catch (e: unknown) {
console.error(`Failed to fetch ${url}`, e)
return {
title: url,
url: url,
content: noContent
}
}
}
}

View File

@ -3,6 +3,9 @@ import { WebSearchProvider } from '@renderer/types'
import BaseWebSearchProvider from './BaseWebSearchProvider' import BaseWebSearchProvider from './BaseWebSearchProvider'
import DefaultProvider from './DefaultProvider' import DefaultProvider from './DefaultProvider'
import ExaProvider from './ExaProvider' import ExaProvider from './ExaProvider'
import LocalBaiduProvider from './LocalBaiduProvider'
import LocalBingProvider from './LocalBingProvider'
import LocalGoogleProvider from './LocalGoogleProvider'
import SearxngProvider from './SearxngProvider' import SearxngProvider from './SearxngProvider'
import TavilyProvider from './TavilyProvider' import TavilyProvider from './TavilyProvider'
@ -15,7 +18,12 @@ export default class WebSearchProviderFactory {
return new SearxngProvider(provider) return new SearxngProvider(provider)
case 'exa': case 'exa':
return new ExaProvider(provider) return new ExaProvider(provider)
case 'local-google':
return new LocalGoogleProvider(provider)
case 'local-baidu':
return new LocalBaiduProvider(provider)
case 'local-bing':
return new LocalBingProvider(provider)
default: default:
return new DefaultProvider(provider) return new DefaultProvider(provider)
} }

View File

@ -34,4 +34,17 @@ export default class ImageStorage {
const id = IMAGE_PREFIX + key const id = IMAGE_PREFIX + key
return (await db.settings.get(id))?.value return (await db.settings.get(id))?.value
} }
static async remove(key: string): Promise<void> {
const id = IMAGE_PREFIX + key
try {
const record = await db.settings.get(id)
if (record) {
await db.settings.delete(id)
}
} catch (error) {
console.error('Error removing the image', error)
throw error
}
}
} }

View File

@ -31,6 +31,10 @@ class WebSearchService {
return false return false
} }
if (provider.id.startsWith('local-')) {
return true
}
if (hasObjectKey(provider, 'apiKey')) { if (hasObjectKey(provider, 'apiKey')) {
return provider.apiKey !== '' return provider.apiKey !== ''
} }

View File

@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 94, version: 95,
blacklist: ['runtime', 'messages'], blacklist: ['runtime', 'messages'],
migrate migrate
}, },

View File

@ -14,6 +14,7 @@ import { RootState } from '.'
import { INITIAL_PROVIDERS, moveProvider } from './llm' import { INITIAL_PROVIDERS, moveProvider } from './llm'
import { mcpSlice } from './mcp' import { mcpSlice } from './mcp'
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings' import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
import { defaultWebSearchProviders } from './websearch'
// remove logo base64 data to reduce the size of the state // remove logo base64 data to reduce the size of the state
function removeMiniAppIconsFromState(state: RootState) { function removeMiniAppIconsFromState(state: RootState) {
@ -52,6 +53,17 @@ function addProvider(state: RootState, id: string) {
} }
} }
function addWebSearchProvider(state: RootState, id: string) {
if (state.websearch && state.websearch.providers) {
if (!state.websearch.providers.find((p) => p.id === id)) {
const provider = defaultWebSearchProviders.find((p) => p.id === id)
if (provider) {
state.websearch.providers.push(provider)
}
}
}
}
const migrateConfig = { const migrateConfig = {
'2': (state: RootState) => { '2': (state: RootState) => {
try { try {
@ -985,21 +997,9 @@ const migrateConfig = {
}, },
'77': (state: RootState) => { '77': (state: RootState) => {
try { try {
addWebSearchProvider(state, 'searxng')
addWebSearchProvider(state, 'exa')
if (state.websearch) { if (state.websearch) {
if (!state.websearch.providers.find((p) => p.id === 'searxng')) {
state.websearch.providers.push(
{
id: 'searxng',
name: 'Searxng',
apiHost: ''
},
{
id: 'exa',
name: 'Exa',
apiKey: ''
}
)
}
state.websearch.providers.forEach((p) => { state.websearch.providers.forEach((p) => {
// @ts-ignore eslint-disable-next-line // @ts-ignore eslint-disable-next-line
delete p.enabled delete p.enabled
@ -1192,6 +1192,20 @@ const migrateConfig = {
} catch (error) { } catch (error) {
return state return state
} }
},
'95': (state: RootState) => {
try {
addWebSearchProvider(state, 'local-google')
addWebSearchProvider(state, 'local-bing')
addWebSearchProvider(state, 'local-baidu')
const qiniuProvider = state.llm.providers.find((provider) => provider.id === 'qiniu')
if (qiniuProvider && isEmpty(qiniuProvider.models)) {
qiniuProvider.models = SYSTEM_MODELS.qiniu
}
return state
} catch (error) {
return state
}
} }
} }

View File

@ -35,6 +35,21 @@ const initialState: WebSearchState = {
id: 'exa', id: 'exa',
name: 'Exa', name: 'Exa',
apiKey: '' apiKey: ''
},
{
id: 'local-google',
name: 'Google',
url: 'https://www.google.com/search?q=%s'
},
{
id: 'local-bing',
name: 'Bing',
url: 'https://cn.bing.com/search?q=%s&ensearch=1'
},
{
id: 'local-baidu',
name: 'Baidu',
url: 'https://www.baidu.com/s?wd=%s'
} }
], ],
searchWithTime: true, searchWithTime: true,
@ -44,6 +59,8 @@ const initialState: WebSearchState = {
overwrite: false overwrite: false
} }
export const defaultWebSearchProviders = initialState.providers
const websearchSlice = createSlice({ const websearchSlice = createSlice({
name: 'websearch', name: 'websearch',
initialState, initialState,
@ -77,6 +94,15 @@ const websearchSlice = createSlice({
}, },
setOverwrite: (state, action: PayloadAction<boolean>) => { setOverwrite: (state, action: PayloadAction<boolean>) => {
state.overwrite = action.payload state.overwrite = action.payload
},
addWebSearchProvider: (state, action: PayloadAction<WebSearchProvider>) => {
// Check if provider with same ID already exists
const exists = state.providers.some((provider) => provider.id === action.payload.id)
if (!exists) {
// Add the new provider to the array
state.providers.push(action.payload)
}
} }
} }
}) })
@ -90,7 +116,8 @@ export const {
setExcludeDomains, setExcludeDomains,
setMaxResult, setMaxResult,
setEnhanceMode, setEnhanceMode,
setOverwrite setOverwrite,
addWebSearchProvider
} = websearchSlice.actions } = websearchSlice.actions
export default websearchSlice.reducer export default websearchSlice.reducer

View File

@ -336,6 +336,9 @@ export type WebSearchProvider = {
apiKey?: string apiKey?: string
apiHost?: string apiHost?: string
engines?: string[] engines?: string[]
url?: string
contentLimit?: number
usingBrowser?: boolean
} }
export type WebSearchResponse = { export type WebSearchResponse = {

View File

@ -2527,6 +2527,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@mozilla/readability@npm:^0.6.0":
version: 0.6.0
resolution: "@mozilla/readability@npm:0.6.0"
checksum: 10c0/05c0fdb837f6bddd307b9dbc396538cdf17ab6a0d3bec96971c2dfc079737fb7ab830a0797c5fad3d66db71cb9c305d8e05fe68d9b7eb0be951d4b802e43e588
languageName: node
linkType: hard
"@neon-rs/load@npm:^0.0.4": "@neon-rs/load@npm:^0.0.4":
version: 0.0.4 version: 0.0.4
resolution: "@neon-rs/load@npm:0.0.4" resolution: "@neon-rs/load@npm:0.0.4"
@ -3911,6 +3918,7 @@ __metadata:
"@kangfenmao/keyv-storage": "npm:^0.1.0" "@kangfenmao/keyv-storage": "npm:^0.1.0"
"@langchain/community": "npm:^0.3.36" "@langchain/community": "npm:^0.3.36"
"@modelcontextprotocol/sdk": "npm:^1.9.0" "@modelcontextprotocol/sdk": "npm:^1.9.0"
"@mozilla/readability": "npm:^0.6.0"
"@notionhq/client": "npm:^2.2.15" "@notionhq/client": "npm:^2.2.15"
"@reduxjs/toolkit": "npm:^2.2.5" "@reduxjs/toolkit": "npm:^2.2.5"
"@strongtz/win32-arm64-msvc": "npm:^0.4.7" "@strongtz/win32-arm64-msvc": "npm:^0.4.7"