Merge remote-tracking branch 'upstream/main' into feat-knowlege-ocr

This commit is contained in:
eeee0717
2025-06-19 16:02:53 +08:00
84 changed files with 27721 additions and 1036 deletions
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -68,12 +68,16 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['pyodide']
exclude: ['pyodide'],
esbuildOptions: {
target: 'esnext' // for dev
}
},
worker: {
format: 'es'
},
build: {
target: 'esnext', // for build
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
+3 -1
View File
@@ -96,7 +96,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",
@@ -166,10 +166,12 @@
"fast-xml-parser": "^5.2.0",
"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",
"jest-styled-components": "^7.2.0",
"linguist-languages": "^8.0.0",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
+10 -1
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
-1
View File
@@ -1,7 +1,6 @@
import { app } from 'electron'
import { getDataPath } from './utils'
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
+2 -4
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.
+77 -2
View File
@@ -7,7 +7,7 @@ import { handleZoomFactor } from '@main/utils/zoom'
import { FeedUrl } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, Provider, 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()
@@ -298,6 +364,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())
+13
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
}
+30
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) {
+2 -1
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) : []
+2 -2
View File
@@ -27,12 +27,12 @@ import OcrProvider from '@main/ocr/OcrProvider'
import PreprocessProvider from '@main/preprocess/PreprocessProvider'
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 { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { v4 as uuidv4 } from 'uuid'
@@ -92,7 +92,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
+25 -8
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() {
+142
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
+56 -10
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 { FileMetadata, 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
@@ -100,12 +155,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)
}
}
}
+13
View File
@@ -37,6 +37,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),
@@ -159,6 +165,13 @@ const api = {
selectionMenu: {
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
},
vertexAI: {
getAuthHeaders: (params: { projectId: string; serviceAccount?: { privateKey: string; clientEmail: string } }) =>
ipcRenderer.invoke(IpcChannel.VertexAI_GetAuthHeaders, params),
clearAuthCache: (projectId: string, clientEmail?: string) =>
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
},
config: {
set: (key: string, value: any, isNotify: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
@@ -20,6 +20,7 @@ import {
SdkToolCall
} from '@renderer/types/sdk'
import { CompletionsContext } from '../middleware/types'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
@@ -163,8 +164,8 @@ export class AihubmixAPIClient extends BaseApiClient {
return this.currentClient.getRequestTransformer()
}
getResponseChunkTransformer(): ResponseChunkTransformer<SdkRawChunk> {
return this.currentClient.getResponseChunkTransformer()
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<SdkRawChunk> {
return this.currentClient.getResponseChunkTransformer(ctx)
}
convertMcpToolsToSdkTools(mcpTools: MCPTool[]): SdkTool[] {
@@ -4,6 +4,7 @@ import { AihubmixAPIClient } from './AihubmixAPIClient'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { VertexAPIClient } from './gemini/VertexAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
@@ -44,6 +45,9 @@ export class ApiClientFactory {
case 'gemini':
instance = new GeminiAPIClient(provider) as BaseApiClient
break
case 'vertexai':
instance = new VertexAPIClient(provider) as BaseApiClient
break
case 'anthropic':
instance = new AnthropicAPIClient(provider) as BaseApiClient
break
@@ -42,7 +42,8 @@ import { defaultTimeout } from '@shared/config/constant'
import Logger from 'electron-log/renderer'
import { isEmpty } from 'lodash'
import { ApiClient, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from './types'
import { CompletionsContext } from '../middleware/types'
import { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types'
/**
* Abstract base class for API clients.
@@ -95,7 +96,7 @@ export abstract class BaseApiClient<
// 在 CoreRequestToSdkParamsMiddleware中使用
abstract getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
// 在RawSdkChunkToGenericChunkMiddleware中使用
abstract getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
abstract getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
/**
* 工具转换
@@ -110,7 +111,7 @@ export abstract class BaseApiClient<
abstract buildSdkMessages(
currentReqMessages: TMessageParam[],
output: TRawOutput | string,
output: TRawOutput | string | undefined,
toolResults: TMessageParam[],
toolCalls?: TToolCall[]
): TMessageParam[]
@@ -129,17 +130,6 @@ export abstract class BaseApiClient<
*/
abstract extractMessagesFromSdkPayload(sdkPayload: TSdkParams): TMessageParam[]
/**
* 附加原始流监听器
*/
public attachRawStreamListener<TListener extends RawStreamListener<TRawChunk>>(
rawOutput: TRawOutput,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_listener: TListener
): TRawOutput {
return rawOutput
}
/**
* 通用函数
**/
@@ -125,7 +125,7 @@ export class AnthropicAPIClient extends BaseApiClient<
// @ts-ignore sdk未提供
override async getEmbeddingDimensions(): Promise<number> {
return 0
throw new Error("Anthropic SDK doesn't support getEmbeddingDimensions method.")
}
override getTemperature(assistant: Assistant, model: Model): number | undefined {
@@ -367,12 +367,13 @@ export class AnthropicAPIClient extends BaseApiClient<
* Anthropic专用的原始流监听器
* 处理MessageStream对象的特定事件
*/
override attachRawStreamListener(
attachRawStreamListener(
rawOutput: AnthropicSdkRawOutput,
listener: RawStreamListener<AnthropicSdkRawChunk>
): AnthropicSdkRawOutput {
console.log(`[AnthropicApiClient] 附加流监听器到原始输出`)
// 专用的Anthropic事件处理
const anthropicListener = listener as AnthropicStreamListener
// 检查是否为MessageStream
if (rawOutput instanceof MessageStream) {
console.log(`[AnthropicApiClient] 检测到 Anthropic MessageStream,附加专用监听器`)
@@ -387,9 +388,6 @@ export class AnthropicAPIClient extends BaseApiClient<
})
}
// 专用的Anthropic事件处理
const anthropicListener = listener as AnthropicStreamListener
if (anthropicListener.onContentBlock) {
rawOutput.on('contentBlock', anthropicListener.onContentBlock)
}
@@ -413,6 +411,10 @@ export class AnthropicAPIClient extends BaseApiClient<
return rawOutput
}
if (anthropicListener.onMessage) {
anthropicListener.onMessage(rawOutput)
}
// 对于非MessageStream响应
return rawOutput
}
@@ -518,6 +520,7 @@ export class AnthropicAPIClient extends BaseApiClient<
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
switch (rawChunk.type) {
case 'message': {
let i = 0
for (const content of rawChunk.content) {
switch (content.type) {
case 'text': {
@@ -528,7 +531,8 @@ export class AnthropicAPIClient extends BaseApiClient<
break
}
case 'tool_use': {
toolCalls[0] = content
toolCalls[i] = content
i++
break
}
case 'thinking': {
@@ -550,6 +554,22 @@ export class AnthropicAPIClient extends BaseApiClient<
}
}
}
if (i > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: Object.values(toolCalls)
} as MCPToolCreatedChunk)
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
usage: {
prompt_tokens: rawChunk.usage.input_tokens || 0,
completion_tokens: rawChunk.usage.output_tokens || 0,
total_tokens: (rawChunk.usage.input_tokens || 0) + (rawChunk.usage.output_tokens || 0)
}
}
})
break
}
case 'content_block_start': {
@@ -147,15 +147,12 @@ export class GeminiAPIClient extends BaseApiClient<
override async getEmbeddingDimensions(model: Model): Promise<number> {
const sdk = await this.getSdkInstance()
try {
const data = await sdk.models.embedContent({
model: model.id,
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
})
return data.embeddings?.[0]?.values?.length || 0
} catch (e) {
return 0
}
const data = await sdk.models.embedContent({
model: model.id,
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
})
return data.embeddings?.[0]?.values?.length || 0
}
override async listModels(): Promise<GeminiModel[]> {
@@ -176,12 +173,23 @@ export class GeminiAPIClient extends BaseApiClient<
this.sdkInstance = new GoogleGenAI({
vertexai: false,
apiKey: this.apiKey,
httpOptions: { baseUrl: this.getBaseURL() }
apiVersion: this.getApiVersion(),
httpOptions: {
baseUrl: this.getBaseURL(),
apiVersion: this.getApiVersion()
}
})
return this.sdkInstance
}
protected getApiVersion(): string {
if (this.provider.isVertex) {
return 'v1'
}
return 'v1beta'
}
/**
* Handle a PDF file
* @param file - The file
@@ -405,8 +413,9 @@ export class GeminiAPIClient extends BaseApiClient<
}
}
const { max } = findTokenLimit(model.id) || { max: 0 }
const budget = Math.floor(max * effortRatio)
const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 }
// 计算 budgetTokens,确保不低于 min
const budget = Math.floor((max - min) * effortRatio + min)
return {
thinkingConfig: {
@@ -455,7 +464,7 @@ export class GeminiAPIClient extends BaseApiClient<
systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools, assistant)
}
let messageContents: Content
let messageContents: Content = { role: 'user', parts: [] } // Initialize messageContents
const history: Content[] = []
// 3. 处理用户消息
if (typeof messages === 'string') {
@@ -464,10 +473,12 @@ export class GeminiAPIClient extends BaseApiClient<
parts: [{ text: messages }]
}
} else {
const userLastMessage = messages.pop()!
messageContents = await this.convertMessageToSdkParam(userLastMessage)
for (const message of messages) {
history.push(await this.convertMessageToSdkParam(message))
const userLastMessage = messages.pop()
if (userLastMessage) {
messageContents = await this.convertMessageToSdkParam(userLastMessage)
for (const message of messages) {
history.push(await this.convertMessageToSdkParam(message))
}
}
}
@@ -480,6 +491,10 @@ export class GeminiAPIClient extends BaseApiClient<
if (isGemmaModel(model) && assistant.prompt) {
const isFirstMessage = history.length === 0
if (isFirstMessage && messageContents) {
const userMessageText =
messageContents.parts && messageContents.parts.length > 0
? (messageContents.parts[0] as Part).text || ''
: ''
const systemMessage = [
{
text:
@@ -487,7 +502,7 @@ export class GeminiAPIClient extends BaseApiClient<
systemInstruction +
'<end_of_turn>\n' +
'<start_of_turn>user\n' +
(messageContents?.parts?.[0] as Part).text +
userMessageText +
'<end_of_turn>'
}
] as Part[]
@@ -504,13 +519,7 @@ export class GeminiAPIClient extends BaseApiClient<
const newMessageContents =
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? {
...messageContents,
parts: [
...(messageContents.parts || []),
...(recursiveSdkMessages[recursiveSdkMessages.length - 1].parts || [])
]
}
? recursiveSdkMessages[recursiveSdkMessages.length - 1]
: messageContents
const generateContentConfig: GenerateContentConfig = {
@@ -544,7 +553,7 @@ export class GeminiAPIClient extends BaseApiClient<
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
return () => ({
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
let toolCalls: FunctionCall[] = []
const toolCalls: FunctionCall[] = []
if (chunk.candidates && chunk.candidates.length > 0) {
for (const candidate of chunk.candidates) {
if (candidate.content) {
@@ -572,6 +581,8 @@ export class GeminiAPIClient extends BaseApiClient<
]
}
})
} else if (part.functionCall) {
toolCalls.push(part.functionCall)
}
})
}
@@ -586,9 +597,6 @@ export class GeminiAPIClient extends BaseApiClient<
}
} as LLMWebSearchCompleteChunk)
}
if (chunk.functionCalls) {
toolCalls = toolCalls.concat(chunk.functionCalls)
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
@@ -691,12 +699,11 @@ export class GeminiAPIClient extends BaseApiClient<
.filter((p) => p !== undefined)
)
const userMessage: Content = {
role: 'user',
parts: parts
const lastMessage = currentReqMessages[currentReqMessages.length - 1]
if (lastMessage) {
lastMessage.parts?.push(...parts)
}
return [...currentReqMessages, userMessage]
return currentReqMessages
}
override estimateMessageTokens(message: GeminiSdkMessageParam): number {
@@ -723,7 +730,20 @@ export class GeminiAPIClient extends BaseApiClient<
}
public extractMessagesFromSdkPayload(sdkPayload: GeminiSdkParams): GeminiSdkMessageParam[] {
return sdkPayload.history || []
const messageParam: GeminiSdkMessageParam = {
role: 'user',
parts: []
}
if (Array.isArray(sdkPayload.message)) {
sdkPayload.message.forEach((part) => {
if (typeof part === 'string') {
messageParam.parts?.push({ text: part })
} else if (typeof part === 'object') {
messageParam.parts?.push(part)
}
})
}
return [messageParam, ...(sdkPayload.history || [])]
}
private async uploadFile(file: FileMetadata): Promise<File> {
@@ -0,0 +1,95 @@
import { GoogleGenAI } from '@google/genai'
import { getVertexAILocation, getVertexAIProjectId, getVertexAIServiceAccount } from '@renderer/hooks/useVertexAI'
import { Provider } from '@renderer/types'
import { GeminiAPIClient } from './GeminiAPIClient'
export class VertexAPIClient extends GeminiAPIClient {
private authHeaders?: Record<string, string>
private authHeadersExpiry?: number
constructor(provider: Provider) {
super(provider)
}
override async getSdkInstance() {
if (this.sdkInstance) {
return this.sdkInstance
}
const serviceAccount = getVertexAIServiceAccount()
const projectId = getVertexAIProjectId()
const location = getVertexAILocation()
if (!serviceAccount.privateKey || !serviceAccount.clientEmail || !projectId || !location) {
throw new Error('Vertex AI settings are not configured')
}
const authHeaders = await this.getServiceAccountAuthHeaders()
this.sdkInstance = new GoogleGenAI({
vertexai: true,
project: projectId,
location: location,
httpOptions: {
apiVersion: this.getApiVersion(),
headers: authHeaders
}
})
return this.sdkInstance
}
/**
* 获取认证头,如果配置了 service account 则从主进程获取
*/
private async getServiceAccountAuthHeaders(): Promise<Record<string, string> | undefined> {
const serviceAccount = getVertexAIServiceAccount()
const projectId = getVertexAIProjectId()
// 检查是否配置了 service account
if (!serviceAccount.privateKey || !serviceAccount.clientEmail || !projectId) {
return undefined
}
// 检查是否已有有效的认证头(提前 5 分钟过期)
const now = Date.now()
if (this.authHeaders && this.authHeadersExpiry && this.authHeadersExpiry - now > 5 * 60 * 1000) {
return this.authHeaders
}
try {
// 从主进程获取认证头
this.authHeaders = await window.api.vertexAI.getAuthHeaders({
projectId,
serviceAccount: {
privateKey: serviceAccount.privateKey,
clientEmail: serviceAccount.clientEmail
}
})
// 设置过期时间(通常认证头有效期为 1 小时)
this.authHeadersExpiry = now + 60 * 60 * 1000
return this.authHeaders
} catch (error: any) {
console.error('Failed to get auth headers:', error)
throw new Error(`Service Account authentication failed: ${error.message}`)
}
}
/**
* 清理认证缓存并重新初始化
*/
clearAuthCache(): void {
this.authHeaders = undefined
this.authHeadersExpiry = undefined
const serviceAccount = getVertexAIServiceAccount()
const projectId = getVertexAIProjectId()
if (projectId && serviceAccount.clientEmail) {
window.api.vertexAI.clearAuthCache(projectId, serviceAccount.clientEmail)
}
}
}
@@ -337,10 +337,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
public buildSdkMessages(
currentReqMessages: OpenAISdkMessageParam[],
output: string,
output: string | undefined,
toolResults: OpenAISdkMessageParam[],
toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[]
): OpenAISdkMessageParam[] {
if (!output && toolCalls.length === 0) {
return [...currentReqMessages, ...toolResults]
}
const assistantMessage: OpenAISdkMessageParam = {
role: 'assistant',
content: output,
@@ -490,7 +494,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 在RawSdkChunkToGenericChunkMiddleware中使用
getResponseChunkTransformer = (): ResponseChunkTransformer<OpenAISdkRawChunk> => {
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAISdkRawChunk> {
let hasBeenCollectedWebSearch = false
const collectWebSearchData = (
chunk: OpenAISdkRawChunk,
@@ -584,9 +588,52 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
return null
}
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []
let isFinished = false
let lastUsageInfo: any = null
/**
* 统一的完成信号发送逻辑
* - 有 finish_reason 时
* - 无 finish_reason 但是流正常结束时
*/
const emitCompletionSignals = (controller: TransformStreamDefaultController<GenericChunk>) => {
if (isFinished) return
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
}
const usage = lastUsageInfo || {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: { usage }
})
// 防止重复发送
isFinished = true
}
return (context: ResponseChunkTransformerContext) => ({
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 持续更新usage信息
if (chunk.usage) {
lastUsageInfo = {
prompt_tokens: chunk.usage.prompt_tokens || 0,
completion_tokens: chunk.usage.completion_tokens || 0,
total_tokens: (chunk.usage.prompt_tokens || 0) + (chunk.usage.completion_tokens || 0)
}
}
// 处理chunk
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
const choice = chunk.choices[0]
@@ -651,12 +698,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 处理finish_reason,发送流结束信号
if ('finish_reason' in choice && choice.finish_reason) {
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
}
const webSearchData = collectWebSearchData(chunk, contentSource, context)
if (webSearchData) {
controller.enqueue({
@@ -664,18 +705,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
llm_web_search: webSearchData
})
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
usage: {
prompt_tokens: chunk.usage?.prompt_tokens || 0,
completion_tokens: chunk.usage?.completion_tokens || 0,
total_tokens: (chunk.usage?.prompt_tokens || 0) + (chunk.usage?.completion_tokens || 0)
}
}
})
emitCompletionSignals(controller)
}
}
},
// 流正常结束时,检查是否需要发送完成信号
flush(controller) {
if (isFinished) return
Logger.debug('[OpenAIApiClient] Stream ended without finish_reason, emitting fallback completion signals')
emitCompletionSignals(controller)
}
})
}
@@ -85,16 +85,13 @@ export abstract class OpenAIBaseClient<
override async getEmbeddingDimensions(model: Model): Promise<number> {
const sdk = await this.getSdkInstance()
try {
const data = await sdk.embeddings.create({
model: model.id,
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
encoding_format: 'float'
})
return data.data[0].embedding.length
} catch (e) {
return 0
}
const data = await sdk.embeddings.create({
model: model.id,
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
encoding_format: 'float'
})
return data.data[0].embedding.length
}
override async listModels(): Promise<OpenAI.Models.Model[]> {
@@ -1,4 +1,5 @@
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
import {
isOpenAIChatCompletionOnlyModel,
isSupportedReasoningEffortOpenAIModel,
@@ -38,6 +39,7 @@ import { buildSystemPrompt } from '@renderer/utils/prompt'
import { MB } from '@shared/config/constant'
import { isEmpty } from 'lodash'
import OpenAI from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
import { OpenAIAPIClient } from './OpenAIApiClient'
@@ -225,17 +227,29 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return
}
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
const content: OpenAI.Responses.ResponseInput = []
content.push(...response.output)
return content
}
public buildSdkMessages(
currentReqMessages: OpenAIResponseSdkMessageParam[],
output: string,
output: OpenAI.Responses.Response | undefined,
toolResults: OpenAIResponseSdkMessageParam[],
toolCalls: OpenAIResponseSdkToolCall[]
): OpenAIResponseSdkMessageParam[] {
const assistantMessage: OpenAIResponseSdkMessageParam = {
role: 'assistant',
content: [{ type: 'input_text', text: output }]
if (!output && toolCalls.length === 0) {
return [...currentReqMessages, ...toolResults]
}
const newReqMessages = [...currentReqMessages, assistantMessage, ...(toolCalls || []), ...(toolResults || [])]
if (!output) {
return [...currentReqMessages, ...(toolCalls || []), ...(toolResults || [])]
}
const content = this.convertResponseToMessageContent(output)
const newReqMessages = [...currentReqMessages, ...content, ...(toolResults || [])]
return newReqMessages
}
@@ -407,13 +421,17 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
}
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
const toolCalls: OpenAIResponseSdkToolCall[] = []
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
let hasBeenCollectedToolCalls = false
return () => ({
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 处理chunk
if ('output' in chunk) {
if (ctx._internal?.toolProcessingState) {
ctx._internal.toolProcessingState.output = chunk
}
for (const output of chunk.output) {
switch (output.type) {
case 'message':
@@ -455,6 +473,22 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
})
}
}
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
usage: {
prompt_tokens: chunk.usage?.input_tokens || 0,
completion_tokens: chunk.usage?.output_tokens || 0,
total_tokens: chunk.usage?.total_tokens || 0
}
}
})
} else {
switch (chunk.type) {
case 'response.output_item.added':
@@ -502,7 +536,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
if (outputItem.type === 'function_call') {
toolCalls.push({
...outputItem,
arguments: chunk.arguments
arguments: chunk.arguments,
status: 'completed'
})
}
}
@@ -518,15 +553,26 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
})
}
if (toolCalls.length > 0) {
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
hasBeenCollectedToolCalls = true
}
break
}
case 'response.completed': {
if (ctx._internal?.toolProcessingState) {
ctx._internal.toolProcessingState.output = chunk.response
}
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
hasBeenCollectedToolCalls = true
}
const completion_tokens = chunk.response.usage?.output_tokens || 0
const total_tokens = chunk.response.usage?.total_tokens || 0
controller.enqueue({
+12 -1
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
+4 -2
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
}
}
@@ -1,5 +1,4 @@
import { Chunk } from '@renderer/types/chunk'
import { isAbortError } from '@renderer/utils/error'
import { CompletionsResult } from '../schemas'
import { CompletionsContext } from '../types'
@@ -26,30 +25,27 @@ export const ErrorHandlerMiddleware =
// 尝试执行下一个中间件
return await next(ctx, params)
} catch (error: any) {
let errorStream: ReadableStream<Chunk> | undefined
// 有些sdk的abort error 是直接抛出的
if (!isAbortError(error)) {
// 1. 使用通用的工具函数将错误解析为标准格式
const errorChunk = createErrorChunk(error)
// 2. 调用从外部传入的 onError 回调
if (params.onError) {
params.onError(error)
}
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
if (shouldThrow) {
throw error
}
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
errorStream = new ReadableStream<Chunk>({
start(controller) {
controller.enqueue(errorChunk)
controller.close()
}
})
console.log('ErrorHandlerMiddleware_error', error)
// 1. 使用通用的工具函数将错误解析为标准格式
const errorChunk = createErrorChunk(error)
// 2. 调用从外部传入的 onError 回调
if (params.onError) {
params.onError(error)
}
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
if (shouldThrow) {
throw error
}
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
const errorStream = new ReadableStream<Chunk>({
start(controller) {
controller.enqueue(errorChunk)
controller.close()
}
})
return {
rawOutput: undefined,
stream: errorStream, // 将包含错误的流传递下去
@@ -153,7 +153,7 @@ function createToolHandlingTransform(
if (toolResult.length > 0) {
const output = ctx._internal.toolProcessingState?.output
const newParams = buildParamsWithToolResults(ctx, currentParams, output!, toolResult, toolCalls)
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
await executeWithToolHandling(newParams, depth + 1)
}
} catch (error) {
@@ -243,7 +243,7 @@ async function executeToolUseResponses(
function buildParamsWithToolResults(
ctx: CompletionsContext,
currentParams: CompletionsParams,
output: SdkRawOutput | string,
output: SdkRawOutput | string | undefined,
toolResults: SdkMessageParam[],
toolCalls: SdkToolCall[]
): CompletionsParams {
@@ -15,8 +15,6 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
// 在这里可以监听到从SDK返回的最原始流
if (result.rawOutput) {
console.log(`[${MIDDLEWARE_NAME}] 检测到原始SDK输出,准备附加监听器`)
const providerType = ctx.apiClientInstance.provider.type
// TODO: 后面下放到AnthropicAPIClient
if (providerType === 'anthropic') {
@@ -37,7 +37,7 @@ export const ResponseTransformMiddleware: CompletionsMiddleware =
}
// 获取响应转换器
const responseChunkTransformer = apiClient.getResponseChunkTransformer?.()
const responseChunkTransformer = apiClient.getResponseChunkTransformer(ctx)
if (!responseChunkTransformer) {
Logger.warn(`[${MIDDLEWARE_NAME}] No ResponseChunkTransformer available, skipping transformation`)
return result
@@ -25,7 +25,6 @@ export const StreamAdapterMiddleware: CompletionsMiddleware =
// 但是这个中间件的职责是流适配,是否在这调用优待商榷
// 调用下游中间件
const result = await next(ctx, params)
if (
result.rawOutput &&
!(result.rawOutput instanceof ReadableStream) &&
@@ -14,8 +14,6 @@ export const TransformCoreToSdkParamsMiddleware: CompletionsMiddleware =
() =>
(next) =>
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
Logger.debug(`🔄 [${MIDDLEWARE_NAME}] Starting core to SDK params transformation:`, ctx)
const internal = ctx._internal
// 🔧 检测递归调用:检查 params 中是否携带了预处理的 SDK 消息
@@ -17,7 +17,6 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
const { assistant, messages } = params
const client = context.apiClientInstance as BaseApiClient<OpenAI>
const signal = context._internal?.flowControl?.abortSignal
if (!assistant.model || !isDedicatedImageGenerationModel(assistant.model) || typeof messages === 'string') {
return next(context, params)
}
+8 -1
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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

@@ -4,7 +4,7 @@ import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/compon
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
import { isValidPlantUML } from '@renderer/utils/markdown'
import { getExtensionByLanguage, isValidPlantUML } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
@@ -67,23 +67,21 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
}, [children, t])
const handleDownloadSource = useCallback(() => {
const handleDownloadSource = useCallback(async () => {
let fileName = ''
// 尝试提取标题
// 尝试提取 HTML 标题
if (language === 'html' && children.includes('</html>')) {
const title = extractTitle(children)
if (title) {
fileName = `${title}.html`
}
fileName = extractTitle(children) || ''
}
// 默认使用日期格式命名
if (!fileName) {
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
}
window.api.file.save(fileName, children)
const ext = await getExtensionByLanguage(language)
window.api.file.save(`${fileName}${ext}`, children)
}, [children, language])
const handleRunScript = useCallback(() => {
@@ -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
+2 -162
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
}
}
+125 -104
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'
}
],
@@ -2179,76 +2204,83 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'DeepSeek-R1满血版',
group: 'DeepSeek'
}
]
],
lanyun: []
}
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 = [
@@ -2257,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',
@@ -2276,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']
@@ -2334,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
@@ -2623,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') {
@@ -2806,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 },
@@ -2832,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)
+27 -1
View File
@@ -22,6 +22,7 @@ import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png'
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
@@ -41,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'
@@ -98,7 +100,9 @@ const PROVIDER_LOGO_MAP = {
voyageai: VoyageAIProviderLogo,
qiniu: QiniuProviderLogo,
tokenflux: TokenFluxProviderLogo,
cephalon: CephalonProviderLogo
cephalon: CephalonProviderLogo,
lanyun: LanyunProviderLogo,
vertexai: VertexAIProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -638,5 +642,27 @@ export const PROVIDER_CONFIG = {
docs: 'https://cephalon.cloud/apitoken/1864244127731589124',
models: 'https://cephalon.cloud/model'
}
},
lanyun: {
api: {
url: 'https://maas-api.lanyun.net'
},
websites: {
official: 'https://lanyun.net',
apiKey: 'https://maas.lanyun.net/api/#/system/apiKey',
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'
}
}
}
+3 -5
View File
@@ -15,7 +15,7 @@ import {
updateTopic,
updateTopics
} from '@renderer/store/assistants'
import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { useCallback, useMemo } from 'react'
@@ -103,17 +103,15 @@ export function useDefaultAssistant() {
}
export function useDefaultModel() {
const { defaultModel, topicNamingModel, translateModel, quickAssistantModel } = useAppSelector((state) => state.llm)
const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm)
const dispatch = useAppDispatch()
return {
defaultModel,
topicNamingModel,
translateModel,
quickAssistantModel,
setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })),
setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })),
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })),
setQuickAssistantModel: (model: Model) => dispatch(setQuickAssistantModel({ model }))
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model }))
}
}
+37
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
}
+69 -11
View File
@@ -183,7 +183,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 ",
@@ -765,7 +765,8 @@
"backspace_clear": "Backspace to clear",
"esc": "ESC to {{action}}",
"esc_back": "return",
"esc_close": "close"
"esc_close": "close",
"esc_pause": "pause"
},
"input": {
"placeholder": {
@@ -796,9 +797,21 @@
"string": "Text"
},
"pinned": "Pinned",
"rerank_model": "Reordering Model",
"rerank_model_support_provider": "Currently, the reordering model only supports some providers ({{provider}})",
"rerank_model_not_support_provider": "Currently, the reordering model does not support this provider ({{provider}})",
"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}})",
"rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add.",
"search": "Search models...",
"stream_output": "Stream output",
@@ -808,7 +821,7 @@
"free": "Free",
"function_calling": "Tool",
"reasoning": "Reasoning",
"rerank": "Reordering",
"rerank": "Reranker",
"select": "Select Model Types",
"text": "Text",
"vision": "Vision",
@@ -1029,7 +1042,9 @@
"voyageai": "Voyage AI",
"qiniu": "Qiniu AI",
"tokenflux": "TokenFlux",
"302ai": "302.AI"
"302ai": "302.AI",
"lanyun": "LANYUN",
"vertexai": "Vertex AI"
},
"restore": {
"confirm": "Are you sure you want to restore data?",
@@ -1080,6 +1095,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",
@@ -1112,7 +1145,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": {
@@ -1203,8 +1237,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",
@@ -1601,6 +1633,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!",
@@ -1686,6 +1722,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": {
@@ -1890,7 +1947,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",
+67 -9
View File
@@ -183,7 +183,7 @@
"input.new.context": "コンテキストをクリア {{Command}}",
"input.new_topic": "新しいトピック {{Command}}",
"input.pause": "一時停止",
"input.placeholder": "ここにメッセージを入力...",
"input.placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"input.send": "送信",
"input.settings": "設定",
"input.topics": " トピック ",
@@ -762,10 +762,11 @@
},
"footer": {
"copy_last_message": "C キーを押してコピー",
"backspace_clear": "バックスペースを押してクリアします",
"esc": "ESC キーを押して{{action}}",
"esc_back": "戻る",
"esc_close": "ウィンドウを閉じる",
"backspace_clear": "バックスペースを押してクリアします"
"esc_pause": "一時停止"
},
"input": {
"placeholder": {
@@ -813,7 +814,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": "ダイアログを展開",
@@ -1029,7 +1042,9 @@
"qiniu": "七牛云 AI 推理",
"tokenflux": "TokenFlux",
"302ai": "302.AI",
"cephalon": "Cephalon"
"cephalon": "Cephalon",
"lanyun": "LANYUN",
"vertexai": "Vertex AI"
},
"restore": {
"confirm": "データを復元しますか?",
@@ -1078,7 +1093,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": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?",
@@ -1110,7 +1143,8 @@
"obsidian": "Obsidianにエクスポート",
"siyuan": "思源ノートにエクスポート",
"joplin": "Joplinにエクスポート",
"docx": "Wordとしてエクスポート"
"docx": "Wordとしてエクスポート",
"plain_text": "プレーンテキストとしてコピー"
},
"joplin": {
"check": {
@@ -1183,8 +1217,6 @@
"restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?",
"restore.confirm.title": "復元を確認",
"restore.content": "WebDAVから復元すると現在のデータが上書きされます。続行しますか?",
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
"restore.modal.title": "WebDAV から復元",
"restore.title": "WebDAVから復元",
"syncError": "バックアップエラー",
"syncStatus": "バックアップ状態",
@@ -1595,6 +1627,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": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
@@ -1674,6 +1710,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": {
@@ -1889,7 +1946,8 @@
"model_desc": "翻訳サービスで使用されるモデル",
"bidirectional": "双方向翻訳設定",
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
"scroll_sync": "スクロール同期設定"
"scroll_sync": "スクロール同期設定",
"preview": "Markdown プレビュー"
},
"title": "翻訳",
"tooltip.newline": "改行",
+70 -12
View File
@@ -183,7 +183,7 @@
"input.new.context": "Очистить контекст {{Command}}",
"input.new_topic": "Новый топик {{Command}}",
"input.pause": "Остановить",
"input.placeholder": "Введите ваше сообщение здесь...",
"input.placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"input.send": "Отправить",
"input.settings": "Настройки",
"input.topics": " Топики ",
@@ -762,10 +762,11 @@
},
"footer": {
"copy_last_message": "Нажмите C для копирования",
"backspace_clear": "Нажмите Backspace, чтобы очистить",
"esc": "Нажмите ESC {{action}}",
"esc_back": "возвращения",
"esc_close": "закрытия окна",
"backspace_clear": "Нажмите Backspace, чтобы очистить"
"esc_pause": "пауза"
},
"input": {
"placeholder": {
@@ -813,7 +814,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": "Развернуть диалоговое окно",
@@ -859,9 +872,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": "Изображение недоступно",
@@ -972,7 +984,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": "Объясните мне этот концепт",
@@ -1029,7 +1042,9 @@
"voyageai": "Voyage AI",
"qiniu": "Qiniu AI",
"tokenflux": "TokenFlux",
"302ai": "302.AI"
"302ai": "302.AI",
"lanyun": "LANYUN",
"vertexai": "Vertex AI"
},
"restore": {
"confirm": "Вы уверены, что хотите восстановить данные?",
@@ -1078,7 +1093,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": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?",
@@ -1110,7 +1143,8 @@
"obsidian": "Экспорт в Obsidian",
"siyuan": "Экспорт в SiYuan Note",
"joplin": "Экспорт в Joplin",
"docx": "Экспорт в Word"
"docx": "Экспорт в Word",
"plain_text": "Копировать как чистый текст"
},
"joplin": {
"check": {
@@ -1201,8 +1235,6 @@
"restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
"restore.confirm.title": "Подтверждение восстановления",
"restore.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
"restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления",
"restore.modal.title": "Восстановление с WebDAV",
"restore.title": "Восстановление с WebDAV",
"syncError": "Ошибка резервного копирования",
"syncStatus": "Статус резервного копирования",
@@ -1595,6 +1627,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": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
@@ -1674,6 +1710,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": {
@@ -1889,7 +1946,8 @@
"model_desc": "Модель, используемая для службы перевода",
"bidirectional": "Настройки двунаправленного перевода",
"scroll_sync": "Настройки синхронизации прокрутки",
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.",
"preview": "Markdown предпросмотр"
},
"title": "Перевод",
"tooltip.newline": "Перевести",
+65 -7
View File
@@ -183,7 +183,7 @@
"input.new.context": "清除上下文 {{Command}}",
"input.new_topic": "新话题 {{Command}}",
"input.pause": "暂停",
"input.placeholder": "在这里输入消息...",
"input.placeholder": "在这里输入消息,按 {{key}} 发送...",
"input.translating": "翻译中...",
"input.send": "发送",
"input.settings": "设置",
@@ -765,7 +765,8 @@
"backspace_clear": "按 Backspace 清空",
"esc": "按 ESC {{action}}",
"esc_back": "返回",
"esc_close": "关闭"
"esc_close": "关闭",
"esc_pause": "暂停"
},
"input": {
"placeholder": {
@@ -796,6 +797,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}})",
@@ -1029,7 +1042,9 @@
"voyageai": "Voyage AI",
"qiniu": "七牛云 AI 推理",
"tokenflux": "TokenFlux",
"302ai": "302.AI"
"302ai": "302.AI",
"lanyun": "蓝耘科技",
"vertexai": "Vertex AI"
},
"restore": {
"confirm": "确定要恢复数据吗?",
@@ -1080,6 +1095,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": "删除知识库文件",
@@ -1112,7 +1145,8 @@
"obsidian": "导出到Obsidian",
"siyuan": "导出到思源笔记",
"joplin": "导出到Joplin",
"docx": "导出为Word"
"docx": "导出为Word",
"plain_text": "复制为纯文本"
},
"joplin": {
"check": {
@@ -1205,8 +1239,6 @@
"restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?",
"restore.confirm.title": "确认恢复",
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
"restore.modal.title": "从 WebDAV 恢复",
"restore.title": "从 WebDAV 恢复",
"syncError": "备份错误",
"syncStatus": "备份状态",
@@ -1601,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": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
@@ -1686,6 +1722,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": {
@@ -1892,7 +1949,8 @@
"model_desc": "翻译服务使用的模型",
"bidirectional": "双向翻译设置",
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
"scroll_sync": "滚动同步设置"
"scroll_sync": "滚动同步设置",
"preview": "Markdown 预览"
},
"title": "翻译",
"tooltip.newline": "换行",
+68 -10
View File
@@ -183,7 +183,7 @@
"input.new.context": "清除上下文 {{Command}}",
"input.new_topic": "新話題 {{Command}}",
"input.pause": "暫停",
"input.placeholder": "在此輸入您的訊息...",
"input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...",
"input.send": "傳送",
"input.settings": "設定",
"input.topics": " 話題 ",
@@ -762,10 +762,11 @@
},
"footer": {
"copy_last_message": "按 C 鍵複製",
"backspace_clear": "按 Backspace 清空",
"esc": "按 ESC {{action}}",
"esc_back": "返回",
"esc_close": "關閉視窗",
"backspace_clear": "按 Backspace 清空"
"esc_pause": "暫停"
},
"input": {
"placeholder": {
@@ -813,7 +814,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": "伸縮對話框",
@@ -1029,7 +1042,9 @@
"voyageai": "Voyage AI",
"qiniu": "七牛雲 AI 推理",
"tokenflux": "TokenFlux",
"302ai": "302.AI"
"302ai": "302.AI",
"lanyun": "藍耘",
"vertexai": "Vertex AI"
},
"restore": {
"confirm": "確定要復原資料嗎?",
@@ -1079,7 +1094,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": "刪除知識庫檔案",
@@ -1112,7 +1145,8 @@
"obsidian": "匯出到Obsidian",
"siyuan": "匯出到思源筆記",
"joplin": "匯出到Joplin",
"docx": "匯出為Word"
"docx": "匯出為Word",
"plain_text": "複製為純文本"
},
"joplin": {
"check": {
@@ -1203,8 +1237,6 @@
"restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
"restore.confirm.title": "復元確認",
"restore.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
"restore.modal.select.placeholder": "請選擇要恢復的備份文件",
"restore.modal.title": "從 WebDAV 恢復",
"restore.title": "從 WebDAV 恢復",
"syncError": "備份錯誤",
"syncStatus": "備份狀態",
@@ -1598,6 +1630,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": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
@@ -1677,6 +1713,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": {
@@ -1889,7 +1946,8 @@
"model_desc": "翻譯服務使用的模型",
"bidirectional": "雙向翻譯設定",
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
"scroll_sync": "滾動同步設定"
"scroll_sync": "滾動同步設定",
"preview": "Markdown 預覽"
},
"title": "翻譯",
"tooltip.newline": "換行",
@@ -1989,7 +2047,7 @@
},
"opacity": {
"title": "透明度",
"description": "設置視窗的默認透明度,100%為完全不透明"
"description": "設置視窗的預設透明度,100%為完全不透明"
}
},
"actions": {
@@ -1,5 +1,7 @@
import { groupTranslations } from '@renderer/pages/agents/agentGroupTranslations'
import { DynamicIcon, IconName } from 'lucide-react/dynamic'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
groupName: string
@@ -8,6 +10,25 @@ interface Props {
}
export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth = 1.2 }) => {
const { i18n } = useTranslation()
const currentLanguage = i18n.language as keyof (typeof groupTranslations)[string]
const findOriginalKey = (name: string): string => {
if (groupTranslations[name]) {
return name
}
for (const key in groupTranslations) {
if (groupTranslations[key][currentLanguage] === name) {
return key
}
}
return name
}
const originalKey = findOriginalKey(groupName)
const iconMap: { [key: string]: IconName } = {
: 'user-check',
: 'star',
@@ -46,5 +67,5 @@ export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth =
: 'search'
} as const
return <DynamicIcon name={iconMap[groupName] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
return <DynamicIcon name={iconMap[originalKey] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
}
+19 -4
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
}
@@ -13,7 +13,6 @@ import {
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
@@ -36,6 +35,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'
@@ -104,7 +104,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const currentMessageId = useRef<string>('')
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const { activedMcpServers } = useMCPServers()
const { bases: knowledgeBases } = useKnowledgeBases()
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
@@ -175,22 +174,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
if (uploadedFiles) {
baseUserMessage.files = uploadedFiles
}
const knowledgeBaseIds = selectedKnowledgeBases?.map((base) => base.id)
if (knowledgeBaseIds) {
baseUserMessage.knowledgeBaseIds = knowledgeBaseIds
}
if (mentionModels) {
baseUserMessage.mentions = mentionModels
}
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
baseUserMessage.enabledMCPs = activedMcpServers.filter((server) =>
assistant.mcpServers?.some((s) => s.id === server.id)
)
}
const assistantWithTopicPrompt = topic.prompt
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
: assistant
@@ -211,19 +199,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} catch (error) {
console.error('Failed to send message:', error)
}
}, [
activedMcpServers,
assistant,
dispatch,
files,
inputEmpty,
loading,
mentionModels,
resizeTextArea,
selectedKnowledgeBases,
text,
topic
])
}, [assistant, dispatch, files, inputEmpty, loading, mentionModels, resizeTextArea, text, topic])
const translate = useCallback(async () => {
if (isTranslating) {
@@ -309,8 +285,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}, [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 +340,37 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}
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) {
@@ -798,7 +777,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
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"
@@ -1,4 +1,3 @@
import { DownOutlined } from '@ant-design/icons'
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { getModelLogo } from '@renderer/config/models'
@@ -14,6 +13,7 @@ import type { Message } from '@renderer/types/newMessage'
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { Avatar } from 'antd'
import { CircleChevronDown } from 'lucide-react'
import { type FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -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)
}
@@ -183,16 +187,9 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6
}}
onClick={scrollToBottom}>
<MessageItemContainer
style={{ transform: `scale(${1 + calculateValueByDistance('bottom-anchor', 1)})` }}></MessageItemContainer>
<Avatar
icon={<DownOutlined style={{ color: theme === 'dark' ? 'var(--color-text)' : 'var(--color-primary)' }} />}
<CircleChevronDown
size={10 + calculateValueByDistance('bottom-anchor', 20)}
style={{
backgroundColor: theme === 'dark' ? 'var(--color-background-soft)' : 'var(--color-primary-light)',
border: `1px solid ${theme === 'dark' ? 'var(--color-border-soft)' : 'var(--color-primary-soft)'}`,
opacity: 0.9
}}
style={{ color: theme === 'dark' ? 'var(--color-text)' : 'var(--color-primary)' }}
/>
</MessageItem>
{messages.map((message, index) => {
@@ -203,6 +200,8 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
const username = removeLeadingEmoji(getUserName(message))
const content = getMainTextContent(message)
if (message.type === 'clear') return null
return (
<MessageItem
key={message.id}
@@ -262,7 +261,6 @@ const MessageItemContainer = styled.div`
justify-content: space-between;
text-align: right;
gap: 4px;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
opacity: 0;
transform-origin: right center;
`
@@ -8,7 +8,7 @@ import PasteService from '@renderer/services/PasteService'
import { FileMetadata, 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}
@@ -205,7 +205,7 @@ const MessageMenubar: FC<Props> = (props) => {
key: 'export',
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
children: [
{
exportMenuOptions.plain_text && {
label: t('chat.topics.copy.plain_text'),
key: 'copy_message_plain_text',
onClick: () => copyMessageAsPlainText(message)
@@ -18,6 +18,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 />
}
@@ -49,6 +72,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>
)
@@ -1,13 +1,7 @@
import { CheckOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import {
DEFAULT_CONTEXTCOUNT,
DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE,
isMac,
isWindows
} from '@renderer/config/constant'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import {
isOpenAIModel,
isSupportedFlexServiceTier,
@@ -59,6 +53,7 @@ import {
TranslateLanguageVarious
} from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp, Settings2 } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
@@ -670,10 +665,11 @@ const SettingsTab: FC<Props> = (props) => {
value={sendMessageShortcut}
menuItemSelectedIcon={<CheckOutlined />}
options={[
{ value: 'Enter', label: 'Enter' },
{ value: 'Shift+Enter', label: 'Shift + Enter' },
{ value: 'Ctrl+Enter', label: 'Ctrl + Enter' },
{ value: 'Command+Enter', label: `${isMac ? '⌘' : isWindows ? 'Win' : 'Super'} + Enter` }
{ value: 'Enter', label: getSendMessageShortcutLabel('Enter') },
{ value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') },
{ value: 'Alt+Enter', label: getSendMessageShortcutLabel('Alt+Enter') },
{ value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') },
{ value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') }
]}
onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)}
style={{ width: 135 }}
@@ -135,7 +135,6 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
setDimensions(finalDimensions)
} catch (error) {
console.error('获取嵌入维度时出错:', error)
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
setLoading(false)
return
@@ -2,6 +2,7 @@ import {
CloudSyncOutlined,
FileSearchOutlined,
FolderOpenOutlined,
LoadingOutlined,
SaveOutlined,
YuqueOutlined
} from '@ant-design/icons'
@@ -18,7 +19,7 @@ import store, { useAppDispatch } from '@renderer/store'
import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings'
import { AppInfo } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Button, Switch, Typography } from 'antd'
import { Button, Progress, Switch, Typography } from 'antd'
import { FileText, FolderCog, FolderInput, Sparkle } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -179,6 +180,281 @@ const DataSettings: FC = () => {
})
}
const handleSelectAppDataPath = async () => {
if (!appInfo || !appInfo.appDataPath) {
return
}
const newAppDataPath = await window.api.select({
properties: ['openDirectory', 'createDirectory'],
title: t('settings.data.app_data.select_title')
})
if (!newAppDataPath) {
return
}
// check new app data path is root path
// if is root path, show error
const pathParts = newAppDataPath.split(/[/\\]/).filter((part: string) => part !== '')
if (pathParts.length <= 1) {
window.message.error(t('settings.data.app_data.select_error_root_path'))
return
}
// check new app data path has write permission
const hasWritePermission = await window.api.hasWritePermission(newAppDataPath)
if (!hasWritePermission) {
window.message.error(t('settings.data.app_data.select_error_write_permission'))
return
}
const migrationTitle = (
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
)
const migrationClassName = 'migration-modal'
const messageKey = 'data-migration'
// 显示确认对话框
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName, messageKey)
}
// 显示确认迁移的对话框
const showMigrationConfirmModal = (
originalPath: string,
newPath: string,
title: React.ReactNode,
className: string,
messageKey: string
) => {
// 复制数据选项状态
let shouldCopyData = true
// 创建路径内容组件
const PathsContent = () => (
<div>
<MigrationPathRow>
<MigrationPathLabel>{t('settings.data.app_data.original_path')}:</MigrationPathLabel>
<MigrationPathValue>{originalPath}</MigrationPathValue>
</MigrationPathRow>
<MigrationPathRow style={{ marginTop: '16px' }}>
<MigrationPathLabel>{t('settings.data.app_data.new_path')}:</MigrationPathLabel>
<MigrationPathValue>{newPath}</MigrationPathValue>
</MigrationPathRow>
</div>
)
const CopyDataContent = () => (
<div>
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
<Switch
defaultChecked={true}
onChange={(checked) => {
shouldCopyData = checked
}}
style={{ marginRight: '8px' }}
/>
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
{t('settings.data.app_data.copy_data_option')}
</MigrationPathLabel>
</MigrationPathRow>
</div>
)
// 显示确认模态框
const modal = window.modal.confirm({
title,
className,
width: 'min(600px, 90vw)',
style: { minHeight: '400px' },
content: (
<MigrationModalContent>
<PathsContent />
<CopyDataContent />
<MigrationNotice>
<p style={{ color: 'var(--color-warning)' }}>{t('settings.data.app_data.restart_notice')}</p>
<p style={{ color: 'var(--color-text-3)', marginTop: '8px' }}>
{t('settings.data.app_data.copy_time_notice')}
</p>
</MigrationNotice>
</MigrationModalContent>
),
centered: true,
okButtonProps: {
danger: true
},
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
// 立即关闭确认对话框
modal.destroy()
// 设置停止退出应用
window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason'))
if (shouldCopyData) {
// 如果选择复制数据,显示进度模态框并执行迁移
const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent)
try {
await startMigration(originalPath, newPath, progressInterval, updateProgress, loadingModal, messageKey)
} catch (error) {
if (progressInterval) {
clearInterval(progressInterval)
}
loadingModal.destroy()
throw error
}
} else {
// 如果不复制数据,直接设置新的应用数据路径
await window.api.setAppDataPath(newPath)
window.message.success(t('settings.data.app_data.path_changed_without_copy'))
}
// 更新应用数据路径
setAppInfo(await window.api.getAppInfo())
// 通知用户并重启应用
setTimeout(() => {
window.message.success(t('settings.data.app_data.select_success'))
window.api.setStopQuitApp(false, '')
window.api.relaunchApp()
}, 1000)
} catch (error) {
window.api.setStopQuitApp(false, '')
window.message.error({
content:
(shouldCopyData
? t('settings.data.app_data.copy_failed')
: t('settings.data.app_data.path_change_failed')) +
': ' +
error,
duration: 5
})
}
}
})
}
// 显示进度模态框
const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => {
let currentProgress = 0
let progressInterval: NodeJS.Timeout | null = null
// 创建进度更新模态框
const loadingModal = window.modal.info({
title,
className,
width: 'min(600px, 90vw)',
style: { minHeight: '400px' },
icon: <LoadingOutlined style={{ fontSize: 18 }} />,
content: (
<MigrationModalContent>
<PathsContent />
<MigrationNotice>
<p>{t('settings.data.app_data.copying')}</p>
<div style={{ marginTop: '12px' }}>
<Progress percent={currentProgress} status="active" strokeWidth={8} />
</div>
<p style={{ color: 'var(--color-warning)', marginTop: '12px', fontSize: '13px' }}>
{t('settings.data.app_data.copying_warning')}
</p>
</MigrationNotice>
</MigrationModalContent>
),
centered: true,
closable: false,
maskClosable: false,
okButtonProps: { style: { display: 'none' } }
})
// 更新进度的函数
const updateProgress = (progress: number, status: 'active' | 'success' = 'active') => {
loadingModal.update({
title,
content: (
<MigrationModalContent>
<PathsContent />
<MigrationNotice>
<p>{t('settings.data.app_data.copying')}</p>
<div style={{ marginTop: '12px' }}>
<Progress percent={Math.round(progress)} status={status} strokeWidth={8} />
</div>
<p style={{ color: 'var(--color-warning)', marginTop: '12px', fontSize: '13px' }}>
{t('settings.data.app_data.copying_warning')}
</p>
</MigrationNotice>
</MigrationModalContent>
)
})
}
// 开始模拟进度更新
progressInterval = setInterval(() => {
if (currentProgress < 95) {
currentProgress += Math.random() * 5 + 1
if (currentProgress > 95) currentProgress = 95
updateProgress(currentProgress)
}
}, 500)
return { loadingModal, progressInterval, updateProgress }
}
// 开始迁移数据
const startMigration = async (
originalPath: string,
newPath: string,
progressInterval: NodeJS.Timeout | null,
updateProgress: (progress: number, status?: 'active' | 'success') => void,
loadingModal: { destroy: () => void },
messageKey: string
): Promise<void> => {
// 开始复制过程
const copyResult = await window.api.copy(originalPath, newPath)
// 停止进度更新
if (progressInterval) {
clearInterval(progressInterval)
}
// 显示100%完成
updateProgress(100, 'success')
if (!copyResult.success) {
// 延迟关闭加载模态框
await new Promise<void>((resolve) => {
setTimeout(() => {
loadingModal.destroy()
window.message.error({
content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error,
key: messageKey,
duration: 5
})
resolve()
}, 500)
})
throw new Error(copyResult.error || 'Unknown error during copy')
}
// 在复制成功后设置新的AppDataPath
await window.api.setAppDataPath(newPath)
// 短暂延迟以显示100%完成
await new Promise((resolve) => setTimeout(resolve, 500))
// 关闭加载模态框
loadingModal.destroy()
window.message.success({
content: t('settings.data.app_data.copy_success'),
key: messageKey,
duration: 2
})
}
const onSkipBackupFilesChange = (value: boolean) => {
setSkipBackupFile(value)
dispatch(_setSkipBackupFile(value))
@@ -245,6 +521,9 @@ const DataSettings: FC = () => {
<PathRow>
<PathText style={{ color: 'var(--color-text-3)' }}>{appInfo?.appDataPath}</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
<HStack gap="5px" style={{ marginLeft: '8px' }}>
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
</HStack>
</PathRow>
</SettingRow>
<SettingDivider />
@@ -352,4 +631,38 @@ const PathRow = styled(HStack)`
gap: 5px;
`
// Add styled components for migration modal
const MigrationModalContent = styled.div`
padding: 20px 0 10px;
display: flex;
flex-direction: column;
`
const MigrationNotice = styled.div`
margin-top: 24px;
font-size: 14px;
`
const MigrationPathRow = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
`
const MigrationPathLabel = styled.div`
font-weight: 600;
font-size: 15px;
color: var(--color-text-1);
`
const MigrationPathValue = styled.div`
font-size: 14px;
color: var(--color-text-2);
background-color: var(--color-background-soft);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
border: 1px solid var(--color-border);
`
export default DataSettings
@@ -84,6 +84,16 @@ const ExportMenuOptions: FC = () => {
<SettingRowTitle>{t('settings.data.export_menu.docx')}</SettingRowTitle>
<Switch checked={exportMenuOptions.docx} onChange={(checked) => handleToggleOption('docx', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.plain_text')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.plain_text}
onChange={(checked) => handleToggleOption('plain_text', checked)}
/>
</SettingRow>
</SettingGroup>
)
}
@@ -1,37 +1,35 @@
import { RedoOutlined } from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { HStack } from '@renderer/components/Layout'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { isEmbeddingModel } from '@renderer/config/models'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useAssistants, useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProviders } from '@renderer/hooks/useProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
import { useAppSelector } from '@renderer/store'
import { useAppDispatch } from '@renderer/store'
import { setQuickAssistantId } from '@renderer/store/llm'
import { setTranslateModelPrompt } from '@renderer/store/settings'
import { Model } from '@renderer/types'
import { Button, Select, Tooltip } from 'antd'
import { find, sortBy } from 'lodash'
import { FolderPen, Languages, MessageSquareMore, Rocket, Settings2 } from 'lucide-react'
import { 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'),
@@ -163,27 +157,125 @@ 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')}
/>
<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')}>
<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
@@ -9,7 +9,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 { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -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,20 +134,22 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
</Form.Item>
<Form.Item style={{ marginBottom: 15, textAlign: 'center' }}>
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
<MoreSettingsRow onClick={() => setShowModelTypes(!showModelTypes)}>
<Flex justify="center" align="center" style={{ position: 'relative' }}>
<MoreSettingsRow
onClick={() => setShowMoreSettings(!showMoreSettings)}
style={{ position: 'absolute', right: 0 }}>
{t('settings.moresetting')}
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
<ExpandIcon>{showMoreSettings ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
</MoreSettingsRow>
<Button type="primary" htmlType="submit" size="middle">
{t('common.save')}
</Button>
</Flex>
</Form.Item>
{showModelTypes && (
{showMoreSettings && (
<div>
<Divider style={{ margin: '0 0 15px 0' }} />
<TypeTitle>{t('models.type.select')}:</TypeTitle>
<TypeTitle>{t('models.type.select')}</TypeTitle>
{(() => {
const defaultTypes = [
...(isVisionModel(model) ? ['vision'] : []),
@@ -193,6 +220,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>
@@ -201,6 +281,7 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
}
const TypeTitle = styled.div`
margin-top: 16px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
@@ -42,6 +42,7 @@ import ModelListSearchBar from './ModelListSearchBar'
import ProviderOAuth from './ProviderOAuth'
import ProviderSettingsPopup from './ProviderSettingsPopup'
import SelectProviderModelPopup from './SelectProviderModelPopup'
import VertexAISettings from './VertexAISettings'
interface Props {
provider: Provider
@@ -335,72 +336,76 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
)}
{provider.id === 'openai' && <OpenAIAlert />}
{isDmxapi && <DMXAPISettings provider={provider} setApiKey={setApiKey} />}
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={inputValue}
placeholder={t('settings.provider.api_key')}
onChange={(e) => {
setInputValue(e.target.value)
debouncedSetApiKey(e.target.value)
}}
onBlur={() => {
const formattedValue = formatApiKeys(inputValue)
setInputValue(formattedValue)
setApiKey(formattedValue)
onUpdateApiKey()
}}
spellCheck={false}
autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
/>
<Button
type={apiValid ? 'primary' : 'default'}
ghost={apiValid}
onClick={onCheckApi}
disabled={!apiHost || apiChecking}>
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
</Button>
</Space.Compact>
{apiKeyWebsite && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<HStack>
{!isDmxapi && (
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</HStack>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
)}
{!isDmxapi && (
{provider.id !== 'vertexai' && (
<>
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
<Input.Password
value={inputValue}
placeholder={t('settings.provider.api_key')}
onChange={(e) => {
setInputValue(e.target.value)
debouncedSetApiKey(e.target.value)
}}
onBlur={() => {
const formattedValue = formatApiKeys(inputValue)
setInputValue(formattedValue)
setApiKey(formattedValue)
onUpdateApiKey()
}}
spellCheck={false}
autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
/>
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
<Button danger onClick={onReset}>
{t('settings.provider.api.url.reset')}
</Button>
)}
<Button
type={apiValid ? 'primary' : 'default'}
ghost={apiValid}
onClick={onCheckApi}
disabled={!apiHost || apiChecking}>
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
</Button>
</Space.Compact>
{isOpenAIProvider(provider) && (
{apiKeyWebsite && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
{hostPreview()}
</SettingHelpText>
<SettingHelpText style={{ minWidth: 'fit-content' }}>
{t('settings.provider.api.url.tip')}
</SettingHelpText>
<HStack>
{!isDmxapi && (
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</HStack>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
)}
{!isDmxapi && (
<>
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
<Button danger onClick={onReset}>
{t('settings.provider.api.url.reset')}
</Button>
)}
</Space.Compact>
{isOpenAIProvider(provider) && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
{hostPreview()}
</SettingHelpText>
<SettingHelpText style={{ minWidth: 'fit-content' }}>
{t('settings.provider.api.url.tip')}
</SettingHelpText>
</SettingHelpTextRow>
)}
</>
)}
</>
)}
{isAzureOpenAI && (
@@ -419,6 +424,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{provider.id === 'lmstudio' && <LMStudioSettings />}
{provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
{provider.id === 'vertexai' && <VertexAISettings />}
<SettingSubtitle style={{ marginBottom: 5 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<HStack alignItems="center" gap={8} mb={5}>
@@ -0,0 +1,138 @@
import { HStack } from '@renderer/components/Layout'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useVertexAISettings } from '@renderer/hooks/useVertexAI'
import { Alert, Input } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
const VertexAISettings: FC = () => {
const { t } = useTranslation()
const {
projectId,
location,
serviceAccount,
setProjectId,
setLocation,
setServiceAccountPrivateKey,
setServiceAccountClientEmail
} = useVertexAISettings()
const providerConfig = PROVIDER_CONFIG['vertexai']
const apiKeyWebsite = providerConfig?.websites?.apiKey
const [localProjectId, setLocalProjectId] = useState(projectId)
const [localLocation, setLocalLocation] = useState(location)
const handleProjectIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalProjectId(e.target.value)
}
const handleLocationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newLocation = e.target.value
setLocalLocation(newLocation)
}
const handleServiceAccountPrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setServiceAccountPrivateKey(e.target.value)
}
const handleServiceAccountPrivateKeyBlur = () => {
setServiceAccountPrivateKey(serviceAccount.privateKey)
}
const handleServiceAccountClientEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setServiceAccountClientEmail(e.target.value)
}
const handleServiceAccountClientEmailBlur = () => {
setServiceAccountClientEmail(serviceAccount.clientEmail)
}
const handleProjectIdBlur = () => {
setProjectId(localProjectId)
}
const handleLocationBlur = () => {
setLocation(localLocation)
}
return (
<>
<SettingSubtitle style={{ marginTop: 5 }}>
{t('settings.provider.vertex_ai.service_account.title')}
</SettingSubtitle>
<Alert
type="info"
style={{ marginTop: 5 }}
message={t('settings.provider.vertex_ai.service_account.description')}
showIcon
/>
<SettingSubtitle style={{ marginTop: 5 }}>
{t('settings.provider.vertex_ai.service_account.client_email')}
</SettingSubtitle>
<Input.Password
value={serviceAccount.clientEmail}
placeholder={t('settings.provider.vertex_ai.service_account.client_email_placeholder')}
onChange={handleServiceAccountClientEmailChange}
onBlur={handleServiceAccountClientEmailBlur}
style={{ marginTop: 5 }}
/>
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.service_account.client_email_help')}</SettingHelpText>
</SettingHelpTextRow>
<SettingSubtitle style={{ marginTop: 5 }}>
{t('settings.provider.vertex_ai.service_account.private_key')}
</SettingSubtitle>
<Input.TextArea
value={serviceAccount.privateKey}
placeholder={t('settings.provider.vertex_ai.service_account.private_key_placeholder')}
onChange={handleServiceAccountPrivateKeyChange}
onBlur={handleServiceAccountPrivateKeyBlur}
style={{ marginTop: 5 }}
spellCheck={false}
autoSize={{ minRows: 4, maxRows: 4 }}
/>
{apiKeyWebsite && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<HStack>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
</HStack>
<SettingHelpText>{t('settings.provider.vertex_ai.service_account.private_key_help')}</SettingHelpText>
</SettingHelpTextRow>
)}
<>
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.vertex_ai.project_id')}</SettingSubtitle>
<Input.Password
value={localProjectId}
placeholder={t('settings.provider.vertex_ai.project_id_placeholder')}
onChange={handleProjectIdChange}
onBlur={handleProjectIdBlur}
style={{ marginTop: 5 }}
/>
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.project_id_help')}</SettingHelpText>
</SettingHelpTextRow>
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.vertex_ai.location')}</SettingSubtitle>
<Input
value={localLocation}
placeholder="us-central1"
onChange={handleLocationChange}
onBlur={handleLocationBlur}
style={{ marginTop: 5 }}
/>
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.location_help')}</SettingHelpText>
</SettingHelpTextRow>
</>
</>
)
}
export default VertexAISettings
@@ -26,6 +26,7 @@ import { find, isEmpty, sortBy } from 'lodash'
import { 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'
@@ -135,6 +141,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>
@@ -212,6 +225,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)
@@ -388,6 +402,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)
})
}, [])
@@ -586,7 +603,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>
@@ -598,6 +621,8 @@ const TranslatePage: FC = () => {
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
isBidirectional={isBidirectional}
setIsBidirectional={toggleBidirectional}
enableMarkdown={enableMarkdown}
setEnableMarkdown={setEnableMarkdown}
bidirectionalPair={bidirectionalPair}
setBidirectionalPair={setBidirectionalPair}
translateModel={translateModel}
+31 -15
View File
@@ -33,10 +33,11 @@ import { SdkModel } from '@renderer/types/sdk'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import { isAbortError } from '@renderer/utils/error'
import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract'
import { findFileBlocks, getKnowledgeBaseIds, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { findLast, isEmpty, takeRight } from 'lodash'
import AiProvider from '../aiCore'
import store from '../store'
import {
getAssistantProvider,
getAssistantSettings,
@@ -63,7 +64,7 @@ async function fetchExternalTool(
lastAnswer?: Message
): Promise<ExternalToolResult> {
// 可能会有重复?
const knowledgeBaseIds = getKnowledgeBaseIds(lastUserMessage)
const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
const webSearchProvider = WebSearchService.getWebSearchProvider(assistant.webSearchProviderId)
@@ -251,15 +252,28 @@ async function fetchExternalTool(
// Get MCP tools (Fix duplicate declaration)
let mcpTools: MCPTool[] = [] // Initialize as empty array
const enabledMCPs = assistant.mcpServers
const allMcpServers = store.getState().mcp.servers || []
const activedMcpServers = allMcpServers.filter((s) => s.isActive)
const assistantMcpServers = assistant.mcpServers || []
const enabledMCPs = activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id))
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 +530,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 +552,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 +584,7 @@ export async function checkApi(provider: Provider, model: Model): Promise<void>
assistant.model = model
try {
if (isEmbeddingModel(model)) {
const result = await ai.getEmbeddingDimensions(model)
if (result === 0) {
throw new Error(i18n.t('message.error.enter.model'))
}
await ai.getEmbeddingDimensions(model)
} else {
const params: CompletionsParams = {
callType: 'check',
@@ -48,10 +48,12 @@ 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,
// preprocessing: base.preprocessing,
preprocessOrOcrProvider: base.preprocessOrOcrProvider
// topN: base.topN
}
}
@@ -106,7 +108,7 @@ export const searchKnowledgeBase = async (
// 执行搜索
const searchResults = await window.api.knowledgeBase.search({
search: query,
search: rewrite || query,
base: baseParams
})
+3 -8
View File
@@ -6,7 +6,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService'
import store from '@renderer/store'
import { messageBlocksSelectors, removeManyBlocks } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import type { Assistant, FileMetadata, MCPServer, Model, Topic, Usage } from '@renderer/types'
import type { Assistant, FileMetadata, Model, Topic, Usage } from '@renderer/types'
import { FileTypes } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
@@ -108,9 +108,7 @@ export function getUserMessage({
content,
files,
// Keep other potential params if needed by createMessage
knowledgeBaseIds,
mentions,
enabledMCPs,
usage
}: {
assistant: Assistant
@@ -120,7 +118,6 @@ export function getUserMessage({
files?: FileMetadata[]
knowledgeBaseIds?: string[]
mentions?: Model[]
enabledMCPs?: MCPServer[]
usage?: Usage
}): { message: Message; blocks: MessageBlock[] } {
const defaultModel = getDefaultModel()
@@ -133,8 +130,7 @@ export function getUserMessage({
if (content !== undefined) {
// Pass messageId when creating blocks
const textBlock = createMainTextBlock(messageId, content, {
status: MessageBlockStatus.SUCCESS,
knowledgeBaseIds
status: MessageBlockStatus.SUCCESS
})
blocks.push(textBlock)
blockIds.push(textBlock.id)
@@ -165,7 +161,7 @@ export function getUserMessage({
blocks: blockIds,
// 移除knowledgeBaseIds
mentions,
enabledMCPs,
// 移除mcp
type,
usage
}
@@ -203,7 +199,6 @@ export function resetAssistantMessage(message: Message, model?: Model): Message
useful: undefined,
askId: undefined,
mentions: undefined,
enabledMCPs: undefined,
blocks: [],
createdAt: new Date().toISOString()
}
+62 -7
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
}
@@ -146,6 +154,16 @@ export const INITIAL_PROVIDERS: Provider[] = [
isSystem: true,
enabled: false
},
{
id: 'lanyun',
name: 'LANYUN',
type: 'openai',
apiKey: '',
apiHost: 'https://maas-api.lanyun.net',
models: SYSTEM_MODELS.lanyun,
isSystem: true,
enabled: false
},
{
id: 'openrouter',
name: 'OpenRouter',
@@ -215,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',
@@ -497,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: {
@@ -515,6 +545,14 @@ const initialState: LlmState = {
},
gpustack: {
keepAliveTime: 0
},
vertexai: {
serviceAccount: {
privateKey: '',
clientEmail: ''
},
projectId: '',
location: ''
}
}
}
@@ -611,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
@@ -623,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<{
@@ -651,10 +702,14 @@ export const {
setDefaultModel,
setTopicNamingModel,
setTranslateModel,
setQuickAssistantModel,
setQuickAssistantId,
setOllamaKeepAliveTime,
setLMStudioKeepAliveTime,
setGPUStackKeepAliveTime,
setVertexAIProjectId,
setVertexAILocation,
setVertexAIServiceAccountPrivateKey,
setVertexAIServiceAccountClientEmail,
updateModel
} = llmSlice.actions
+43 -4
View File
@@ -5,14 +5,14 @@ import { SYSTEM_MODELS } from '@renderer/config/models'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { Assistant, WebSearchProvider } from '@renderer/types'
import { Assistant, Provider, 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'
@@ -56,6 +56,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
@@ -1567,8 +1574,40 @@ const migrateConfig = {
try {
addProvider(state, 'cephalon')
addProvider(state, '302ai')
addProvider(state, 'lanyun')
state.llm.providers = moveProvider(state.llm.providers, 'cephalon', 13)
state.llm.providers = moveProvider(state.llm.providers, '302ai', 14)
state.llm.providers = moveProvider(state.llm.providers, 'lanyun', 15)
return state
} catch (error) {
return state
}
},
'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
+4 -2
View File
@@ -15,7 +15,7 @@ import { uuid } from '@renderer/utils'
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'
@@ -169,6 +169,7 @@ export interface SettingsState {
obsidian: boolean
siyuan: boolean
docx: boolean
plain_text: boolean
}
// OpenAI
openAI: {
@@ -311,7 +312,8 @@ export const initialState: SettingsState = {
joplin: true,
obsidian: true,
siyuan: true,
docx: true
docx: true,
plain_text: true
},
// OpenAI
openAI: {
+16 -1
View File
@@ -161,13 +161,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' | 'mistral'
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
@@ -176,6 +190,7 @@ export type Model = {
owned_by?: string
description?: string
type?: ModelType[]
pricing?: ModelPricing
}
export type Suggestion = {
+6
View File
@@ -173,6 +173,9 @@ export type Message = {
useful?: boolean
askId?: string // 关联的问题消息ID
mentions?: Model[]
/**
* @deprecated
*/
enabledMCPs?: MCPServer[]
usage?: Usage
@@ -206,6 +209,9 @@ export interface MessageInputBaseParams {
files?: FileMetadata[]
knowledgeBaseIds?: string[]
mentions?: Model[]
/**
* @deprecated
*/
enabledMCPs?: MCPServer[]
usage?: CompletionUsage
}
@@ -7,6 +7,7 @@ import {
convertMathFormula,
findCitationInChildren,
getCodeBlockId,
getExtensionByLanguage,
markdownToPlainText,
removeTrailingDoubleSpaces,
updateCodeBlock
@@ -143,6 +144,67 @@ describe('markdown', () => {
})
})
describe('getExtensionByLanguage', () => {
// 批量测试语言名称到扩展名的映射
const testLanguageExtensions = async (testCases: Record<string, string>) => {
for (const [language, expectedExtension] of Object.entries(testCases)) {
const result = await getExtensionByLanguage(language)
expect(result).toBe(expectedExtension)
}
}
it('should return extension for exact language name match', async () => {
await testLanguageExtensions({
'4D': '.4dm',
'C#': '.cs',
JavaScript: '.js',
TypeScript: '.ts',
'Objective-C++': '.mm',
Python: '.py',
SVG: '.svg',
'Visual Basic .NET': '.vb'
})
})
it('should return extension for case-insensitive language name match', async () => {
await testLanguageExtensions({
'4d': '.4dm',
'c#': '.cs',
javascript: '.js',
typescript: '.ts',
'objective-c++': '.mm',
python: '.py',
svg: '.svg',
'visual basic .net': '.vb'
})
})
it('should return extension for language aliases', async () => {
await testLanguageExtensions({
js: '.js',
node: '.js',
'obj-c++': '.mm',
'objc++': '.mm',
'objectivec++': '.mm',
py: '.py',
'visual basic': '.vb'
})
})
it('should return fallback extension for unknown languages', async () => {
await testLanguageExtensions({
'unknown-language': '.unknown-language',
custom: '.custom'
})
})
it('should handle empty string input', async () => {
await testLanguageExtensions({
'': '.'
})
})
})
describe('getCodeBlockId', () => {
it('should generate ID from position information', () => {
// 从位置信息生成ID
+46
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 { FileMetadata } from '@renderer/types'
export const getFilesFromDropEvent = async (e: React.DragEvent<HTMLDivElement>): Promise<FileMetadata[]> => {
@@ -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
}
+54
View File
@@ -54,6 +54,60 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
return markdown.replace(/ {2}$/gm, '')
}
const predefinedExtensionMap: Record<string, string> = {
html: '.html',
javascript: '.js',
typescript: '.ts',
python: '.py',
json: '.json',
markdown: '.md',
text: '.txt'
}
/**
*
* -
* -
* @param language
* @returns
*/
export async function getExtensionByLanguage(language: string): Promise<string> {
const lowerLanguage = language.toLowerCase()
// 常用的扩展名
const predefined = predefinedExtensionMap[lowerLanguage]
if (predefined) {
return predefined
}
const languages = await import('linguist-languages')
// 精确匹配语言名称
const directMatch = languages[language as keyof typeof languages] as any
if (directMatch?.extensions?.[0]) {
return directMatch.extensions[0]
}
// 大小写不敏感的语言名称匹配
for (const [langName, data] of Object.entries(languages)) {
const languageData = data as any
if (langName.toLowerCase() === lowerLanguage && languageData.extensions?.[0]) {
return languageData.extensions[0]
}
}
// 通过别名匹配
for (const [, data] of Object.entries(languages)) {
const languageData = data as any
if (languageData.aliases?.includes(lowerLanguage)) {
return languageData.extensions?.[0] || `.${language}`
}
}
// 回退到语言名称
return `.${language}`
}
/**
* ID
* @param start
@@ -140,17 +140,6 @@ export const getCitationContent = (message: Message): string => {
.join('\n\n')
}
/**
* Gets the knowledgeBaseIds array from the *first* MainTextMessageBlock of a message.
* Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed.
* @param message - The message object.
* @returns The knowledgeBaseIds array or undefined if not found.
*/
export const getKnowledgeBaseIds = (message: Message): string[] | undefined => {
const firstTextBlock = findMainTextBlocks(message)
return firstTextBlock?.flatMap((block) => block.knowledgeBaseIds).filter((id): id is string => Boolean(id))
}
/**
* Gets the file content from all FileMessageBlocks and ImageMessageBlocks of a message.
* @param message - The message object.
@@ -1,21 +1,22 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { getDefaultModel } from '@renderer/services/AssistantService'
import { Assistant } from '@renderer/types'
import { Assistant, Topic } from '@renderer/types'
import { FC } from 'react'
import styled from 'styled-components'
import Messages from './components/Messages'
interface Props {
route: string
assistant: Assistant
assistant: Assistant | null
topic: Topic | null
isOutputted: boolean
}
const ChatWindow: FC<Props> = ({ route, assistant }) => {
// const { defaultAssistant } = useDefaultAssistant()
const ChatWindow: FC<Props> = ({ route, assistant, topic, isOutputted }) => {
if (!assistant || !topic) return null
return (
<Main className="bubble">
<Messages assistant={{ ...assistant, model: getDefaultModel() }} route={route} />
<Messages assistant={assistant} topic={topic} route={route} isOutputted={isOutputted} />
</Main>
)
}
@@ -1,61 +1,29 @@
import { LoadingOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { Assistant } from '@renderer/types'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { last } from 'lodash'
import { FC, 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 messages = useTopicMessages(assistant.topics[0].id)
const containerRef = useRef<HTMLDivElement>(null)
const messagesRef = useRef(messages)
const Messages: FC<Props> = ({ assistant, topic, route, isOutputted }) => {
const messages = useTopicMessages(topic.id)
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} />
))}
+416 -213
View File
@@ -1,27 +1,27 @@
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultAssistant, useDefaultModel } 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 React, { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { last } from 'lodash'
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -33,61 +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 topic = defaultAssistant.topics[0]
const { defaultModel, quickAssistantModel } = useDefaultModel()
// 如果 quickAssistantModel 未設定,則使用 defaultModel
const model = quickAssistantModel || defaultModel
const { language, readClipboardAtStartup, windowStyle } = 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>) => {
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
@@ -95,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
}
@@ -106,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()
}
}
@@ -121,11 +170,9 @@ const HomeWindow: FC = () => {
break
case 'Backspace':
{
textChange(() => {
if (text.length === 0) {
clearClipboard()
}
})
if (userInputText.length === 0) {
clearClipboard()
}
}
break
case 'ArrowUp':
@@ -146,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: assistant.topics[0] })
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 && windowStyle === 'transparent' && theme === ThemeMode.light) {
return 'transparent'
}
return 'var(--color-background)'
}
}, [windowStyle, 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`
@@ -360,4 +552,15 @@ const Main = styled.main`
overflow: hidden;
`
const ErrorMsg = styled.div`
color: var(--color-error);
background: rgba(255, 0, 0, 0.15);
border: 1px solid var(--color-error);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
word-break: break-all;
`
export default HomeWindow
@@ -1,25 +1,45 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
import { ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons'
import { Tag as AntdTag, Tooltip } from 'antd'
import { CircleArrowLeft, Copy, Pin } from 'lucide-react'
import { FC, useState } from 'react'
import { FC } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface FooterProps {
route: string
canUseBackspace?: boolean
loading?: boolean
setIsPinned: (isPinned: boolean) => void
isPinned: boolean
clearClipboard?: () => void
onExit: () => void
onEsc: () => void
onCopy?: () => void
}
const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExit }) => {
const Footer: FC<FooterProps> = ({
route,
canUseBackspace,
loading,
clearClipboard,
onEsc,
setIsPinned,
isPinned,
onCopy
}) => {
const { t } = useTranslation()
const [isPinned, setIsPinned] = useState(false)
const onClickPin = () => {
window.api.miniWindow.setPin(!isPinned).then(() => {
setIsPinned(!isPinned)
})
useHotkeys('esc', () => {
onEsc()
})
useHotkeys('c', () => {
handleCopy()
})
const handleCopy = () => {
if (loading || !onCopy) return
onCopy()
}
return (
@@ -27,11 +47,21 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExi
<FooterText>
<Tag
bordered={false}
icon={<CircleArrowLeft size={14} color="var(--color-text)" />}
icon={
loading ? (
<LoadingOutlined style={{ fontSize: 12, color: 'var(--color-error)', padding: 0 }} spin />
) : (
<CircleArrowLeft size={14} color="var(--color-text)" />
)
}
className="nodrag"
onClick={() => onExit()}>
onClick={onEsc}>
{t('miniwindow.footer.esc', {
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
action: loading
? t('miniwindow.footer.esc_pause')
: route === 'home'
? t('miniwindow.footer.esc_close')
: t('miniwindow.footer.esc_back')
})}
</Tag>
{route === 'home' && !canUseBackspace && (
@@ -44,19 +74,27 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExi
{t('miniwindow.footer.backspace_clear')}
</Tag>
)}
{route !== 'home' && (
{route !== 'home' && !loading && (
<Tag
bordered={false}
icon={<Copy size={14} color="var(--color-text)" />}
style={{ cursor: 'pointer' }}
className="nodrag">
className="nodrag"
onClick={handleCopy}>
{t('miniwindow.footer.copy_last_message')}
</Tag>
)}
</FooterText>
<PinButtonArea onClick={() => onClickPin()} className="nodrag">
<PinButtonArea onClick={() => setIsPinned(!isPinned)} className="nodrag">
<Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left">
<Pin size={14} stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'} />
<Pin
size={14}
stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'}
style={{
transform: isPinned ? 'rotate(40deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease-in-out'
}}
/>
</Tooltip>
</PinButtonArea>
</WindowFooter>
@@ -84,6 +122,7 @@ const PinButtonArea = styled.div`
cursor: pointer;
display: flex;
align-items: center;
margin-right: 5px;
`
const Tag = styled(AntdTag)`
@@ -91,6 +130,12 @@ const Tag = styled(AntdTag)`
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s ease-in-out;
&:hover {
background-color: var(--color-background-soft);
color: var(--color-primary);
}
`
export default Footer
@@ -1,5 +1,5 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { Assistant } from '@renderer/types'
import { Input as AntdInput } from 'antd'
import { InputRef } from 'rc-input/lib/interface'
import React, { useRef } from 'react'
@@ -7,9 +7,10 @@ import styled from 'styled-components'
interface InputBarProps {
text: string
model: any
assistant: Assistant
referenceText: string
placeholder: string
loading: boolean
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
@@ -17,19 +18,19 @@ interface InputBarProps {
const InputBar = ({
ref,
text,
model,
assistant,
placeholder,
loading,
handleKeyDown,
handleChange
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
const { generating } = useRuntime()
const inputRef = useRef<InputRef>(null)
if (!generating) {
if (!loading) {
setTimeout(() => inputRef.current?.input?.focus(), 0)
}
return (
<InputWrapper ref={ref}>
<ModelAvatar model={model} size={30} />
{assistant.model && <ModelAvatar model={assistant.model} size={30} />}
<Input
value={text}
placeholder={placeholder}
@@ -37,7 +38,6 @@ const InputBar = ({
autoFocus
onKeyDown={handleKeyDown}
onChange={handleChange}
disabled={generating}
ref={inputRef}
/>
</InputWrapper>
+26 -3
View File
@@ -2044,7 +2044,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:
@@ -2058,6 +2058,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"
@@ -5741,7 +5755,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"
@@ -5814,11 +5828,13 @@ __metadata:
fast-xml-parser: "npm:^5.2.0"
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"
jest-styled-components: "npm:^7.2.0"
jsdom: "npm:26.1.0"
linguist-languages: "npm:^8.0.0"
lint-staged: "npm:^15.5.0"
lodash: "npm:^4.17.21"
lru-cache: "npm:^11.1.0"
@@ -10424,7 +10440,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:
@@ -12025,6 +12041,13 @@ __metadata:
languageName: node
linkType: hard
"linguist-languages@npm:^8.0.0":
version: 8.0.0
resolution: "linguist-languages@npm:8.0.0"
checksum: 10c0/eaae46254247b9aa5b287ac98e062e7fe859314328ce305e34e152bc7bb172d69633999320cb47dc2a710388179712a76bb1ddd6e39e249af2684a4f0a66256c
languageName: node
linkType: hard
"linkify-it@npm:^5.0.0":
version: 5.0.0
resolution: "linkify-it@npm:5.0.0"