mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
Merge branch 'main' into feat/sidebar-ui
This commit is contained in:
commit
5ca0ce682b
6471
.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch
vendored
Normal file
6471
.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -94,7 +94,7 @@
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@eslint-react/eslint-plugin": "^1.36.1",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "^1.0.1",
|
||||
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
@ -164,6 +164,7 @@
|
||||
"framer-motion": "^12.17.3",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
|
||||
@ -15,7 +15,12 @@ export enum IpcChannel {
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
|
||||
App_Select = 'app:select',
|
||||
App_HasWritePermission = 'app:has-write-permission',
|
||||
App_Copy = 'app:copy',
|
||||
App_SetStopQuitApp = 'app:set-stop-quit-app',
|
||||
App_SetAppDataPath = 'app:set-app-data-path',
|
||||
App_RelaunchApp = 'app:relaunch-app',
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
@ -86,6 +91,10 @@ export enum IpcChannel {
|
||||
Gemini_ListFiles = 'gemini:list-files',
|
||||
Gemini_DeleteFile = 'gemini:delete-file',
|
||||
|
||||
// VertexAI
|
||||
VertexAI_GetAuthHeaders = 'vertexai:get-auth-headers',
|
||||
VertexAI_ClearAuthCache = 'vertexai:clear-auth-cache',
|
||||
|
||||
Windows_ResetMinimumSize = 'window:reset-minimum-size',
|
||||
Windows_SetMinimumSize = 'window:set-minimum-size',
|
||||
|
||||
|
||||
9098
resources/data/agents-en.json
Normal file
9098
resources/data/agents-en.json
Normal file
File diff suppressed because one or more lines are too long
9098
resources/data/agents-zh.json
Normal file
9098
resources/data/agents-zh.json
Normal file
File diff suppressed because one or more lines are too long
@ -1,7 +1,6 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
if (isDev) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import '@main/config'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { initAppDataDir } from '@main/utils/file'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
@ -20,8 +21,8 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { setUserDataDir } from './utils/file'
|
||||
|
||||
initAppDataDir()
|
||||
Logger.initialize()
|
||||
|
||||
/**
|
||||
@ -72,9 +73,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
} else {
|
||||
// Portable dir must be setup before app ready
|
||||
setUserDataDir()
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
@ -7,7 +7,7 @@ import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@ -30,17 +30,19 @@ import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import VertexAIService from './services/VertexAIService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
@ -174,6 +176,70 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
let preventQuitListener: ((event: Electron.Event) => void) | null = null
|
||||
ipcMain.handle(IpcChannel.App_SetStopQuitApp, (_, stop: boolean = false, reason: string = '') => {
|
||||
if (stop) {
|
||||
// Only add listener if not already added
|
||||
if (!preventQuitListener) {
|
||||
preventQuitListener = (event: Electron.Event) => {
|
||||
event.preventDefault()
|
||||
notificationService.sendNotification({
|
||||
title: reason,
|
||||
message: reason
|
||||
} as Notification)
|
||||
}
|
||||
app.on('before-quit', preventQuitListener)
|
||||
}
|
||||
} else {
|
||||
// Remove listener if it exists
|
||||
if (preventQuitListener) {
|
||||
app.removeListener('before-quit', preventQuitListener)
|
||||
preventQuitListener = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Select app data path
|
||||
ipcMain.handle(IpcChannel.App_Select, async (_, options: Electron.OpenDialogOptions) => {
|
||||
try {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(options)
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return filePaths[0]
|
||||
} catch (error: any) {
|
||||
log.error('Failed to select app data path:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => {
|
||||
return hasWritePermission(filePath)
|
||||
})
|
||||
|
||||
// Set app data path
|
||||
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
|
||||
updateConfig(filePath)
|
||||
app.setPath('userData', filePath)
|
||||
})
|
||||
|
||||
// Copy user data to new location
|
||||
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => {
|
||||
try {
|
||||
await fs.promises.cp(oldPath, newPath, { recursive: true })
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
log.error('Failed to copy user data:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle(IpcChannel.App_RelaunchApp, () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
// check for update
|
||||
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
|
||||
return await appUpdater.checkForUpdates()
|
||||
@ -275,6 +341,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
// VertexAI
|
||||
ipcMain.handle(IpcChannel.VertexAI_GetAuthHeaders, async (_, params) => {
|
||||
return vertexAIService.getAuthHeaders(params)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.VertexAI_ClearAuthCache, async (_, projectId: string, clientEmail?: string) => {
|
||||
vertexAIService.clearAuthCache(projectId, clientEmail)
|
||||
})
|
||||
|
||||
// mini window
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
|
||||
|
||||
@ -61,6 +61,12 @@ export default abstract class BaseReranker {
|
||||
top_n: topN
|
||||
}
|
||||
}
|
||||
} else if (provider?.includes('tei')) {
|
||||
return {
|
||||
query,
|
||||
texts: documents,
|
||||
return_text: true
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
model: this.base.rerankModel,
|
||||
@ -80,6 +86,13 @@ export default abstract class BaseReranker {
|
||||
return data.output.results
|
||||
} else if (provider === 'voyageai') {
|
||||
return data.data
|
||||
} else if (provider === 'mis-tei') {
|
||||
return data.map((item: any) => {
|
||||
return {
|
||||
index: item.index,
|
||||
relevance_score: item.score
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return data.results
|
||||
}
|
||||
|
||||
@ -64,6 +64,30 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private async _getIpCountry() {
|
||||
try {
|
||||
// add timeout using AbortController
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const ipinfo = await fetch('https://ipinfo.io/json', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const data = await ipinfo.json()
|
||||
return data.country || 'CN'
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ipinfo:', error)
|
||||
return 'CN'
|
||||
}
|
||||
}
|
||||
|
||||
public setAutoUpdate(isActive: boolean) {
|
||||
autoUpdater.autoDownload = isActive
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
@ -82,6 +106,12 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry !== 'CN') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS)
|
||||
}
|
||||
|
||||
try {
|
||||
const update = await this.autoUpdater.checkForUpdates()
|
||||
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
|
||||
@ -9,6 +9,7 @@ import StreamZip from 'node-stream-zip'
|
||||
import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@ -253,7 +254,7 @@ class BackupManager {
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
const destPath = getDataPath()
|
||||
|
||||
const dataExists = await fs.pathExists(sourcePath)
|
||||
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
|
||||
|
||||
@ -25,12 +25,12 @@ import Embeddings from '@main/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@ -88,7 +88,7 @@ const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
}
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
|
||||
// Byte based
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isMac } from '@main/constant'
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
|
||||
|
||||
@ -6,6 +6,7 @@ import icon from '../../../build/tray_icon.png?asset'
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
@ -29,14 +30,14 @@ export class TrayService {
|
||||
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
|
||||
const tray = new Tray(iconPath)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (isWin) {
|
||||
tray.setImage(iconPath)
|
||||
} else if (process.platform === 'darwin') {
|
||||
} else if (isMac) {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
resizedImage.setTemplateImage(true)
|
||||
tray.setImage(resizedImage)
|
||||
} else if (process.platform === 'linux') {
|
||||
} else if (isLinux) {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
tray.setImage(resizedImage)
|
||||
@ -46,7 +47,7 @@ export class TrayService {
|
||||
|
||||
this.updateContextMenu()
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
if (isLinux) {
|
||||
this.tray.setContextMenu(this.contextMenu)
|
||||
}
|
||||
|
||||
@ -69,19 +70,31 @@ export class TrayService {
|
||||
|
||||
private updateContextMenu() {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale } = locale.translation
|
||||
const { tray: trayLocale, selection: selectionLocale } = locale.translation
|
||||
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
const quickAssistantEnabled = configManager.getEnableQuickAssistant()
|
||||
const selectionAssistantEnabled = configManager.getSelectionAssistantEnabled()
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: trayLocale.show_window,
|
||||
click: () => windowService.showMainWindow()
|
||||
},
|
||||
enableQuickAssistant && {
|
||||
quickAssistantEnabled && {
|
||||
label: trayLocale.show_mini_window,
|
||||
click: () => windowService.showMiniWindow()
|
||||
},
|
||||
isWin && {
|
||||
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||
// type: 'checkbox',
|
||||
// checked: selectionAssistantEnabled,
|
||||
click: () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
this.updateContextMenu()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: trayLocale.quit,
|
||||
@ -118,6 +131,10 @@ export class TrayService {
|
||||
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
}
|
||||
|
||||
private quit() {
|
||||
|
||||
142
src/main/services/VertexAIService.ts
Normal file
142
src/main/services/VertexAIService.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { GoogleAuth } from 'google-auth-library'
|
||||
|
||||
interface ServiceAccountCredentials {
|
||||
privateKey: string
|
||||
clientEmail: string
|
||||
}
|
||||
|
||||
interface VertexAIAuthParams {
|
||||
projectId: string
|
||||
serviceAccount?: ServiceAccountCredentials
|
||||
}
|
||||
|
||||
const REQUIRED_VERTEX_AI_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
|
||||
|
||||
class VertexAIService {
|
||||
private static instance: VertexAIService
|
||||
private authClients: Map<string, GoogleAuth> = new Map()
|
||||
|
||||
static getInstance(): VertexAIService {
|
||||
if (!VertexAIService.instance) {
|
||||
VertexAIService.instance = new VertexAIService()
|
||||
}
|
||||
return VertexAIService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化私钥,确保它包含正确的PEM头部和尾部
|
||||
*/
|
||||
private formatPrivateKey(privateKey: string): string {
|
||||
if (!privateKey || typeof privateKey !== 'string') {
|
||||
throw new Error('Private key must be a non-empty string')
|
||||
}
|
||||
|
||||
// 处理JSON字符串中的转义换行符
|
||||
let key = privateKey.replace(/\\n/g, '\n')
|
||||
|
||||
// 如果已经是正确格式的PEM,直接返回
|
||||
if (key.includes('-----BEGIN PRIVATE KEY-----') && key.includes('-----END PRIVATE KEY-----')) {
|
||||
return key
|
||||
}
|
||||
|
||||
// 移除所有换行符和空白字符(为了重新格式化)
|
||||
key = key.replace(/\s+/g, '')
|
||||
|
||||
// 移除可能存在的头部和尾部
|
||||
key = key.replace(/-----BEGIN[^-]*-----/g, '')
|
||||
key = key.replace(/-----END[^-]*-----/g, '')
|
||||
|
||||
// 确保私钥不为空
|
||||
if (!key) {
|
||||
throw new Error('Private key is empty after formatting')
|
||||
}
|
||||
|
||||
// 添加正确的PEM头部和尾部,并格式化为64字符一行
|
||||
const formattedKey = key.match(/.{1,64}/g)?.join('\n') || key
|
||||
|
||||
return `-----BEGIN PRIVATE KEY-----\n${formattedKey}\n-----END PRIVATE KEY-----`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证头用于 Vertex AI 请求
|
||||
*/
|
||||
async getAuthHeaders(params: VertexAIAuthParams): Promise<Record<string, string>> {
|
||||
const { projectId, serviceAccount } = params
|
||||
|
||||
if (!serviceAccount?.privateKey || !serviceAccount?.clientEmail) {
|
||||
throw new Error('Service account credentials are required')
|
||||
}
|
||||
|
||||
// 创建缓存键
|
||||
const cacheKey = `${projectId}-${serviceAccount.clientEmail}`
|
||||
|
||||
// 检查是否已有客户端实例
|
||||
let auth = this.authClients.get(cacheKey)
|
||||
|
||||
if (!auth) {
|
||||
try {
|
||||
// 格式化私钥
|
||||
const formattedPrivateKey = this.formatPrivateKey(serviceAccount.privateKey)
|
||||
|
||||
// 创建新的认证客户端
|
||||
auth = new GoogleAuth({
|
||||
credentials: {
|
||||
private_key: formattedPrivateKey,
|
||||
client_email: serviceAccount.clientEmail
|
||||
},
|
||||
projectId,
|
||||
scopes: [REQUIRED_VERTEX_AI_SCOPE]
|
||||
})
|
||||
|
||||
this.authClients.set(cacheKey, auth)
|
||||
} catch (formatError: any) {
|
||||
throw new Error(`Invalid private key format: ${formatError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取认证头
|
||||
const authHeaders = await auth.getRequestHeaders()
|
||||
|
||||
// 转换为普通对象
|
||||
const headers: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(authHeaders)) {
|
||||
if (typeof value === 'string') {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
} catch (error: any) {
|
||||
// 如果认证失败,清除缓存的客户端
|
||||
this.authClients.delete(cacheKey)
|
||||
throw new Error(`Failed to authenticate with service account: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定项目的认证缓存
|
||||
*/
|
||||
clearAuthCache(projectId: string, clientEmail?: string): void {
|
||||
if (clientEmail) {
|
||||
const cacheKey = `${projectId}-${clientEmail}`
|
||||
this.authClients.delete(cacheKey)
|
||||
} else {
|
||||
// 清理该项目的所有缓存
|
||||
for (const [key] of this.authClients) {
|
||||
if (key.startsWith(`${projectId}-`)) {
|
||||
this.authClients.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有认证缓存
|
||||
*/
|
||||
clearAllAuthCache(): void {
|
||||
this.authClients.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export default VertexAIService
|
||||
@ -2,7 +2,7 @@ import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isMac } from '@main/constant'
|
||||
import { isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileType, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
@ -23,6 +23,61 @@ function initFileTypeMap() {
|
||||
// 初始化映射表
|
||||
initFileTypeMap()
|
||||
|
||||
export function hasWritePermission(path: string) {
|
||||
try {
|
||||
fs.accessSync(path, fs.constants.W_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getAppDataPathFromConfig() {
|
||||
try {
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) {
|
||||
return config.appDataPath
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function initAppDataDir() {
|
||||
const appDataPath = getAppDataPathFromConfig()
|
||||
if (appDataPath) {
|
||||
app.setPath('userData', appDataPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPortable) {
|
||||
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfig(appDataPath: string) {
|
||||
const configDir = getConfigDir()
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
config.appDataPath = appDataPath
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
export function getFileType(ext: string): FileTypes {
|
||||
ext = ext.toLowerCase()
|
||||
return fileTypeMap.get(ext) || FileTypes.OTHER
|
||||
@ -88,12 +143,3 @@ export function getCacheDir() {
|
||||
export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
export function setUserDataDir() {
|
||||
if (!isMac) {
|
||||
const dir = path.join(path.dirname(app.getPath('exe')), 'data')
|
||||
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
||||
app.setPath('userData', dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,12 @@ const api = {
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
|
||||
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
|
||||
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
|
||||
copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath),
|
||||
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
|
||||
relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
@ -129,6 +135,13 @@ const api = {
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
||||
},
|
||||
|
||||
vertexAI: {
|
||||
getAuthHeaders: (params: { projectId: string; serviceAccount?: { privateKey: string; clientEmail: string } }) =>
|
||||
ipcRenderer.invoke(IpcChannel.VertexAI_GetAuthHeaders, params),
|
||||
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
SdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
@ -163,8 +164,8 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
return this.currentClient.getRequestTransformer()
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer()
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer(ctx)
|
||||
}
|
||||
|
||||
convertMcpToolsToSdkTools(mcpTools: MCPTool[]): SdkTool[] {
|
||||
|
||||
@ -4,6 +4,7 @@ import { AihubmixAPIClient } from './AihubmixAPIClient'
|
||||
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
import { VertexAPIClient } from './gemini/VertexAPIClient'
|
||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||
|
||||
@ -44,6 +45,9 @@ export class ApiClientFactory {
|
||||
case 'gemini':
|
||||
instance = new GeminiAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
case 'vertexai':
|
||||
instance = new VertexAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
case 'anthropic':
|
||||
instance = new AnthropicAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
|
||||
@ -42,7 +42,8 @@ import { defaultTimeout } from '@shared/config/constant'
|
||||
import Logger from 'electron-log/renderer'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { ApiClient, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
|
||||
/**
|
||||
* Abstract base class for API clients.
|
||||
@ -95,7 +96,7 @@ export abstract class BaseApiClient<
|
||||
// 在 CoreRequestToSdkParamsMiddleware中使用
|
||||
abstract getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
|
||||
// 在RawSdkChunkToGenericChunkMiddleware中使用
|
||||
abstract getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
|
||||
abstract getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
|
||||
|
||||
/**
|
||||
* 工具转换
|
||||
@ -110,7 +111,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
abstract buildSdkMessages(
|
||||
currentReqMessages: TMessageParam[],
|
||||
output: TRawOutput | string,
|
||||
output: TRawOutput | string | undefined,
|
||||
toolResults: TMessageParam[],
|
||||
toolCalls?: TToolCall[]
|
||||
): TMessageParam[]
|
||||
@ -129,17 +130,6 @@ export abstract class BaseApiClient<
|
||||
*/
|
||||
abstract extractMessagesFromSdkPayload(sdkPayload: TSdkParams): TMessageParam[]
|
||||
|
||||
/**
|
||||
* 附加原始流监听器
|
||||
*/
|
||||
public attachRawStreamListener<TListener extends RawStreamListener<TRawChunk>>(
|
||||
rawOutput: TRawOutput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_listener: TListener
|
||||
): TRawOutput {
|
||||
return rawOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用函数
|
||||
**/
|
||||
|
||||
@ -125,7 +125,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
override async getEmbeddingDimensions(): Promise<number> {
|
||||
return 0
|
||||
throw new Error("Anthropic SDK doesn't support getEmbeddingDimensions method.")
|
||||
}
|
||||
|
||||
override getTemperature(assistant: Assistant, model: Model): number | undefined {
|
||||
@ -367,12 +367,13 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
* Anthropic专用的原始流监听器
|
||||
* 处理MessageStream对象的特定事件
|
||||
*/
|
||||
override attachRawStreamListener(
|
||||
attachRawStreamListener(
|
||||
rawOutput: AnthropicSdkRawOutput,
|
||||
listener: RawStreamListener<AnthropicSdkRawChunk>
|
||||
): AnthropicSdkRawOutput {
|
||||
console.log(`[AnthropicApiClient] 附加流监听器到原始输出`)
|
||||
|
||||
// 专用的Anthropic事件处理
|
||||
const anthropicListener = listener as AnthropicStreamListener
|
||||
// 检查是否为MessageStream
|
||||
if (rawOutput instanceof MessageStream) {
|
||||
console.log(`[AnthropicApiClient] 检测到 Anthropic MessageStream,附加专用监听器`)
|
||||
@ -387,9 +388,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
})
|
||||
}
|
||||
|
||||
// 专用的Anthropic事件处理
|
||||
const anthropicListener = listener as AnthropicStreamListener
|
||||
|
||||
if (anthropicListener.onContentBlock) {
|
||||
rawOutput.on('contentBlock', anthropicListener.onContentBlock)
|
||||
}
|
||||
@ -413,6 +411,10 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return rawOutput
|
||||
}
|
||||
|
||||
if (anthropicListener.onMessage) {
|
||||
anthropicListener.onMessage(rawOutput)
|
||||
}
|
||||
|
||||
// 对于非MessageStream响应
|
||||
return rawOutput
|
||||
}
|
||||
@ -518,6 +520,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
case 'message': {
|
||||
let i = 0
|
||||
for (const content of rawChunk.content) {
|
||||
switch (content.type) {
|
||||
case 'text': {
|
||||
@ -528,7 +531,8 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'tool_use': {
|
||||
toolCalls[0] = content
|
||||
toolCalls[i] = content
|
||||
i++
|
||||
break
|
||||
}
|
||||
case 'thinking': {
|
||||
@ -550,6 +554,22 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
}
|
||||
if (i > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: Object.values(toolCalls)
|
||||
} as MCPToolCreatedChunk)
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: rawChunk.usage.input_tokens || 0,
|
||||
completion_tokens: rawChunk.usage.output_tokens || 0,
|
||||
total_tokens: (rawChunk.usage.input_tokens || 0) + (rawChunk.usage.output_tokens || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'content_block_start': {
|
||||
|
||||
@ -147,15 +147,12 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
override async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
try {
|
||||
const data = await sdk.models.embedContent({
|
||||
model: model.id,
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
|
||||
})
|
||||
return data.embeddings?.[0]?.values?.length || 0
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const data = await sdk.models.embedContent({
|
||||
model: model.id,
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
|
||||
})
|
||||
return data.embeddings?.[0]?.values?.length || 0
|
||||
}
|
||||
|
||||
override async listModels(): Promise<GeminiModel[]> {
|
||||
@ -176,12 +173,23 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
this.sdkInstance = new GoogleGenAI({
|
||||
vertexai: false,
|
||||
apiKey: this.apiKey,
|
||||
httpOptions: { baseUrl: this.getBaseURL() }
|
||||
apiVersion: this.getApiVersion(),
|
||||
httpOptions: {
|
||||
baseUrl: this.getBaseURL(),
|
||||
apiVersion: this.getApiVersion()
|
||||
}
|
||||
})
|
||||
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
protected getApiVersion(): string {
|
||||
if (this.provider.isVertex) {
|
||||
return 'v1'
|
||||
}
|
||||
return 'v1beta'
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a PDF file
|
||||
* @param file - The file
|
||||
@ -405,8 +413,9 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
const { max } = findTokenLimit(model.id) || { max: 0 }
|
||||
const budget = Math.floor(max * effortRatio)
|
||||
const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 }
|
||||
// 计算 budgetTokens,确保不低于 min
|
||||
const budget = Math.floor((max - min) * effortRatio + min)
|
||||
|
||||
return {
|
||||
thinkingConfig: {
|
||||
@ -455,7 +464,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools, assistant)
|
||||
}
|
||||
|
||||
let messageContents: Content
|
||||
let messageContents: Content = { role: 'user', parts: [] } // Initialize messageContents
|
||||
const history: Content[] = []
|
||||
// 3. 处理用户消息
|
||||
if (typeof messages === 'string') {
|
||||
@ -464,10 +473,12 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
parts: [{ text: messages }]
|
||||
}
|
||||
} else {
|
||||
const userLastMessage = messages.pop()!
|
||||
messageContents = await this.convertMessageToSdkParam(userLastMessage)
|
||||
for (const message of messages) {
|
||||
history.push(await this.convertMessageToSdkParam(message))
|
||||
const userLastMessage = messages.pop()
|
||||
if (userLastMessage) {
|
||||
messageContents = await this.convertMessageToSdkParam(userLastMessage)
|
||||
for (const message of messages) {
|
||||
history.push(await this.convertMessageToSdkParam(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -480,6 +491,10 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
if (isGemmaModel(model) && assistant.prompt) {
|
||||
const isFirstMessage = history.length === 0
|
||||
if (isFirstMessage && messageContents) {
|
||||
const userMessageText =
|
||||
messageContents.parts && messageContents.parts.length > 0
|
||||
? (messageContents.parts[0] as Part).text || ''
|
||||
: ''
|
||||
const systemMessage = [
|
||||
{
|
||||
text:
|
||||
@ -487,7 +502,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
systemInstruction +
|
||||
'<end_of_turn>\n' +
|
||||
'<start_of_turn>user\n' +
|
||||
(messageContents?.parts?.[0] as Part).text +
|
||||
userMessageText +
|
||||
'<end_of_turn>'
|
||||
}
|
||||
] as Part[]
|
||||
@ -504,13 +519,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
const newMessageContents =
|
||||
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
|
||||
? {
|
||||
...messageContents,
|
||||
parts: [
|
||||
...(messageContents.parts || []),
|
||||
...(recursiveSdkMessages[recursiveSdkMessages.length - 1].parts || [])
|
||||
]
|
||||
}
|
||||
? recursiveSdkMessages[recursiveSdkMessages.length - 1]
|
||||
: messageContents
|
||||
|
||||
const generateContentConfig: GenerateContentConfig = {
|
||||
@ -544,7 +553,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
let toolCalls: FunctionCall[] = []
|
||||
const toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
@ -572,6 +581,8 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
]
|
||||
}
|
||||
})
|
||||
} else if (part.functionCall) {
|
||||
toolCalls.push(part.functionCall)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -586,9 +597,6 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (chunk.functionCalls) {
|
||||
toolCalls = toolCalls.concat(chunk.functionCalls)
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
@ -691,12 +699,11 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
.filter((p) => p !== undefined)
|
||||
)
|
||||
|
||||
const userMessage: Content = {
|
||||
role: 'user',
|
||||
parts: parts
|
||||
const lastMessage = currentReqMessages[currentReqMessages.length - 1]
|
||||
if (lastMessage) {
|
||||
lastMessage.parts?.push(...parts)
|
||||
}
|
||||
|
||||
return [...currentReqMessages, userMessage]
|
||||
return currentReqMessages
|
||||
}
|
||||
|
||||
override estimateMessageTokens(message: GeminiSdkMessageParam): number {
|
||||
@ -723,7 +730,20 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
public extractMessagesFromSdkPayload(sdkPayload: GeminiSdkParams): GeminiSdkMessageParam[] {
|
||||
return sdkPayload.history || []
|
||||
const messageParam: GeminiSdkMessageParam = {
|
||||
role: 'user',
|
||||
parts: []
|
||||
}
|
||||
if (Array.isArray(sdkPayload.message)) {
|
||||
sdkPayload.message.forEach((part) => {
|
||||
if (typeof part === 'string') {
|
||||
messageParam.parts?.push({ text: part })
|
||||
} else if (typeof part === 'object') {
|
||||
messageParam.parts?.push(part)
|
||||
}
|
||||
})
|
||||
}
|
||||
return [messageParam, ...(sdkPayload.history || [])]
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileType): Promise<File> {
|
||||
|
||||
95
src/renderer/src/aiCore/clients/gemini/VertexAPIClient.ts
Normal file
95
src/renderer/src/aiCore/clients/gemini/VertexAPIClient.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { getVertexAILocation, getVertexAIProjectId, getVertexAIServiceAccount } from '@renderer/hooks/useVertexAI'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
import { GeminiAPIClient } from './GeminiAPIClient'
|
||||
|
||||
export class VertexAPIClient extends GeminiAPIClient {
|
||||
private authHeaders?: Record<string, string>
|
||||
private authHeadersExpiry?: number
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
}
|
||||
|
||||
override async getSdkInstance() {
|
||||
if (this.sdkInstance) {
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
const serviceAccount = getVertexAIServiceAccount()
|
||||
const projectId = getVertexAIProjectId()
|
||||
const location = getVertexAILocation()
|
||||
|
||||
if (!serviceAccount.privateKey || !serviceAccount.clientEmail || !projectId || !location) {
|
||||
throw new Error('Vertex AI settings are not configured')
|
||||
}
|
||||
|
||||
const authHeaders = await this.getServiceAccountAuthHeaders()
|
||||
|
||||
this.sdkInstance = new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project: projectId,
|
||||
location: location,
|
||||
httpOptions: {
|
||||
apiVersion: this.getApiVersion(),
|
||||
headers: authHeaders
|
||||
}
|
||||
})
|
||||
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证头,如果配置了 service account 则从主进程获取
|
||||
*/
|
||||
private async getServiceAccountAuthHeaders(): Promise<Record<string, string> | undefined> {
|
||||
const serviceAccount = getVertexAIServiceAccount()
|
||||
const projectId = getVertexAIProjectId()
|
||||
|
||||
// 检查是否配置了 service account
|
||||
if (!serviceAccount.privateKey || !serviceAccount.clientEmail || !projectId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 检查是否已有有效的认证头(提前 5 分钟过期)
|
||||
const now = Date.now()
|
||||
if (this.authHeaders && this.authHeadersExpiry && this.authHeadersExpiry - now > 5 * 60 * 1000) {
|
||||
return this.authHeaders
|
||||
}
|
||||
|
||||
try {
|
||||
// 从主进程获取认证头
|
||||
this.authHeaders = await window.api.vertexAI.getAuthHeaders({
|
||||
projectId,
|
||||
serviceAccount: {
|
||||
privateKey: serviceAccount.privateKey,
|
||||
clientEmail: serviceAccount.clientEmail
|
||||
}
|
||||
})
|
||||
|
||||
// 设置过期时间(通常认证头有效期为 1 小时)
|
||||
this.authHeadersExpiry = now + 60 * 60 * 1000
|
||||
|
||||
return this.authHeaders
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get auth headers:', error)
|
||||
throw new Error(`Service Account authentication failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理认证缓存并重新初始化
|
||||
*/
|
||||
clearAuthCache(): void {
|
||||
this.authHeaders = undefined
|
||||
this.authHeadersExpiry = undefined
|
||||
|
||||
const serviceAccount = getVertexAIServiceAccount()
|
||||
const projectId = getVertexAIProjectId()
|
||||
|
||||
if (projectId && serviceAccount.clientEmail) {
|
||||
window.api.vertexAI.clearAuthCache(projectId, serviceAccount.clientEmail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -337,10 +337,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
public buildSdkMessages(
|
||||
currentReqMessages: OpenAISdkMessageParam[],
|
||||
output: string,
|
||||
output: string | undefined,
|
||||
toolResults: OpenAISdkMessageParam[],
|
||||
toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[]
|
||||
): OpenAISdkMessageParam[] {
|
||||
if (!output && toolCalls.length === 0) {
|
||||
return [...currentReqMessages, ...toolResults]
|
||||
}
|
||||
|
||||
const assistantMessage: OpenAISdkMessageParam = {
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
@ -490,7 +494,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 在RawSdkChunkToGenericChunkMiddleware中使用
|
||||
getResponseChunkTransformer = (): ResponseChunkTransformer<OpenAISdkRawChunk> => {
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAISdkRawChunk> {
|
||||
let hasBeenCollectedWebSearch = false
|
||||
const collectWebSearchData = (
|
||||
chunk: OpenAISdkRawChunk,
|
||||
@ -584,9 +588,52 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []
|
||||
let isFinished = false
|
||||
let lastUsageInfo: any = null
|
||||
|
||||
/**
|
||||
* 统一的完成信号发送逻辑
|
||||
* - 有 finish_reason 时
|
||||
* - 无 finish_reason 但是流正常结束时
|
||||
*/
|
||||
const emitCompletionSignals = (controller: TransformStreamDefaultController<GenericChunk>) => {
|
||||
if (isFinished) return
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
|
||||
const usage = lastUsageInfo || {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: { usage }
|
||||
})
|
||||
|
||||
// 防止重复发送
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
if (chunk.usage) {
|
||||
lastUsageInfo = {
|
||||
prompt_tokens: chunk.usage.prompt_tokens || 0,
|
||||
completion_tokens: chunk.usage.completion_tokens || 0,
|
||||
total_tokens: (chunk.usage.prompt_tokens || 0) + (chunk.usage.completion_tokens || 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理chunk
|
||||
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
|
||||
const choice = chunk.choices[0]
|
||||
@ -651,12 +698,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// 处理finish_reason,发送流结束信号
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
controller.enqueue({
|
||||
@ -664,18 +705,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
llm_web_search: webSearchData
|
||||
})
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage?.prompt_tokens || 0,
|
||||
completion_tokens: chunk.usage?.completion_tokens || 0,
|
||||
total_tokens: (chunk.usage?.prompt_tokens || 0) + (chunk.usage?.completion_tokens || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 流正常结束时,检查是否需要发送完成信号
|
||||
flush(controller) {
|
||||
if (isFinished) return
|
||||
|
||||
Logger.debug('[OpenAIApiClient] Stream ended without finish_reason, emitting fallback completion signals')
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -85,16 +85,13 @@ export abstract class OpenAIBaseClient<
|
||||
|
||||
override async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
try {
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
}
|
||||
|
||||
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
|
||||
import {
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
@ -38,6 +39,7 @@ import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
import { OpenAIAPIClient } from './OpenAIApiClient'
|
||||
@ -225,17 +227,29 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return
|
||||
}
|
||||
|
||||
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
|
||||
const content: OpenAI.Responses.ResponseInput = []
|
||||
content.push(...response.output)
|
||||
return content
|
||||
}
|
||||
|
||||
public buildSdkMessages(
|
||||
currentReqMessages: OpenAIResponseSdkMessageParam[],
|
||||
output: string,
|
||||
output: OpenAI.Responses.Response | undefined,
|
||||
toolResults: OpenAIResponseSdkMessageParam[],
|
||||
toolCalls: OpenAIResponseSdkToolCall[]
|
||||
): OpenAIResponseSdkMessageParam[] {
|
||||
const assistantMessage: OpenAIResponseSdkMessageParam = {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'input_text', text: output }]
|
||||
if (!output && toolCalls.length === 0) {
|
||||
return [...currentReqMessages, ...toolResults]
|
||||
}
|
||||
const newReqMessages = [...currentReqMessages, assistantMessage, ...(toolCalls || []), ...(toolResults || [])]
|
||||
|
||||
if (!output) {
|
||||
return [...currentReqMessages, ...(toolCalls || []), ...(toolResults || [])]
|
||||
}
|
||||
|
||||
const content = this.convertResponseToMessageContent(output)
|
||||
|
||||
const newReqMessages = [...currentReqMessages, ...content, ...(toolResults || [])]
|
||||
return newReqMessages
|
||||
}
|
||||
|
||||
@ -407,13 +421,17 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
|
||||
const toolCalls: OpenAIResponseSdkToolCall[] = []
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
if ('output' in chunk) {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = chunk
|
||||
}
|
||||
for (const output of chunk.output) {
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
@ -455,6 +473,22 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
}
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
total_tokens: chunk.usage?.total_tokens || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
switch (chunk.type) {
|
||||
case 'response.output_item.added':
|
||||
@ -502,7 +536,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
if (outputItem.type === 'function_call') {
|
||||
toolCalls.push({
|
||||
...outputItem,
|
||||
arguments: chunk.arguments
|
||||
arguments: chunk.arguments,
|
||||
status: 'completed'
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -518,15 +553,26 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
})
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
hasBeenCollectedToolCalls = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'response.completed': {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = chunk.response
|
||||
}
|
||||
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
hasBeenCollectedToolCalls = true
|
||||
}
|
||||
const completion_tokens = chunk.response.usage?.output_tokens || 0
|
||||
const total_tokens = chunk.response.usage?.total_tokens || 0
|
||||
controller.enqueue({
|
||||
|
||||
@ -3,6 +3,8 @@ import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@r
|
||||
import { Provider } from '@renderer/types'
|
||||
import {
|
||||
AnthropicSdkRawChunk,
|
||||
OpenAIResponseSdkRawChunk,
|
||||
OpenAIResponseSdkRawOutput,
|
||||
OpenAISdkRawChunk,
|
||||
SdkMessageParam,
|
||||
SdkParams,
|
||||
@ -14,6 +16,7 @@ import {
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
|
||||
/**
|
||||
* 原始流监听器接口
|
||||
@ -33,6 +36,14 @@ export interface OpenAIStreamListener extends RawStreamListener<OpenAISdkRawChun
|
||||
onFinishReason?: (reason: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Response 专用的流监听器
|
||||
*/
|
||||
export interface OpenAIResponseStreamListener<TChunk extends OpenAIResponseSdkRawChunk = OpenAIResponseSdkRawChunk>
|
||||
extends RawStreamListener<TChunk> {
|
||||
onMessage?: (response: OpenAIResponseSdkRawOutput) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic 专用的流监听器
|
||||
*/
|
||||
@ -101,7 +112,7 @@ export interface ApiClient<
|
||||
// SDK相关方法
|
||||
getSdkInstance(): Promise<TSdkInstance> | TSdkInstance
|
||||
getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
|
||||
|
||||
// 原始流监听方法
|
||||
attachRawStreamListener?(rawOutput: TRawOutput, listener: RawStreamListener<TRawChunk>): TRawOutput
|
||||
|
||||
@ -11,6 +11,7 @@ import { AnthropicAPIClient } from './clients/anthropic/AnthropicAPIClient'
|
||||
import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient'
|
||||
import { CompletionsMiddlewareBuilder } from './middleware/builder'
|
||||
import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware'
|
||||
import { MIDDLEWARE_NAME as ErrorHandlerMiddlewareName } from './middleware/common/ErrorHandlerMiddleware'
|
||||
import { MIDDLEWARE_NAME as FinalChunkConsumerMiddlewareName } from './middleware/common/FinalChunkConsumerMiddleware'
|
||||
import { applyCompletionsMiddlewares } from './middleware/composer'
|
||||
import { MIDDLEWARE_NAME as McpToolChunkMiddlewareName } from './middleware/core/McpToolChunkMiddleware'
|
||||
@ -62,6 +63,7 @@ export default class AiProvider {
|
||||
builder.clear()
|
||||
builder
|
||||
.add(MiddlewareRegistry[FinalChunkConsumerMiddlewareName])
|
||||
.add(MiddlewareRegistry[ErrorHandlerMiddlewareName])
|
||||
.add(MiddlewareRegistry[AbortHandlerMiddlewareName])
|
||||
.add(MiddlewareRegistry[ImageGenerationMiddlewareName])
|
||||
} else {
|
||||
@ -74,7 +76,7 @@ export default class AiProvider {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient)) {
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
builder.remove(RawStreamListenerMiddlewareName)
|
||||
}
|
||||
if (!params.enableWebSearch) {
|
||||
@ -112,7 +114,7 @@ export default class AiProvider {
|
||||
return dimensions
|
||||
} catch (error) {
|
||||
console.error('Error getting embedding dimensions:', error)
|
||||
return 0
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Chunk } from '@renderer/types/chunk'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
|
||||
import { CompletionsResult } from '../schemas'
|
||||
import { CompletionsContext } from '../types'
|
||||
@ -26,30 +25,27 @@ export const ErrorHandlerMiddleware =
|
||||
// 尝试执行下一个中间件
|
||||
return await next(ctx, params)
|
||||
} catch (error: any) {
|
||||
let errorStream: ReadableStream<Chunk> | undefined
|
||||
// 有些sdk的abort error 是直接抛出的
|
||||
if (!isAbortError(error)) {
|
||||
// 1. 使用通用的工具函数将错误解析为标准格式
|
||||
const errorChunk = createErrorChunk(error)
|
||||
// 2. 调用从外部传入的 onError 回调
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
|
||||
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
|
||||
if (shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
|
||||
errorStream = new ReadableStream<Chunk>({
|
||||
start(controller) {
|
||||
controller.enqueue(errorChunk)
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
console.log('ErrorHandlerMiddleware_error', error)
|
||||
// 1. 使用通用的工具函数将错误解析为标准格式
|
||||
const errorChunk = createErrorChunk(error)
|
||||
// 2. 调用从外部传入的 onError 回调
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
|
||||
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
|
||||
if (shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
|
||||
const errorStream = new ReadableStream<Chunk>({
|
||||
start(controller) {
|
||||
controller.enqueue(errorChunk)
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
rawOutput: undefined,
|
||||
stream: errorStream, // 将包含错误的流传递下去
|
||||
|
||||
@ -153,7 +153,7 @@ function createToolHandlingTransform(
|
||||
if (toolResult.length > 0) {
|
||||
const output = ctx._internal.toolProcessingState?.output
|
||||
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output!, toolResult, toolCalls)
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
|
||||
await executeWithToolHandling(newParams, depth + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
@ -243,7 +243,7 @@ async function executeToolUseResponses(
|
||||
function buildParamsWithToolResults(
|
||||
ctx: CompletionsContext,
|
||||
currentParams: CompletionsParams,
|
||||
output: SdkRawOutput | string,
|
||||
output: SdkRawOutput | string | undefined,
|
||||
toolResults: SdkMessageParam[],
|
||||
toolCalls: SdkToolCall[]
|
||||
): CompletionsParams {
|
||||
|
||||
@ -15,8 +15,6 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 在这里可以监听到从SDK返回的最原始流
|
||||
if (result.rawOutput) {
|
||||
console.log(`[${MIDDLEWARE_NAME}] 检测到原始SDK输出,准备附加监听器`)
|
||||
|
||||
const providerType = ctx.apiClientInstance.provider.type
|
||||
// TODO: 后面下放到AnthropicAPIClient
|
||||
if (providerType === 'anthropic') {
|
||||
|
||||
@ -37,7 +37,7 @@ export const ResponseTransformMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
|
||||
// 获取响应转换器
|
||||
const responseChunkTransformer = apiClient.getResponseChunkTransformer?.()
|
||||
const responseChunkTransformer = apiClient.getResponseChunkTransformer(ctx)
|
||||
if (!responseChunkTransformer) {
|
||||
Logger.warn(`[${MIDDLEWARE_NAME}] No ResponseChunkTransformer available, skipping transformation`)
|
||||
return result
|
||||
|
||||
@ -25,7 +25,6 @@ export const StreamAdapterMiddleware: CompletionsMiddleware =
|
||||
// 但是这个中间件的职责是流适配,是否在这调用优待商榷
|
||||
// 调用下游中间件
|
||||
const result = await next(ctx, params)
|
||||
|
||||
if (
|
||||
result.rawOutput &&
|
||||
!(result.rawOutput instanceof ReadableStream) &&
|
||||
|
||||
@ -14,8 +14,6 @@ export const TransformCoreToSdkParamsMiddleware: CompletionsMiddleware =
|
||||
() =>
|
||||
(next) =>
|
||||
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
|
||||
Logger.debug(`🔄 [${MIDDLEWARE_NAME}] Starting core to SDK params transformation:`, ctx)
|
||||
|
||||
const internal = ctx._internal
|
||||
|
||||
// 🔧 检测递归调用:检查 params 中是否携带了预处理的 SDK 消息
|
||||
|
||||
@ -17,7 +17,6 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
|
||||
const { assistant, messages } = params
|
||||
const client = context.apiClientInstance as BaseApiClient<OpenAI>
|
||||
const signal = context._internal?.flowControl?.abortSignal
|
||||
|
||||
if (!assistant.model || !isDedicatedImageGenerationModel(assistant.model) || typeof messages === 'string') {
|
||||
return next(context, params)
|
||||
}
|
||||
|
||||
@ -1 +1,8 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Dify</title><clipPath id="lobe-icons-dify-fill"><path d="M1 0h10.286c6.627 0 12 5.373 12 12s-5.373 12-12 12H1V0z"></path></clipPath><foreignObject clip-path="url(#lobe-icons-dify-fill)" height="24" style="background:conic-gradient(from 180deg at 50% 50%, #0222C3, #8FB1F4, #FFFFFF)" width="24"></foreignObject></svg>
|
||||
<svg width="22" height="22" viewBox="13 -2 25 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="White=False">
|
||||
<g id="if">
|
||||
<path d="M21.2002 3.73454C22.5633 3.73454 23.0666 2.89917 23.0666 1.86812C23.0666 0.837081 22.5623 0.00170898 21.2002 0.00170898C19.838 0.00170898 19.3337 0.837081 19.3337 1.86812C19.3337 2.89917 19.838 3.73454 21.2002 3.73454Z" fill="#0033FF"/>
|
||||
<path d="M27.7336 4.13435V5.33473H24.6668V8.00171H27.7336V14.6687H22.6668V5.33567H15.9998V8.00265H19.7336V14.6696H15.3337V17.3366H35.3337V14.6696H30.6668V8.00265H35.3337V5.33567H30.6668V2.66869H35.3337V0.00170898H31.8671C29.5877 0.00170898 27.7336 1.8559 27.7336 4.13529V4.13435Z" fill="#0033FF"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 480 B After Width: | Height: | Size: 680 B |
1
src/renderer/src/assets/images/providers/vertexai.svg
Normal file
1
src/renderer/src/assets/images/providers/vertexai.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>VertexAI</title><path d="M11.995 20.216a1.892 1.892 0 100 3.785 1.892 1.892 0 000-3.785zm0 2.806a.927.927 0 11.927-.914.914.914 0 01-.927.914z" fill="#4285F4"></path><path clip-rule="evenodd" d="M21.687 14.144c.237.038.452.16.605.344a.978.978 0 01-.18 1.3l-8.24 6.082a1.892 1.892 0 00-1.147-1.508l8.28-6.08a.991.991 0 01.682-.138z" fill="#669DF6" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M10.122 21.842l-8.217-6.066a.952.952 0 01-.206-1.287.978.978 0 011.287-.206l8.28 6.08a1.893 1.893 0 00-1.144 1.479z" fill="#AECBFA" fill-rule="evenodd"></path><path d="M4.273 4.475a.978.978 0 01-.965-.965V1.09a.978.978 0 111.943 0v2.42a.978.978 0 01-.978.965zM4.247 13.034a.978.978 0 100-1.956.978.978 0 000 1.956zM4.247 10.19a.978.978 0 100-1.956.978.978 0 000 1.956zM4.247 7.332a.978.978 0 100-1.956.978.978 0 000 1.956z" fill="#AECBFA"></path><path d="M19.718 7.307a.978.978 0 01-.965-.979v-2.42a.965.965 0 011.93 0v2.42a.964.964 0 01-.965.979zM19.743 13.047a.978.978 0 100-1.956.978.978 0 000 1.956zM19.743 10.151a.978.978 0 100-1.956.978.978 0 000 1.956zM19.743 2.068a.978.978 0 100-1.956.978.978 0 000 1.956z" fill="#4285F4"></path><path d="M11.995 15.917a.978.978 0 01-.965-.965v-2.459a.978.978 0 011.943 0v2.433a.976.976 0 01-.978.991zM11.995 18.762a.978.978 0 100-1.956.978.978 0 000 1.956zM11.995 10.64a.978.978 0 100-1.956.978.978 0 000 1.956zM11.995 7.783a.978.978 0 100-1.956.978.978 0 000 1.956z" fill="#669DF6"></path><path d="M15.856 10.177a.978.978 0 01-.965-.965v-2.42a.977.977 0 011.702-.763.979.979 0 01.241.763v2.42a.978.978 0 01-.978.965zM15.869 4.913a.978.978 0 100-1.956.978.978 0 000 1.956zM15.869 15.853a.978.978 0 100-1.956.978.978 0 000 1.956zM15.869 12.996a.978.978 0 100-1.956.978.978 0 000 1.956z" fill="#4285F4"></path><path d="M8.121 15.853a.978.978 0 100-1.956.978.978 0 000 1.956zM8.121 7.783a.978.978 0 100-1.956.978.978 0 000 1.956zM8.121 4.913a.978.978 0 100-1.957.978.978 0 000 1.957zM8.134 12.996a.978.978 0 01-.978-.94V9.611a.965.965 0 011.93 0v2.445a.966.966 0 01-.952.94z" fill="#AECBFA"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@ -72,6 +72,11 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
placeholder={inputPlaceholder}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
styles={{
|
||||
textarea: {
|
||||
maxHeight: '80vh'
|
||||
}
|
||||
}}
|
||||
allowClear
|
||||
onKeyDown={(e) => {
|
||||
const isEnterPressed = e.keyCode === 13
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Input, Modal, Select, Spin } from 'antd'
|
||||
import { backupToWebdav } from '@renderer/services/BackupService'
|
||||
import { Input, Modal } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface WebdavModalProps {
|
||||
isModalVisible: boolean
|
||||
handleBackup: () => void
|
||||
@ -87,156 +80,3 @@ export function WebdavBackupModal({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface WebdavRestoreModalProps {
|
||||
isRestoreModalVisible: boolean
|
||||
handleRestore: () => void
|
||||
handleCancel: () => void
|
||||
restoring: boolean
|
||||
selectedFile: string | null
|
||||
setSelectedFile: (value: string | null) => void
|
||||
loadingFiles: boolean
|
||||
backupFiles: BackupFile[]
|
||||
}
|
||||
|
||||
interface UseWebdavRestoreModalProps {
|
||||
webdavHost: string | undefined
|
||||
webdavUser: string | undefined
|
||||
webdavPass: string | undefined
|
||||
webdavPath: string | undefined
|
||||
restoreMethod?: typeof restoreFromWebdav
|
||||
}
|
||||
|
||||
export function useWebdavRestoreModal({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath,
|
||||
restoreMethod
|
||||
}: UseWebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = useState(false)
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
|
||||
const showRestoreModal = useCallback(async () => {
|
||||
if (!webdavHost) {
|
||||
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsRestoreModalVisible(true)
|
||||
setLoadingFiles(true)
|
||||
try {
|
||||
const files = await window.api.backup.listWebdavFiles({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
})
|
||||
setBackupFiles(files)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'list-files-error' })
|
||||
} finally {
|
||||
setLoadingFiles(false)
|
||||
}
|
||||
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!selectedFile || !webdavHost) {
|
||||
window.message.error({
|
||||
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
|
||||
key: 'restore-error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.webdav.restore.confirm.title'),
|
||||
content: t('settings.data.webdav.restore.confirm.content'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod ?? restoreFromWebdav)(selectedFile)
|
||||
setIsRestoreModalVisible(false)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'restore-error' })
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [selectedFile, webdavHost, t, restoreMethod])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRestoreModalVisible(false)
|
||||
}
|
||||
|
||||
return {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
}
|
||||
}
|
||||
|
||||
export function WebdavRestoreModal({
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles
|
||||
}: WebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.webdav.restore.modal.title')}
|
||||
open={isRestoreModalVisible}
|
||||
onOk={handleRestore}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{ loading: restoring }}
|
||||
width={600}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
|
||||
value={selectedFile}
|
||||
onChange={setSelectedFile}
|
||||
options={backupFiles.map(formatFileOption)}
|
||||
loading={loadingFiles}
|
||||
showSearch
|
||||
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
{loadingFiles && (
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileOption(file: BackupFile) {
|
||||
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
|
||||
const size = formatFileSize(file.size)
|
||||
return {
|
||||
label: `${file.fileName} (${date}, ${size})`,
|
||||
value: file.fileName
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,7 +184,7 @@ const visionAllowedModels = [
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest',
|
||||
'gemma-3(?:-[\\w-]+)',
|
||||
'doubao-1.6-seed(?:-[\\w-]+)'
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)'
|
||||
]
|
||||
|
||||
const visionExcludedModels = [
|
||||
@ -238,7 +238,8 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'glm-4(?:-[\\w-]+)?',
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||
'grok-3(?:-[\\w-]+)?'
|
||||
'grok-3(?:-[\\w-]+)?',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
@ -520,41 +521,65 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
}
|
||||
],
|
||||
aihubmix: [
|
||||
{
|
||||
id: 'o3',
|
||||
provider: 'aihubmix',
|
||||
name: 'o3',
|
||||
group: 'gpt'
|
||||
},
|
||||
{
|
||||
id: 'o4-mini',
|
||||
provider: 'aihubmix',
|
||||
name: 'o4-mini',
|
||||
group: 'gpt'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
provider: 'aihubmix',
|
||||
name: 'gpt-4.1',
|
||||
group: 'gpt'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
provider: 'aihubmix',
|
||||
name: 'GPT-4o',
|
||||
group: 'GPT-4o'
|
||||
name: 'gpt-4o',
|
||||
group: 'gpt'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-sonnet-latest',
|
||||
id: 'gpt-image-1',
|
||||
provider: 'aihubmix',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
group: 'Claude 3.5'
|
||||
name: 'gpt-image-1',
|
||||
group: 'gpt'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash-exp-search',
|
||||
id: 'DeepSeek-V3',
|
||||
provider: 'aihubmix',
|
||||
name: 'Gemini 2.0 Flash Exp Search',
|
||||
group: 'Gemini 2.0'
|
||||
name: 'DeepSeek-V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-chat',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
provider: 'aihubmix',
|
||||
name: 'DeepSeek Chat',
|
||||
group: 'DeepSeek Chat'
|
||||
name: 'claude-sonnet-4-20250514',
|
||||
group: 'claude'
|
||||
},
|
||||
{
|
||||
id: 'aihubmix-Llama-3-3-70B-Instruct',
|
||||
id: 'gemini-2.5-pro-preview-05-06',
|
||||
provider: 'aihubmix',
|
||||
name: 'Llama-3.3-70b',
|
||||
group: 'Llama 3.3'
|
||||
name: 'gemini-2.5-pro-preview-05-06',
|
||||
group: 'gemini'
|
||||
},
|
||||
{
|
||||
id: 'Qwen/QVQ-72B-Preview',
|
||||
id: 'gemini-2.5-flash-preview-05-20-nothink',
|
||||
provider: 'aihubmix',
|
||||
name: 'Qwen/QVQ-72B',
|
||||
group: 'Qwen'
|
||||
name: 'gemini-2.5-flash-preview-05-20-nothink',
|
||||
group: 'gemini'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
provider: 'aihubmix',
|
||||
name: 'gemini-2.5-flash',
|
||||
group: 'gemini'
|
||||
}
|
||||
],
|
||||
|
||||
@ -2185,71 +2210,77 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
|
||||
export const TEXT_TO_IMAGES_MODELS = [
|
||||
{
|
||||
id: 'black-forest-labs/FLUX.1-schnell',
|
||||
id: 'Kwai-Kolors/Kolors',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1 Schnell',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'black-forest-labs/FLUX.1-dev',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1 Dev',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'black-forest-labs/FLUX.1-pro',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1 Pro',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'Pro/black-forest-labs/FLUX.1-schnell',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1 Schnell Pro',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'LoRA/black-forest-labs/FLUX.1-dev',
|
||||
provider: 'silicon',
|
||||
name: 'FLUX.1 Dev LoRA',
|
||||
group: 'FLUX'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-ai/Janus-Pro-7B',
|
||||
provider: 'silicon',
|
||||
name: 'Janus-Pro-7B',
|
||||
group: 'deepseek-ai'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-3-5-large',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 3.5 Large',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-3-5-large-turbo',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 3.5 Large Turbo',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-3-medium',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 3 Medium',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-2-1',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion 2.1',
|
||||
group: 'Stable Diffusion'
|
||||
},
|
||||
{
|
||||
id: 'stabilityai/stable-diffusion-xl-base-1.0',
|
||||
provider: 'silicon',
|
||||
name: 'Stable Diffusion XL Base 1.0',
|
||||
group: 'Stable Diffusion'
|
||||
name: 'Kolors',
|
||||
group: 'Kwai-Kolors'
|
||||
}
|
||||
// {
|
||||
// id: 'black-forest-labs/FLUX.1-schnell',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Schnell',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'black-forest-labs/FLUX.1-dev',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Dev',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'black-forest-labs/FLUX.1-pro',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Pro',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'Pro/black-forest-labs/FLUX.1-schnell',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Schnell Pro',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'LoRA/black-forest-labs/FLUX.1-dev',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Dev LoRA',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'deepseek-ai/Janus-Pro-7B',
|
||||
// provider: 'silicon',
|
||||
// name: 'Janus-Pro-7B',
|
||||
// group: 'deepseek-ai'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-3-5-large',
|
||||
// provider: 'silicon',
|
||||
// name: 'Stable Diffusion 3.5 Large',
|
||||
// group: 'Stable Diffusion'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-3-5-large-turbo',
|
||||
// provider: 'silicon',
|
||||
// name: 'Stable Diffusion 3.5 Large Turbo',
|
||||
// group: 'Stable Diffusion'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-3-medium',
|
||||
// provider: 'silicon',
|
||||
// name: 'Stable Diffusion 3 Medium',
|
||||
// group: 'Stable Diffusion'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-2-1',
|
||||
// provider: 'silicon',
|
||||
// name: 'Stable Diffusion 2.1',
|
||||
// group: 'Stable Diffusion'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-xl-base-1.0',
|
||||
// provider: 'silicon',
|
||||
// name: 'Stable Diffusion XL Base 1.0',
|
||||
// group: 'Stable Diffusion'
|
||||
// }
|
||||
]
|
||||
|
||||
export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
@ -2258,6 +2289,8 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
]
|
||||
|
||||
export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@ -2277,21 +2310,7 @@ export const GENERATE_IMAGE_MODELS = [
|
||||
...SUPPORTED_DISABLE_GENERATION_MODELS
|
||||
]
|
||||
|
||||
export const GEMINI_SEARCH_MODELS = [
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-pro-exp-02-05',
|
||||
'gemini-2.0-pro-exp',
|
||||
'gemini-2.5-pro-exp',
|
||||
'gemini-2.5-pro-exp-03-25',
|
||||
'gemini-2.5-pro-preview',
|
||||
'gemini-2.5-pro-preview-03-25',
|
||||
'gemini-2.5-pro-preview-05-06',
|
||||
'gemini-2.5-flash-preview',
|
||||
'gemini-2.5-flash-preview-04-17'
|
||||
]
|
||||
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
||||
|
||||
export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini']
|
||||
|
||||
@ -2335,7 +2354,7 @@ export function isVisionModel(model: Model): boolean {
|
||||
// }
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return VISION_REGEX.test(model.name) || model.type?.includes('vision') || false
|
||||
return VISION_REGEX.test(model.name) || VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
}
|
||||
|
||||
return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
@ -2624,13 +2643,13 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (GEMINI_SEARCH_MODELS.includes(baseName) || isOpenAIWebSearchModel(model)) {
|
||||
if (GEMINI_SEARCH_REGEX.test(baseName) || isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.id === 'gemini' || provider?.type === 'gemini') {
|
||||
return GEMINI_SEARCH_MODELS.includes(baseName)
|
||||
return GEMINI_SEARCH_REGEX.test(baseName)
|
||||
}
|
||||
|
||||
if (provider.id === 'hunyuan') {
|
||||
@ -2807,6 +2826,7 @@ export function groupQwenModels(models: Model[]): Record<string, Model[]> {
|
||||
|
||||
export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
|
||||
// Gemini models
|
||||
'gemini-2\\.5-flash-lite.*$': { min: 512, max: 24576 },
|
||||
'gemini-.*-flash.*$': { min: 0, max: 24576 },
|
||||
'gemini-.*-pro.*$': { min: 128, max: 32768 },
|
||||
|
||||
@ -2833,10 +2853,10 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
|
||||
|
||||
// Doubao 支持思考模式的模型正则
|
||||
export const DOUBAO_THINKING_MODEL_REGEX =
|
||||
/doubao-(?:1(\.|-5)-thinking-vision-pro|1(\.|-)5-thinking-pro-m|seed-1\.6|seed-1\.6-flash)(?:-[\\w-]+)?/i
|
||||
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-[\w-]+)?/i
|
||||
|
||||
// 支持 auto 的 Doubao 模型
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(?:1-5-thinking-pro-m|seed-1.6)(?:-[\\w-]+)?/i
|
||||
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i
|
||||
|
||||
export function isDoubaoThinkingAutoModel(model: Model): boolean {
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id)
|
||||
|
||||
@ -42,6 +42,7 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
|
||||
import VertexAIProviderLogo from '@renderer/assets/images/providers/vertexai.svg'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
|
||||
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
|
||||
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
|
||||
@ -100,7 +101,8 @@ const PROVIDER_LOGO_MAP = {
|
||||
qiniu: QiniuProviderLogo,
|
||||
tokenflux: TokenFluxProviderLogo,
|
||||
cephalon: CephalonProviderLogo,
|
||||
lanyun: LanyunProviderLogo
|
||||
lanyun: LanyunProviderLogo,
|
||||
vertexai: VertexAIProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@ -651,5 +653,16 @@ export const PROVIDER_CONFIG = {
|
||||
docs: 'https://archive.lanyun.net/maas/doc/',
|
||||
models: 'https://maas.lanyun.net/api/#/model/modelSquare'
|
||||
}
|
||||
},
|
||||
vertexai: {
|
||||
api: {
|
||||
url: 'https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/overview'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cloud.google.com/vertex-ai',
|
||||
apiKey: 'https://console.cloud.google.com/apis/credentials',
|
||||
docs: 'https://cloud.google.com/vertex-ai/generative-ai/docs',
|
||||
models: 'https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
updateAssistantSettings,
|
||||
updateDefaultAssistant
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { selectTopicsForAssistant, topicsActions } from '@renderer/store/topics'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
@ -116,17 +116,15 @@ export function useDefaultAssistant() {
|
||||
}
|
||||
|
||||
export function useDefaultModel() {
|
||||
const { defaultModel, topicNamingModel, translateModel, quickAssistantModel } = useAppSelector((state) => state.llm)
|
||||
const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
defaultModel,
|
||||
topicNamingModel,
|
||||
translateModel,
|
||||
quickAssistantModel,
|
||||
setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })),
|
||||
setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })),
|
||||
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })),
|
||||
setQuickAssistantModel: (model: Model) => dispatch(setQuickAssistantModel({ model }))
|
||||
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model }))
|
||||
}
|
||||
}
|
||||
|
||||
37
src/renderer/src/hooks/useVertexAI.ts
Normal file
37
src/renderer/src/hooks/useVertexAI.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setVertexAILocation,
|
||||
setVertexAIProjectId,
|
||||
setVertexAIServiceAccountClientEmail,
|
||||
setVertexAIServiceAccountPrivateKey
|
||||
} from '@renderer/store/llm'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
export function useVertexAISettings() {
|
||||
const settings = useAppSelector((state) => state.llm.settings.vertexai)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return {
|
||||
...settings,
|
||||
setProjectId: (projectId: string) => dispatch(setVertexAIProjectId(projectId)),
|
||||
setLocation: (location: string) => dispatch(setVertexAILocation(location)),
|
||||
setServiceAccountPrivateKey: (privateKey: string) => dispatch(setVertexAIServiceAccountPrivateKey(privateKey)),
|
||||
setServiceAccountClientEmail: (clientEmail: string) => dispatch(setVertexAIServiceAccountClientEmail(clientEmail))
|
||||
}
|
||||
}
|
||||
|
||||
export function getVertexAISettings() {
|
||||
return store.getState().llm.settings.vertexai
|
||||
}
|
||||
|
||||
export function getVertexAILocation() {
|
||||
return store.getState().llm.settings.vertexai.location
|
||||
}
|
||||
|
||||
export function getVertexAIProjectId() {
|
||||
return store.getState().llm.settings.vertexai.projectId
|
||||
}
|
||||
|
||||
export function getVertexAIServiceAccount() {
|
||||
return store.getState().llm.settings.vertexai.serviceAccount
|
||||
}
|
||||
@ -195,7 +195,7 @@
|
||||
"input.new.context": "Clear Context {{Command}}",
|
||||
"input.new_topic": "New Topic {{Command}}",
|
||||
"input.pause": "Pause",
|
||||
"input.placeholder": "Type your message here...",
|
||||
"input.placeholder": "Type your message here, press {{key}} to send...",
|
||||
"input.send": "Send",
|
||||
"input.settings": "Settings",
|
||||
"input.topics": " Topics ",
|
||||
@ -771,7 +771,8 @@
|
||||
"backspace_clear": "Backspace to clear",
|
||||
"esc": "ESC to {{action}}",
|
||||
"esc_back": "return",
|
||||
"esc_close": "close"
|
||||
"esc_close": "close",
|
||||
"esc_pause": "pause"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@ -802,6 +803,18 @@
|
||||
"string": "Text"
|
||||
},
|
||||
"pinned": "Pinned",
|
||||
"price": {
|
||||
"cost": "Cost",
|
||||
"currency": "Currency",
|
||||
"custom": "Custom",
|
||||
"custom_currency": "Custom Currency",
|
||||
"custom_currency_placeholder": "Enter Custom Currency",
|
||||
"input": "Input Price",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "Output Price",
|
||||
"price": "Price"
|
||||
},
|
||||
"reasoning": "Reasoning",
|
||||
"rerank_model": "Reranker",
|
||||
"rerank_model_support_provider": "Currently, the reranker model only supports some providers ({{provider}})",
|
||||
"rerank_model_not_support_provider": "Currently, the reranker model does not support this provider ({{provider}})",
|
||||
@ -1036,7 +1049,8 @@
|
||||
"qiniu": "Qiniu AI",
|
||||
"tokenflux": "TokenFlux",
|
||||
"302ai": "302.AI",
|
||||
"lanyun": "LANYUN"
|
||||
"lanyun": "LANYUN",
|
||||
"vertexai": "Vertex AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Are you sure you want to restore data?",
|
||||
@ -1087,6 +1101,24 @@
|
||||
"assistant.title": "Default Assistant",
|
||||
"data": {
|
||||
"app_data": "App Data",
|
||||
"app_data.select": "Modify Directory",
|
||||
"app_data.select_title": "Change App Data Directory",
|
||||
"app_data.restart_notice": "The app will need to restart to apply the changes",
|
||||
"app_data.copy_data_option": "Copy data from original directory to new directory",
|
||||
"app_data.copy_time_notice": "Copying data may take a while, do not force quit app",
|
||||
"app_data.path_changed_without_copy": "Path changed successfully, but data not copied",
|
||||
"app_data.copying_warning": "Data copying, do not force quit app",
|
||||
"app_data.copying": "Copying data to new location...",
|
||||
"app_data.copy_success": "Successfully copied data to new location",
|
||||
"app_data.copy_failed": "Failed to copy data",
|
||||
"app_data.select_success": "Data directory changed, the app will restart to apply changes",
|
||||
"app_data.select_error": "Failed to change data directory",
|
||||
"app_data.migration_title": "Data Migration",
|
||||
"app_data.original_path": "Original Path",
|
||||
"app_data.new_path": "New Path",
|
||||
"app_data.select_error_root_path": "New path cannot be the root path",
|
||||
"app_data.select_error_write_permission": "New path does not have write permission",
|
||||
"app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited",
|
||||
"app_knowledge": "Knowledge Base Files",
|
||||
"app_knowledge.button.delete": "Delete File",
|
||||
"app_knowledge.remove_all": "Remove Knowledge Base Files",
|
||||
@ -1119,7 +1151,8 @@
|
||||
"obsidian": "Export to Obsidian",
|
||||
"siyuan": "Export to SiYuan Note",
|
||||
"joplin": "Export to Joplin",
|
||||
"docx": "Export as Word"
|
||||
"docx": "Export as Word",
|
||||
"plain_text": "Copy as Plain Text"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@ -1210,8 +1243,6 @@
|
||||
"restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?",
|
||||
"restore.confirm.title": "Confirm Restore",
|
||||
"restore.content": "Restore from WebDAV will overwrite the current data, continue?",
|
||||
"restore.modal.select.placeholder": "Please select a backup file to restore",
|
||||
"restore.modal.title": "Restore from WebDAV",
|
||||
"restore.title": "Restore from WebDAV",
|
||||
"syncError": "Backup Error",
|
||||
"syncStatus": "Backup Status",
|
||||
@ -1608,6 +1639,10 @@
|
||||
"models.translate_model_prompt_title": "Translate Model Prompt",
|
||||
"models.quick_assistant_model": "Quick Assistant Model",
|
||||
"models.quick_assistant_model_description": "Default model used by Quick Assistant",
|
||||
"models.quick_assistant_selection": "Select Assistant",
|
||||
"models.quick_assistant_default_tag": "Default",
|
||||
"models.use_model": "Default Model",
|
||||
"models.use_assistant": "Use Assistant",
|
||||
"moresetting": "More Settings",
|
||||
"moresetting.check.confirm": "Confirm Selection",
|
||||
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",
|
||||
@ -1693,6 +1728,27 @@
|
||||
"title": "Model Notes",
|
||||
"placeholder": "Enter Markdown content...",
|
||||
"markdown_editor_default_value": "Preview area"
|
||||
},
|
||||
"vertex_ai": {
|
||||
"project_id": "Project ID",
|
||||
"project_id_placeholder": "your-google-cloud-project-id",
|
||||
"project_id_help": "Your Google Cloud project ID",
|
||||
"location": "Location",
|
||||
"location_help": "Vertex AI service location, e.g., us-central1",
|
||||
"service_account": {
|
||||
"title": "Service Account Configuration",
|
||||
"private_key": "Private Key",
|
||||
"private_key_placeholder": "Enter Service Account private key",
|
||||
"private_key_help": "The private_key field from the JSON key file downloaded from Google Cloud Console",
|
||||
"client_email": "Client Email",
|
||||
"client_email_placeholder": "Enter Service Account client email",
|
||||
"client_email_help": "The client_email field from the JSON key file downloaded from Google Cloud Console",
|
||||
"description": "Use Service Account for authentication, suitable for environments where ADC is not available",
|
||||
"auth_success": "Service Account authenticated successfully",
|
||||
"incomplete_config": "Please complete Service Account configuration first"
|
||||
},
|
||||
"documentation": "View official documentation for more configuration details:",
|
||||
"learn_more": "Learn More"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@ -1874,7 +1930,8 @@
|
||||
"model_desc": "Model used for translation service",
|
||||
"bidirectional": "Bidirectional Translation Settings",
|
||||
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
|
||||
"scroll_sync": "Scroll Sync Settings"
|
||||
"scroll_sync": "Scroll Sync Settings",
|
||||
"preview": "Markdown Preview"
|
||||
},
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline",
|
||||
|
||||
@ -195,7 +195,7 @@
|
||||
"input.new.context": "コンテキストをクリア {{Command}}",
|
||||
"input.new_topic": "新しいトピック {{Command}}",
|
||||
"input.pause": "一時停止",
|
||||
"input.placeholder": "ここにメッセージを入力...",
|
||||
"input.placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"input.send": "送信",
|
||||
"input.settings": "設定",
|
||||
"input.topics": " トピック ",
|
||||
@ -768,10 +768,11 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "C キーを押してコピー",
|
||||
"backspace_clear": "バックスペースを押してクリアします",
|
||||
"esc": "ESC キーを押して{{action}}",
|
||||
"esc_back": "戻る",
|
||||
"esc_close": "ウィンドウを閉じる",
|
||||
"backspace_clear": "バックスペースを押してクリアします"
|
||||
"esc_pause": "一時停止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@ -819,7 +820,19 @@
|
||||
"vision": "画像",
|
||||
"websearch": "ウェブ検索"
|
||||
},
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。"
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。",
|
||||
"price": {
|
||||
"cost": "コスト",
|
||||
"currency": "通貨",
|
||||
"custom": "カスタム",
|
||||
"custom_currency": "カスタム通貨",
|
||||
"custom_currency_placeholder": "カスタム通貨を入力してください",
|
||||
"input": "入力価格",
|
||||
"million_tokens": "百万トークン",
|
||||
"output": "出力価格",
|
||||
"price": "価格"
|
||||
},
|
||||
"reasoning": "思考"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "ダイアログを展開",
|
||||
@ -1036,7 +1049,8 @@
|
||||
"tokenflux": "TokenFlux",
|
||||
"302ai": "302.AI",
|
||||
"cephalon": "Cephalon",
|
||||
"lanyun": "LANYUN"
|
||||
"lanyun": "LANYUN",
|
||||
"vertexai": "Vertex AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "データを復元しますか?",
|
||||
@ -1085,7 +1099,25 @@
|
||||
"assistant.title": "デフォルトアシスタント",
|
||||
"data": {
|
||||
"app_data": "アプリデータ",
|
||||
"app_knowledge": "ナレッジベースファイル",
|
||||
"app_data.select": "ディレクトリを変更",
|
||||
"app_data.select_title": "アプリデータディレクトリの変更",
|
||||
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
|
||||
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
|
||||
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
|
||||
"app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません",
|
||||
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください",
|
||||
"app_data.copying": "新しい場所にデータをコピーしています...",
|
||||
"app_data.copy_success": "データを新しい場所に正常にコピーしました",
|
||||
"app_data.copy_failed": "データのコピーに失敗しました",
|
||||
"app_data.select_success": "データディレクトリが変更されました。変更を適用するためにアプリが再起動します",
|
||||
"app_data.select_error": "データディレクトリの変更に失敗しました",
|
||||
"app_data.migration_title": "データ移行",
|
||||
"app_data.original_path": "元のパス",
|
||||
"app_data.new_path": "新しいパス",
|
||||
"app_data.select_error_root_path": "新しいパスはルートパスにできません",
|
||||
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
|
||||
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません",
|
||||
"app_knowledge": "知識ベースファイル",
|
||||
"app_knowledge.button.delete": "ファイルを削除",
|
||||
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
|
||||
"app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?",
|
||||
@ -1117,7 +1149,8 @@
|
||||
"obsidian": "Obsidianにエクスポート",
|
||||
"siyuan": "思源ノートにエクスポート",
|
||||
"joplin": "Joplinにエクスポート",
|
||||
"docx": "Wordとしてエクスポート"
|
||||
"docx": "Wordとしてエクスポート",
|
||||
"plain_text": "プレーンテキストとしてコピー"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@ -1190,8 +1223,6 @@
|
||||
"restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.confirm.title": "復元を確認",
|
||||
"restore.content": "WebDAVから復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
|
||||
"restore.modal.title": "WebDAV から復元",
|
||||
"restore.title": "WebDAVから復元",
|
||||
"syncError": "バックアップエラー",
|
||||
"syncStatus": "バックアップ状態",
|
||||
@ -1602,6 +1633,10 @@
|
||||
"models.translate_model_prompt_title": "翻訳モデルのプロンプト",
|
||||
"models.quick_assistant_model": "クイックアシスタントモデル",
|
||||
"models.quick_assistant_model_description": "クイックアシスタントで使用されるデフォルトモデル",
|
||||
"models.quick_assistant_selection": "アシスタントを選択します",
|
||||
"models.quick_assistant_default_tag": "デフォルト",
|
||||
"models.use_model": "デフォルトモデル",
|
||||
"models.use_assistant": "アシスタントの活用",
|
||||
"moresetting": "詳細設定",
|
||||
"moresetting.check.confirm": "選択を確認",
|
||||
"moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
|
||||
@ -1681,6 +1716,27 @@
|
||||
},
|
||||
"openai": {
|
||||
"alert": "OpenAIプロバイダーは旧式の呼び出し方法をサポートしなくなりました。サードパーティのAPIを使用している場合は、新しいサービスプロバイダーを作成してください。"
|
||||
},
|
||||
"vertex_ai": {
|
||||
"project_id": "プロジェクトID",
|
||||
"project_id_placeholder": "your-google-cloud-project-id",
|
||||
"project_id_help": "Google CloudプロジェクトID",
|
||||
"location": "場所",
|
||||
"location_help": "Vertex AIサービスの場所、例:us-central1",
|
||||
"service_account": {
|
||||
"title": "サービスアカウント設定",
|
||||
"private_key": "秘密鍵",
|
||||
"private_key_placeholder": "サービスアカウントの秘密鍵を入力してください",
|
||||
"private_key_help": "Google Cloud ConsoleからダウンロードしたJSONキーファイルのprivate_keyフィールド",
|
||||
"client_email": "クライアントメール",
|
||||
"client_email_placeholder": "サービスアカウントのクライアントメールを入力してください",
|
||||
"client_email_help": "Google Cloud ConsoleからダウンロードしたJSONキーファイルのclient_emailフィールド",
|
||||
"description": "ADCが利用できない環境での認証に適しています",
|
||||
"auth_success": "サービスアカウントの認証が成功しました",
|
||||
"incomplete_config": "まずサービスアカウントの設定を完了してください"
|
||||
},
|
||||
"documentation": "詳細な設定については、公式ドキュメントを参照してください:",
|
||||
"learn_more": "詳細を確認"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@ -1873,7 +1929,8 @@
|
||||
"model_desc": "翻訳サービスで使用されるモデル",
|
||||
"bidirectional": "双方向翻訳設定",
|
||||
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||
"scroll_sync": "スクロール同期設定"
|
||||
"scroll_sync": "スクロール同期設定",
|
||||
"preview": "Markdown プレビュー"
|
||||
},
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行",
|
||||
|
||||
@ -195,7 +195,7 @@
|
||||
"input.new.context": "Очистить контекст {{Command}}",
|
||||
"input.new_topic": "Новый топик {{Command}}",
|
||||
"input.pause": "Остановить",
|
||||
"input.placeholder": "Введите ваше сообщение здесь...",
|
||||
"input.placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
|
||||
"input.send": "Отправить",
|
||||
"input.settings": "Настройки",
|
||||
"input.topics": " Топики ",
|
||||
@ -768,10 +768,11 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "Нажмите C для копирования",
|
||||
"backspace_clear": "Нажмите Backspace, чтобы очистить",
|
||||
"esc": "Нажмите ESC {{action}}",
|
||||
"esc_back": "возвращения",
|
||||
"esc_close": "закрытия окна",
|
||||
"backspace_clear": "Нажмите Backspace, чтобы очистить"
|
||||
"esc_pause": "пауза"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@ -819,7 +820,19 @@
|
||||
"vision": "Визуальные",
|
||||
"websearch": "Веб-поисковые"
|
||||
},
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})"
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})",
|
||||
"price": {
|
||||
"cost": "Стоимость",
|
||||
"currency": "Валюта",
|
||||
"custom": "Пользовательский",
|
||||
"custom_currency": "Пользовательская валюта",
|
||||
"custom_currency_placeholder": "Введите пользовательскую валюту",
|
||||
"input": "Цена ввода",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "Цена вывода",
|
||||
"price": "Цена"
|
||||
},
|
||||
"reasoning": "Рассуждение"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "Развернуть диалоговое окно",
|
||||
@ -865,9 +878,8 @@
|
||||
"rendering_speed": "Скорость рендеринга",
|
||||
"learn_more": "Узнать больше",
|
||||
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"prompt_placeholder_en": "Введите” английский “описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"paint_course": "Руководство / Учебник",
|
||||
"proxy_required": "Открыть прокси и включить “TUN режим” для просмотра сгенерированных изображений или скопировать их в браузер для открытия. В будущем будет поддерживаться прямое соединение",
|
||||
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
|
||||
"image_file_required": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_file_retry": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_placeholder": "Изображение недоступно",
|
||||
@ -978,7 +990,8 @@
|
||||
"per_image": "за изображение",
|
||||
"per_images": "за изображения",
|
||||
"required_field": "Обязательное поле",
|
||||
"uploaded_input": "Загруженный ввод"
|
||||
"uploaded_input": "Загруженный ввод",
|
||||
"prompt_placeholder_en": "[to be translated]:Enter your image description, currently Imagen only supports English prompts"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
@ -1036,7 +1049,8 @@
|
||||
"qiniu": "Qiniu AI",
|
||||
"tokenflux": "TokenFlux",
|
||||
"302ai": "302.AI",
|
||||
"lanyun": "LANYUN"
|
||||
"lanyun": "LANYUN",
|
||||
"vertexai": "Vertex AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Вы уверены, что хотите восстановить данные?",
|
||||
@ -1085,7 +1099,25 @@
|
||||
"assistant.title": "Ассистент по умолчанию",
|
||||
"data": {
|
||||
"app_data": "Данные приложения",
|
||||
"app_knowledge": "База знаний",
|
||||
"app_data.select": "Изменить директорию",
|
||||
"app_data.select_title": "Изменить директорию данных приложения",
|
||||
"app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения",
|
||||
"app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию",
|
||||
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
|
||||
"app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы",
|
||||
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение",
|
||||
"app_data.copying": "Копирование данных в новое место...",
|
||||
"app_data.copy_success": "Данные успешно скопированы в новое место",
|
||||
"app_data.copy_failed": "Не удалось скопировать данные",
|
||||
"app_data.select_success": "Директория данных изменена, приложение будет перезапущено для применения изменений",
|
||||
"app_data.select_error": "Не удалось изменить директорию данных",
|
||||
"app_data.migration_title": "Миграция данных",
|
||||
"app_data.original_path": "Исходный путь",
|
||||
"app_data.new_path": "Новый путь",
|
||||
"app_data.select_error_root_path": "Новый путь не может быть корневым",
|
||||
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
|
||||
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто",
|
||||
"app_knowledge": "Файлы базы знаний",
|
||||
"app_knowledge.button.delete": "Удалить файл",
|
||||
"app_knowledge.remove_all": "Удалить файлы базы знаний",
|
||||
"app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?",
|
||||
@ -1117,7 +1149,8 @@
|
||||
"obsidian": "Экспорт в Obsidian",
|
||||
"siyuan": "Экспорт в SiYuan Note",
|
||||
"joplin": "Экспорт в Joplin",
|
||||
"docx": "Экспорт в Word"
|
||||
"docx": "Экспорт в Word",
|
||||
"plain_text": "Копировать как чистый текст"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@ -1208,8 +1241,6 @@
|
||||
"restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.confirm.title": "Подтверждение восстановления",
|
||||
"restore.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления",
|
||||
"restore.modal.title": "Восстановление с WebDAV",
|
||||
"restore.title": "Восстановление с WebDAV",
|
||||
"syncError": "Ошибка резервного копирования",
|
||||
"syncStatus": "Статус резервного копирования",
|
||||
@ -1602,6 +1633,10 @@
|
||||
"models.translate_model_prompt_title": "Модель перевода",
|
||||
"models.quick_assistant_model": "Модель быстрого помощника",
|
||||
"models.quick_assistant_model_description": "Модель по умолчанию, используемая быстрым помощником",
|
||||
"models.quick_assistant_selection": "Выберите помощника",
|
||||
"models.quick_assistant_default_tag": "умолчанию",
|
||||
"models.use_model": "модель по умолчанию",
|
||||
"models.use_assistant": "Использование ассистентов",
|
||||
"moresetting": "Дополнительные настройки",
|
||||
"moresetting.check.confirm": "Подтвердить выбор",
|
||||
"moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
|
||||
@ -1681,6 +1716,27 @@
|
||||
},
|
||||
"openai": {
|
||||
"alert": "Поставщик OpenAI больше не поддерживает старые методы вызова. Если вы используете сторонний API, создайте нового поставщика услуг."
|
||||
},
|
||||
"vertex_ai": {
|
||||
"project_id": "ID проекта",
|
||||
"project_id_placeholder": "your-google-cloud-project-id",
|
||||
"project_id_help": "Ваш ID проекта Google Cloud",
|
||||
"location": "Местоположение",
|
||||
"location_help": "Местоположение службы Vertex AI, например, us-central1",
|
||||
"service_account": {
|
||||
"title": "Конфигурация Service Account",
|
||||
"private_key": "Приватный ключ",
|
||||
"private_key_placeholder": "Введите приватный ключ Service Account",
|
||||
"private_key_help": "Поле private_key из файла ключа JSON, загруженного из Google Cloud Console",
|
||||
"client_email": "Email клиента",
|
||||
"client_email_placeholder": "Введите email клиента Service Account",
|
||||
"client_email_help": "Поле client_email из файла ключа JSON, загруженного из Google Cloud Console",
|
||||
"description": "Используйте Service Account для аутентификации, подходит для сред, где ADC недоступен",
|
||||
"auth_success": "Service Account успешно аутентифицирован",
|
||||
"incomplete_config": "Пожалуйста, сначала завершите конфигурацию Service Account"
|
||||
},
|
||||
"documentation": "Смотрите официальную документацию для получения более подробной информации о конфигурации:",
|
||||
"learn_more": "Узнать больше"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@ -1873,7 +1929,8 @@
|
||||
"model_desc": "Модель, используемая для службы перевода",
|
||||
"bidirectional": "Настройки двунаправленного перевода",
|
||||
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.",
|
||||
"preview": "Markdown предпросмотр"
|
||||
},
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести",
|
||||
|
||||
@ -195,7 +195,7 @@
|
||||
"input.new.context": "清除上下文 {{Command}}",
|
||||
"input.new_topic": "新话题 {{Command}}",
|
||||
"input.pause": "暂停",
|
||||
"input.placeholder": "在这里输入消息...",
|
||||
"input.placeholder": "在这里输入消息,按 {{key}} 发送...",
|
||||
"input.translating": "翻译中...",
|
||||
"input.send": "发送",
|
||||
"input.settings": "设置",
|
||||
@ -771,7 +771,8 @@
|
||||
"backspace_clear": "按 Backspace 清空",
|
||||
"esc": "按 ESC {{action}}",
|
||||
"esc_back": "返回",
|
||||
"esc_close": "关闭"
|
||||
"esc_close": "关闭",
|
||||
"esc_pause": "暂停"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@ -802,6 +803,18 @@
|
||||
"string": "文本"
|
||||
},
|
||||
"pinned": "已固定",
|
||||
"price": {
|
||||
"cost": "花费",
|
||||
"currency": "币种",
|
||||
"custom": "自定义",
|
||||
"custom_currency": "自定义币种",
|
||||
"custom_currency_placeholder": "请输入自定义币种",
|
||||
"input": "输入价格",
|
||||
"million_tokens": "百万 Token",
|
||||
"output": "输出价格",
|
||||
"price": "价格"
|
||||
},
|
||||
"reasoning": "推理",
|
||||
"rerank_model": "重排模型",
|
||||
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
|
||||
"rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})",
|
||||
@ -1036,7 +1049,8 @@
|
||||
"qiniu": "七牛云 AI 推理",
|
||||
"tokenflux": "TokenFlux",
|
||||
"302ai": "302.AI",
|
||||
"lanyun": "蓝耘科技"
|
||||
"lanyun": "蓝耘科技",
|
||||
"vertexai": "Vertex AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "确定要恢复数据吗?",
|
||||
@ -1087,6 +1101,24 @@
|
||||
"assistant.title": "默认助手",
|
||||
"data": {
|
||||
"app_data": "应用数据",
|
||||
"app_data.select": "修改目录",
|
||||
"app_data.select_title": "更改应用数据目录",
|
||||
"app_data.restart_notice": "应用需要重启以应用更改",
|
||||
"app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录",
|
||||
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
|
||||
"app_data.path_changed_without_copy": "路径已更改成功,但数据未复制",
|
||||
"app_data.copying_warning": "数据复制中,不要强制退出app",
|
||||
"app_data.copying": "正在将数据复制到新位置...",
|
||||
"app_data.copy_success": "已成功复制数据到新位置",
|
||||
"app_data.copy_failed": "复制数据失败",
|
||||
"app_data.select_success": "数据目录已更改,应用将重启以应用更改",
|
||||
"app_data.select_error": "更改数据目录失败",
|
||||
"app_data.migration_title": "数据迁移",
|
||||
"app_data.original_path": "原始路径",
|
||||
"app_data.new_path": "新路径",
|
||||
"app_data.select_error_root_path": "新路径不能是根路径",
|
||||
"app_data.select_error_write_permission": "新路径没有写入权限",
|
||||
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出",
|
||||
"app_knowledge": "知识库文件",
|
||||
"app_knowledge.button.delete": "删除文件",
|
||||
"app_knowledge.remove_all": "删除知识库文件",
|
||||
@ -1119,7 +1151,8 @@
|
||||
"obsidian": "导出到Obsidian",
|
||||
"siyuan": "导出到思源笔记",
|
||||
"joplin": "导出到Joplin",
|
||||
"docx": "导出为Word"
|
||||
"docx": "导出为Word",
|
||||
"plain_text": "复制为纯文本"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@ -1212,8 +1245,6 @@
|
||||
"restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?",
|
||||
"restore.confirm.title": "确认恢复",
|
||||
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
|
||||
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
|
||||
"restore.modal.title": "从 WebDAV 恢复",
|
||||
"restore.title": "从 WebDAV 恢复",
|
||||
"syncError": "备份错误",
|
||||
"syncStatus": "备份状态",
|
||||
@ -1608,6 +1639,10 @@
|
||||
"models.translate_model_prompt_title": "翻译模型提示词",
|
||||
"models.quick_assistant_model": "快捷助手模型",
|
||||
"models.quick_assistant_model_description": "快捷助手使用的默认模型",
|
||||
"models.quick_assistant_selection": "选择助手",
|
||||
"models.quick_assistant_default_tag": "默认",
|
||||
"models.use_model": "默认模型",
|
||||
"models.use_assistant": "使用助手",
|
||||
"moresetting": "更多设置",
|
||||
"moresetting.check.confirm": "确认勾选",
|
||||
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
|
||||
@ -1693,6 +1728,27 @@
|
||||
"title": "模型备注",
|
||||
"placeholder": "请输入Markdown格式内容...",
|
||||
"markdown_editor_default_value": "预览区域"
|
||||
},
|
||||
"vertex_ai": {
|
||||
"project_id": "项目 ID",
|
||||
"project_id_placeholder": "your-google-cloud-project-id",
|
||||
"project_id_help": "您的 Google Cloud 项目 ID",
|
||||
"location": "地区",
|
||||
"location_help": "Vertex AI 服务的地区,例如 us-central1",
|
||||
"service_account": {
|
||||
"title": "Service Account 配置",
|
||||
"private_key": "私钥",
|
||||
"private_key_placeholder": "请输入 Service Account 私钥",
|
||||
"private_key_help": "从 Google Cloud Console 下载的 JSON 密钥文件中的 private_key 字段",
|
||||
"client_email": "客户端邮箱",
|
||||
"client_email_placeholder": "请输入 Service Account 客户端邮箱",
|
||||
"client_email_help": "从 Google Cloud Console 下载的 JSON 密钥文件中的 client_email 字段",
|
||||
"description": "使用 Service Account 进行身份验证,适用于无法使用 ADC 的环境",
|
||||
"auth_success": "Service Account 认证成功",
|
||||
"incomplete_config": "请先完整配置 Service Account 信息"
|
||||
},
|
||||
"documentation": "查看官方文档了解更多配置详情:",
|
||||
"learn_more": "了解更多"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@ -1876,7 +1932,8 @@
|
||||
"model_desc": "翻译服务使用的模型",
|
||||
"bidirectional": "双向翻译设置",
|
||||
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||
"scroll_sync": "滚动同步设置"
|
||||
"scroll_sync": "滚动同步设置",
|
||||
"preview": "Markdown 预览"
|
||||
},
|
||||
"title": "翻译",
|
||||
"tooltip.newline": "换行",
|
||||
|
||||
@ -195,7 +195,7 @@
|
||||
"input.new.context": "清除上下文 {{Command}}",
|
||||
"input.new_topic": "新話題 {{Command}}",
|
||||
"input.pause": "暫停",
|
||||
"input.placeholder": "在此輸入您的訊息...",
|
||||
"input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...",
|
||||
"input.send": "傳送",
|
||||
"input.settings": "設定",
|
||||
"input.topics": " 話題 ",
|
||||
@ -768,10 +768,11 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "按 C 鍵複製",
|
||||
"backspace_clear": "按 Backspace 清空",
|
||||
"esc": "按 ESC {{action}}",
|
||||
"esc_back": "返回",
|
||||
"esc_close": "關閉視窗",
|
||||
"backspace_clear": "按 Backspace 清空"
|
||||
"esc_pause": "暫停"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@ -819,7 +820,19 @@
|
||||
"vision": "視覺",
|
||||
"websearch": "網路搜尋"
|
||||
},
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})"
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})",
|
||||
"price": {
|
||||
"cost": "花費",
|
||||
"currency": "幣種",
|
||||
"custom": "自訂",
|
||||
"custom_currency": "自訂幣種",
|
||||
"custom_currency_placeholder": "請輸入自訂幣種",
|
||||
"input": "輸入價格",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "輸出價格",
|
||||
"price": "價格"
|
||||
},
|
||||
"reasoning": "推理"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "伸縮對話框",
|
||||
@ -1036,7 +1049,8 @@
|
||||
"qiniu": "七牛雲 AI 推理",
|
||||
"tokenflux": "TokenFlux",
|
||||
"302ai": "302.AI",
|
||||
"lanyun": "藍耘"
|
||||
"lanyun": "藍耘",
|
||||
"vertexai": "Vertex AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "確定要復原資料嗎?",
|
||||
@ -1086,7 +1100,25 @@
|
||||
"assistant.icon.type.none": "不顯示",
|
||||
"assistant.title": "預設助手",
|
||||
"data": {
|
||||
"app_data": "應用程式資料",
|
||||
"app_data": "應用數據",
|
||||
"app_data.select": "修改目錄",
|
||||
"app_data.select_title": "變更應用數據目錄",
|
||||
"app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效",
|
||||
"app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄",
|
||||
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
|
||||
"app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製",
|
||||
"app_data.copying_warning": "數據複製中,不要強制退出應用",
|
||||
"app_data.copying": "正在複製數據到新位置...",
|
||||
"app_data.copy_success": "成功複製數據到新位置",
|
||||
"app_data.copy_failed": "複製數據失敗",
|
||||
"app_data.select_success": "數據目錄已變更,應用將重啟以應用變更",
|
||||
"app_data.select_error": "變更數據目錄失敗",
|
||||
"app_data.migration_title": "數據遷移",
|
||||
"app_data.original_path": "原始路徑",
|
||||
"app_data.new_path": "新路徑",
|
||||
"app_data.select_error_root_path": "新路徑不能是根路徑",
|
||||
"app_data.select_error_write_permission": "新路徑沒有寫入權限",
|
||||
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出",
|
||||
"app_knowledge": "知識庫文件",
|
||||
"app_knowledge.button.delete": "刪除檔案",
|
||||
"app_knowledge.remove_all": "刪除知識庫檔案",
|
||||
@ -1119,7 +1151,8 @@
|
||||
"obsidian": "匯出到Obsidian",
|
||||
"siyuan": "匯出到思源筆記",
|
||||
"joplin": "匯出到Joplin",
|
||||
"docx": "匯出為Word"
|
||||
"docx": "匯出為Word",
|
||||
"plain_text": "複製為純文本"
|
||||
},
|
||||
"joplin": {
|
||||
"check": {
|
||||
@ -1210,8 +1243,6 @@
|
||||
"restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
|
||||
"restore.confirm.title": "復元確認",
|
||||
"restore.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
|
||||
"restore.modal.select.placeholder": "請選擇要恢復的備份文件",
|
||||
"restore.modal.title": "從 WebDAV 恢復",
|
||||
"restore.title": "從 WebDAV 恢復",
|
||||
"syncError": "備份錯誤",
|
||||
"syncStatus": "備份狀態",
|
||||
@ -1605,6 +1636,10 @@
|
||||
"models.translate_model_prompt_title": "翻譯模型提示詞",
|
||||
"models.quick_assistant_model": "快捷助手模型",
|
||||
"models.quick_assistant_model_description": "快捷助手使用的預設模型",
|
||||
"models.quick_assistant_selection": "選擇助手",
|
||||
"models.quick_assistant_default_tag": "預設",
|
||||
"models.use_model": "預設模型",
|
||||
"models.use_assistant": "使用助手",
|
||||
"moresetting": "更多設定",
|
||||
"moresetting.check.confirm": "確認勾選",
|
||||
"moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
|
||||
@ -1684,6 +1719,27 @@
|
||||
},
|
||||
"openai": {
|
||||
"alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API,請建立新的服務供應商"
|
||||
},
|
||||
"vertex_ai": {
|
||||
"project_id": "專案ID",
|
||||
"project_id_placeholder": "your-google-cloud-project-id",
|
||||
"project_id_help": "您的 Google Cloud 專案 ID",
|
||||
"location": "地區",
|
||||
"location_help": "Vertex AI 服務地區,例如:us-central1",
|
||||
"service_account": {
|
||||
"title": "服務帳戶設定",
|
||||
"private_key": "私密金鑰",
|
||||
"private_key_placeholder": "輸入服務帳戶私密金鑰",
|
||||
"private_key_help": "從 Google Cloud Console 下載的 JSON 金鑰檔案中的 private_key 欄位",
|
||||
"client_email": "Client Email",
|
||||
"client_email_placeholder": "輸入服務帳戶 client email",
|
||||
"client_email_help": "從 Google Cloud Console 下載的 JSON 金鑰檔案中的 client_email 欄位",
|
||||
"description": "使用服務帳戶進行身份驗證,適用於 ADC 不可用的環境",
|
||||
"auth_success": "服務帳戶驗證成功",
|
||||
"incomplete_config": "請先完成服務帳戶設定"
|
||||
},
|
||||
"documentation": "檢視官方文件以取得更多設定詳細資訊:",
|
||||
"learn_more": "瞭解更多"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@ -1872,7 +1928,8 @@
|
||||
"model_desc": "翻譯服務使用的模型",
|
||||
"bidirectional": "雙向翻譯設定",
|
||||
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||
"scroll_sync": "滾動同步設定"
|
||||
"scroll_sync": "滾動同步設定",
|
||||
"preview": "Markdown 預覽"
|
||||
},
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行",
|
||||
@ -1972,7 +2029,7 @@
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "設置視窗的默認透明度,100%為完全不透明"
|
||||
"description": "設置視窗的預設透明度,100%為完全不透明"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { groupTranslations } from '@renderer/pages/agents/agentGroupTranslations'
|
||||
import { DynamicIcon, IconName } from 'lucide-react/dynamic'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
groupName: string
|
||||
@ -8,6 +10,25 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth = 1.2 }) => {
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language as keyof (typeof groupTranslations)[string]
|
||||
|
||||
const findOriginalKey = (name: string): string => {
|
||||
if (groupTranslations[name]) {
|
||||
return name
|
||||
}
|
||||
|
||||
for (const key in groupTranslations) {
|
||||
if (groupTranslations[key][currentLanguage] === name) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
const originalKey = findOriginalKey(groupName)
|
||||
|
||||
const iconMap: { [key: string]: IconName } = {
|
||||
我的: 'user-check',
|
||||
精选: 'star',
|
||||
@ -46,5 +67,5 @@ export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth =
|
||||
搜索: 'search'
|
||||
} as const
|
||||
|
||||
return <DynamicIcon name={iconMap[groupName] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
|
||||
return <DynamicIcon name={iconMap[originalKey] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import store from '@renderer/store'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
let _agents: Agent[] = []
|
||||
|
||||
@ -22,6 +23,8 @@ export function useSystemAgents() {
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const { resourcesPath } = useRuntime()
|
||||
const { agentssubscribeUrl } = store.getState().settings
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language
|
||||
|
||||
useEffect(() => {
|
||||
const loadAgents = async () => {
|
||||
@ -44,9 +47,21 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
// 如果没有远程配置或获取失败,加载本地代理
|
||||
if (resourcesPath && _agents.length === 0) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
if (resourcesPath) {
|
||||
try {
|
||||
let fileName = 'agents.json'
|
||||
if (currentLanguage === 'zh-CN') {
|
||||
fileName = 'agents-zh.json'
|
||||
} else {
|
||||
fileName = 'agents-en.json'
|
||||
}
|
||||
|
||||
const localAgentsData = await window.api.fs.read(`${resourcesPath}/data/${fileName}`, 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
} catch (error) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
}
|
||||
}
|
||||
|
||||
setAgents(_agents)
|
||||
@ -58,7 +73,7 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
loadAgents()
|
||||
}, [defaultAgent, resourcesPath, agentssubscribeUrl])
|
||||
}, [defaultAgent, resourcesPath, agentssubscribeUrl, currentLanguage])
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
@ -309,8 +310,6 @@ const Inputbar: FC = () => {
|
||||
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isEnterPressed = event.key === 'Enter'
|
||||
|
||||
// 按下Tab键,自动选中${xxx}
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
event.preventDefault()
|
||||
@ -366,32 +365,37 @@ const Inputbar: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
//to check if the SendMessage key is pressed
|
||||
//other keys should be ignored
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
} else {
|
||||
//shift+enter's default behavior is to add a new line, ignore it
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
|
||||
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
// update text by setState, not directly modify textarea.value
|
||||
setText(newText)
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
// set cursor position in the next render cycle
|
||||
setTimeout(() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
onInput() // trigger resizeTextArea
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) {
|
||||
@ -784,7 +788,11 @@ const Inputbar: FC = () => {
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
|
||||
placeholder={
|
||||
isTranslating
|
||||
? t('chat.input.translating')
|
||||
: t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
|
||||
}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
variant="borderless"
|
||||
|
||||
@ -104,14 +104,18 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
if (groupMessages.length > 1) {
|
||||
for (const m of groupMessages) {
|
||||
dispatch(
|
||||
newMessagesActions.updateMessage({ topicId: m.topicId, messageId: m.id, updates: { foldSelected: true } })
|
||||
newMessagesActions.updateMessage({
|
||||
topicId: m.topicId,
|
||||
messageId: m.id,
|
||||
updates: { foldSelected: m.id === message.id }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
messageElement.scrollIntoView({ behavior: 'auto', block: 'start' })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import PasteService from '@renderer/services/PasteService'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { classNames, getFileExtension } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { findAllBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
@ -169,31 +169,39 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
onResend(updatedBlocks)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>, blockId: string) => {
|
||||
if (message.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
const isEnterPressed = event.key === 'Enter'
|
||||
// keep the same enter behavior as inputbar
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
} else {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
|
||||
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
//same with onChange()
|
||||
handleTextChange(blockId, newText)
|
||||
|
||||
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
// set cursor position in the next render cycle
|
||||
setTimeout(() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
resizeTextArea() // trigger resizeTextArea
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,7 +220,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
handleTextChange(block.id, e.target.value)
|
||||
resizeTextArea()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={(e) => handleKeyDown(e, block.id)}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
spellCheck={false}
|
||||
|
||||
@ -222,7 +222,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
|
||||
expandIcon: <ChevronRight size={16} style={{ position: 'absolute', insetInlineEnd: 5, marginTop: 3 }} />,
|
||||
children: [
|
||||
{
|
||||
exportMenuOptions.plain_text && {
|
||||
label: t('chat.topics.copy.plain_text'),
|
||||
key: 'copy_message_plain_text',
|
||||
onClick: () => copyMessageAsPlainText(message)
|
||||
|
||||
@ -16,6 +16,29 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
||||
}
|
||||
|
||||
const getPrice = () => {
|
||||
const inputTokens = message?.usage?.prompt_tokens ?? 0
|
||||
const outputTokens = message?.usage?.completion_tokens ?? 0
|
||||
const model = message.model
|
||||
if (!model || model.pricing?.input_per_million_tokens === 0 || model.pricing?.output_per_million_tokens === 0) {
|
||||
return 0
|
||||
}
|
||||
return (
|
||||
(inputTokens * (model.pricing?.input_per_million_tokens ?? 0) +
|
||||
outputTokens * (model.pricing?.output_per_million_tokens ?? 0)) /
|
||||
1000000
|
||||
)
|
||||
}
|
||||
|
||||
const getPriceString = () => {
|
||||
const price = getPrice()
|
||||
if (price === 0) {
|
||||
return ''
|
||||
}
|
||||
const currencySymbol = message.model?.pricing?.currencySymbol || '$'
|
||||
return `| ${t('models.price.cost')}: ${currencySymbol}${price}`
|
||||
}
|
||||
|
||||
if (!message.usage) {
|
||||
return <div />
|
||||
}
|
||||
@ -47,6 +70,7 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
<span>{message?.usage?.total_tokens}</span>
|
||||
<span>↑{message?.usage?.prompt_tokens}</span>
|
||||
<span>↓{message?.usage?.completion_tokens}</span>
|
||||
<span>{getPriceString()}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
|
||||
@ -117,7 +117,6 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const aiProvider = new AiProvider(provider)
|
||||
values.dimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
|
||||
} catch (error) {
|
||||
console.error('Error getting embedding dimensions:', error)
|
||||
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
|
||||
setLoading(false)
|
||||
return
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
CloudSyncOutlined,
|
||||
FileSearchOutlined,
|
||||
FolderOpenOutlined,
|
||||
LoadingOutlined,
|
||||
SaveOutlined,
|
||||
YuqueOutlined
|
||||
} from '@ant-design/icons'
|
||||
@ -18,7 +19,7 @@ import store, { useAppDispatch } from '@renderer/store'
|
||||
import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Switch, Typography } from 'antd'
|
||||
import { Button, Progress, Switch, Typography } from 'antd'
|
||||
import { FileText, FolderCog, FolderInput, Sparkle } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -179,6 +180,281 @@ const DataSettings: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAppDataPath = async () => {
|
||||
if (!appInfo || !appInfo.appDataPath) {
|
||||
return
|
||||
}
|
||||
|
||||
const newAppDataPath = await window.api.select({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: t('settings.data.app_data.select_title')
|
||||
})
|
||||
|
||||
if (!newAppDataPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// check new app data path is root path
|
||||
// if is root path, show error
|
||||
const pathParts = newAppDataPath.split(/[/\\]/).filter((part: string) => part !== '')
|
||||
if (pathParts.length <= 1) {
|
||||
window.message.error(t('settings.data.app_data.select_error_root_path'))
|
||||
return
|
||||
}
|
||||
|
||||
// check new app data path has write permission
|
||||
const hasWritePermission = await window.api.hasWritePermission(newAppDataPath)
|
||||
if (!hasWritePermission) {
|
||||
window.message.error(t('settings.data.app_data.select_error_write_permission'))
|
||||
return
|
||||
}
|
||||
|
||||
const migrationTitle = (
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
|
||||
)
|
||||
const migrationClassName = 'migration-modal'
|
||||
const messageKey = 'data-migration'
|
||||
|
||||
// 显示确认对话框
|
||||
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName, messageKey)
|
||||
}
|
||||
|
||||
// 显示确认迁移的对话框
|
||||
const showMigrationConfirmModal = (
|
||||
originalPath: string,
|
||||
newPath: string,
|
||||
title: React.ReactNode,
|
||||
className: string,
|
||||
messageKey: string
|
||||
) => {
|
||||
// 复制数据选项状态
|
||||
let shouldCopyData = true
|
||||
|
||||
// 创建路径内容组件
|
||||
const PathsContent = () => (
|
||||
<div>
|
||||
<MigrationPathRow>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.original_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{originalPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
<MigrationPathRow style={{ marginTop: '16px' }}>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.new_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{newPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CopyDataContent = () => (
|
||||
<div>
|
||||
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onChange={(checked) => {
|
||||
shouldCopyData = checked
|
||||
}}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
||||
{t('settings.data.app_data.copy_data_option')}
|
||||
</MigrationPathLabel>
|
||||
</MigrationPathRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 显示确认模态框
|
||||
const modal = window.modal.confirm({
|
||||
title,
|
||||
className,
|
||||
width: 'min(600px, 90vw)',
|
||||
style: { minHeight: '400px' },
|
||||
content: (
|
||||
<MigrationModalContent>
|
||||
<PathsContent />
|
||||
<CopyDataContent />
|
||||
<MigrationNotice>
|
||||
<p style={{ color: 'var(--color-warning)' }}>{t('settings.data.app_data.restart_notice')}</p>
|
||||
<p style={{ color: 'var(--color-text-3)', marginTop: '8px' }}>
|
||||
{t('settings.data.app_data.copy_time_notice')}
|
||||
</p>
|
||||
</MigrationNotice>
|
||||
</MigrationModalContent>
|
||||
),
|
||||
centered: true,
|
||||
okButtonProps: {
|
||||
danger: true
|
||||
},
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 立即关闭确认对话框
|
||||
modal.destroy()
|
||||
|
||||
// 设置停止退出应用
|
||||
window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason'))
|
||||
|
||||
if (shouldCopyData) {
|
||||
// 如果选择复制数据,显示进度模态框并执行迁移
|
||||
const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent)
|
||||
|
||||
try {
|
||||
await startMigration(originalPath, newPath, progressInterval, updateProgress, loadingModal, messageKey)
|
||||
} catch (error) {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
loadingModal.destroy()
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
// 如果不复制数据,直接设置新的应用数据路径
|
||||
await window.api.setAppDataPath(newPath)
|
||||
window.message.success(t('settings.data.app_data.path_changed_without_copy'))
|
||||
}
|
||||
|
||||
// 更新应用数据路径
|
||||
setAppInfo(await window.api.getAppInfo())
|
||||
|
||||
// 通知用户并重启应用
|
||||
setTimeout(() => {
|
||||
window.message.success(t('settings.data.app_data.select_success'))
|
||||
window.api.setStopQuitApp(false, '')
|
||||
window.api.relaunchApp()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
window.api.setStopQuitApp(false, '')
|
||||
window.message.error({
|
||||
content:
|
||||
(shouldCopyData
|
||||
? t('settings.data.app_data.copy_failed')
|
||||
: t('settings.data.app_data.path_change_failed')) +
|
||||
': ' +
|
||||
error,
|
||||
duration: 5
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示进度模态框
|
||||
const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => {
|
||||
let currentProgress = 0
|
||||
let progressInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// 创建进度更新模态框
|
||||
const loadingModal = window.modal.info({
|
||||
title,
|
||||
className,
|
||||
width: 'min(600px, 90vw)',
|
||||
style: { minHeight: '400px' },
|
||||
icon: <LoadingOutlined style={{ fontSize: 18 }} />,
|
||||
content: (
|
||||
<MigrationModalContent>
|
||||
<PathsContent />
|
||||
<MigrationNotice>
|
||||
<p>{t('settings.data.app_data.copying')}</p>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<Progress percent={currentProgress} status="active" strokeWidth={8} />
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-warning)', marginTop: '12px', fontSize: '13px' }}>
|
||||
{t('settings.data.app_data.copying_warning')}
|
||||
</p>
|
||||
</MigrationNotice>
|
||||
</MigrationModalContent>
|
||||
),
|
||||
centered: true,
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
okButtonProps: { style: { display: 'none' } }
|
||||
})
|
||||
|
||||
// 更新进度的函数
|
||||
const updateProgress = (progress: number, status: 'active' | 'success' = 'active') => {
|
||||
loadingModal.update({
|
||||
title,
|
||||
content: (
|
||||
<MigrationModalContent>
|
||||
<PathsContent />
|
||||
<MigrationNotice>
|
||||
<p>{t('settings.data.app_data.copying')}</p>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<Progress percent={Math.round(progress)} status={status} strokeWidth={8} />
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-warning)', marginTop: '12px', fontSize: '13px' }}>
|
||||
{t('settings.data.app_data.copying_warning')}
|
||||
</p>
|
||||
</MigrationNotice>
|
||||
</MigrationModalContent>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 开始模拟进度更新
|
||||
progressInterval = setInterval(() => {
|
||||
if (currentProgress < 95) {
|
||||
currentProgress += Math.random() * 5 + 1
|
||||
if (currentProgress > 95) currentProgress = 95
|
||||
updateProgress(currentProgress)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return { loadingModal, progressInterval, updateProgress }
|
||||
}
|
||||
|
||||
// 开始迁移数据
|
||||
const startMigration = async (
|
||||
originalPath: string,
|
||||
newPath: string,
|
||||
progressInterval: NodeJS.Timeout | null,
|
||||
updateProgress: (progress: number, status?: 'active' | 'success') => void,
|
||||
loadingModal: { destroy: () => void },
|
||||
messageKey: string
|
||||
): Promise<void> => {
|
||||
// 开始复制过程
|
||||
const copyResult = await window.api.copy(originalPath, newPath)
|
||||
|
||||
// 停止进度更新
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
|
||||
// 显示100%完成
|
||||
updateProgress(100, 'success')
|
||||
|
||||
if (!copyResult.success) {
|
||||
// 延迟关闭加载模态框
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
loadingModal.destroy()
|
||||
window.message.error({
|
||||
content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error,
|
||||
key: messageKey,
|
||||
duration: 5
|
||||
})
|
||||
resolve()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
throw new Error(copyResult.error || 'Unknown error during copy')
|
||||
}
|
||||
|
||||
// 在复制成功后设置新的AppDataPath
|
||||
await window.api.setAppDataPath(newPath)
|
||||
|
||||
// 短暂延迟以显示100%完成
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// 关闭加载模态框
|
||||
loadingModal.destroy()
|
||||
|
||||
window.message.success({
|
||||
content: t('settings.data.app_data.copy_success'),
|
||||
key: messageKey,
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
|
||||
const onSkipBackupFilesChange = (value: boolean) => {
|
||||
setSkipBackupFile(value)
|
||||
dispatch(_setSkipBackupFile(value))
|
||||
@ -245,6 +521,9 @@ const DataSettings: FC = () => {
|
||||
<PathRow>
|
||||
<PathText style={{ color: 'var(--color-text-3)' }}>{appInfo?.appDataPath}</PathText>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
|
||||
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
||||
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
|
||||
</HStack>
|
||||
</PathRow>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -352,4 +631,38 @@ const PathRow = styled(HStack)`
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
// Add styled components for migration modal
|
||||
const MigrationModalContent = styled.div`
|
||||
padding: 20px 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const MigrationNotice = styled.div`
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const MigrationPathRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const MigrationPathLabel = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const MigrationPathValue = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
border: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default DataSettings
|
||||
|
||||
@ -84,6 +84,16 @@ const ExportMenuOptions: FC = () => {
|
||||
<SettingRowTitle>{t('settings.data.export_menu.docx')}</SettingRowTitle>
|
||||
<Switch checked={exportMenuOptions.docx} onChange={(checked) => handleToggleOption('docx', checked)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.export_menu.plain_text')}</SettingRowTitle>
|
||||
<Switch
|
||||
checked={exportMenuOptions.plain_text}
|
||||
onChange={(checked) => handleToggleOption('plain_text', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,37 +1,35 @@
|
||||
import { RedoOutlined } from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { isEmbeddingModel } from '@renderer/config/models'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useAssistants, useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setQuickAssistantId } from '@renderer/store/llm'
|
||||
import { setTranslateModelPrompt } from '@renderer/store/settings'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Button, Select, Tooltip } from 'antd'
|
||||
import { find, sortBy } from 'lodash'
|
||||
import { ChevronDown, FolderPen, Languages, MessageSquareMore, Rocket, Settings2 } from 'lucide-react'
|
||||
import { ChevronDown, CircleHelp, FolderPen, Languages, MessageSquareMore, Rocket, Settings2 } from 'lucide-react'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDescription, SettingGroup, SettingTitle } from '..'
|
||||
import DefaultAssistantSettings from './DefaultAssistantSettings'
|
||||
import TopicNamingModalPopup from './TopicNamingModalPopup'
|
||||
|
||||
const ModelSettings: FC = () => {
|
||||
const {
|
||||
defaultModel,
|
||||
topicNamingModel,
|
||||
translateModel,
|
||||
quickAssistantModel,
|
||||
setDefaultModel,
|
||||
setTopicNamingModel,
|
||||
setTranslateModel,
|
||||
setQuickAssistantModel
|
||||
} = useDefaultModel()
|
||||
const { defaultModel, topicNamingModel, translateModel, setDefaultModel, setTopicNamingModel, setTranslateModel } =
|
||||
useDefaultModel()
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { assistants } = useAssistants()
|
||||
const { providers } = useProviders()
|
||||
const allModels = providers.map((p) => p.models).flat()
|
||||
const { theme } = useTheme()
|
||||
@ -39,6 +37,7 @@ const ModelSettings: FC = () => {
|
||||
const { translateModelPrompt } = useSettings()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
|
||||
const selectOptions = providers
|
||||
.filter((p) => p.models.length > 0)
|
||||
@ -68,11 +67,6 @@ const ModelSettings: FC = () => {
|
||||
[translateModel]
|
||||
)
|
||||
|
||||
const defaultQuickAssistantModel = useMemo(
|
||||
() => (hasModel(quickAssistantModel) ? getModelUniqId(quickAssistantModel) : undefined),
|
||||
[quickAssistantModel]
|
||||
)
|
||||
|
||||
const onUpdateTranslateModel = async () => {
|
||||
const prompt = await PromptPopup.show({
|
||||
title: t('settings.models.translate_model_prompt_title'),
|
||||
@ -166,28 +160,126 @@ const ModelSettings: FC = () => {
|
||||
<SettingDescription>{t('settings.models.translate_model_description')}</SettingDescription>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle style={{ marginBottom: 12 }}>
|
||||
<HStack alignItems="center" gap={10}>
|
||||
<Rocket size={18} color="var(--color-text)" />
|
||||
{t('settings.models.quick_assistant_model')}
|
||||
</HStack>
|
||||
</SettingTitle>
|
||||
<HStack alignItems="center">
|
||||
<Select
|
||||
value={defaultQuickAssistantModel}
|
||||
defaultValue={defaultQuickAssistantModel}
|
||||
style={{ width: 360 }}
|
||||
onChange={(value) => setQuickAssistantModel(find(allModels, JSON.parse(value)) as Model)}
|
||||
options={selectOptions}
|
||||
showSearch
|
||||
placeholder={t('settings.models.empty')}
|
||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
||||
/>
|
||||
<HStack alignItems="center" style={{ marginBottom: 12 }}>
|
||||
<SettingTitle>
|
||||
<HStack alignItems="center" gap={10}>
|
||||
<Rocket size={18} color="var(--color-text)" />
|
||||
{t('settings.models.quick_assistant_model')}
|
||||
<Tooltip title={t('selection.settings.user_modal.model.tooltip')} arrow>
|
||||
<QuestionIcon size={12} />
|
||||
</Tooltip>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={0}>
|
||||
<StyledButton
|
||||
type={!quickAssistantId ? 'primary' : 'default'}
|
||||
onClick={() => dispatch(setQuickAssistantId(''))}
|
||||
selected={!quickAssistantId}>
|
||||
{t('settings.models.use_model')}
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type={quickAssistantId ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
dispatch(setQuickAssistantId(defaultAssistant.id))
|
||||
}}
|
||||
selected={!!quickAssistantId}>
|
||||
{t('settings.models.use_assistant')}
|
||||
</StyledButton>
|
||||
</HStack>
|
||||
</SettingTitle>
|
||||
</HStack>
|
||||
{!quickAssistantId ? null : (
|
||||
<HStack alignItems="center" style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
value={quickAssistantId || defaultAssistant.id}
|
||||
style={{ width: 360 }}
|
||||
onChange={(value) => dispatch(setQuickAssistantId(value))}
|
||||
placeholder={t('settings.models.quick_assistant_selection')}
|
||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}>
|
||||
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||
<Spacer />
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
{assistants
|
||||
.filter((a) => a.id !== defaultAssistant.id)
|
||||
.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
)}
|
||||
<SettingDescription>{t('settings.models.quick_assistant_model_description')}</SettingDescription>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const QuestionIcon = styled(CircleHelp)`
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const StyledButton = styled(Button)<{ selected: boolean }>`
|
||||
border-radius: ${(props) => (props.selected ? '6px' : '6px')};
|
||||
z-index: ${(props) => (props.selected ? 1 : 0)};
|
||||
min-width: 80px;
|
||||
|
||||
&:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right-width: 0px; // No right border for the first button when not selected
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left-width: 1px; // Ensure left border for the last button
|
||||
}
|
||||
|
||||
// Override Ant Design's default hover and focus styles for a cleaner look
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
border-color: ${(props) => (props.selected ? 'var(--ant-primary-color)' : 'var(--ant-primary-color-hover)')};
|
||||
box-shadow: ${(props) =>
|
||||
props.selected ? '0 0 0 2px var(--ant-primary-color-outline)' : '0 0 0 2px var(--ant-primary-color-outline)'};
|
||||
}
|
||||
`
|
||||
|
||||
const AssistantItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
`
|
||||
|
||||
const AssistantName = styled.span`
|
||||
max-width: calc(100% - 60px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const DefaultTag = styled.span<{ isCurrent: boolean }>`
|
||||
color: ${(props) => (props.isCurrent ? 'var(--color-primary)' : 'var(--color-text-3)')};
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
export default ModelSettings
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
} from '@renderer/config/models'
|
||||
import { Model, ModelType } from '@renderer/types'
|
||||
import { getDefaultGroupName } from '@renderer/utils'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, InputNumber, message, Modal, Select } from 'antd'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -20,25 +20,42 @@ interface ModelEditContentProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const symbols = ['$', '¥', '€', '£']
|
||||
const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, open, onClose }) => {
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const [showModelTypes, setShowModelTypes] = useState(false)
|
||||
const [showMoreSettings, setShowMoreSettings] = useState(false)
|
||||
const [currencySymbol, setCurrencySymbol] = useState(model.pricing?.currencySymbol || '$')
|
||||
const [isCustomCurrency, setIsCustomCurrency] = useState(!symbols.includes(model.pricing?.currencySymbol || '$'))
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
const finalCurrencySymbol = isCustomCurrency ? values.customCurrencySymbol : values.currencySymbol
|
||||
const updatedModel = {
|
||||
...model,
|
||||
id: values.id || model.id,
|
||||
name: values.name || model.name,
|
||||
group: values.group || model.group
|
||||
group: values.group || model.group,
|
||||
pricing: {
|
||||
input_per_million_tokens: Number(values.input_per_million_tokens) || 0,
|
||||
output_per_million_tokens: Number(values.output_per_million_tokens) || 0,
|
||||
currencySymbol: finalCurrencySymbol || '$'
|
||||
}
|
||||
}
|
||||
onUpdateModel(updatedModel)
|
||||
setShowModelTypes(false)
|
||||
setShowMoreSettings(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModelTypes(false)
|
||||
setShowMoreSettings(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const currencyOptions = [
|
||||
...symbols.map((symbol) => ({ label: symbol, value: symbol })),
|
||||
{ label: t('models.price.custom'), value: 'custom' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('models.edit')}
|
||||
@ -52,7 +69,7 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
if (visible) {
|
||||
form.getFieldInstance('id')?.focus()
|
||||
} else {
|
||||
setShowModelTypes(false)
|
||||
setShowMoreSettings(false)
|
||||
}
|
||||
}}>
|
||||
<Form
|
||||
@ -64,7 +81,15 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
initialValues={{
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
group: model.group
|
||||
group: model.group,
|
||||
input_per_million_tokens: model.pricing?.input_per_million_tokens ?? 0,
|
||||
output_per_million_tokens: model.pricing?.output_per_million_tokens ?? 0,
|
||||
currencySymbol: symbols.includes(model.pricing?.currencySymbol || '$')
|
||||
? model.pricing?.currencySymbol || '$'
|
||||
: 'custom',
|
||||
customCurrencySymbol: symbols.includes(model.pricing?.currencySymbol || '$')
|
||||
? ''
|
||||
: model.pricing?.currencySymbol || ''
|
||||
}}
|
||||
onFinish={onFinish}>
|
||||
<Form.Item
|
||||
@ -109,13 +134,13 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'center' }}>
|
||||
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
|
||||
<Flex justify="center" align="center" style={{ position: 'relative' }}>
|
||||
<Button
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={showModelTypes ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
icon={showMoreSettings ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
iconPosition="end"
|
||||
onClick={() => setShowModelTypes(!showModelTypes)}
|
||||
onClick={() => setShowMoreSettings(!showMoreSettings)}
|
||||
style={{ color: 'var(--color-text-3)' }}>
|
||||
{t('settings.moresetting')}
|
||||
</Button>
|
||||
@ -124,10 +149,10 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
{showModelTypes && (
|
||||
{showMoreSettings && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Divider style={{ margin: '16px 0 16px 0' }} />
|
||||
<TypeTitle>{t('models.type.select')}:</TypeTitle>
|
||||
<TypeTitle>{t('models.type.select')}</TypeTitle>
|
||||
{(() => {
|
||||
const defaultTypes = [
|
||||
...(isVisionModel(model) ? ['vision'] : []),
|
||||
@ -198,6 +223,59 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<TypeTitle>{t('models.price.price')}</TypeTitle>
|
||||
<Form.Item name="currencySymbol" label={t('models.price.currency')} style={{ marginBottom: 10 }}>
|
||||
<Select
|
||||
style={{ width: '100px' }}
|
||||
options={currencyOptions}
|
||||
onChange={(value) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustomCurrency(true)
|
||||
setCurrencySymbol(form.getFieldValue('customCurrencySymbol') || '')
|
||||
} else {
|
||||
setIsCustomCurrency(false)
|
||||
setCurrencySymbol(value)
|
||||
}
|
||||
}}
|
||||
dropdownMatchSelectWidth={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{isCustomCurrency && (
|
||||
<Form.Item
|
||||
name="customCurrencySymbol"
|
||||
label={t('models.price.custom_currency')}
|
||||
style={{ marginBottom: 10 }}
|
||||
rules={[{ required: isCustomCurrency }]}>
|
||||
<Input
|
||||
style={{ width: '100px' }}
|
||||
placeholder={t('models.price.custom_currency_placeholder')}
|
||||
maxLength={5}
|
||||
onChange={(e) => setCurrencySymbol(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('models.price.input')} name="input_per_million_tokens">
|
||||
<InputNumber
|
||||
placeholder="0.00"
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '240px' }}
|
||||
addonAfter={`${currencySymbol} / ${t('models.price.million_tokens')}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('models.price.output')} name="output_per_million_tokens">
|
||||
<InputNumber
|
||||
placeholder="0.00"
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '240px' }}
|
||||
addonAfter={`${currencySymbol} / ${t('models.price.million_tokens')}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
@ -206,6 +284,7 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
}
|
||||
|
||||
const TypeTitle = styled.div`
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@ -42,6 +42,7 @@ import ModelListSearchBar from './ModelListSearchBar'
|
||||
import ProviderOAuth from './ProviderOAuth'
|
||||
import ProviderSettingsPopup from './ProviderSettingsPopup'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
import VertexAISettings from './VertexAISettings'
|
||||
|
||||
interface Props {
|
||||
provider: Provider
|
||||
@ -335,72 +336,76 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
)}
|
||||
{provider.id === 'openai' && <OpenAIAlert />}
|
||||
{isDmxapi && <DMXAPISettings provider={provider} setApiKey={setApiKey} />}
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input.Password
|
||||
value={inputValue}
|
||||
placeholder={t('settings.provider.api_key')}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
debouncedSetApiKey(e.target.value)
|
||||
}}
|
||||
onBlur={() => {
|
||||
const formattedValue = formatApiKeys(inputValue)
|
||||
setInputValue(formattedValue)
|
||||
setApiKey(formattedValue)
|
||||
onUpdateApiKey()
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)}
|
||||
disabled={provider.id === 'copilot'}
|
||||
/>
|
||||
<Button
|
||||
type={apiValid ? 'primary' : 'default'}
|
||||
ghost={apiValid}
|
||||
onClick={onCheckApi}
|
||||
disabled={!apiHost || apiChecking}>
|
||||
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{!isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{!isDmxapi && (
|
||||
{provider.id !== 'vertexai' && (
|
||||
<>
|
||||
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
<Input.Password
|
||||
value={inputValue}
|
||||
placeholder={t('settings.provider.api_key')}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
debouncedSetApiKey(e.target.value)
|
||||
}}
|
||||
onBlur={() => {
|
||||
const formattedValue = formatApiKeys(inputValue)
|
||||
setInputValue(formattedValue)
|
||||
setApiKey(formattedValue)
|
||||
onUpdateApiKey()
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)}
|
||||
disabled={provider.id === 'copilot'}
|
||||
/>
|
||||
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
|
||||
<Button danger onClick={onReset}>
|
||||
{t('settings.provider.api.url.reset')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type={apiValid ? 'primary' : 'default'}
|
||||
ghost={apiValid}
|
||||
onClick={onCheckApi}
|
||||
disabled={!apiHost || apiChecking}>
|
||||
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{isOpenAIProvider(provider) && (
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpText
|
||||
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
{hostPreview()}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText style={{ minWidth: 'fit-content' }}>
|
||||
{t('settings.provider.api.url.tip')}
|
||||
</SettingHelpText>
|
||||
<HStack>
|
||||
{!isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{!isDmxapi && (
|
||||
<>
|
||||
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
/>
|
||||
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
|
||||
<Button danger onClick={onReset}>
|
||||
{t('settings.provider.api.url.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</Space.Compact>
|
||||
{isOpenAIProvider(provider) && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpText
|
||||
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
{hostPreview()}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText style={{ minWidth: 'fit-content' }}>
|
||||
{t('settings.provider.api.url.tip')}
|
||||
</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isAzureOpenAI && (
|
||||
@ -419,6 +424,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
{provider.id === 'lmstudio' && <LMStudioSettings />}
|
||||
{provider.id === 'gpustack' && <GPUStackSettings />}
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings />}
|
||||
<SettingSubtitle style={{ marginBottom: 5 }}>
|
||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<HStack alignItems="center" gap={8} mb={5}>
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useVertexAISettings } from '@renderer/hooks/useVertexAI'
|
||||
import { Alert, Input } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
|
||||
|
||||
const VertexAISettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
projectId,
|
||||
location,
|
||||
serviceAccount,
|
||||
setProjectId,
|
||||
setLocation,
|
||||
setServiceAccountPrivateKey,
|
||||
setServiceAccountClientEmail
|
||||
} = useVertexAISettings()
|
||||
|
||||
const providerConfig = PROVIDER_CONFIG['vertexai']
|
||||
const apiKeyWebsite = providerConfig?.websites?.apiKey
|
||||
|
||||
const [localProjectId, setLocalProjectId] = useState(projectId)
|
||||
const [localLocation, setLocalLocation] = useState(location)
|
||||
|
||||
const handleProjectIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalProjectId(e.target.value)
|
||||
}
|
||||
|
||||
const handleLocationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newLocation = e.target.value
|
||||
setLocalLocation(newLocation)
|
||||
}
|
||||
|
||||
const handleServiceAccountPrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setServiceAccountPrivateKey(e.target.value)
|
||||
}
|
||||
|
||||
const handleServiceAccountPrivateKeyBlur = () => {
|
||||
setServiceAccountPrivateKey(serviceAccount.privateKey)
|
||||
}
|
||||
|
||||
const handleServiceAccountClientEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setServiceAccountClientEmail(e.target.value)
|
||||
}
|
||||
|
||||
const handleServiceAccountClientEmailBlur = () => {
|
||||
setServiceAccountClientEmail(serviceAccount.clientEmail)
|
||||
}
|
||||
|
||||
const handleProjectIdBlur = () => {
|
||||
setProjectId(localProjectId)
|
||||
}
|
||||
|
||||
const handleLocationBlur = () => {
|
||||
setLocation(localLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>
|
||||
{t('settings.provider.vertex_ai.service_account.title')}
|
||||
</SettingSubtitle>
|
||||
<Alert
|
||||
type="info"
|
||||
style={{ marginTop: 5 }}
|
||||
message={t('settings.provider.vertex_ai.service_account.description')}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>
|
||||
{t('settings.provider.vertex_ai.service_account.client_email')}
|
||||
</SettingSubtitle>
|
||||
<Input.Password
|
||||
value={serviceAccount.clientEmail}
|
||||
placeholder={t('settings.provider.vertex_ai.service_account.client_email_placeholder')}
|
||||
onChange={handleServiceAccountClientEmailChange}
|
||||
onBlur={handleServiceAccountClientEmailBlur}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.vertex_ai.service_account.client_email_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>
|
||||
{t('settings.provider.vertex_ai.service_account.private_key')}
|
||||
</SettingSubtitle>
|
||||
<Input.TextArea
|
||||
value={serviceAccount.privateKey}
|
||||
placeholder={t('settings.provider.vertex_ai.service_account.private_key_placeholder')}
|
||||
onChange={handleServiceAccountPrivateKeyChange}
|
||||
onBlur={handleServiceAccountPrivateKeyBlur}
|
||||
style={{ marginTop: 5 }}
|
||||
spellCheck={false}
|
||||
autoSize={{ minRows: 4, maxRows: 4 }}
|
||||
/>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.vertex_ai.service_account.private_key_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.vertex_ai.project_id')}</SettingSubtitle>
|
||||
<Input.Password
|
||||
value={localProjectId}
|
||||
placeholder={t('settings.provider.vertex_ai.project_id_placeholder')}
|
||||
onChange={handleProjectIdChange}
|
||||
onBlur={handleProjectIdBlur}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.vertex_ai.project_id_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.vertex_ai.location')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localLocation}
|
||||
placeholder="us-central1"
|
||||
onChange={handleLocationChange}
|
||||
onBlur={handleLocationBlur}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.vertex_ai.location_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VertexAISettings
|
||||
@ -26,6 +26,7 @@ import { find, isEmpty, sortBy } from 'lodash'
|
||||
import { ChevronDown, HelpCircle, Settings2, TriangleAlert } from 'lucide-react'
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
let _text = ''
|
||||
@ -39,6 +40,8 @@ const TranslateSettings: FC<{
|
||||
setIsScrollSyncEnabled: (value: boolean) => void
|
||||
isBidirectional: boolean
|
||||
setIsBidirectional: (value: boolean) => void
|
||||
enableMarkdown: boolean
|
||||
setEnableMarkdown: (value: boolean) => void
|
||||
bidirectionalPair: [string, string]
|
||||
setBidirectionalPair: (value: [string, string]) => void
|
||||
translateModel: Model | undefined
|
||||
@ -52,6 +55,8 @@ const TranslateSettings: FC<{
|
||||
setIsScrollSyncEnabled,
|
||||
isBidirectional,
|
||||
setIsBidirectional,
|
||||
enableMarkdown,
|
||||
setEnableMarkdown,
|
||||
bidirectionalPair,
|
||||
setBidirectionalPair,
|
||||
translateModel,
|
||||
@ -82,6 +87,7 @@ const TranslateSettings: FC<{
|
||||
setBidirectionalPair(localPair)
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: localPair })
|
||||
db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled })
|
||||
db.settings.put({ id: 'translate:markdown:enabled', value: enableMarkdown })
|
||||
window.message.success({
|
||||
content: t('message.save.success.title'),
|
||||
key: 'translate-settings-save'
|
||||
@ -136,6 +142,13 @@ const TranslateSettings: FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div style={{ fontWeight: 500 }}>{t('translate.settings.preview')}</div>
|
||||
<Switch checked={enableMarkdown} onChange={setEnableMarkdown} />
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div style={{ fontWeight: 500 }}>{t('translate.settings.scroll_sync')}</div>
|
||||
@ -215,6 +228,7 @@ const TranslatePage: FC = () => {
|
||||
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
||||
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false)
|
||||
const [isBidirectional, setIsBidirectional] = useState(false)
|
||||
const [enableMarkdown, setEnableMarkdown] = useState(false)
|
||||
const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese'])
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const [detectedLanguage, setDetectedLanguage] = useState<string | null>(null)
|
||||
@ -391,6 +405,9 @@ const TranslatePage: FC = () => {
|
||||
|
||||
const scrollSyncSetting = await db.settings.get({ id: 'translate:scroll:sync' })
|
||||
setIsScrollSyncEnabled(scrollSyncSetting ? scrollSyncSetting.value : false)
|
||||
|
||||
const markdownSetting = await db.settings.get({ id: 'translate:markdown:enabled' })
|
||||
setEnableMarkdown(markdownSetting ? markdownSetting.value : false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -591,7 +608,13 @@ const TranslatePage: FC = () => {
|
||||
</OperationBar>
|
||||
|
||||
<OutputText ref={outputTextRef} onScroll={handleOutputScroll} className="selectable">
|
||||
{result || t('translate.output.placeholder')}
|
||||
{!result ? (
|
||||
t('translate.output.placeholder')
|
||||
) : enableMarkdown ? (
|
||||
<ReactMarkdown>{result}</ReactMarkdown>
|
||||
) : (
|
||||
result
|
||||
)}
|
||||
</OutputText>
|
||||
</OutputContainer>
|
||||
</ContentContainer>
|
||||
@ -603,6 +626,8 @@ const TranslatePage: FC = () => {
|
||||
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
|
||||
isBidirectional={isBidirectional}
|
||||
setIsBidirectional={toggleBidirectional}
|
||||
enableMarkdown={enableMarkdown}
|
||||
setEnableMarkdown={setEnableMarkdown}
|
||||
bidirectionalPair={bidirectionalPair}
|
||||
setBidirectionalPair={setBidirectionalPair}
|
||||
translateModel={translateModel}
|
||||
|
||||
@ -254,12 +254,20 @@ async function fetchExternalTool(
|
||||
const enabledMCPs = assistant.mcpServers
|
||||
if (enabledMCPs && enabledMCPs.length > 0) {
|
||||
try {
|
||||
const toolPromises = enabledMCPs.map(async (mcpServer) => {
|
||||
const tools = await window.api.mcp.listTools(mcpServer)
|
||||
return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
|
||||
const toolPromises = enabledMCPs.map<Promise<MCPTool[]>>(async (mcpServer) => {
|
||||
try {
|
||||
const tools = await window.api.mcp.listTools(mcpServer)
|
||||
return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
|
||||
} catch (error) {
|
||||
console.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
const results = await Promise.all(toolPromises)
|
||||
mcpTools = results.flat() // Flatten the array of arrays
|
||||
const results = await Promise.allSettled(toolPromises)
|
||||
mcpTools = results
|
||||
.filter((result): result is PromiseFulfilledResult<MCPTool[]> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
.flat()
|
||||
} catch (toolError) {
|
||||
console.error('Error fetching MCP tools:', toolError)
|
||||
}
|
||||
@ -516,7 +524,7 @@ export async function fetchGenerate({ prompt, content }: { prompt: string; conte
|
||||
|
||||
function hasApiKey(provider: Provider) {
|
||||
if (!provider) return false
|
||||
if (provider.id === 'ollama' || provider.id === 'lmstudio') return true
|
||||
if (provider.id === 'ollama' || provider.id === 'lmstudio' || provider.type === 'vertexai') return true
|
||||
return !isEmpty(provider.apiKey)
|
||||
}
|
||||
|
||||
@ -538,14 +546,19 @@ export function checkApiProvider(provider: Provider): void {
|
||||
const key = 'api-check'
|
||||
const style = { marginTop: '3vh' }
|
||||
|
||||
if (provider.id !== 'ollama' && provider.id !== 'lmstudio') {
|
||||
if (
|
||||
provider.id !== 'ollama' &&
|
||||
provider.id !== 'lmstudio' &&
|
||||
provider.type !== 'vertexai' &&
|
||||
provider.id !== 'copilot'
|
||||
) {
|
||||
if (!provider.apiKey) {
|
||||
window.message.error({ content: i18n.t('message.error.enter.api.key'), key, style })
|
||||
throw new Error(i18n.t('message.error.enter.api.key'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.apiHost) {
|
||||
if (!provider.apiHost && provider.type !== 'vertexai') {
|
||||
window.message.error({ content: i18n.t('message.error.enter.api.host'), key, style })
|
||||
throw new Error(i18n.t('message.error.enter.api.host'))
|
||||
}
|
||||
@ -565,10 +578,7 @@ export async function checkApi(provider: Provider, model: Model): Promise<void>
|
||||
assistant.model = model
|
||||
try {
|
||||
if (isEmbeddingModel(model)) {
|
||||
const result = await ai.getEmbeddingDimensions(model)
|
||||
if (result === 0) {
|
||||
throw new Error(i18n.t('message.error.enter.model'))
|
||||
}
|
||||
await ai.getEmbeddingDimensions(model)
|
||||
} else {
|
||||
const params: CompletionsParams = {
|
||||
callType: 'check',
|
||||
|
||||
@ -48,7 +48,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
|
||||
rerankBaseURL: rerankHost,
|
||||
rerankApiKey: rerankAiProvider.getApiKey() || 'secret',
|
||||
rerankModel: base.rerankModel?.id,
|
||||
rerankModelProvider: base.rerankModel?.provider
|
||||
rerankModelProvider: rerankProvider.name.toLowerCase()
|
||||
// topN: base.topN
|
||||
}
|
||||
}
|
||||
@ -101,7 +101,7 @@ export const searchKnowledgeBase = async (
|
||||
|
||||
// 执行搜索
|
||||
const searchResults = await window.api.knowledgeBase.search({
|
||||
search: query,
|
||||
search: rewrite || query,
|
||||
base: baseParams
|
||||
})
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 114,
|
||||
version: 116,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -14,6 +14,14 @@ type LlmSettings = {
|
||||
gpustack: {
|
||||
keepAliveTime: number
|
||||
}
|
||||
vertexai: {
|
||||
serviceAccount: {
|
||||
privateKey: string
|
||||
clientEmail: string
|
||||
}
|
||||
projectId: string
|
||||
location: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LlmState {
|
||||
@ -21,7 +29,7 @@ export interface LlmState {
|
||||
defaultModel: Model
|
||||
topicNamingModel: Model
|
||||
translateModel: Model
|
||||
quickAssistantModel: Model
|
||||
quickAssistantId: string
|
||||
settings: LlmSettings
|
||||
}
|
||||
|
||||
@ -225,7 +233,8 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
apiHost: 'https://generativelanguage.googleapis.com',
|
||||
models: SYSTEM_MODELS.gemini,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
enabled: false,
|
||||
isVertex: false
|
||||
},
|
||||
{
|
||||
id: 'zhipu',
|
||||
@ -507,14 +516,25 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
models: SYSTEM_MODELS.voyageai,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'vertexai',
|
||||
name: 'VertexAI',
|
||||
type: 'vertexai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://aiplatform.googleapis.com',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false,
|
||||
isVertex: true
|
||||
}
|
||||
]
|
||||
|
||||
const initialState: LlmState = {
|
||||
export const initialState: LlmState = {
|
||||
defaultModel: SYSTEM_MODELS.defaultModel[0],
|
||||
topicNamingModel: SYSTEM_MODELS.defaultModel[1],
|
||||
translateModel: SYSTEM_MODELS.defaultModel[2],
|
||||
quickAssistantModel: SYSTEM_MODELS.defaultModel[3],
|
||||
quickAssistantId: '',
|
||||
providers: INITIAL_PROVIDERS,
|
||||
settings: {
|
||||
ollama: {
|
||||
@ -525,6 +545,14 @@ const initialState: LlmState = {
|
||||
},
|
||||
gpustack: {
|
||||
keepAliveTime: 0
|
||||
},
|
||||
vertexai: {
|
||||
serviceAccount: {
|
||||
privateKey: '',
|
||||
clientEmail: ''
|
||||
},
|
||||
projectId: '',
|
||||
location: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -621,8 +649,9 @@ const llmSlice = createSlice({
|
||||
setTranslateModel: (state, action: PayloadAction<{ model: Model }>) => {
|
||||
state.translateModel = action.payload.model
|
||||
},
|
||||
setQuickAssistantModel: (state, action: PayloadAction<{ model: Model }>) => {
|
||||
state.quickAssistantModel = action.payload.model
|
||||
|
||||
setQuickAssistantId: (state, action: PayloadAction<string>) => {
|
||||
state.quickAssistantId = action.payload
|
||||
},
|
||||
setOllamaKeepAliveTime: (state, action: PayloadAction<number>) => {
|
||||
state.settings.ollama.keepAliveTime = action.payload
|
||||
@ -633,6 +662,18 @@ const llmSlice = createSlice({
|
||||
setGPUStackKeepAliveTime: (state, action: PayloadAction<number>) => {
|
||||
state.settings.gpustack.keepAliveTime = action.payload
|
||||
},
|
||||
setVertexAIProjectId: (state, action: PayloadAction<string>) => {
|
||||
state.settings.vertexai.projectId = action.payload
|
||||
},
|
||||
setVertexAILocation: (state, action: PayloadAction<string>) => {
|
||||
state.settings.vertexai.location = action.payload
|
||||
},
|
||||
setVertexAIServiceAccountPrivateKey: (state, action: PayloadAction<string>) => {
|
||||
state.settings.vertexai.serviceAccount.privateKey = action.payload
|
||||
},
|
||||
setVertexAIServiceAccountClientEmail: (state, action: PayloadAction<string>) => {
|
||||
state.settings.vertexai.serviceAccount.clientEmail = action.payload
|
||||
},
|
||||
updateModel: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
@ -661,10 +702,14 @@ export const {
|
||||
setDefaultModel,
|
||||
setTopicNamingModel,
|
||||
setTranslateModel,
|
||||
setQuickAssistantModel,
|
||||
setQuickAssistantId,
|
||||
setOllamaKeepAliveTime,
|
||||
setLMStudioKeepAliveTime,
|
||||
setGPUStackKeepAliveTime,
|
||||
setVertexAIProjectId,
|
||||
setVertexAILocation,
|
||||
setVertexAIServiceAccountPrivateKey,
|
||||
setVertexAIServiceAccountClientEmail,
|
||||
updateModel
|
||||
} = llmSlice.actions
|
||||
|
||||
|
||||
@ -6,14 +6,14 @@ import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { Assistant, Topic, WebSearchProvider } from '@renderer/types'
|
||||
import { Assistant, Provider, Topic, WebSearchProvider } from '@renderer/types'
|
||||
import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { createMigrate } from 'redux-persist'
|
||||
|
||||
import { RootState } from '.'
|
||||
import { DEFAULT_TOOL_ORDER } from './inputTools'
|
||||
import { INITIAL_PROVIDERS, moveProvider } from './llm'
|
||||
import { INITIAL_PROVIDERS, initialState as llmInitialState, moveProvider } from './llm'
|
||||
import { mcpSlice } from './mcp'
|
||||
import { defaultActionItems } from './selectionStore'
|
||||
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
|
||||
@ -57,6 +57,15 @@ function addProvider(state: RootState, id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateProvider(state: RootState, id: string, provider: Partial<Provider>) {
|
||||
if (state.llm.providers) {
|
||||
const index = state.llm.providers.findIndex((p) => p.id === id)
|
||||
if (index !== -1) {
|
||||
state.llm.providers[index] = { ...state.llm.providers[index], ...provider }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addWebSearchProvider(state: RootState, id: string) {
|
||||
if (state.websearch && state.websearch.providers) {
|
||||
if (!state.websearch.providers.find((p) => p.id === id)) {
|
||||
@ -1461,8 +1470,6 @@ const migrateConfig = {
|
||||
searchMessageShortcut.shortcut = [isMac ? 'Command' : 'Ctrl', 'Shift', 'F']
|
||||
}
|
||||
}
|
||||
// Quick assistant model
|
||||
state.llm.quickAssistantModel = state.llm.defaultModel || SYSTEM_MODELS.silicon[1]
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
@ -1571,6 +1578,36 @@ const migrateConfig = {
|
||||
}
|
||||
},
|
||||
'113': (state: RootState) => {
|
||||
try {
|
||||
addProvider(state, 'vertexai')
|
||||
state.llm.providers = moveProvider(state.llm.providers, 'vertexai', 10)
|
||||
if (!state.llm.settings.vertexai) {
|
||||
state.llm.settings.vertexai = llmInitialState.settings.vertexai
|
||||
}
|
||||
updateProvider(state, 'gemini', {
|
||||
isVertex: false
|
||||
})
|
||||
updateProvider(state, 'vertexai', {
|
||||
isVertex: true
|
||||
})
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'114': (state: RootState) => {
|
||||
try {
|
||||
if (state.settings && state.settings.exportMenuOptions) {
|
||||
if (typeof state.settings.exportMenuOptions.plain_text === 'undefined') {
|
||||
state.settings.exportMenuOptions.plain_text = true
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'115': (state: RootState) => {
|
||||
try {
|
||||
// Step 1: 把默认助手模板下面的话题合并到主页列表的默认助手Id下面,保持默认助手模板的纯粹性
|
||||
|
||||
@ -1665,7 +1702,7 @@ const migrateConfig = {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'114': (state: RootState) => {
|
||||
'116': (state: RootState) => {
|
||||
try {
|
||||
if (
|
||||
state.assistants &&
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
|
||||
import { WebDAVSyncState } from './backup'
|
||||
|
||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
|
||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter'
|
||||
|
||||
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
|
||||
|
||||
@ -165,6 +165,7 @@ export interface SettingsState {
|
||||
obsidian: boolean
|
||||
siyuan: boolean
|
||||
docx: boolean
|
||||
plain_text: boolean
|
||||
}
|
||||
// OpenAI
|
||||
openAI: {
|
||||
@ -305,7 +306,8 @@ export const initialState: SettingsState = {
|
||||
joplin: true,
|
||||
obsidian: true,
|
||||
siyuan: true,
|
||||
docx: true
|
||||
docx: true,
|
||||
plain_text: true
|
||||
},
|
||||
// OpenAI
|
||||
openAI: {
|
||||
|
||||
@ -165,13 +165,27 @@ export type Provider = {
|
||||
isAuthed?: boolean
|
||||
rateLimit?: number
|
||||
isNotSupportArrayContent?: boolean
|
||||
isVertex?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type ProviderType = 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai'
|
||||
export type ProviderType =
|
||||
| 'openai'
|
||||
| 'openai-response'
|
||||
| 'anthropic'
|
||||
| 'gemini'
|
||||
| 'qwenlm'
|
||||
| 'azure-openai'
|
||||
| 'vertexai'
|
||||
|
||||
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search'
|
||||
|
||||
export type ModelPricing = {
|
||||
input_per_million_tokens: number
|
||||
output_per_million_tokens: number
|
||||
currencySymbol?: string
|
||||
}
|
||||
|
||||
export type Model = {
|
||||
id: string
|
||||
provider: string
|
||||
@ -180,6 +194,7 @@ export type Model = {
|
||||
owned_by?: string
|
||||
description?: string
|
||||
type?: ModelType[]
|
||||
pricing?: ModelPricing
|
||||
}
|
||||
|
||||
export type Suggestion = {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import type { SendMessageShortcut } from '@renderer/store/settings'
|
||||
import { FileType } from '@renderer/types'
|
||||
|
||||
export const getFilesFromDropEvent = async (e: React.DragEvent<HTMLDivElement>): Promise<FileType[]> => {
|
||||
@ -58,3 +60,47 @@ export const getFilesFromDropEvent = async (e: React.DragEvent<HTMLDivElement>):
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// convert send message shortcut to human readable label
|
||||
export const getSendMessageShortcutLabel = (shortcut: SendMessageShortcut) => {
|
||||
switch (shortcut) {
|
||||
case 'Enter':
|
||||
return 'Enter'
|
||||
case 'Ctrl+Enter':
|
||||
return 'Ctrl + Enter'
|
||||
case 'Alt+Enter':
|
||||
return `${isMac ? '⌥' : 'Alt'} + Enter`
|
||||
case 'Command+Enter':
|
||||
return `${isMac ? '⌘' : isWindows ? 'Win' : 'Super'} + Enter`
|
||||
case 'Shift+Enter':
|
||||
return 'Shift + Enter'
|
||||
default:
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
|
||||
// check if the send message key is pressed in textarea
|
||||
export const isSendMessageKeyPressed = (
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
shortcut: SendMessageShortcut
|
||||
) => {
|
||||
let isSendMessageKeyPressed = false
|
||||
switch (shortcut) {
|
||||
case 'Enter':
|
||||
if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Ctrl+Enter':
|
||||
if (event.ctrlKey && !event.shiftKey && !event.metaKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Command+Enter':
|
||||
if (event.metaKey && !event.shiftKey && !event.ctrlKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Alt+Enter':
|
||||
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Shift+Enter':
|
||||
if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
}
|
||||
return isSendMessageKeyPressed
|
||||
}
|
||||
|
||||
@ -1,21 +1,22 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Messages from './components/Messages'
|
||||
interface Props {
|
||||
route: string
|
||||
assistant: Assistant
|
||||
assistant: Assistant | null
|
||||
topic: Topic | null
|
||||
isOutputted: boolean
|
||||
}
|
||||
|
||||
const ChatWindow: FC<Props> = ({ route, assistant }) => {
|
||||
// const { defaultAssistant } = useDefaultAssistant()
|
||||
const ChatWindow: FC<Props> = ({ route, assistant, topic, isOutputted }) => {
|
||||
if (!assistant || !topic) return null
|
||||
|
||||
return (
|
||||
<Main className="bubble">
|
||||
<Messages assistant={{ ...assistant, model: getDefaultModel() }} route={route} />
|
||||
<Messages assistant={assistant} topic={topic} route={route} isOutputted={isOutputted} />
|
||||
</Main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,65 +1,30 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useTopicsForAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { last } from 'lodash'
|
||||
import { FC, useMemo, useRef } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageItem from './Message'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
topic: Topic
|
||||
route: string
|
||||
isOutputted: boolean
|
||||
}
|
||||
|
||||
interface ContainerProps {
|
||||
right?: boolean
|
||||
}
|
||||
|
||||
const Messages: FC<Props> = ({ assistant, route }) => {
|
||||
// const [messages, setMessages] = useState<Message[]>([])
|
||||
const topics = useTopicsForAssistant(assistant.id)
|
||||
const firstTopic = useMemo(() => topics[0], [topics])
|
||||
const Messages: FC<Props> = ({ assistant, topic, route, isOutputted }) => {
|
||||
const messages = useTopicMessages(topic.id)
|
||||
|
||||
const messages = useTopicMessages(firstTopic?.id || '')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const messagesRef = useRef(messages)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
messagesRef.current = messages
|
||||
|
||||
// const onSendMessage = useCallback(
|
||||
// async (message: Message) => {
|
||||
// setMessages((prev) => {
|
||||
// const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
|
||||
// store.dispatch(newMessagesActions.addMessage({ topicId: assistant.topics[0].id, message: assistantMessage }))
|
||||
// const messages = prev.concat([message, assistantMessage])
|
||||
// return messages
|
||||
// })
|
||||
// },
|
||||
// [assistant]
|
||||
// )
|
||||
|
||||
// useEffect(() => {
|
||||
// const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage)]
|
||||
// return () => unsubscribes.forEach((unsub) => unsub())
|
||||
// }, [assistant.id])
|
||||
|
||||
useHotkeys('c', () => {
|
||||
const lastMessage = last(messages)
|
||||
if (lastMessage) {
|
||||
const content = getMainTextContent(lastMessage)
|
||||
navigator.clipboard.writeText(content)
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Container id="messages" key={assistant.id} ref={containerRef}>
|
||||
<Container id="messages" key={assistant.id}>
|
||||
{!isOutputted && <LoadingOutlined style={{ fontSize: 16 }} spin />}
|
||||
{[...messages].reverse().map((message, index) => (
|
||||
<MessageItem key={message.id} message={message} index={index} total={messages.length} route={route} />
|
||||
))}
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultAssistant, useDefaultModel, useTopicsForAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import { getDefaultAssistant, getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import store from '@renderer/store'
|
||||
import { upsertManyBlocks } from '@renderer/store/messageBlock'
|
||||
import { updateOneBlock, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { ThemeMode, Topic } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import { AssistantMessageStatus } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { createMainTextBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
import { createMainTextBlock, createThinkingBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Divider } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { last } from 'lodash'
|
||||
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -33,64 +33,111 @@ import Footer from './components/Footer'
|
||||
import InputBar from './components/InputBar'
|
||||
|
||||
const HomeWindow: FC = () => {
|
||||
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
||||
const [clipboardText, setClipboardText] = useState('')
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [lastClipboardText, setLastClipboardText] = useState<string | null>(null)
|
||||
const textChange = useState(() => {})[1]
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
|
||||
const topics = useTopicsForAssistant(defaultAssistant.id)
|
||||
const topic = useMemo(() => topics[0], [topics])
|
||||
|
||||
const { defaultModel, quickAssistantModel } = useDefaultModel()
|
||||
// 如果 quickAssistantModel 未設定,則使用 defaultModel
|
||||
const model = quickAssistantModel || defaultModel
|
||||
const { language, readClipboardAtStartup } = useSettings()
|
||||
const { language, readClipboardAtStartup, transparentWindow } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
||||
|
||||
const [userInputText, setUserInputText] = useState('')
|
||||
|
||||
const [clipboardText, setClipboardText] = useState('')
|
||||
const lastClipboardTextRef = useRef<string | null>(null)
|
||||
|
||||
const [isPinned, setIsPinned] = useState(false)
|
||||
|
||||
// Indicator for loading(thinking/streaming)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
// Indicator for whether the first message is outputted
|
||||
const [isOutputted, setIsOutputted] = useState(false)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
const { assistant: currentAssistant } = useAssistant(quickAssistantId)
|
||||
|
||||
const currentTopic = useRef<Topic>(getDefaultTopic(currentAssistant.id))
|
||||
const currentAskId = useRef('')
|
||||
|
||||
const inputBarRef = useRef<HTMLDivElement>(null)
|
||||
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
||||
const referenceText = selectedText || clipboardText || text
|
||||
|
||||
const content = isFirstMessage ? (referenceText === text ? text : `${referenceText}\n\n${text}`).trim() : text.trim()
|
||||
const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
|
||||
|
||||
const readClipboard = useCallback(async () => {
|
||||
if (!readClipboardAtStartup) return
|
||||
|
||||
const text = await navigator.clipboard.readText().catch(() => null)
|
||||
if (text && text !== lastClipboardText) {
|
||||
setLastClipboardText(text)
|
||||
setClipboardText(text.trim())
|
||||
const userContent = useMemo(() => {
|
||||
if (isFirstMessage) {
|
||||
return referenceText === userInputText ? userInputText : `${referenceText}\n\n${userInputText}`.trim()
|
||||
}
|
||||
}, [readClipboardAtStartup, lastClipboardText])
|
||||
return userInputText.trim()
|
||||
}, [isFirstMessage, referenceText, userInputText])
|
||||
|
||||
const focusInput = () => {
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
// Reset state when switching to home route
|
||||
useEffect(() => {
|
||||
if (route === 'home') {
|
||||
setIsFirstMessage(true)
|
||||
setError(null)
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (inputBarRef.current) {
|
||||
const input = inputBarRef.current.querySelector('input')
|
||||
if (input) {
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Use useCallback with stable dependencies to avoid infinite loops
|
||||
const readClipboard = useCallback(async () => {
|
||||
if (!readClipboardAtStartup || !document.hasFocus()) return
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text && text !== lastClipboardTextRef.current) {
|
||||
lastClipboardTextRef.current = text
|
||||
setClipboardText(text.trim())
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle clipboard read errors (common in some environments)
|
||||
console.warn('Failed to read clipboard:', error)
|
||||
}
|
||||
}, [readClipboardAtStartup])
|
||||
|
||||
const clearClipboard = useCallback(async () => {
|
||||
setClipboardText('')
|
||||
lastClipboardTextRef.current = null
|
||||
focusInput()
|
||||
}, [focusInput])
|
||||
|
||||
const onWindowShow = useCallback(async () => {
|
||||
featureMenusRef.current?.resetSelectedIndex()
|
||||
readClipboard().then()
|
||||
await readClipboard()
|
||||
focusInput()
|
||||
}, [readClipboard])
|
||||
}, [readClipboard, focusInput])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.miniWindow.setPin(isPinned)
|
||||
}, [isPinned])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
|
||||
}
|
||||
}, [onWindowShow])
|
||||
|
||||
useEffect(() => {
|
||||
readClipboard()
|
||||
}, [readClipboard])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
const onCloseWindow = () => window.api.miniWindow.hide()
|
||||
const handleCloseWindow = useCallback(() => window.api.miniWindow.hide(), [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
|
||||
@ -98,10 +145,7 @@ const HomeWindow: FC = () => {
|
||||
// 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名
|
||||
// 输入法可以`Esc`终止候选词过程
|
||||
// 这两个例子的`Enter`和`Esc`快捷助手都不应该响应
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Process') {
|
||||
if (e.nativeEvent.isComposing || e.key === 'Process') {
|
||||
return
|
||||
}
|
||||
|
||||
@ -109,14 +153,16 @@ const HomeWindow: FC = () => {
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
{
|
||||
if (isLoading) return
|
||||
|
||||
e.preventDefault()
|
||||
if (content) {
|
||||
if (userContent) {
|
||||
if (route === 'home') {
|
||||
featureMenusRef.current?.useFeature()
|
||||
} else {
|
||||
// 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat'
|
||||
// Currently text input is only available in 'chat' mode
|
||||
setRoute('chat')
|
||||
onSendMessage().then()
|
||||
handleSendMessage()
|
||||
focusInput()
|
||||
}
|
||||
}
|
||||
@ -124,11 +170,9 @@ const HomeWindow: FC = () => {
|
||||
break
|
||||
case 'Backspace':
|
||||
{
|
||||
textChange(() => {
|
||||
if (text.length === 0) {
|
||||
clearClipboard()
|
||||
}
|
||||
})
|
||||
if (userInputText.length === 0) {
|
||||
clearClipboard()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
@ -149,200 +193,345 @@ const HomeWindow: FC = () => {
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
setText('')
|
||||
setRoute('home')
|
||||
route === 'home' && onCloseWindow()
|
||||
handleEsc()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.target.value)
|
||||
setUserInputText(e.target.value)
|
||||
}
|
||||
|
||||
const onSendMessage = useCallback(
|
||||
const handleError = (error: Error) => {
|
||||
setIsLoading(false)
|
||||
setError(error.message)
|
||||
}
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (isEmpty(content)) {
|
||||
if (isEmpty(userContent) || !currentTopic.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const messageParams = {
|
||||
role: 'user',
|
||||
content: prompt ? `${prompt}\n\n${content}` : content,
|
||||
assistant: defaultAssistant,
|
||||
topic,
|
||||
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
status: 'success'
|
||||
}
|
||||
const topicId = topic.id
|
||||
const { message: userMessage, blocks } = getUserMessage(messageParams)
|
||||
try {
|
||||
const topicId = currentTopic.current.id
|
||||
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(blocks))
|
||||
const { message: userMessage, blocks } = getUserMessage({
|
||||
content: [prompt, userContent].filter(Boolean).join('\n\n'),
|
||||
assistant: currentAssistant,
|
||||
topic: currentTopic.current
|
||||
})
|
||||
|
||||
const assistant = getDefaultAssistant()
|
||||
let blockId: string | null = null
|
||||
let blockContent: string = ''
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(blocks))
|
||||
|
||||
const assistantMessage = getAssistantMessage({ assistant, topic })
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
const assistantMessage = getAssistantMessage({
|
||||
assistant: currentAssistant,
|
||||
topic: currentTopic.current
|
||||
})
|
||||
assistantMessage.askId = userMessage.id
|
||||
currentAskId.current = userMessage.id
|
||||
|
||||
fetchChatCompletion({
|
||||
messages: [userMessage],
|
||||
assistant: { ...assistant, model: quickAssistantModel || getDefaultModel(), settings: { streamOutput: true } },
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
blockContent += chunk.text
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
const allMessagesForTopic = selectMessagesForTopic(store.getState(), topicId)
|
||||
const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessage.id)
|
||||
|
||||
const messagesForContext = allMessagesForTopic
|
||||
.slice(0, userMessageIndex + 1)
|
||||
.filter((m) => m && !m.status?.includes('ing'))
|
||||
|
||||
let blockId: string | null = null
|
||||
let blockContent: string = ''
|
||||
let thinkingBlockId: string | null = null
|
||||
let thinkingBlockContent: string = ''
|
||||
|
||||
setIsLoading(true)
|
||||
setIsOutputted(false)
|
||||
setError(null)
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setUserInputText('')
|
||||
|
||||
await fetchChatCompletion({
|
||||
messages: messagesForContext,
|
||||
assistant: { ...currentAssistant, settings: { streamOutput: true } },
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
switch (chunk.type) {
|
||||
case ChunkType.THINKING_DELTA:
|
||||
{
|
||||
thinkingBlockContent += chunk.text
|
||||
setIsOutputted(true)
|
||||
if (!thinkingBlockId) {
|
||||
const block = createThinkingBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING,
|
||||
thinking_millsec: chunk.thinking_millsec
|
||||
})
|
||||
thinkingBlockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { content: thinkingBlockContent, thinking_millsec: chunk.thinking_millsec }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ChunkType.THINKING_COMPLETE:
|
||||
{
|
||||
if (thinkingBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ChunkType.TEXT_DELTA:
|
||||
{
|
||||
blockContent += chunk.text
|
||||
setIsOutputted(true)
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case ChunkType.TEXT_COMPLETE:
|
||||
{
|
||||
blockId &&
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
case ChunkType.ERROR: {
|
||||
//stop the thinking timer
|
||||
const isAborted = isAbortError(chunk.error)
|
||||
const possibleBlockId = thinkingBlockId || blockId
|
||||
if (possibleBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: possibleBlockId,
|
||||
changes: {
|
||||
status: isAborted ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
if (!isAborted) {
|
||||
throw new Error(chunk.error.message)
|
||||
}
|
||||
}
|
||||
//fall through
|
||||
case ChunkType.BLOCK_COMPLETE:
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
break
|
||||
}
|
||||
}
|
||||
if (chunk.type === ChunkType.TEXT_COMPLETE) {
|
||||
blockId && store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setText('') // ✅ 清除输入框内容
|
||||
})
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
handleError(err instanceof Error ? err : new Error('An error occurred'))
|
||||
console.error('Error fetching result:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
}
|
||||
},
|
||||
[content, defaultAssistant, topic, quickAssistantModel]
|
||||
[userContent, currentAssistant]
|
||||
)
|
||||
|
||||
const clearClipboard = () => {
|
||||
setClipboardText('')
|
||||
setSelectedText('')
|
||||
focusInput()
|
||||
}
|
||||
const handlePause = useCallback(() => {
|
||||
if (currentAskId.current) {
|
||||
abortCompletion(currentAskId.current)
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
// If the input is focused, the `Esc` callback will not be triggered here.
|
||||
useHotkeys('esc', () => {
|
||||
if (route === 'home') {
|
||||
onCloseWindow()
|
||||
const handleEsc = useCallback(() => {
|
||||
if (isLoading) {
|
||||
handlePause()
|
||||
} else {
|
||||
setRoute('home')
|
||||
setText('')
|
||||
if (route === 'home') {
|
||||
handleCloseWindow()
|
||||
} else {
|
||||
// Clear the topic messages to reduce memory usage
|
||||
if (currentTopic.current) {
|
||||
store.dispatch(newMessagesActions.clearTopicMessages(currentTopic.current.id))
|
||||
}
|
||||
|
||||
// Reset the topic
|
||||
currentTopic.current = getDefaultTopic(currentAssistant.id)
|
||||
|
||||
setError(null)
|
||||
setRoute('home')
|
||||
setUserInputText('')
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [isLoading, route, handleCloseWindow, currentAssistant.id, handlePause])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!currentTopic.current) return
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
|
||||
const messages = selectMessagesForTopic(store.getState(), currentTopic.current.id)
|
||||
const lastMessage = last(messages)
|
||||
|
||||
if (lastMessage) {
|
||||
const content = getMainTextContent(lastMessage)
|
||||
navigator.clipboard.writeText(content)
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
}, [onWindowShow, onSendMessage, setRoute])
|
||||
}, [currentTopic, t])
|
||||
|
||||
// 当路由为home时,初始化isFirstMessage为true
|
||||
useEffect(() => {
|
||||
if (route === 'home') {
|
||||
setIsFirstMessage(true)
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const backgroundColor = () => {
|
||||
const backgroundColor = useMemo(() => {
|
||||
// ONLY MAC: when transparent style + light theme: use vibrancy effect
|
||||
// because the dark style under mac's vibrancy effect has not been implemented
|
||||
if (isMac && theme === ThemeMode.light) {
|
||||
if (isMac && theme === ThemeMode.light && transparentWindow) {
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
return 'var(--color-background)'
|
||||
}
|
||||
}, [transparentWindow, theme])
|
||||
|
||||
if (['chat', 'summary', 'explanation'].includes(route)) {
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
referenceText={referenceText}
|
||||
placeholder={t('miniwindow.input.placeholder.empty', { model: model.name })}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
)}
|
||||
{['summary', 'explanation'].includes(route) && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
</div>
|
||||
)}
|
||||
<ChatWindow route={route} assistant={defaultAssistant} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
// Memoize placeholder text
|
||||
const inputPlaceholder = useMemo(() => {
|
||||
if (referenceText && route === 'home') {
|
||||
return t('miniwindow.input.placeholder.title')
|
||||
}
|
||||
return t('miniwindow.input.placeholder.empty', {
|
||||
model: quickAssistantId ? currentAssistant.name : currentAssistant.model.name
|
||||
})
|
||||
}, [referenceText, route, t, quickAssistantId, currentAssistant])
|
||||
|
||||
if (route === 'translate') {
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
referenceText={referenceText}
|
||||
placeholder={
|
||||
referenceText && route === 'home'
|
||||
? t('miniwindow.input.placeholder.title')
|
||||
: t('miniwindow.input.placeholder.empty', { model: model.name })
|
||||
}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<Main>
|
||||
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} ref={featureMenusRef} />
|
||||
</Main>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer
|
||||
route={route}
|
||||
canUseBackspace={text.length > 0 || clipboardText.length == 0}
|
||||
clearClipboard={clearClipboard}
|
||||
onExit={() => {
|
||||
setRoute('home')
|
||||
setText('')
|
||||
onCloseWindow()
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
// Memoize footer props
|
||||
const baseFooterProps = useMemo(
|
||||
() => ({
|
||||
route,
|
||||
loading: isLoading,
|
||||
onEsc: handleEsc,
|
||||
setIsPinned,
|
||||
isPinned
|
||||
}),
|
||||
[route, isLoading, handleEsc, isPinned]
|
||||
)
|
||||
|
||||
switch (route) {
|
||||
case 'chat':
|
||||
case 'summary':
|
||||
case 'explanation':
|
||||
return (
|
||||
<Container style={{ backgroundColor }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
text={userInputText}
|
||||
assistant={currentAssistant}
|
||||
referenceText={referenceText}
|
||||
placeholder={inputPlaceholder}
|
||||
loading={isLoading}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
)}
|
||||
{['summary', 'explanation'].includes(route) && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
</div>
|
||||
)}
|
||||
<ChatWindow
|
||||
route={route}
|
||||
assistant={currentAssistant}
|
||||
topic={currentTopic.current}
|
||||
isOutputted={isOutputted}
|
||||
/>
|
||||
{error && <ErrorMsg>{error}</ErrorMsg>}
|
||||
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer key="footer" {...baseFooterProps} onCopy={handleCopy} />
|
||||
</Container>
|
||||
)
|
||||
|
||||
case 'translate':
|
||||
return (
|
||||
<Container style={{ backgroundColor }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer key="footer" {...baseFooterProps} />
|
||||
</Container>
|
||||
)
|
||||
|
||||
// Home
|
||||
default:
|
||||
return (
|
||||
<Container style={{ backgroundColor }}>
|
||||
<InputBar
|
||||
text={userInputText}
|
||||
assistant={currentAssistant}
|
||||
referenceText={referenceText}
|
||||
placeholder={inputPlaceholder}
|
||||
loading={isLoading}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<Main>
|
||||
<FeatureMenus
|
||||
setRoute={setRoute}
|
||||
onSendMessage={handleSendMessage}
|
||||
text={userContent}
|
||||
ref={featureMenusRef}
|
||||
/>
|
||||
</Main>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer
|
||||
key="footer"
|
||||
{...baseFooterProps}
|
||||
canUseBackspace={userInputText.length > 0 || clipboardText.length === 0}
|
||||
clearClipboard={clearClipboard}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
@ -363,4 +552,15 @@ const Main = styled.main`
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const ErrorMsg = styled.div`
|
||||
color: var(--color-error);
|
||||
background: rgba(255, 0, 0, 0.15);
|
||||
border: 1px solid var(--color-error);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
export default HomeWindow
|
||||
|
||||
@ -1,25 +1,45 @@
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { Tag as AntdTag, Tooltip } from 'antd'
|
||||
import { CircleArrowLeft, Copy, Pin } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface FooterProps {
|
||||
route: string
|
||||
canUseBackspace?: boolean
|
||||
loading?: boolean
|
||||
setIsPinned: (isPinned: boolean) => void
|
||||
isPinned: boolean
|
||||
clearClipboard?: () => void
|
||||
onExit: () => void
|
||||
onEsc: () => void
|
||||
onCopy?: () => void
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExit }) => {
|
||||
const Footer: FC<FooterProps> = ({
|
||||
route,
|
||||
canUseBackspace,
|
||||
loading,
|
||||
clearClipboard,
|
||||
onEsc,
|
||||
setIsPinned,
|
||||
isPinned,
|
||||
onCopy
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isPinned, setIsPinned] = useState(false)
|
||||
|
||||
const onClickPin = () => {
|
||||
window.api.miniWindow.setPin(!isPinned).then(() => {
|
||||
setIsPinned(!isPinned)
|
||||
})
|
||||
useHotkeys('esc', () => {
|
||||
onEsc()
|
||||
})
|
||||
|
||||
useHotkeys('c', () => {
|
||||
handleCopy()
|
||||
})
|
||||
|
||||
const handleCopy = () => {
|
||||
if (loading || !onCopy) return
|
||||
onCopy()
|
||||
}
|
||||
|
||||
return (
|
||||
@ -27,11 +47,21 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExi
|
||||
<FooterText>
|
||||
<Tag
|
||||
bordered={false}
|
||||
icon={<CircleArrowLeft size={14} color="var(--color-text)" />}
|
||||
icon={
|
||||
loading ? (
|
||||
<LoadingOutlined style={{ fontSize: 12, color: 'var(--color-error)', padding: 0 }} spin />
|
||||
) : (
|
||||
<CircleArrowLeft size={14} color="var(--color-text)" />
|
||||
)
|
||||
}
|
||||
className="nodrag"
|
||||
onClick={() => onExit()}>
|
||||
onClick={onEsc}>
|
||||
{t('miniwindow.footer.esc', {
|
||||
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
|
||||
action: loading
|
||||
? t('miniwindow.footer.esc_pause')
|
||||
: route === 'home'
|
||||
? t('miniwindow.footer.esc_close')
|
||||
: t('miniwindow.footer.esc_back')
|
||||
})}
|
||||
</Tag>
|
||||
{route === 'home' && !canUseBackspace && (
|
||||
@ -44,19 +74,27 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExi
|
||||
{t('miniwindow.footer.backspace_clear')}
|
||||
</Tag>
|
||||
)}
|
||||
{route !== 'home' && (
|
||||
{route !== 'home' && !loading && (
|
||||
<Tag
|
||||
bordered={false}
|
||||
icon={<Copy size={14} color="var(--color-text)" />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className="nodrag">
|
||||
className="nodrag"
|
||||
onClick={handleCopy}>
|
||||
{t('miniwindow.footer.copy_last_message')}
|
||||
</Tag>
|
||||
)}
|
||||
</FooterText>
|
||||
<PinButtonArea onClick={() => onClickPin()} className="nodrag">
|
||||
<PinButtonArea onClick={() => setIsPinned(!isPinned)} className="nodrag">
|
||||
<Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left">
|
||||
<Pin size={14} stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'} />
|
||||
<Pin
|
||||
size={14}
|
||||
stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'}
|
||||
style={{
|
||||
transform: isPinned ? 'rotate(40deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</PinButtonArea>
|
||||
</WindowFooter>
|
||||
@ -84,6 +122,7 @@ const PinButtonArea = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 5px;
|
||||
`
|
||||
|
||||
const Tag = styled(AntdTag)`
|
||||
@ -91,6 +130,12 @@ const Tag = styled(AntdTag)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
export default Footer
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Input as AntdInput } from 'antd'
|
||||
import { InputRef } from 'rc-input/lib/interface'
|
||||
import React, { useRef } from 'react'
|
||||
@ -7,9 +7,10 @@ import styled from 'styled-components'
|
||||
|
||||
interface InputBarProps {
|
||||
text: string
|
||||
model: any
|
||||
assistant: Assistant
|
||||
referenceText: string
|
||||
placeholder: string
|
||||
loading: boolean
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
@ -17,19 +18,19 @@ interface InputBarProps {
|
||||
const InputBar = ({
|
||||
ref,
|
||||
text,
|
||||
model,
|
||||
assistant,
|
||||
placeholder,
|
||||
loading,
|
||||
handleKeyDown,
|
||||
handleChange
|
||||
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const { generating } = useRuntime()
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
if (!generating) {
|
||||
if (!loading) {
|
||||
setTimeout(() => inputRef.current?.input?.focus(), 0)
|
||||
}
|
||||
return (
|
||||
<InputWrapper ref={ref}>
|
||||
<ModelAvatar model={model} size={30} />
|
||||
{assistant.model && <ModelAvatar model={assistant.model} size={30} />}
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
@ -37,7 +38,6 @@ const InputBar = ({
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
disabled={generating}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
||||
21
yarn.lock
21
yarn.lock
@ -2021,7 +2021,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@google/genai@npm:^1.0.1":
|
||||
"@google/genai@npm:1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "@google/genai@npm:1.0.1"
|
||||
dependencies:
|
||||
@ -2035,6 +2035,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@google/genai@patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch":
|
||||
version: 1.0.1
|
||||
resolution: "@google/genai@patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch::version=1.0.1&hash=b1e680"
|
||||
dependencies:
|
||||
google-auth-library: "npm:^9.14.2"
|
||||
ws: "npm:^8.18.0"
|
||||
zod: "npm:^3.22.4"
|
||||
zod-to-json-schema: "npm:^3.22.4"
|
||||
peerDependencies:
|
||||
"@modelcontextprotocol/sdk": ^1.11.0
|
||||
checksum: 10c0/aa38b73de3d84944f51c1f45a3945ea7578b6660276ea748f2349ed42106edc5c81c08872f7fb62cd6e158fc0517283cfe9cdbcce806eee3b62439f60b82496a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@hello-pangea/dnd@npm:^16.6.0":
|
||||
version: 16.6.0
|
||||
resolution: "@hello-pangea/dnd@npm:16.6.0"
|
||||
@ -5589,7 +5603,7 @@ __metadata:
|
||||
"@emotion/is-prop-valid": "npm:^1.3.1"
|
||||
"@eslint-react/eslint-plugin": "npm:^1.36.1"
|
||||
"@eslint/js": "npm:^9.22.0"
|
||||
"@google/genai": "npm:^1.0.1"
|
||||
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch"
|
||||
"@hello-pangea/dnd": "npm:^16.6.0"
|
||||
"@kangfenmao/keyv-storage": "npm:^0.1.0"
|
||||
"@langchain/community": "npm:^0.3.36"
|
||||
@ -5662,6 +5676,7 @@ __metadata:
|
||||
framer-motion: "npm:^12.17.3"
|
||||
franc-min: "npm:^6.2.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
google-auth-library: "npm:^9.15.1"
|
||||
html-to-image: "npm:^1.11.13"
|
||||
husky: "npm:^9.1.7"
|
||||
i18next: "npm:^23.11.5"
|
||||
@ -10281,7 +10296,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"google-auth-library@npm:^9.14.2":
|
||||
"google-auth-library@npm:^9.14.2, google-auth-library@npm:^9.15.1":
|
||||
version: 9.15.1
|
||||
resolution: "google-auth-library@npm:9.15.1"
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user