mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-22 00:13:09 +08:00
Merge branch 'main' into 1600822305-patch-2
# Conflicts: # src/main/ipc.ts
This commit is contained in:
commit
dba84bb04e
@ -66,6 +66,7 @@
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
|
||||
@ -18,6 +18,10 @@ export enum IpcChannel {
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
// ASR Server
|
||||
Asr_StartServer = 'start-asr-server',
|
||||
Asr_StopServer = 'stop-asr-server',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
Open_Website = 'open:website',
|
||||
@ -146,5 +150,10 @@ export enum IpcChannel {
|
||||
MiniWindowReload = 'miniwindow-reload',
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@ -18,35 +18,48 @@ exports.default = async function (context) {
|
||||
'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') {
|
||||
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']
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
if (arch === Arch.arm64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
}
|
||||
if (arch === Arch.x64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['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)
|
||||
|
||||
if (!fs.existsSync(modulePath)) {
|
||||
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const dirs = fs.readdirSync(modulePath)
|
||||
dirs
|
||||
.filter((dir) => !arch.includes(dir))
|
||||
.forEach((dir) => {
|
||||
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
|
||||
console.log(`Removed dir: ${dir}`, arch)
|
||||
console.log(`[After Pack] Removed dir: ${dir}`, arch)
|
||||
})
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import mcpService from './services/MCPService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@ -297,8 +298,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
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服务器
|
||||
ipcMain.handle('start-asr-server', async () => {
|
||||
ipcMain.handle(IpcChannel.Asr_StartServer, async () => {
|
||||
try {
|
||||
if (asrServerProcess) {
|
||||
return { success: true, pid: asrServerProcess.pid }
|
||||
@ -371,7 +383,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// 停止ASR服务器
|
||||
ipcMain.handle('stop-asr-server', async (_event, pid) => {
|
||||
ipcMain.handle(IpcChannel.Asr_StopServer, async (_event, pid) => {
|
||||
try {
|
||||
if (!asrServerProcess) {
|
||||
return { success: true }
|
||||
|
||||
@ -147,8 +147,12 @@ class McpService {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
}
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
transport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
|
||||
82
src/main/services/SearchService.ts
Normal file
82
src/main/services/SearchService.ts
Normal 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()
|
||||
@ -17,7 +17,6 @@ export class WindowService {
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
private miniWindow: BrowserWindow | null = null
|
||||
private isPinnedMiniWindow: boolean = false
|
||||
private wasFullScreen: boolean = false
|
||||
//hacky-fix: store the focused status of mainWindow before miniWindow shows
|
||||
//to restore the focus status when miniWindow hides
|
||||
private wasMainWindowFocused: boolean = false
|
||||
@ -41,7 +40,8 @@ export class WindowService {
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670
|
||||
defaultHeight: 670,
|
||||
fullScreen: false
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
@ -53,7 +53,7 @@ export class WindowService {
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minHeight: 600,
|
||||
show: false, // 初始不显示
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'sidebar',
|
||||
@ -138,12 +138,10 @@ export class WindowService {
|
||||
|
||||
// 处理全屏相关事件
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
this.wasFullScreen = true
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
|
||||
})
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
this.wasFullScreen = 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()
|
||||
mainWindow.hide()
|
||||
@ -316,16 +304,29 @@ export class WindowService {
|
||||
this.mainWindow.restore()
|
||||
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)会导致窗口进入"假弹出"状态
|
||||
// 因此在 Linux 环境下不执行这两行代码
|
||||
/**
|
||||
* About setVisibleOnAllWorkspaces
|
||||
*
|
||||
* [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) {
|
||||
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.focus()
|
||||
if (!isLinux) {
|
||||
@ -338,7 +339,9 @@ export class WindowService {
|
||||
|
||||
public toggleMainWindow() {
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -392,7 +395,8 @@ export class WindowService {
|
||||
//miniWindow should show in current desktop
|
||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
//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', () => {
|
||||
if (isPreload) {
|
||||
|
||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@ -175,6 +175,11 @@ declare global {
|
||||
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
|
||||
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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,6 +169,11 @@ const api = {
|
||||
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
|
||||
getDirectoryContents: (token: string, path: string) =>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
import { compressImage, isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
|
||||
@ -234,6 +234,10 @@ export function isFunctionCallingModel(model: Model): boolean {
|
||||
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)) {
|
||||
return true
|
||||
}
|
||||
@ -500,12 +504,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'text-embedding-3-small',
|
||||
group: '嵌入模型'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-small',
|
||||
provider: 'o3',
|
||||
name: 'text-embedding-3-small',
|
||||
group: '嵌入模型'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-ada-002',
|
||||
provider: 'o3',
|
||||
@ -2015,7 +2013,56 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
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 = [
|
||||
|
||||
@ -31,5 +31,20 @@ export const WEB_SEARCH_PROVIDER_CONFIG = {
|
||||
official: 'https://exa.ai',
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,11 +25,20 @@ export const useDefaultWebSearchProvider = () => {
|
||||
|
||||
export const useWebSearchProviders = () => {
|
||||
const providers = useAppSelector((state) => state.websearch.providers)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
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 provider = providers.find((provider) => provider.id === id)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Web search provider with id ${id} not found`)
|
||||
}
|
||||
|
||||
@ -1308,7 +1308,9 @@
|
||||
},
|
||||
"title": "Web Search",
|
||||
"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": {
|
||||
"title": "Quick Phrases",
|
||||
|
||||
@ -1307,7 +1307,9 @@
|
||||
},
|
||||
"title": "ウェブ検索",
|
||||
"overwrite": "サービス検索を上書き",
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する"
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
|
||||
"apikey": "API キー",
|
||||
"free": "無料"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新チェックを有効にする",
|
||||
"quickPhrase": {
|
||||
|
||||
@ -1307,7 +1307,9 @@
|
||||
},
|
||||
"title": "Поиск в Интернете",
|
||||
"overwrite": "Переопределить поставщика поиска",
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM"
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
|
||||
"apikey": "Ключ API",
|
||||
"free": "Бесплатно"
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
|
||||
"quickPhrase": {
|
||||
|
||||
@ -1308,7 +1308,9 @@
|
||||
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "网络搜索"
|
||||
"title": "网络搜索",
|
||||
"apikey": "API 密钥",
|
||||
"free": "免费"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
|
||||
@ -1307,7 +1307,9 @@
|
||||
},
|
||||
"title": "網路搜尋",
|
||||
"overwrite": "覆蓋搜尋服務商",
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋"
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
||||
"apikey": "API 金鑰",
|
||||
"free": "免費"
|
||||
},
|
||||
"general.auto_check_update.title": "啟用自動更新檢查",
|
||||
"quickPhrase": {
|
||||
|
||||
@ -1,23 +1,53 @@
|
||||
import { Center, VStack } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { Provider, ProviderType } from '@renderer/types'
|
||||
import { Divider, Form, Input, Modal, Select } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { compressImage } from '@renderer/utils'
|
||||
import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
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 [open, setOpen] = useState(true)
|
||||
const [name, setName] = useState(provider?.name || '')
|
||||
const [type, setType] = useState<ProviderType>(provider?.type || 'openai')
|
||||
const [logo, setLogo] = useState<string | null>(null)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
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)
|
||||
resolve({ name, type })
|
||||
|
||||
// 返回结果,但不包含文件对象,因为文件已经直接保存到 ImageStorage
|
||||
const result = {
|
||||
name,
|
||||
type,
|
||||
logo: logo || undefined
|
||||
}
|
||||
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@ -26,11 +56,94 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({ name, type })
|
||||
resolve({ name, type, logo: logo || undefined })
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
@ -43,6 +156,23 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
title={t('settings.provider.add.title')}
|
||||
okButtonProps={{ disabled: buttonDisabled }}>
|
||||
<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.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}>
|
||||
<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 {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('AddProviderPopup')
|
||||
}
|
||||
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(
|
||||
<PopupContainer
|
||||
provider={provider}
|
||||
|
||||
@ -10,12 +10,12 @@ import {
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import CustomCollapse from '@renderer/components/CustomCollapse'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setModel } from '@renderer/store/assistants'
|
||||
@ -270,52 +270,48 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
|
||||
const isChecking = modelStatus?.checking === true
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
key={model.id}
|
||||
fileInfo={{
|
||||
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
|
||||
name: (
|
||||
<ListItemName>
|
||||
<Tooltip
|
||||
styles={{
|
||||
root: {
|
||||
width: 'auto',
|
||||
maxWidth: '500px'
|
||||
}
|
||||
}}
|
||||
destroyTooltipOnHide
|
||||
title={
|
||||
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
|
||||
{model.id}
|
||||
</Typography.Text>
|
||||
<ListItem key={model.id}>
|
||||
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
|
||||
<Avatar src={getModelLogo(model.id)} style={{ width: 30, height: 30 }}>
|
||||
{model?.name?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<ListItemName>
|
||||
<Tooltip
|
||||
styles={{
|
||||
root: {
|
||||
width: 'auto',
|
||||
maxWidth: '500px'
|
||||
}
|
||||
placement="top">
|
||||
<span>{model.name}</span>
|
||||
</Tooltip>
|
||||
<ModelTagsWithLabel model={model} size={11} />
|
||||
</ListItemName>
|
||||
),
|
||||
ext: '.model',
|
||||
actions: (
|
||||
<Flex gap={4} align="center">
|
||||
{renderLatencyText(modelStatus)}
|
||||
{renderStatusIndicator(modelStatus)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && onEditModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && removeModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<MinusOutlined />}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
destroyTooltipOnHide
|
||||
title={
|
||||
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
|
||||
{model.id}
|
||||
</Typography.Text>
|
||||
}
|
||||
placement="top">
|
||||
<span>{model.name}</span>
|
||||
</Tooltip>
|
||||
<ModelTagsWithLabel model={model} size={11} />
|
||||
</ListItemName>
|
||||
</HStack>
|
||||
<Flex gap={4} align="center">
|
||||
{renderLatencyText(modelStatus)}
|
||||
{renderStatusIndicator(modelStatus)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && onEditModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && removeModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<MinusOutlined />}
|
||||
/>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</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`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -3,10 +3,11 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
|
||||
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 styled from 'styled-components'
|
||||
|
||||
@ -20,6 +21,28 @@ const ProvidersList: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
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) => {
|
||||
setDragging(false)
|
||||
@ -32,15 +55,15 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const provider = {
|
||||
id: uuid(),
|
||||
name: prividerName.trim(),
|
||||
name: providerName.trim(),
|
||||
type,
|
||||
apiKey: '',
|
||||
apiHost: '',
|
||||
@ -49,6 +72,21 @@ const ProvidersList: FC = () => {
|
||||
isSystem: false
|
||||
} 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)
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
@ -60,8 +98,36 @@ const ProvidersList: FC = () => {
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
async onClick() {
|
||||
const { name, type } = await AddProviderPopup.show(provider)
|
||||
name && updateProvider({ ...provider, name, type })
|
||||
const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
|
||||
|
||||
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 },
|
||||
okText: t('common.delete'),
|
||||
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])
|
||||
removeProvider(provider)
|
||||
}
|
||||
@ -96,17 +176,33 @@ const ProvidersList: FC = () => {
|
||||
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) => {
|
||||
// 获取 provider 的名称
|
||||
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
|
||||
|
||||
// 检查 provider 的 id 和 name 是否匹配搜索条件
|
||||
const isProviderMatch =
|
||||
provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
providerName.toLowerCase().includes(searchText.toLowerCase())
|
||||
|
||||
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
|
||||
const isModelMatch = provider.models.some((model) => {
|
||||
return (
|
||||
model.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
@ -114,7 +210,6 @@ const ProvidersList: FC = () => {
|
||||
)
|
||||
})
|
||||
|
||||
// 如果 provider 或 model 匹配,则保留该 provider
|
||||
return isProviderMatch || isModelMatch
|
||||
})
|
||||
|
||||
@ -161,17 +256,7 @@ const ProvidersList: FC = () => {
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
{provider.isSystem && (
|
||||
<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>
|
||||
)}
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">
|
||||
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
|
||||
</ProviderItemName>
|
||||
|
||||
@ -117,7 +117,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo shape="square" src={getWebSearchProviderLogo(provider.id)} size={16} />
|
||||
|
||||
<ProviderName> {provider.name}</ProviderName>
|
||||
{officialWebsite && webSearchProviderConfig?.websites && (
|
||||
<Link target="_blank" href={webSearchProviderConfig.websites.official}>
|
||||
@ -156,7 +155,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(provider, 'apiHost') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { WebSearchProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Select } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -17,6 +18,8 @@ const WebSearchSettings: FC = () => {
|
||||
const [selectedProvider, setSelectedProvider] = useState<WebSearchProvider | undefined>(defaultProvider)
|
||||
const { theme: themeMode } = useTheme()
|
||||
|
||||
const isLocalProvider = selectedProvider?.id.startsWith('local')
|
||||
|
||||
function updateSelectedWebSearchProvider(providerId: string) {
|
||||
const provider = providers.find((p) => p.id === providerId)
|
||||
if (!provider) {
|
||||
@ -39,14 +42,19 @@ const WebSearchSettings: FC = () => {
|
||||
style={{ width: '200px' }}
|
||||
onChange={(value: string) => updateSelectedWebSearchProvider(value)}
|
||||
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>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={themeMode}>
|
||||
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
|
||||
</SettingGroup>
|
||||
{!isLocalProvider && (
|
||||
<SettingGroup theme={themeMode}>
|
||||
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
|
||||
</SettingGroup>
|
||||
)}
|
||||
<BasicSettings />
|
||||
<BlacklistSettings />
|
||||
</SettingContainer>
|
||||
|
||||
@ -2,7 +2,7 @@ import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
|
||||
|
||||
export default abstract class BaseWebSearchProvider {
|
||||
// @ts-ignore this
|
||||
private provider: WebSearchProvider
|
||||
protected provider: WebSearchProvider
|
||||
protected apiKey: string
|
||||
|
||||
constructor(provider: WebSearchProvider) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,9 @@ import { WebSearchProvider } from '@renderer/types'
|
||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||
import DefaultProvider from './DefaultProvider'
|
||||
import ExaProvider from './ExaProvider'
|
||||
import LocalBaiduProvider from './LocalBaiduProvider'
|
||||
import LocalBingProvider from './LocalBingProvider'
|
||||
import LocalGoogleProvider from './LocalGoogleProvider'
|
||||
import SearxngProvider from './SearxngProvider'
|
||||
import TavilyProvider from './TavilyProvider'
|
||||
|
||||
@ -15,7 +18,12 @@ export default class WebSearchProviderFactory {
|
||||
return new SearxngProvider(provider)
|
||||
case 'exa':
|
||||
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:
|
||||
return new DefaultProvider(provider)
|
||||
}
|
||||
|
||||
@ -34,4 +34,17 @@ export default class ImageStorage {
|
||||
const id = IMAGE_PREFIX + key
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,10 @@ class WebSearchService {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider.id.startsWith('local-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (hasObjectKey(provider, 'apiKey')) {
|
||||
return provider.apiKey !== ''
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 94,
|
||||
version: 95,
|
||||
blacklist: ['runtime', 'messages'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -14,6 +14,7 @@ import { RootState } from '.'
|
||||
import { INITIAL_PROVIDERS, moveProvider } from './llm'
|
||||
import { mcpSlice } from './mcp'
|
||||
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
|
||||
import { defaultWebSearchProviders } from './websearch'
|
||||
|
||||
// remove logo base64 data to reduce the size of the state
|
||||
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 = {
|
||||
'2': (state: RootState) => {
|
||||
try {
|
||||
@ -985,21 +997,9 @@ const migrateConfig = {
|
||||
},
|
||||
'77': (state: RootState) => {
|
||||
try {
|
||||
addWebSearchProvider(state, 'searxng')
|
||||
addWebSearchProvider(state, 'exa')
|
||||
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) => {
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
delete p.enabled
|
||||
@ -1192,6 +1192,20 @@ const migrateConfig = {
|
||||
} catch (error) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,21 @@ const initialState: WebSearchState = {
|
||||
id: 'exa',
|
||||
name: 'Exa',
|
||||
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,
|
||||
@ -44,6 +59,8 @@ const initialState: WebSearchState = {
|
||||
overwrite: false
|
||||
}
|
||||
|
||||
export const defaultWebSearchProviders = initialState.providers
|
||||
|
||||
const websearchSlice = createSlice({
|
||||
name: 'websearch',
|
||||
initialState,
|
||||
@ -77,6 +94,15 @@ const websearchSlice = createSlice({
|
||||
},
|
||||
setOverwrite: (state, action: PayloadAction<boolean>) => {
|
||||
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,
|
||||
setMaxResult,
|
||||
setEnhanceMode,
|
||||
setOverwrite
|
||||
setOverwrite,
|
||||
addWebSearchProvider
|
||||
} = websearchSlice.actions
|
||||
|
||||
export default websearchSlice.reducer
|
||||
|
||||
@ -336,6 +336,9 @@ export type WebSearchProvider = {
|
||||
apiKey?: string
|
||||
apiHost?: string
|
||||
engines?: string[]
|
||||
url?: string
|
||||
contentLimit?: number
|
||||
usingBrowser?: boolean
|
||||
}
|
||||
|
||||
export type WebSearchResponse = {
|
||||
|
||||
@ -2527,6 +2527,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 0.0.4
|
||||
resolution: "@neon-rs/load@npm:0.0.4"
|
||||
@ -3911,6 +3918,7 @@ __metadata:
|
||||
"@kangfenmao/keyv-storage": "npm:^0.1.0"
|
||||
"@langchain/community": "npm:^0.3.36"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.9.0"
|
||||
"@mozilla/readability": "npm:^0.6.0"
|
||||
"@notionhq/client": "npm:^2.2.15"
|
||||
"@reduxjs/toolkit": "npm:^2.2.5"
|
||||
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user