Merge branch 'main' into feat/sidebar-ui

This commit is contained in:
suyao 2025-06-19 14:58:48 +08:00
commit 5ca0ce682b
No known key found for this signature in database
76 changed files with 27517 additions and 961 deletions

File diff suppressed because one or more lines are too long

View File

@ -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",

View File

@ -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',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,6 @@
import { app } from 'electron'
import { getDataPath } from './utils'
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {

View File

@ -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.

View File

@ -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())

View File

@ -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
}

View File

@ -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) {

View File

@ -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) : []

View File

@ -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

View File

@ -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() {

View 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

View File

@ -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)
}
}
}

View File

@ -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),

View File

@ -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[] {

View File

@ -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

View File

@ -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
}
/**
*
**/

View File

@ -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': {

View File

@ -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> {

View 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)
}
}
}

View File

@ -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)
}
})
}

View File

@ -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[]> {

View File

@ -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({

View File

@ -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

View File

@ -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
}
}

View File

@ -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, // 将包含错误的流传递下去

View File

@ -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 {

View File

@ -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') {

View File

@ -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

View File

@ -25,7 +25,6 @@ export const StreamAdapterMiddleware: CompletionsMiddleware =
// 但是这个中间件的职责是流适配,是否在这调用优待商榷
// 调用下游中间件
const result = await next(ctx, params)
if (
result.rawOutput &&
!(result.rawOutput instanceof ReadableStream) &&

View File

@ -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 消息

View File

@ -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)
}

View File

@ -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

View 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

View File

@ -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

View File

@ -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
}
}

View File

@ -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)

View File

@ -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'
}
}
}

View File

@ -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 }))
}
}

View 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
}

View File

@ -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",

View File

@ -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": "改行",

View File

@ -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": "Перевести",

View File

@ -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": "换行",

View File

@ -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": {

View File

@ -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} />
}

View File

@ -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
}

View File

@ -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"

View File

@ -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)
}

View File

@ -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}

View File

@ -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)

View File

@ -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>
)

View File

@ -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

View File

@ -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

View File

@ -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>
)
}

View File

@ -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

View File

@ -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;

View File

@ -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}>

View File

@ -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

View File

@ -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}

View File

@ -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',

View File

@ -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
})

View File

@ -54,7 +54,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 114,
version: 116,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@ -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

View File

@ -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 &&

View File

@ -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: {

View File

@ -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 = {

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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} />
))}

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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: