Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 229a8f60b2 | |||
| aa635eba88 | |||
| bf30bf28a9 | |||
| 1bf380a921 | |||
| a4c61bcd66 | |||
| a172a1052a | |||
| f4ef2ec934 | |||
| 4cda5f1787 | |||
| ceef19e55b | |||
| 0634baf780 | |||
| d424bb1224 | |||
| f97943006e | |||
| ea8b7f317d | |||
| 2dd2bee940 | |||
| d579872078 | |||
| df587fc61f | |||
| 7c2a9d141e | |||
| e4e1325b08 | |||
| 399118174e | |||
| fecf452592 | |||
| 1c7b7a1a55 | |||
| 793ccf978e | |||
| ef57e543c6 | |||
| 42800a6195 | |||
| be29f163a3 | |||
| 207f2e1689 | |||
| 4fd00af273 | |||
| 1e8143eb8c | |||
| 5398953555 | |||
| 809a532a6c | |||
| c666361611 | |||
| 5771d0c9e8 | |||
| bfd2f9d156 | |||
| 30b7028dd8 | |||
| d68529096b |
@@ -127,5 +127,5 @@ jobs:
|
||||
allowUpdates: true
|
||||
makeLatest: false
|
||||
tag: ${{ steps.get-tag.outputs.tag }}
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/beta*.yml,dist/*.blockmap'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index b54962b2d332c1a3affadbdb37d39fdf90ab9f82..7906b4ea3bf9dffe60d74c279e9cfe885489c9f9 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -36,12 +36,12 @@ async function getWindowsSystemProxy() {
|
||||
const proxies = Object.fromEntries(proxyConfigString
|
||||
.split(';')
|
||||
.map((proxyPair) => proxyPair.split('=')));
|
||||
- const proxyUrl = proxies['https']
|
||||
- ? `https://${proxies['https']}`
|
||||
- : proxies['http']
|
||||
- ? `http://${proxies['http']}`
|
||||
- : proxies['socks']
|
||||
- ? `socks://${proxies['socks']}`
|
||||
+ const proxyUrl = proxies['http']
|
||||
+ ? `http://${proxies['http']}`
|
||||
+ : proxies['socks']
|
||||
+ ? `socks://${proxies['socks']}`
|
||||
+ : proxies['https']
|
||||
+ ? `https://${proxies['https']}`
|
||||
: undefined;
|
||||
if (!proxyUrl) {
|
||||
throw new Error(`Could not get usable proxy URL from ${proxyConfigString}`);
|
||||
@@ -116,4 +116,9 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
支持 GPT-5 模型
|
||||
新增代码工具,支持快速启动 Qwen Code, Gemini Cli, Claude Code
|
||||
翻译页面改版,支持更多设置
|
||||
支持保存整个话题到知识库
|
||||
坚果云备份支持设置最大备份数量
|
||||
稳定性改进和错误修复
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.6",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -88,6 +88,7 @@
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.41.0",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
"@aws-sdk/client-bedrock": "^3.840.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
|
||||
"@aws-sdk/client-s3": "^3.840.0",
|
||||
"@cherrystudio/embedjs": "^0.1.31",
|
||||
@@ -212,7 +213,7 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"macos-release": "^3.4.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.7.0",
|
||||
"mermaid": "^11.9.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"notion-helper": "^1.3.22",
|
||||
@@ -269,7 +270,7 @@
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"word-extractor": "^1.0.4",
|
||||
"zipread": "^1.3.3",
|
||||
"zod": "^3.25.74"
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
@@ -285,7 +286,6 @@
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"windows-system-proxy@npm:^1.0.0": "patch:windows-system-proxy@npm%3A1.0.0#~/.yarn/patches/windows-system-proxy-npm-1.0.0-ff2a828eec.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch"
|
||||
},
|
||||
|
||||
@@ -276,5 +276,8 @@ export enum IpcChannel {
|
||||
TRACE_SET_TITLE = 'trace:setTitle',
|
||||
TRACE_ADD_END_MESSAGE = 'trace:addEndMessage',
|
||||
TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData',
|
||||
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage'
|
||||
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
|
||||
|
||||
// CodeTools
|
||||
CodeTools_Run = 'code-tools:run'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
const https = require('https')
|
||||
const { loggerService } = require('@logger')
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
/**
|
||||
* 获取用户的IP地址所在国家
|
||||
* @returns {Promise<string>} 返回国家代码,默认为'CN'
|
||||
*/
|
||||
async function getIpCountry() {
|
||||
return new Promise((resolve) => {
|
||||
// 添加超时控制
|
||||
const timeout = setTimeout(() => {
|
||||
logger.info('IP Address Check Timeout, default to China Mirror')
|
||||
resolve('CN')
|
||||
}, 5000)
|
||||
|
||||
const options = {
|
||||
hostname: 'ipinfo.io',
|
||||
path: '/json',
|
||||
method: 'GET',
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
clearTimeout(timeout)
|
||||
let data = ''
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const country = parsed.country || 'CN'
|
||||
logger.info(`Detected user IP address country: ${country}`)
|
||||
resolve(country)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse IP address information:', error.message)
|
||||
resolve('CN')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
logger.error('Failed to get IP address information:', error.message)
|
||||
resolve('CN')
|
||||
})
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在中国
|
||||
* @returns {Promise<boolean>} 如果用户在中国返回true,否则返回false
|
||||
*/
|
||||
async function isUserInChina() {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户位置获取适合的npm镜像URL
|
||||
* @returns {Promise<string>} 返回npm镜像URL
|
||||
*/
|
||||
async function getNpmRegistryUrl() {
|
||||
const inChina = await isUserInChina()
|
||||
if (inChina) {
|
||||
logger.info('User in China, using Taobao npm mirror')
|
||||
return 'https://registry.npmmirror.com'
|
||||
} else {
|
||||
logger.info('User not in China, using default npm mirror')
|
||||
return 'https://registry.npmjs.org'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getIpCountry,
|
||||
isUserInChina,
|
||||
getNpmRegistryUrl
|
||||
}
|
||||
+7
-4
@@ -16,11 +16,12 @@ import { Notification } from 'src/renderer/src/types/notification'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { codeToolsService } from './services/CodeToolsService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import { fileStorage as fileManager } from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -61,16 +62,15 @@ import { compress, decompress } from './utils/zip'
|
||||
|
||||
const logger = loggerService.withContext('IPC')
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
const exportService = new ExportService()
|
||||
const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
const appUpdater = new AppUpdater()
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
// Initialize Python service with main window
|
||||
@@ -701,4 +701,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
(_, spanId: string, modelName: string, context: string, msg: any) =>
|
||||
addStreamMessage(spanId, modelName, context, msg)
|
||||
)
|
||||
|
||||
// CodeTools
|
||||
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
||||
}
|
||||
|
||||
@@ -73,17 +73,19 @@ export async function addFileLoader(
|
||||
// 获取文件类型,如果没有匹配则默认为文本类型
|
||||
const loaderType = FILE_LOADER_MAP[file.ext.toLowerCase()] || 'text'
|
||||
let loaderReturn: AddLoaderReturn
|
||||
// 使用文件的实际路径
|
||||
const filePath = file.path
|
||||
|
||||
// JSON类型处理
|
||||
let jsonObject = {}
|
||||
let jsonParsed = true
|
||||
logger.info(`[KnowledgeBase] processing file ${file.path} as ${loaderType} type`)
|
||||
logger.info(`[KnowledgeBase] processing file ${filePath} as ${loaderType} type`)
|
||||
switch (loaderType) {
|
||||
case 'common':
|
||||
// 内置类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new LocalPathLoader({
|
||||
path: file.path,
|
||||
path: filePath,
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -99,7 +101,7 @@ export async function addFileLoader(
|
||||
// epub类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new EpubLoader({
|
||||
filePath: file.path,
|
||||
filePath: filePath,
|
||||
chunkSize: base.chunkSize ?? 1000,
|
||||
chunkOverlap: base.chunkOverlap ?? 200
|
||||
}) as any,
|
||||
@@ -109,14 +111,14 @@ export async function addFileLoader(
|
||||
|
||||
case 'drafts':
|
||||
// Drafts类型处理
|
||||
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(file.path) as any, forceReload)
|
||||
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(filePath), forceReload)
|
||||
break
|
||||
|
||||
case 'html':
|
||||
// HTML类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: await readTextFileWithAutoEncoding(file.path),
|
||||
urlOrContent: await readTextFileWithAutoEncoding(filePath),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -126,11 +128,11 @@ export async function addFileLoader(
|
||||
|
||||
case 'json':
|
||||
try {
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(filePath))
|
||||
} catch (error) {
|
||||
jsonParsed = false
|
||||
logger.warn(
|
||||
`[KnowledgeBase] failed parsing json file, falling back to text processing: ${file.path}`,
|
||||
`[KnowledgeBase] failed parsing json file, falling back to text processing: ${filePath}`,
|
||||
error as Error
|
||||
)
|
||||
}
|
||||
@@ -145,7 +147,7 @@ export async function addFileLoader(
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({
|
||||
text: await readTextFileWithAutoEncoding(file.path),
|
||||
text: await readTextFileWithAutoEncoding(filePath),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
@@ -54,20 +55,21 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||
try {
|
||||
logger.info(`Preprocess processing started: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`Preprocess processing started: ${filePath}`)
|
||||
|
||||
// 步骤1: 准备上传
|
||||
const { uid, url } = await this.preupload()
|
||||
logger.info(`Preprocess preupload completed: uid=${uid}`)
|
||||
|
||||
await this.validateFile(file.path)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 步骤2: 上传文件
|
||||
await this.putFile(file.path, url)
|
||||
await this.putFile(filePath, url)
|
||||
|
||||
// 步骤3: 等待处理完成
|
||||
await this.waitForProcessing(sourceId, uid)
|
||||
logger.info(`Preprocess parsing completed successfully for: ${file.path}`)
|
||||
logger.info(`Preprocess parsing completed successfully for: ${filePath}`)
|
||||
|
||||
// 步骤4: 导出文件
|
||||
const { path: outputPath } = await this.exportFile(file, uid)
|
||||
@@ -77,9 +79,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
logger.error(`Preprocess processing failed for:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @returns 导出文件的路径
|
||||
*/
|
||||
public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> {
|
||||
logger.info(`Exporting file: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`Exporting file: ${filePath}`)
|
||||
|
||||
// 步骤1: 转换文件
|
||||
await this.convertFile(uid, file.path)
|
||||
logger.info(`File conversion completed for: ${file.path}`)
|
||||
await this.convertFile(uid, filePath)
|
||||
logger.info(`File conversion completed for: ${filePath}`)
|
||||
|
||||
// 步骤2: 等待导出并获取URL
|
||||
const exportUrl = await this.waitForExport(uid)
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios from 'axios'
|
||||
@@ -63,8 +64,9 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||
try {
|
||||
logger.info(`MinerU preprocess processing started: ${file.path}`)
|
||||
await this.validateFile(file.path)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`MinerU preprocess processing started: ${filePath}`)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 1. 获取上传URL并上传文件
|
||||
const batchId = await this.uploadFile(file)
|
||||
@@ -86,7 +88,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
quota
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`)
|
||||
logger.error(`MinerU preprocess processing failed for:`, error as Error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
@@ -205,16 +207,14 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
try {
|
||||
// 步骤1: 获取上传URL
|
||||
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
|
||||
logger.debug(`Got upload URLs for batch: ${batchId}`)
|
||||
|
||||
logger.debug(`batchId: ${batchId}, fileurls: ${fileUrls}`)
|
||||
// 步骤2: 上传文件到获取的URL
|
||||
await this.putFileToUrl(file.path, fileUrls[0])
|
||||
logger.info(`File uploaded successfully: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
await this.putFileToUrl(filePath, fileUrls[0])
|
||||
logger.info(`File uploaded successfully: ${filePath}`, { batchId, fileUrls })
|
||||
|
||||
return batchId
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to upload file ${file.path}: ${error.message}`)
|
||||
logger.error(`Failed to upload file:`, error as Error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { MistralClientManager } from '@main/services/MistralClientManager'
|
||||
import { MistralService } from '@main/services/remotefile/MistralService'
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
@@ -38,7 +39,8 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
private async preupload(file: FileMetadata): Promise<PreuploadResponse> {
|
||||
let document: PreuploadResponse
|
||||
logger.info(`preprocess preupload started for local file: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`preprocess preupload started for local file: ${filePath}`)
|
||||
|
||||
if (file.ext.toLowerCase() === '.pdf') {
|
||||
const uploadResponse = await this.fileService.uploadFile(file)
|
||||
@@ -58,7 +60,7 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
documentUrl: fileUrl.url
|
||||
}
|
||||
} else {
|
||||
const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64')
|
||||
const base64Image = Buffer.from(fs.readFileSync(filePath)).toString('base64')
|
||||
document = {
|
||||
type: 'image_url',
|
||||
imageUrl: `data:image/png;base64,${base64Image}`
|
||||
@@ -97,8 +99,8 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
// 使用统一的存储路径:Data/Files/{file.id}/
|
||||
const conversionId = file.id
|
||||
const outputPath = path.join(this.storageDir, file.id)
|
||||
// const outputPath = this.storageDir
|
||||
const outputFileName = path.basename(file.path, path.extname(file.path))
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
const outputFileName = path.basename(filePath, path.extname(filePath))
|
||||
fs.mkdirSync(outputPath, { recursive: true })
|
||||
|
||||
const markdownParts: string[] = []
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import TurndownService from 'turndown'
|
||||
import { z } from 'zod'
|
||||
import { z } from 'zod/v3'
|
||||
|
||||
export const RequestPayloadSchema = z.object({
|
||||
url: z.string().url(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
@@ -11,6 +12,7 @@ import path from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('AppUpdater')
|
||||
|
||||
@@ -20,7 +22,7 @@ export default class AppUpdater {
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
constructor() {
|
||||
autoUpdater.logger = logger as Logger
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
@@ -32,12 +34,12 @@ export default class AppUpdater {
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
logger.error('update error', error as Error)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateError, error)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateError, error)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
logger.info('update available', releaseInfo)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
@@ -48,17 +50,17 @@ export default class AppUpdater {
|
||||
return
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateNotAvailable)
|
||||
})
|
||||
|
||||
// 更新下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.DownloadProgress, progress)
|
||||
})
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('update downloaded', releaseInfo)
|
||||
})
|
||||
@@ -98,30 +100,6 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
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 as Error)
|
||||
return 'CN'
|
||||
}
|
||||
}
|
||||
|
||||
public setAutoUpdate(isActive: boolean) {
|
||||
autoUpdater.autoDownload = isActive
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
@@ -186,7 +164,7 @@ export default class AppUpdater {
|
||||
}
|
||||
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
|
||||
const ipCountry = await this._getIpCountry()
|
||||
const ipCountry = await getIpCountry()
|
||||
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
|
||||
if (ipCountry.toLowerCase() !== 'cn') {
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { removeEnvProxy } from '@main/utils'
|
||||
import { isUserInChina } from '@main/utils/ipService'
|
||||
import { getBinaryName } from '@main/utils/process'
|
||||
import { spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(require('child_process').exec)
|
||||
const logger = loggerService.withContext('CodeToolsService')
|
||||
|
||||
interface VersionInfo {
|
||||
installed: string | null
|
||||
latest: string | null
|
||||
needsUpdate: boolean
|
||||
}
|
||||
|
||||
class CodeToolsService {
|
||||
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
||||
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||
|
||||
constructor() {
|
||||
this.getBunPath = this.getBunPath.bind(this)
|
||||
this.getPackageName = this.getPackageName.bind(this)
|
||||
this.getCliExecutableName = this.getCliExecutableName.bind(this)
|
||||
this.isPackageInstalled = this.isPackageInstalled.bind(this)
|
||||
this.getVersionInfo = this.getVersionInfo.bind(this)
|
||||
this.updatePackage = this.updatePackage.bind(this)
|
||||
this.run = this.run.bind(this)
|
||||
}
|
||||
|
||||
public async getBunPath() {
|
||||
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const bunName = await getBinaryName('bun')
|
||||
const bunPath = path.join(dir, bunName)
|
||||
return bunPath
|
||||
}
|
||||
|
||||
public async getPackageName(cliTool: string) {
|
||||
if (cliTool === 'claude-code') {
|
||||
return '@anthropic-ai/claude-code'
|
||||
}
|
||||
if (cliTool === 'gemini-cli') {
|
||||
return '@google/gemini-cli'
|
||||
}
|
||||
return '@qwen-code/qwen-code'
|
||||
}
|
||||
|
||||
public async getCliExecutableName(cliTool: string) {
|
||||
if (cliTool === 'claude-code') {
|
||||
return 'claude'
|
||||
}
|
||||
if (cliTool === 'gemini-cli') {
|
||||
return 'gemini'
|
||||
}
|
||||
return 'qwen'
|
||||
}
|
||||
|
||||
private async isPackageInstalled(cliTool: string): Promise<boolean> {
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
|
||||
// Ensure bin directory exists
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
}
|
||||
|
||||
return fs.existsSync(executablePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version information for a CLI tool
|
||||
*/
|
||||
public async getVersionInfo(cliTool: string): Promise<VersionInfo> {
|
||||
logger.info(`Starting version check for ${cliTool}`)
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const isInstalled = await this.isPackageInstalled(cliTool)
|
||||
|
||||
let installedVersion: string | null = null
|
||||
let latestVersion: string | null = null
|
||||
|
||||
// Get installed version if package is installed
|
||||
if (isInstalled) {
|
||||
logger.info(`${cliTool} is installed, getting current version`)
|
||||
try {
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
|
||||
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
|
||||
// Extract version number from output (format may vary by tool)
|
||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||
logger.info(`${cliTool} current installed version: ${installedVersion}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get installed version for ${cliTool}:`, error as Error)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${cliTool} is not installed`)
|
||||
}
|
||||
|
||||
// Get latest version from npm (with cache)
|
||||
const cacheKey = `${packageName}-latest`
|
||||
const cached = this.versionCache.get(cacheKey)
|
||||
const now = Date.now()
|
||||
|
||||
if (cached && now - cached.timestamp < this.CACHE_DURATION) {
|
||||
logger.info(`Using cached latest version for ${packageName}: ${cached.version}`)
|
||||
latestVersion = cached.version
|
||||
} else {
|
||||
logger.info(`Fetching latest version for ${packageName} from npm`)
|
||||
try {
|
||||
const bunPath = await this.getBunPath()
|
||||
const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 })
|
||||
latestVersion = stdout.trim().replace(/["']/g, '')
|
||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||
|
||||
// Cache the result
|
||||
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
||||
logger.debug(`Cached latest version for ${packageName}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
||||
// If we have a cached version, use it even if expired
|
||||
if (cached) {
|
||||
logger.info(`Using expired cached version for ${packageName}: ${cached.version}`)
|
||||
latestVersion = cached.version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = !!(installedVersion && latestVersion && installedVersion !== latestVersion)
|
||||
logger.info(
|
||||
`Version check result for ${cliTool}: installed=${installedVersion}, latest=${latestVersion}, needsUpdate=${needsUpdate}`
|
||||
)
|
||||
|
||||
return {
|
||||
installed: installedVersion,
|
||||
latest: latestVersion,
|
||||
needsUpdate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get npm registry URL based on user location
|
||||
*/
|
||||
private async getNpmRegistryUrl(): Promise<string> {
|
||||
try {
|
||||
const inChina = await isUserInChina()
|
||||
if (inChina) {
|
||||
logger.info('User in China, using Taobao npm mirror')
|
||||
return 'https://registry.npmmirror.com'
|
||||
} else {
|
||||
logger.info('User not in China, using default npm mirror')
|
||||
return 'https://registry.npmjs.org'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to detect user location, using default npm mirror')
|
||||
return 'https://registry.npmjs.org'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a CLI tool to the latest version
|
||||
*/
|
||||
public async updatePackage(cliTool: string): Promise<{ success: boolean; message: string }> {
|
||||
logger.info(`Starting update process for ${cliTool}`)
|
||||
try {
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const bunPath = await this.getBunPath()
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
const registryUrl = await this.getNpmRegistryUrl()
|
||||
|
||||
const installEnvPrefix =
|
||||
process.platform === 'win32'
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
logger.info(`Executing update command: ${updateCommand}`)
|
||||
|
||||
await execAsync(updateCommand, { timeout: 60000 })
|
||||
logger.info(`Successfully executed update command for ${cliTool}`)
|
||||
|
||||
// Clear version cache for this package
|
||||
const cacheKey = `${packageName}-latest`
|
||||
this.versionCache.delete(cacheKey)
|
||||
logger.debug(`Cleared version cache for ${packageName}`)
|
||||
|
||||
const successMessage = `Successfully updated ${cliTool} to the latest version`
|
||||
logger.info(successMessage)
|
||||
return {
|
||||
success: true,
|
||||
message: successMessage
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const failureMessage = `Failed to update ${cliTool}: ${errorMessage}`
|
||||
logger.error(failureMessage, error as Error)
|
||||
return {
|
||||
success: false,
|
||||
message: failureMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
cliTool: string,
|
||||
_model: string,
|
||||
directory: string,
|
||||
env: Record<string, string>,
|
||||
options: { autoUpdateToLatest?: boolean } = {}
|
||||
) {
|
||||
logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`)
|
||||
logger.debug(`Environment variables:`, Object.keys(env))
|
||||
logger.debug(`Options:`, options)
|
||||
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const bunPath = await this.getBunPath()
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
|
||||
logger.debug(`Package name: ${packageName}`)
|
||||
logger.debug(`Bun path: ${bunPath}`)
|
||||
logger.debug(`Executable name: ${executableName}`)
|
||||
logger.debug(`Executable path: ${executablePath}`)
|
||||
|
||||
// Check if package is already installed
|
||||
const isInstalled = await this.isPackageInstalled(cliTool)
|
||||
|
||||
// Check for updates and auto-update if requested
|
||||
let updateMessage = ''
|
||||
if (isInstalled && options.autoUpdateToLatest) {
|
||||
logger.info(`Auto update to latest enabled for ${cliTool}`)
|
||||
try {
|
||||
const versionInfo = await this.getVersionInfo(cliTool)
|
||||
if (versionInfo.needsUpdate) {
|
||||
logger.info(`Update available for ${cliTool}: ${versionInfo.installed} -> ${versionInfo.latest}`)
|
||||
logger.info(`Auto-updating ${cliTool} to latest version`)
|
||||
updateMessage = ` && echo "Updating ${cliTool} from ${versionInfo.installed} to ${versionInfo.latest}..."`
|
||||
const updateResult = await this.updatePackage(cliTool)
|
||||
if (updateResult.success) {
|
||||
logger.info(`Update completed successfully for ${cliTool}`)
|
||||
updateMessage += ` && echo "Update completed successfully"`
|
||||
} else {
|
||||
logger.error(`Update failed for ${cliTool}: ${updateResult.message}`)
|
||||
updateMessage += ` && echo "Update failed: ${updateResult.message}"`
|
||||
}
|
||||
} else if (versionInfo.installed && versionInfo.latest) {
|
||||
logger.info(`${cliTool} is already up to date (${versionInfo.installed})`)
|
||||
updateMessage = ` && echo "${cliTool} is up to date (${versionInfo.installed})"`
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check version for ${cliTool}:`, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Select different terminal based on operating system
|
||||
const platform = process.platform
|
||||
let terminalCommand: string
|
||||
let terminalArgs: string[]
|
||||
|
||||
// Build environment variable prefix (based on platform)
|
||||
const buildEnvPrefix = (isWindows: boolean) => {
|
||||
if (Object.keys(env).length === 0) return ''
|
||||
|
||||
if (isWindows) {
|
||||
// Windows uses set command
|
||||
return Object.entries(env)
|
||||
.map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`)
|
||||
.join(' && ')
|
||||
} else {
|
||||
// Unix-like systems use export command
|
||||
return Object.entries(env)
|
||||
.map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`)
|
||||
.join(' && ')
|
||||
}
|
||||
}
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand: string
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
if (isInstalled) {
|
||||
// If already installed, run executable directly (with optional update message)
|
||||
baseCommand = `"${executablePath}"`
|
||||
if (updateMessage) {
|
||||
baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
|
||||
}
|
||||
} else {
|
||||
// If not installed, install first then run
|
||||
const registryUrl = await this.getNpmRegistryUrl()
|
||||
const installEnvPrefix =
|
||||
platform === 'win32'
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"`
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
|
||||
const envPrefix = buildEnvPrefix(false)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
terminalCommand = 'osascript'
|
||||
terminalArgs = [
|
||||
'-e',
|
||||
`tell application "Terminal"
|
||||
activate
|
||||
do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}"
|
||||
end tell`
|
||||
]
|
||||
break
|
||||
}
|
||||
case 'win32': {
|
||||
// Windows - Use temp bat file for debugging
|
||||
const envPrefix = buildEnvPrefix(true)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
// Create temp bat file for debugging and avoid complex command line escaping issues
|
||||
const tempDir = path.join(os.tmpdir(), 'cherrystudio')
|
||||
const timestamp = Date.now()
|
||||
const batFileName = `launch_${cliTool}_${timestamp}.bat`
|
||||
const batFilePath = path.join(tempDir, batFileName)
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Build bat file content, including debug information
|
||||
const batContent = [
|
||||
'@echo off',
|
||||
`title ${cliTool} - Cherry Studio`, // Set window title in bat file
|
||||
'echo ================================================',
|
||||
'echo Cherry Studio CLI Tool Launcher',
|
||||
`echo Tool: ${cliTool}`,
|
||||
`echo Directory: ${directory}`,
|
||||
`echo Time: ${new Date().toLocaleString()}`,
|
||||
'echo ================================================',
|
||||
'',
|
||||
':: Change to target directory',
|
||||
`cd /d "${directory}" || (`,
|
||||
' echo ERROR: Failed to change directory',
|
||||
` echo Target directory: ${directory}`,
|
||||
' pause',
|
||||
' exit /b 1',
|
||||
')',
|
||||
'',
|
||||
':: Clear screen',
|
||||
'cls',
|
||||
'',
|
||||
':: Execute command (without displaying environment variable settings)',
|
||||
command,
|
||||
'',
|
||||
':: Command execution completed',
|
||||
'echo.',
|
||||
'echo Command execution completed.',
|
||||
'echo Press any key to close this window...',
|
||||
'pause >nul'
|
||||
].join('\r\n')
|
||||
|
||||
// Write to bat file
|
||||
try {
|
||||
fs.writeFileSync(batFilePath, batContent, 'utf8')
|
||||
logger.info(`Created temp bat file: ${batFilePath}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create bat file: ${error}`)
|
||||
throw new Error(`Failed to create launch script: ${error}`)
|
||||
}
|
||||
|
||||
// Launch bat file - Use safest start syntax, no title parameter
|
||||
terminalCommand = 'cmd'
|
||||
terminalArgs = ['/c', 'start', batFilePath]
|
||||
|
||||
// Set cleanup task (delete temp file after 5 minutes)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fs.existsSync(batFilePath) && fs.unlinkSync(batFilePath)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to cleanup temp bat file: ${error}`)
|
||||
}
|
||||
}, 10 * 1000) // Delete temp file after 10 seconds
|
||||
|
||||
break
|
||||
}
|
||||
case 'linux': {
|
||||
// Linux - Try to use common terminal emulators
|
||||
const envPrefix = buildEnvPrefix(false)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator']
|
||||
let foundTerminal = 'xterm' // Default to xterm
|
||||
|
||||
for (const terminal of linuxTerminals) {
|
||||
try {
|
||||
// Check if terminal exists
|
||||
const checkResult = spawn('which', [terminal], { stdio: 'pipe' })
|
||||
await new Promise((resolve) => {
|
||||
checkResult.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
foundTerminal = terminal
|
||||
}
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
if (foundTerminal === terminal) break
|
||||
} catch (error) {
|
||||
// Continue trying next terminal
|
||||
}
|
||||
}
|
||||
|
||||
if (foundTerminal === 'gnome-terminal') {
|
||||
terminalCommand = 'gnome-terminal'
|
||||
terminalArgs = ['--working-directory', directory, '--', 'bash', '-c', `clear && ${command}; exec bash`]
|
||||
} else if (foundTerminal === 'konsole') {
|
||||
terminalCommand = 'konsole'
|
||||
terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
|
||||
} else {
|
||||
// Default to xterm
|
||||
terminalCommand = 'xterm'
|
||||
terminalArgs = ['-e', `cd "${directory}" && clear && ${command} && bash`]
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported operating system: ${platform}`)
|
||||
}
|
||||
|
||||
const processEnv = { ...process.env, ...env }
|
||||
removeEnvProxy(processEnv as Record<string, string>)
|
||||
|
||||
// Launch terminal process
|
||||
try {
|
||||
logger.info(`Launching terminal with command: ${terminalCommand}`)
|
||||
logger.debug(`Terminal arguments:`, terminalArgs)
|
||||
logger.debug(`Working directory: ${directory}`)
|
||||
logger.debug(`Process environment keys: ${Object.keys(processEnv)}`)
|
||||
|
||||
spawn(terminalCommand, terminalArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: directory,
|
||||
env: processEnv
|
||||
})
|
||||
|
||||
const successMessage = `Launched ${cliTool} in new terminal window`
|
||||
logger.info(successMessage)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: successMessage,
|
||||
command: `${terminalCommand} ${terminalArgs.join(' ')}`
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const failureMessage = `Failed to launch terminal: ${errorMessage}`
|
||||
logger.error(failureMessage, error as Error)
|
||||
return {
|
||||
success: false,
|
||||
message: failureMessage,
|
||||
command: `${terminalCommand} ${terminalArgs.join(' ')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const codeToolsService = new CodeToolsService()
|
||||
@@ -21,15 +21,13 @@ import {
|
||||
import { dialog } from 'electron'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import FileStorage from './FileStorage'
|
||||
import { fileStorage } from './FileStorage'
|
||||
|
||||
const logger = loggerService.withContext('ExportService')
|
||||
export class ExportService {
|
||||
private fileManager: FileStorage
|
||||
private md: MarkdownIt
|
||||
|
||||
constructor(fileManager: FileStorage) {
|
||||
this.fileManager = fileManager
|
||||
constructor() {
|
||||
this.md = new MarkdownIt()
|
||||
}
|
||||
|
||||
@@ -399,7 +397,7 @@ export class ExportService {
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
await this.fileManager.writeFile(_, filePath, buffer)
|
||||
await fileStorage.writeFile(_, filePath, buffer)
|
||||
logger.debug('Document exported successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -156,7 +156,8 @@ class FileStorage {
|
||||
}
|
||||
|
||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
|
||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||
const filePath = file.path
|
||||
const duplicateFile = await this.findDuplicateFile(filePath)
|
||||
|
||||
if (duplicateFile) {
|
||||
return duplicateFile
|
||||
@@ -167,13 +168,13 @@ class FileStorage {
|
||||
const ext = path.extname(origin_name).toLowerCase()
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
logger.info(`[FileStorage] Uploading file: ${file.path}`)
|
||||
logger.info(`[FileStorage] Uploading file: ${filePath}`)
|
||||
|
||||
// 根据文件类型选择处理方式
|
||||
if (imageExts.includes(ext)) {
|
||||
await this.compressImage(file.path, destPath)
|
||||
await this.compressImage(filePath, destPath)
|
||||
} else {
|
||||
await fs.promises.copyFile(file.path, destPath)
|
||||
await fs.promises.copyFile(filePath, destPath)
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
@@ -624,6 +625,10 @@ class FileStorage {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public getFilePathById(file: FileMetadata): string {
|
||||
return path.join(this.storageDir, file.id + file.ext)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileStorage
|
||||
export const fileStorage = new FileStorage()
|
||||
|
||||
@@ -27,6 +27,7 @@ import { addFileLoader } from '@main/knowledge/loader'
|
||||
import { NoteLoader } from '@main/knowledge/loader/noteLoader'
|
||||
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
@@ -689,15 +690,16 @@ class KnowledgeService {
|
||||
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
|
||||
try {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
// Check if file has already been preprocessed
|
||||
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||
if (alreadyProcessed) {
|
||||
logger.debug(`File already preprocess processed, using cached result: ${file.path}`)
|
||||
logger.debug(`File already preprocess processed, using cached result: ${filePath}`)
|
||||
return alreadyProcessed
|
||||
}
|
||||
|
||||
// Execute preprocessing
|
||||
logger.debug(`Starting preprocess processing for scanned PDF: ${file.path}`)
|
||||
logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`)
|
||||
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||
fileToProcess = processedFile
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists } from '@main/utils'
|
||||
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
|
||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
||||
@@ -280,7 +280,7 @@ class McpService {
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.includes('bun')) {
|
||||
this.removeProxyEnv(loginShellEnv)
|
||||
removeEnvProxy(loginShellEnv)
|
||||
}
|
||||
|
||||
const transportOptions: any = {
|
||||
@@ -827,14 +827,6 @@ class McpService {
|
||||
}
|
||||
})
|
||||
|
||||
private removeProxyEnv(env: Record<string, string>) {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
// 实现 abortTool 方法
|
||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||
const activeToolCall = this.activeToolCalls.get(callId)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -29,7 +30,7 @@ export class GeminiService extends BaseFileService {
|
||||
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const uploadResult = await this.fileManager.upload({
|
||||
file: file.path,
|
||||
file: fileStorage.getFilePathById(file),
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
|
||||
@@ -21,7 +22,7 @@ export class MistralService extends BaseFileService {
|
||||
|
||||
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(file.path)
|
||||
const fileBuffer = await fs.readFile(fileStorage.getFilePathById(file))
|
||||
const response = await this.client.files.upload({
|
||||
file: {
|
||||
fileName: file.origin_name,
|
||||
|
||||
@@ -70,3 +70,11 @@ export async function calculateDirectorySize(directoryPath: string): Promise<num
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
|
||||
export const removeEnvProxy = (env: Record<string, string>) => {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
/**
|
||||
* 获取用户的IP地址所在国家
|
||||
* @returns 返回国家代码,默认为'CN'
|
||||
*/
|
||||
export async function getIpCountry(): Promise<string> {
|
||||
try {
|
||||
// 添加超时控制
|
||||
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()
|
||||
const country = data.country || 'CN'
|
||||
logger.info(`Detected user IP address country: ${country}`)
|
||||
return country
|
||||
} catch (error) {
|
||||
logger.error('Failed to get IP address information:', error as Error)
|
||||
return 'CN'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在中国
|
||||
* @returns 如果用户在中国返回true,否则返回false
|
||||
*/
|
||||
export async function isUserInChina(): Promise<boolean> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn'
|
||||
}
|
||||
@@ -394,6 +394,15 @@ const api = {
|
||||
cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA),
|
||||
addStreamMessage: (spanId: string, modelName: string, context: string, message: any) =>
|
||||
ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message)
|
||||
},
|
||||
codeTools: {
|
||||
run: (
|
||||
cliTool: string,
|
||||
model: string,
|
||||
directory: string,
|
||||
env: Record<string, string>,
|
||||
options?: { autoUpdateToLatest?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import TabsContainer from './components/Tab/TabContainer'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import { useNavbarPosition } from './hooks/useSettings'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import CodeToolsPage from './pages/code/CodeToolsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
@@ -30,6 +31,7 @@ const Router: FC = () => {
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from '@aws-sdk/client-bedrock'
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConverseCommand,
|
||||
@@ -87,7 +88,15 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
|
||||
this.sdkInstance = { client, region }
|
||||
const bedrockClient = new BedrockClient({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey
|
||||
}
|
||||
})
|
||||
|
||||
this.sdkInstance = { client, bedrockClient, region }
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
@@ -132,6 +141,8 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
})
|
||||
}))
|
||||
|
||||
logger.info('Creating completions with model ID:', { modelId: payload.modelId })
|
||||
|
||||
const commonParams = {
|
||||
modelId: payload.modelId,
|
||||
messages: awsMessages as any,
|
||||
@@ -295,9 +306,76 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
override async listModels(): Promise<SdkModel[]> {
|
||||
return []
|
||||
try {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
// 获取支持ON_DEMAND的基础模型列表
|
||||
const modelsCommand = new ListFoundationModelsCommand({
|
||||
byInferenceType: 'ON_DEMAND',
|
||||
byOutputModality: 'TEXT'
|
||||
})
|
||||
const modelsResponse = await sdk.bedrockClient.send(modelsCommand)
|
||||
|
||||
// 获取推理配置文件列表
|
||||
const profilesCommand = new ListInferenceProfilesCommand({})
|
||||
const profilesResponse = await sdk.bedrockClient.send(profilesCommand)
|
||||
|
||||
logger.info('Found ON_DEMAND foundation models:', { count: modelsResponse.modelSummaries?.length || 0 })
|
||||
logger.info('Found inference profiles:', { count: profilesResponse.inferenceProfileSummaries?.length || 0 })
|
||||
|
||||
const models: any[] = []
|
||||
|
||||
// 处理ON_DEMAND基础模型
|
||||
if (modelsResponse.modelSummaries) {
|
||||
for (const model of modelsResponse.modelSummaries) {
|
||||
if (!model.modelId || !model.modelName) continue
|
||||
|
||||
logger.info('Adding ON_DEMAND model', { modelId: model.modelId })
|
||||
models.push({
|
||||
id: model.modelId,
|
||||
name: model.modelName,
|
||||
display_name: model.modelName,
|
||||
description: `${model.providerName || 'AWS'} - ${model.modelName}`,
|
||||
owned_by: model.providerName || 'AWS',
|
||||
provider: this.provider.id,
|
||||
group: 'AWS Bedrock',
|
||||
isInferenceProfile: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理推理配置文件
|
||||
if (profilesResponse.inferenceProfileSummaries) {
|
||||
for (const profile of profilesResponse.inferenceProfileSummaries) {
|
||||
if (!profile.inferenceProfileArn || !profile.inferenceProfileName) continue
|
||||
|
||||
logger.info('Adding inference profile', {
|
||||
profileArn: profile.inferenceProfileArn,
|
||||
profileName: profile.inferenceProfileName
|
||||
})
|
||||
|
||||
models.push({
|
||||
id: profile.inferenceProfileArn,
|
||||
name: `${profile.inferenceProfileName} (Profile)`,
|
||||
display_name: `${profile.inferenceProfileName} (Profile)`,
|
||||
description: `AWS Inference Profile - ${profile.inferenceProfileName}`,
|
||||
owned_by: 'AWS',
|
||||
provider: this.provider.id,
|
||||
group: 'AWS Bedrock Profiles',
|
||||
isInferenceProfile: true,
|
||||
inferenceProfileId: profile.inferenceProfileId,
|
||||
inferenceProfileArn: profile.inferenceProfileArn
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Total models added to list', { count: models.length })
|
||||
return models
|
||||
} catch (error) {
|
||||
logger.error('Failed to list AWS Bedrock models:', error as Error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async convertMessageToSdkParam(message: Message): Promise<AwsBedrockSdkMessageParam> {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
MistralDeltaSchema,
|
||||
OpenAISdkMessageParam,
|
||||
OpenAISdkParams,
|
||||
OpenAISdkRawChunk,
|
||||
@@ -758,12 +759,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
let accumulatingText = false
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const isOpenRouter = context.provider?.id === 'openrouter'
|
||||
|
||||
// 持续更新usage信息
|
||||
logger.silly('chunk', chunk)
|
||||
if (chunk.usage) {
|
||||
const usage = chunk.usage as any // OpenRouter may include additional fields like cost
|
||||
const usage = chunk.usage
|
||||
lastUsageInfo = {
|
||||
prompt_tokens: usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.completion_tokens || 0,
|
||||
@@ -771,19 +770,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// Handle OpenRouter specific cost fields
|
||||
...(usage.cost !== undefined ? { cost: usage.cost } : {})
|
||||
}
|
||||
|
||||
// For OpenRouter, if we've seen finish_reason and now have usage, emit completion signals
|
||||
if (isOpenRouter && hasFinishReason && !isFinished) {
|
||||
emitCompletionSignals(controller)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For OpenRouter, if this chunk only contains usage without choices, emit completion signals
|
||||
if (isOpenRouter && chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
|
||||
if (!isFinished) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
// if we've already seen finish_reason, emit completion signals. No matter whether we get usage or not.
|
||||
if (hasFinishReason && !isFinished) {
|
||||
emitCompletionSignals(controller)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -814,7 +805,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
(typeof choice.delta.content === 'string' && choice.delta.content !== '') ||
|
||||
(typeof (choice.delta as any).reasoning_content === 'string' &&
|
||||
(choice.delta as any).reasoning_content !== '') ||
|
||||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== ''))
|
||||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') ||
|
||||
Array.isArray(choice.delta.content))
|
||||
) {
|
||||
contentSource = choice.delta
|
||||
} else if ('message' in choice) {
|
||||
@@ -825,23 +817,26 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
if (!contentSource?.content) {
|
||||
accumulatingText = false
|
||||
}
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
if (!contentSource?.reasoning_content && !contentSource?.reasoning) {
|
||||
const mistralDelta = MistralDeltaSchema.safeParse(contentSource?.content)
|
||||
|
||||
if (
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
!contentSource?.reasoning_content &&
|
||||
// @ts-ignore - reasoning is not in standard OpenAI types but some providers use it
|
||||
!contentSource?.reasoning &&
|
||||
(mistralDelta.data?.[0]?.type !== 'thinking' || !mistralDelta.success)
|
||||
) {
|
||||
isThinking = false
|
||||
}
|
||||
|
||||
if (!contentSource) {
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
// For OpenRouter, don't emit completion signals immediately after finish_reason
|
||||
// Wait for the usage chunk that comes after
|
||||
if (isOpenRouter) {
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
} else {
|
||||
// For other providers, emit completion signals immediately
|
||||
// OpenAI Chat Completions API 在启用 stream_options: { include_usage: true } 以后
|
||||
// 包含 usage 的 chunk 会在包含 finish_reason: stop 的 chunk 之后
|
||||
// 所以试图等到拿到 usage 之后再发出结束信号
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
}
|
||||
@@ -864,12 +859,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
const reasoningText =
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
contentSource.reasoning_content ||
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
contentSource.reasoning ||
|
||||
(mistralDelta.data?.[0]?.type === 'thinking' ? mistralDelta.data?.[0]?.thinking[0]?.text : undefined)
|
||||
if (reasoningText) {
|
||||
// logger.silly('since reasoningText is trusy, try to enqueue THINKING_START AND THINKING_DELTA')
|
||||
if (!isThinking) {
|
||||
// logger.silly('since isThinking is falsy, try to enqueue THINKING_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
@@ -886,22 +883,35 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
|
||||
if (mistralDelta.success && mistralDelta.data?.[0]?.type === 'text') {
|
||||
if (!accumulatingText) {
|
||||
// logger.silly('enqueue TEXT_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
accumulatingText = true
|
||||
}
|
||||
// logger.silly('enqueue TEXT_DELTA')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
text: mistralDelta.data?.[0]?.text
|
||||
})
|
||||
} else {
|
||||
accumulatingText = false
|
||||
} else if (!mistralDelta.success) {
|
||||
if (contentSource.content) {
|
||||
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
|
||||
if (!accumulatingText) {
|
||||
// logger.silly('enqueue TEXT_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
accumulatingText = true
|
||||
}
|
||||
// logger.silly('enqueue TEXT_DELTA')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
})
|
||||
} else {
|
||||
accumulatingText = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
@@ -947,16 +957,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
}
|
||||
|
||||
// For OpenRouter, don't emit completion signals immediately after finish_reason
|
||||
// Don't emit completion signals immediately after finish_reason
|
||||
// Wait for the usage chunk that comes after
|
||||
if (isOpenRouter) {
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
} else {
|
||||
// For other providers, emit completion signals immediately
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,26 +184,3 @@
|
||||
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-splitter-bar {
|
||||
.ant-splitter-bar-dragger {
|
||||
&::before {
|
||||
background-color: var(--color-border) !important;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
&:hover {
|
||||
&::before {
|
||||
width: 4px !important;
|
||||
background-color: var(--color-primary) !important;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-splitter-bar-dragger-active {
|
||||
&::before {
|
||||
width: 4px !important;
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
open={open}
|
||||
afterClose={onClose}
|
||||
centered={!isFullscreen}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
mask={!isFullscreen}
|
||||
maskClosable={false}
|
||||
width={isFullscreen ? '100vw' : '90vw'}
|
||||
|
||||
@@ -58,12 +58,9 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings()
|
||||
|
||||
const [viewState, setViewState] = useState(() => {
|
||||
const initialMode = SPECIAL_VIEWS.includes(language) ? 'special' : 'source'
|
||||
return {
|
||||
mode: initialMode as ViewMode,
|
||||
previousMode: initialMode as ViewMode
|
||||
}
|
||||
const [viewState, setViewState] = useState({
|
||||
mode: 'special' as ViewMode,
|
||||
previousMode: 'special' as ViewMode
|
||||
})
|
||||
const { mode: viewMode } = viewState
|
||||
|
||||
@@ -99,18 +96,10 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||
|
||||
// TODO: 考虑移除
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
}, [hasSpecialView, viewMode])
|
||||
|
||||
// 不支持特殊视图时回退到 source
|
||||
useEffect(() => {
|
||||
if (!hasSpecialView && viewMode !== 'source') {
|
||||
setViewMode('source')
|
||||
}
|
||||
}, [hasSpecialView, viewMode, setViewMode])
|
||||
|
||||
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||
|
||||
@@ -298,11 +287,14 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
// 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图
|
||||
const renderContent = useMemo(() => {
|
||||
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
|
||||
const showSpecialView = !!specialView && ['special', 'split'].includes(viewMode)
|
||||
const showSourceView = !specialView || viewMode !== 'special'
|
||||
|
||||
return (
|
||||
<SplitViewWrapper className="split-view-wrapper" $viewMode={viewMode}>
|
||||
<SplitViewWrapper
|
||||
className="split-view-wrapper"
|
||||
$isSpecialView={showSpecialView && !showSourceView}
|
||||
$isSplitView={showSpecialView && showSourceView}>
|
||||
{showSpecialView && specialView}
|
||||
{showSourceView && sourceView}
|
||||
</SplitViewWrapper>
|
||||
@@ -373,7 +365,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
`
|
||||
|
||||
const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>`
|
||||
const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boolean }>`
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
@@ -383,13 +375,13 @@ const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>`
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
// 特殊视图的 header 会隐藏,所以全都使用圆角
|
||||
border-radius: ${(props) => (props.$viewMode === 'special' ? '8px' : '0 0 8px 8px')};
|
||||
border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 在 split 模式下添加中间分隔线
|
||||
${(props) =>
|
||||
props.$viewMode === 'split' &&
|
||||
props.$isSplitView &&
|
||||
css`
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c
|
||||
if (properties.style) {
|
||||
shikiTheme.style.cssText += `${properties.style}`
|
||||
}
|
||||
shikiTheme.tabIndex = properties.tabindex
|
||||
// FIXME: 临时解决 SelectionToolbar 无法弹出,走剪贴板回退的问题
|
||||
// shikiTheme.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
|
||||
@@ -499,7 +499,6 @@ const MinappPopupContainer: React.FC = () => {
|
||||
placement="bottom"
|
||||
onClose={handlePopupMinimize}
|
||||
open={isPopupShow}
|
||||
destroyOnClose={false}
|
||||
mask={false}
|
||||
rootClassName="minapp-drawer"
|
||||
maskClassName="minapp-mask"
|
||||
|
||||
@@ -349,7 +349,7 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
centered
|
||||
width={500}
|
||||
okText={t('common.save')}
|
||||
|
||||
@@ -66,6 +66,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
|
||||
// 无匹配项自动关闭的定时器
|
||||
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
@@ -128,12 +131,44 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
return newList
|
||||
}, [ctx.isVisible, ctx.list, ctx.symbol, searchText])
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
}, [list, historyPanel])
|
||||
|
||||
// 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板
|
||||
useEffect(() => {
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
|
||||
// 清除之前的定时器(无论面板是否可见都要清理)
|
||||
if (noMatchTimeoutRef.current) {
|
||||
clearTimeout(noMatchTimeoutRef.current)
|
||||
noMatchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// 面板不可见时不设置新定时器
|
||||
if (!ctx.isVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只有在有搜索文本但无匹配项时才设置延迟关闭
|
||||
if (_searchText && _searchText.length > 0 && list.length === 0) {
|
||||
noMatchTimeoutRef.current = setTimeout(() => {
|
||||
ctx.close('no-matches')
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (noMatchTimeoutRef.current) {
|
||||
clearTimeout(noMatchTimeoutRef.current)
|
||||
noMatchTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染
|
||||
}, [ctx.isVisible, searchText, list.length, ctx.close])
|
||||
|
||||
const clearSearchText = useCallback(
|
||||
(includeSymbol = false) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
@@ -275,7 +310,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchText(newSearchText)
|
||||
} else {
|
||||
handleClose('delete-symbol')
|
||||
ctx.close('delete-symbol')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Sparkle,
|
||||
SquareTerminal,
|
||||
Sun,
|
||||
Terminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
@@ -57,6 +58,8 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
return <Folder size={14} />
|
||||
case 'settings':
|
||||
return <Settings size={14} />
|
||||
case 'code':
|
||||
return <Terminal size={14} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -392,10 +392,10 @@ export function getModelLogo(modelId: string) {
|
||||
'gpt-image': ChatGPTImageModelLogo,
|
||||
'gpt-3': isLight ? ChatGPT35ModelLogo : ChatGPT35ModelLogoDark,
|
||||
'gpt-4': isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||
'gpt-5(?:-[0-9]+(?:-[0-9]+)*)?': GPT5ModelLogo,
|
||||
'gpt-5-mini': GPT5MiniModelLogo,
|
||||
'gpt-5-nano': GPT5NanoModelLogo,
|
||||
'gpt-5-chat': GPT5ChatModelLogo,
|
||||
'gpt-5': GPT5ModelLogo,
|
||||
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'text-moderation': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
@@ -2469,7 +2469,7 @@ export function isVisionModel(model: Model): boolean {
|
||||
|
||||
export function isOpenAIReasoningModel(model: Model): boolean {
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1') || modelId.includes('gpt-5-chat')
|
||||
return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1')
|
||||
}
|
||||
|
||||
export function isOpenAILLMModel(model: Model): boolean {
|
||||
|
||||
@@ -1246,7 +1246,7 @@ export const isSupportArrayContentProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe'] as const satisfies SystemProviderId[]
|
||||
const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe', 'qiniu'] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
* 判断提供商是否支持 developer 作为 message role。 Only for OpenAI API.
|
||||
|
||||
@@ -99,11 +99,6 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
},
|
||||
Divider: {
|
||||
colorSplit: 'rgba(128,128,128,0.15)'
|
||||
},
|
||||
Splitter: {
|
||||
splitBarDraggableSize: 0,
|
||||
splitBarSize: 0.5,
|
||||
splitTriggerSize: 10
|
||||
}
|
||||
},
|
||||
token: {
|
||||
|
||||
@@ -86,11 +86,12 @@ export function useAppInit() {
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyMode === 'system') {
|
||||
window.api.setProxy('system', proxyBypassRules)
|
||||
window.api.setProxy('system', undefined)
|
||||
} else if (proxyMode === 'custom') {
|
||||
proxyUrl && window.api.setProxy(proxyUrl, proxyBypassRules)
|
||||
} else {
|
||||
window.api.setProxy('')
|
||||
// set proxy to none for direct mode
|
||||
window.api.setProxy('', undefined)
|
||||
}
|
||||
}, [proxyUrl, proxyMode, proxyBypassRules])
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addDirectory,
|
||||
clearDirectories,
|
||||
removeDirectory,
|
||||
resetCodeTools,
|
||||
setCurrentDirectory,
|
||||
setSelectedCliTool,
|
||||
setSelectedModel
|
||||
} from '@renderer/store/codeTools'
|
||||
import { Model } from '@renderer/types'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export const useCodeTools = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const codeToolsState = useAppSelector((state) => state.codeTools)
|
||||
const logger = loggerService.withContext('useCodeTools')
|
||||
|
||||
// 设置选择的 CLI 工具
|
||||
const setCliTool = useCallback(
|
||||
(tool: string) => {
|
||||
dispatch(setSelectedCliTool(tool))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 设置选择的模型
|
||||
const setModel = useCallback(
|
||||
(model: Model | null) => {
|
||||
dispatch(setSelectedModel(model))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 添加目录
|
||||
const addDir = useCallback(
|
||||
(directory: string) => {
|
||||
dispatch(addDirectory(directory))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 删除目录
|
||||
const removeDir = useCallback(
|
||||
(directory: string) => {
|
||||
dispatch(removeDirectory(directory))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 设置当前目录
|
||||
const setCurrentDir = useCallback(
|
||||
(directory: string) => {
|
||||
dispatch(setCurrentDirectory(directory))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 清空所有目录
|
||||
const clearDirs = useCallback(() => {
|
||||
dispatch(clearDirectories())
|
||||
}, [dispatch])
|
||||
|
||||
// 重置所有设置
|
||||
const resetSettings = useCallback(() => {
|
||||
dispatch(resetCodeTools())
|
||||
}, [dispatch])
|
||||
|
||||
// 选择文件夹的辅助函数
|
||||
const selectFolder = useCallback(async () => {
|
||||
try {
|
||||
const folderPath = await window.api.file.selectFolder()
|
||||
if (folderPath) {
|
||||
setCurrentDir(folderPath)
|
||||
return folderPath
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('选择文件夹失败:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}, [setCurrentDir, logger])
|
||||
|
||||
// 获取当前CLI工具选择的模型
|
||||
const selectedModel = codeToolsState.selectedModels[codeToolsState.selectedCliTool] || null
|
||||
|
||||
// 检查是否可以启动(所有必需字段都已填写)
|
||||
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
selectedCliTool: codeToolsState.selectedCliTool,
|
||||
selectedModel: selectedModel,
|
||||
directories: codeToolsState.directories,
|
||||
currentDirectory: codeToolsState.currentDirectory,
|
||||
canLaunch,
|
||||
|
||||
// 操作函数
|
||||
setCliTool,
|
||||
setModel,
|
||||
addDir,
|
||||
removeDir,
|
||||
setCurrentDir,
|
||||
clearDirs,
|
||||
resetSettings,
|
||||
selectFolder
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export const getProgressLabel = (key: string): string => {
|
||||
const titleKeyMap = {
|
||||
agents: 'title.agents',
|
||||
apps: 'title.apps',
|
||||
code: 'title.code',
|
||||
files: 'title.files',
|
||||
home: 'title.home',
|
||||
knowledge: 'title.knowledge',
|
||||
@@ -292,7 +293,7 @@ export const getFileFieldLabel = (key: string): string => {
|
||||
|
||||
const builtInMcpDescriptionKeyMap = {
|
||||
'@cherry/mcp-auto-install': 'settings.mcp.builtinServersDescriptions.mcp_auto_install',
|
||||
'@cherry/memory': 'settings.mcp.builtinServersDescriptions.mcp_auto_install',
|
||||
'@cherry/memory': 'settings.mcp.builtinServersDescriptions.memory',
|
||||
'@cherry/sequentialthinking': 'settings.mcp.builtinServersDescriptions.sequentialthinking',
|
||||
'@cherry/brave-search': 'settings.mcp.builtinServersDescriptions.brave_search',
|
||||
'@cherry/fetch': 'settings.mcp.builtinServersDescriptions.fetch',
|
||||
|
||||
@@ -648,6 +648,31 @@
|
||||
},
|
||||
"translate": "Translate"
|
||||
},
|
||||
"code": {
|
||||
"auto_update_to_latest": "Automatically update to latest version",
|
||||
"bun_required_message": "Bun environment is required to run CLI tools",
|
||||
"cli_tool": "CLI Tool",
|
||||
"cli_tool_placeholder": "Select the CLI tool to use",
|
||||
"description": "Quickly launch multiple code CLI tools to improve development efficiency",
|
||||
"folder_placeholder": "Select working directory",
|
||||
"install_bun": "Install Bun",
|
||||
"installing_bun": "Installing...",
|
||||
"launch": {
|
||||
"bun_required": "Please install Bun environment first before launching CLI tools",
|
||||
"error": "Launch failed, please try again",
|
||||
"label": "Launch",
|
||||
"success": "Launch successful",
|
||||
"validation_error": "Please complete all required fields: CLI tool, model, and working directory"
|
||||
},
|
||||
"launching": "Launching...",
|
||||
"model": "Model",
|
||||
"model_placeholder": "Select the model to use",
|
||||
"model_required": "Please select a model",
|
||||
"select_folder": "Select Folder",
|
||||
"title": "Code Tools",
|
||||
"update_options": "Update Options",
|
||||
"working_directory": "Working Directory"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
"copy": {
|
||||
@@ -2690,6 +2715,17 @@
|
||||
"title": "Launch",
|
||||
"totray": "Minimize to Tray on Launch"
|
||||
},
|
||||
"math": {
|
||||
"engine": {
|
||||
"label": "Math engine",
|
||||
"none": "None"
|
||||
},
|
||||
"single_dollar": {
|
||||
"label": "Enable $...$",
|
||||
"tip": "Render math equations quoted by single dollar signs $...$. Default is enabled."
|
||||
},
|
||||
"title": "Math Settings"
|
||||
},
|
||||
"mcp": {
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
@@ -2864,7 +2900,7 @@
|
||||
"tagsPlaceholder": "Enter tags",
|
||||
"timeout": "Timeout",
|
||||
"timeoutTooltip": "Timeout in seconds for requests to this server, default is 60 seconds",
|
||||
"title": "MCP Settings",
|
||||
"title": "MCP",
|
||||
"tools": {
|
||||
"autoApprove": {
|
||||
"label": "Auto Approve",
|
||||
@@ -2920,10 +2956,6 @@
|
||||
"title": "Input Settings"
|
||||
},
|
||||
"markdown_rendering_input_message": "Markdown render input message",
|
||||
"math_engine": {
|
||||
"label": "Math engine",
|
||||
"none": "None"
|
||||
},
|
||||
"metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
|
||||
"model": {
|
||||
"title": "Model Settings"
|
||||
@@ -2935,6 +2967,7 @@
|
||||
"none": "None"
|
||||
},
|
||||
"prompt": "Show prompt",
|
||||
"show_message_outline": "Show message outline",
|
||||
"title": "Message Settings",
|
||||
"use_serif_font": "Use serif font"
|
||||
},
|
||||
@@ -3407,10 +3440,10 @@
|
||||
"title": "Settings",
|
||||
"tool": {
|
||||
"preprocess": {
|
||||
"provider": "Pre Process Provider",
|
||||
"provider_placeholder": "Choose a Pre Process provider",
|
||||
"title": "Pre Process",
|
||||
"tooltip": "In Settings -> Tools, set a document preprocessing service provider. Document preprocessing can effectively improve the retrieval performance of complex format documents and scanned documents."
|
||||
"provider": "Document Processing Provider",
|
||||
"provider_placeholder": "Choose a document processing provider",
|
||||
"title": "Document Processing",
|
||||
"tooltip": "In Settings -> Tools, set a document processing service provider. Document processing can effectively improve the retrieval performance of complex format documents and scanned documents."
|
||||
},
|
||||
"title": "Other Settings",
|
||||
"websearch": {
|
||||
@@ -3561,6 +3594,7 @@
|
||||
"title": {
|
||||
"agents": "Agents",
|
||||
"apps": "Apps",
|
||||
"code": "Code",
|
||||
"files": "Files",
|
||||
"home": "Home",
|
||||
"knowledge": "Knowledge Base",
|
||||
@@ -3612,8 +3646,10 @@
|
||||
},
|
||||
"empty": "Translation content is empty",
|
||||
"error": {
|
||||
"detected_unknown": "Unknown language cannot be exchanged",
|
||||
"empty": "The translation result is empty content",
|
||||
"failed": "Translation failed",
|
||||
"invalid_source": "Invalid source language",
|
||||
"not_configured": "Translation model is not configured",
|
||||
"not_supported": "Unsupported language {{language}}",
|
||||
"unknown": "An unknown error occurred during translation"
|
||||
|
||||
@@ -648,6 +648,31 @@
|
||||
},
|
||||
"translate": "翻訳"
|
||||
},
|
||||
"code": {
|
||||
"auto_update_to_latest": "最新バージョンを自動的に更新する",
|
||||
"bun_required_message": "CLI ツールを実行するには Bun 環境が必要です",
|
||||
"cli_tool": "CLI ツール",
|
||||
"cli_tool_placeholder": "使用する CLI ツールを選択してください",
|
||||
"description": "開発効率を向上させるために、複数のコード CLI ツールを迅速に起動します",
|
||||
"folder_placeholder": "作業ディレクトリを選択してください",
|
||||
"install_bun": "Bun をインストール",
|
||||
"installing_bun": "インストール中...",
|
||||
"launch": {
|
||||
"bun_required": "CLI ツールを実行するには Bun 環境が必要です。まず Bun をインストールしてください",
|
||||
"error": "起動に失敗しました。もう一度試してください",
|
||||
"label": "起動",
|
||||
"success": "起動成功",
|
||||
"validation_error": "必須項目を入力してください:CLI ツール、モデル、作業ディレクトリ"
|
||||
},
|
||||
"launching": "起動中...",
|
||||
"model": "モデル",
|
||||
"model_placeholder": "使用するモデルを選択してください",
|
||||
"model_required": "モデルを選択してください",
|
||||
"select_folder": "フォルダを選択",
|
||||
"title": "コードツール",
|
||||
"update_options": "更新オプション",
|
||||
"working_directory": "作業ディレクトリ"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折りたたむ",
|
||||
"copy": {
|
||||
@@ -2690,6 +2715,17 @@
|
||||
"title": "起動",
|
||||
"totray": "起動時にトレイに最小化"
|
||||
},
|
||||
"math": {
|
||||
"engine": {
|
||||
"label": "数式エンジン",
|
||||
"none": "なし"
|
||||
},
|
||||
"single_dollar": {
|
||||
"label": "$...$ を有効にする",
|
||||
"tip": "単一のドル記号 $...$ で囲まれた数式をレンダリングします。デフォルトで有効です。"
|
||||
},
|
||||
"title": "数式設定"
|
||||
},
|
||||
"mcp": {
|
||||
"actions": "操作",
|
||||
"active": "有効",
|
||||
@@ -2864,7 +2900,7 @@
|
||||
"tagsPlaceholder": "タグを入力",
|
||||
"timeout": "タイムアウト",
|
||||
"timeoutTooltip": "このサーバーへのリクエストのタイムアウト時間(秒)、デフォルトは60秒です",
|
||||
"title": "MCP 設定",
|
||||
"title": "MCP",
|
||||
"tools": {
|
||||
"autoApprove": {
|
||||
"label": "自動承認",
|
||||
@@ -2920,10 +2956,6 @@
|
||||
"title": "入力設定"
|
||||
},
|
||||
"markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
|
||||
"math_engine": {
|
||||
"label": "数式エンジン",
|
||||
"none": "なし"
|
||||
},
|
||||
"metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
|
||||
"model": {
|
||||
"title": "モデル設定"
|
||||
@@ -2935,6 +2967,7 @@
|
||||
"none": "表示しない"
|
||||
},
|
||||
"prompt": "プロンプト表示",
|
||||
"show_message_outline": "メッセージの概要を表示します",
|
||||
"title": "メッセージ設定",
|
||||
"use_serif_font": "セリフフォントを使用"
|
||||
},
|
||||
@@ -3561,6 +3594,7 @@
|
||||
"title": {
|
||||
"agents": "エージェント",
|
||||
"apps": "アプリ",
|
||||
"code": "Code",
|
||||
"files": "ファイル",
|
||||
"home": "ホーム",
|
||||
"knowledge": "ナレッジベース",
|
||||
@@ -3612,8 +3646,10 @@
|
||||
},
|
||||
"empty": "翻訳内容が空です",
|
||||
"error": {
|
||||
"detected_unknown": "未知の言語は交換できません",
|
||||
"empty": "翻訳結果が空の内容です",
|
||||
"failed": "翻訳に失敗しました",
|
||||
"invalid_source": "無効なソース言語",
|
||||
"not_configured": "翻訳モデルが設定されていません",
|
||||
"not_supported": "サポートされていない言語 {{language}}",
|
||||
"unknown": "翻訳中に不明なエラーが発生しました"
|
||||
|
||||
@@ -648,6 +648,31 @@
|
||||
},
|
||||
"translate": "Перевести"
|
||||
},
|
||||
"code": {
|
||||
"auto_update_to_latest": "Автоматически обновлять до последней версии",
|
||||
"bun_required_message": "Запуск CLI-инструментов требует установки среды Bun",
|
||||
"cli_tool": "Инструмент",
|
||||
"cli_tool_placeholder": "Выберите CLI-инструмент для использования",
|
||||
"description": "Быстро запускает несколько CLI-инструментов для кода, повышая эффективность разработки",
|
||||
"folder_placeholder": "Выберите рабочую директорию",
|
||||
"install_bun": "Установить Bun",
|
||||
"installing_bun": "Установка...",
|
||||
"launch": {
|
||||
"bun_required": "Пожалуйста, установите среду Bun перед запуском CLI-инструментов",
|
||||
"error": "Не удалось запустить. Пожалуйста, попробуйте снова",
|
||||
"label": "Запуск",
|
||||
"success": "Запуск успешно завершен",
|
||||
"validation_error": "Пожалуйста, заполните все обязательные поля: CLI-инструмент, модель и рабочая директория"
|
||||
},
|
||||
"launching": "Запуск...",
|
||||
"model": "Модель",
|
||||
"model_placeholder": "Выберите модель для использования",
|
||||
"model_required": "Пожалуйста, выберите модель",
|
||||
"select_folder": "Выберите папку",
|
||||
"title": "Инструменты кода",
|
||||
"update_options": "Параметры обновления",
|
||||
"working_directory": "Рабочая директория"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Свернуть",
|
||||
"copy": {
|
||||
@@ -2690,6 +2715,17 @@
|
||||
"title": "Запуск",
|
||||
"totray": "Свернуть в трей при запуске"
|
||||
},
|
||||
"math": {
|
||||
"engine": {
|
||||
"label": "Математический движок",
|
||||
"none": "Нет"
|
||||
},
|
||||
"single_dollar": {
|
||||
"label": "Включить $...$",
|
||||
"tip": "Отображать математические формулы, заключенные в одиночные символы доллара $...$. По умолчанию включено."
|
||||
},
|
||||
"title": "Настройки математических формул"
|
||||
},
|
||||
"mcp": {
|
||||
"actions": "Действия",
|
||||
"active": "Активен",
|
||||
@@ -2864,7 +2900,7 @@
|
||||
"tagsPlaceholder": "Введите теги",
|
||||
"timeout": "Тайм-аут",
|
||||
"timeoutTooltip": "Тайм-аут в секундах для запросов к этому серверу, по умолчанию 60 секунд",
|
||||
"title": "Настройки MCP",
|
||||
"title": "MCP",
|
||||
"tools": {
|
||||
"autoApprove": {
|
||||
"label": "Автоматическое одобрение",
|
||||
@@ -2920,10 +2956,6 @@
|
||||
"title": "Настройки ввода"
|
||||
},
|
||||
"markdown_rendering_input_message": "Отображение ввода в формате Markdown",
|
||||
"math_engine": {
|
||||
"label": "Математический движок",
|
||||
"none": "Нет"
|
||||
},
|
||||
"metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
|
||||
"model": {
|
||||
"title": "Настройки модели"
|
||||
@@ -2935,6 +2967,7 @@
|
||||
"none": "Не показывать"
|
||||
},
|
||||
"prompt": "Показывать подсказки",
|
||||
"show_message_outline": "Показать наброски сообщения",
|
||||
"title": "Настройки сообщений",
|
||||
"use_serif_font": "Использовать serif шрифт"
|
||||
},
|
||||
@@ -3407,10 +3440,10 @@
|
||||
"title": "Настройки",
|
||||
"tool": {
|
||||
"preprocess": {
|
||||
"provider": "Предварительная обработка Поставщик",
|
||||
"provider_placeholder": "Выберите поставщика услуг предварительной обработки",
|
||||
"title": "Предварительная обработка",
|
||||
"tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуги предварительной обработки документов. Предварительная обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов."
|
||||
"provider": "Поставщик обработки документов",
|
||||
"provider_placeholder": "Выберите поставщика услуг обработки документов",
|
||||
"title": "Обработка документов",
|
||||
"tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуг обработки документов. Обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов."
|
||||
},
|
||||
"title": "Другие настройки",
|
||||
"websearch": {
|
||||
@@ -3561,6 +3594,7 @@
|
||||
"title": {
|
||||
"agents": "Агенты",
|
||||
"apps": "Приложения",
|
||||
"code": "Code",
|
||||
"files": "Файлы",
|
||||
"home": "Главная",
|
||||
"knowledge": "База знаний",
|
||||
@@ -3612,8 +3646,10 @@
|
||||
},
|
||||
"empty": "Содержимое перевода пусто",
|
||||
"error": {
|
||||
"detected_unknown": "Неизвестный язык не подлежит обмену",
|
||||
"empty": "Результат перевода пуст",
|
||||
"failed": "Перевод не удалось",
|
||||
"invalid_source": "Недопустимый исходный язык",
|
||||
"not_configured": "Модель перевода не настроена",
|
||||
"not_supported": "Язык не поддерживается {{language}}",
|
||||
"unknown": "Во время перевода возникла неизвестная ошибка"
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
"enableFirst": "请先在 MCP 设置中启用此服务器",
|
||||
"label": "MCP 服务器",
|
||||
"noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
|
||||
"title": "MCP 设置"
|
||||
"title": "MCP 服务器"
|
||||
},
|
||||
"model": "模型设置",
|
||||
"more": "助手设置",
|
||||
@@ -648,6 +648,31 @@
|
||||
},
|
||||
"translate": "翻译"
|
||||
},
|
||||
"code": {
|
||||
"auto_update_to_latest": "检查更新并安装最新版本",
|
||||
"bun_required_message": "运行 CLI 工具需要安装 Bun 环境",
|
||||
"cli_tool": "CLI 工具",
|
||||
"cli_tool_placeholder": "选择要使用的 CLI 工具",
|
||||
"description": "快速启动多个代码 CLI 工具,提高开发效率",
|
||||
"folder_placeholder": "选择工作目录",
|
||||
"install_bun": "安装 Bun",
|
||||
"installing_bun": "安装中...",
|
||||
"launch": {
|
||||
"bun_required": "请先安装 Bun 环境再启动 CLI 工具",
|
||||
"error": "启动失败,请重试",
|
||||
"label": "启动",
|
||||
"success": "启动成功",
|
||||
"validation_error": "请完成所有必填项:CLI 工具、模型和工作目录"
|
||||
},
|
||||
"launching": "启动中...",
|
||||
"model": "模型",
|
||||
"model_placeholder": "选择要使用的模型",
|
||||
"model_required": "请选择模型",
|
||||
"select_folder": "选择文件夹",
|
||||
"title": "代码工具",
|
||||
"update_options": "更新选项",
|
||||
"working_directory": "工作目录"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收起",
|
||||
"copy": {
|
||||
@@ -2690,6 +2715,17 @@
|
||||
"title": "启动",
|
||||
"totray": "启动时最小化到托盘"
|
||||
},
|
||||
"math": {
|
||||
"engine": {
|
||||
"label": "数学公式引擎",
|
||||
"none": "无"
|
||||
},
|
||||
"single_dollar": {
|
||||
"label": "启用 $...$",
|
||||
"tip": "渲染单个美元符号 $...$ 包裹的数学公式,默认启用。"
|
||||
},
|
||||
"title": "数学公式设置"
|
||||
},
|
||||
"mcp": {
|
||||
"actions": "操作",
|
||||
"active": "启用",
|
||||
@@ -2864,7 +2900,7 @@
|
||||
"tagsPlaceholder": "输入标签",
|
||||
"timeout": "超时",
|
||||
"timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒",
|
||||
"title": "MCP 设置",
|
||||
"title": "MCP",
|
||||
"tools": {
|
||||
"autoApprove": {
|
||||
"label": "自动批准",
|
||||
@@ -2920,10 +2956,6 @@
|
||||
"title": "输入设置"
|
||||
},
|
||||
"markdown_rendering_input_message": "Markdown 渲染输入消息",
|
||||
"math_engine": {
|
||||
"label": "数学公式引擎",
|
||||
"none": "无"
|
||||
},
|
||||
"metrics": "首字时延 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens",
|
||||
"model": {
|
||||
"title": "模型设置"
|
||||
@@ -2935,6 +2967,7 @@
|
||||
"none": "不显示"
|
||||
},
|
||||
"prompt": "显示提示词",
|
||||
"show_message_outline": "显示消息大纲",
|
||||
"title": "消息设置",
|
||||
"use_serif_font": "使用衬线字体"
|
||||
},
|
||||
@@ -3407,10 +3440,10 @@
|
||||
"title": "设置",
|
||||
"tool": {
|
||||
"preprocess": {
|
||||
"provider": "文档预处理服务商",
|
||||
"provider_placeholder": "选择一个文档预处理服务商",
|
||||
"title": "文档预处理",
|
||||
"tooltip": "在设置 -> 工具中设置文档预处理服务商,文档预处理可以有效提升复杂格式文档与扫描版文档的检索效果"
|
||||
"provider": "文档处理服务商",
|
||||
"provider_placeholder": "选择一个文档处理服务商",
|
||||
"title": "文档处理",
|
||||
"tooltip": "在设置 -> 工具中设置文档处理服务商,文档处理可以有效提升复杂格式文档与扫描版文档的检索效果"
|
||||
},
|
||||
"title": "其他设置",
|
||||
"websearch": {
|
||||
@@ -3561,6 +3594,7 @@
|
||||
"title": {
|
||||
"agents": "智能体",
|
||||
"apps": "小程序",
|
||||
"code": "Code",
|
||||
"files": "文件",
|
||||
"home": "首页",
|
||||
"knowledge": "知识库",
|
||||
@@ -3612,8 +3646,10 @@
|
||||
},
|
||||
"empty": "翻译内容为空",
|
||||
"error": {
|
||||
"detected_unknown": "未知语言不可交换",
|
||||
"empty": "翻译结果为空内容",
|
||||
"failed": "翻译失败",
|
||||
"invalid_source": "无效的源语言",
|
||||
"not_configured": "翻译模型未配置",
|
||||
"not_supported": "不支持的语言 {{language}}",
|
||||
"unknown": "翻译过程中遇到未知错误"
|
||||
|
||||
@@ -648,6 +648,31 @@
|
||||
},
|
||||
"translate": "翻譯"
|
||||
},
|
||||
"code": {
|
||||
"auto_update_to_latest": "檢查更新並安裝最新版本",
|
||||
"bun_required_message": "運行 CLI 工具需要安裝 Bun 環境",
|
||||
"cli_tool": "CLI 工具",
|
||||
"cli_tool_placeholder": "選擇要使用的 CLI 工具",
|
||||
"description": "快速啟動多個程式碼 CLI 工具,提高開發效率",
|
||||
"folder_placeholder": "選擇工作目錄",
|
||||
"install_bun": "安裝 Bun",
|
||||
"installing_bun": "安裝中...",
|
||||
"launch": {
|
||||
"bun_required": "請先安裝 Bun 環境再啟動 CLI 工具",
|
||||
"error": "啟動失敗,請重試",
|
||||
"label": "啟動",
|
||||
"success": "啟動成功",
|
||||
"validation_error": "請完成所有必填項目:CLI 工具、模型和工作目錄"
|
||||
},
|
||||
"launching": "啟動中...",
|
||||
"model": "模型",
|
||||
"model_placeholder": "選擇要使用的模型",
|
||||
"model_required": "請選擇模型",
|
||||
"select_folder": "選擇資料夾",
|
||||
"title": "程式碼工具",
|
||||
"update_options": "更新選項",
|
||||
"working_directory": "工作目錄"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折疊",
|
||||
"copy": {
|
||||
@@ -2690,6 +2715,17 @@
|
||||
"title": "啟動",
|
||||
"totray": "啟動時最小化到系统匣"
|
||||
},
|
||||
"math": {
|
||||
"engine": {
|
||||
"label": "數學公式引擎",
|
||||
"none": "無"
|
||||
},
|
||||
"single_dollar": {
|
||||
"label": "啟用 $...$",
|
||||
"tip": "渲染單個美元符號 $...$ 包裹的數學公式,默認啟用。"
|
||||
},
|
||||
"title": "數學公式設定"
|
||||
},
|
||||
"mcp": {
|
||||
"actions": "操作",
|
||||
"active": "啟用",
|
||||
@@ -2864,7 +2900,7 @@
|
||||
"tagsPlaceholder": "輸入標籤",
|
||||
"timeout": "超時",
|
||||
"timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為 60 秒",
|
||||
"title": "MCP 設定",
|
||||
"title": "MCP",
|
||||
"tools": {
|
||||
"autoApprove": {
|
||||
"label": "自動批准",
|
||||
@@ -2920,10 +2956,6 @@
|
||||
"title": "輸入設定"
|
||||
},
|
||||
"markdown_rendering_input_message": "Markdown 渲染輸入訊息",
|
||||
"math_engine": {
|
||||
"label": "數學公式引擎",
|
||||
"none": "無"
|
||||
},
|
||||
"metrics": "首字延遲 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens",
|
||||
"model": {
|
||||
"title": "模型設定"
|
||||
@@ -2935,6 +2967,7 @@
|
||||
"none": "不顯示"
|
||||
},
|
||||
"prompt": "提示詞顯示",
|
||||
"show_message_outline": "顯示消息大綱",
|
||||
"title": "訊息設定",
|
||||
"use_serif_font": "使用襯線字型"
|
||||
},
|
||||
@@ -3407,10 +3440,10 @@
|
||||
"title": "設定",
|
||||
"tool": {
|
||||
"preprocess": {
|
||||
"provider": "前置處理供應商",
|
||||
"provider_placeholder": "選擇一個預處理供應商",
|
||||
"title": "前置處理",
|
||||
"tooltip": "在「設定」->「工具」中設定文件預處理服務供應商。文件預處理可有效提升複雜格式文件及掃描文件的檢索效能"
|
||||
"provider": "文件處理供應商",
|
||||
"provider_placeholder": "選擇一個文件處理供應商",
|
||||
"title": "文件處理",
|
||||
"tooltip": "在「設定」->「工具」中設定文件處理服務供應商。文件處理可有效提升複雜格式文件及掃描文件的檢索效能"
|
||||
},
|
||||
"title": "其他設定",
|
||||
"websearch": {
|
||||
@@ -3561,6 +3594,7 @@
|
||||
"title": {
|
||||
"agents": "智能體",
|
||||
"apps": "小程序",
|
||||
"code": "Code",
|
||||
"files": "文件",
|
||||
"home": "主頁",
|
||||
"knowledge": "知識庫",
|
||||
@@ -3612,8 +3646,10 @@
|
||||
},
|
||||
"empty": "翻譯內容為空",
|
||||
"error": {
|
||||
"detected_unknown": "未知語言不可交換",
|
||||
"empty": "翻译结果为空内容",
|
||||
"failed": "翻譯失敗",
|
||||
"invalid_source": "無效的源語言",
|
||||
"not_configured": "翻譯模型未設定",
|
||||
"not_supported": "不支援的語言 {{language}}",
|
||||
"unknown": "翻譯過程中遇到未知錯誤"
|
||||
|
||||
@@ -3612,8 +3612,10 @@
|
||||
},
|
||||
"empty": "Το μεταφρασμένο κείμενο είναι κενό",
|
||||
"error": {
|
||||
"detected_unknown": "Άγνωστη γλώσσα μη ανταλλάξιμη",
|
||||
"empty": "το αποτέλεσμα της μετάφρασης είναι κενό περιεχόμενο",
|
||||
"failed": "Η μετάφραση απέτυχε",
|
||||
"invalid_source": "Ακύρωση γλώσσας πηγής",
|
||||
"not_configured": "Το μοντέλο μετάφρασης δεν είναι ρυθμισμένο",
|
||||
"not_supported": "Μη υποστηριζόμενη γλώσσα {{language}}",
|
||||
"unknown": "κατά τη μετάφραση παρουσιάστηκε άγνωστο σφάλμα"
|
||||
|
||||
@@ -3612,8 +3612,10 @@
|
||||
},
|
||||
"empty": "El contenido de traducción está vacío",
|
||||
"error": {
|
||||
"detected_unknown": "Idioma desconocido no intercambiable",
|
||||
"empty": "El resultado de la traducción está vacío",
|
||||
"failed": "Fallo en la traducción",
|
||||
"invalid_source": "Invalid source language",
|
||||
"not_configured": "El modelo de traducción no está configurado",
|
||||
"not_supported": "Idioma no compatible {{language}}",
|
||||
"unknown": "Se produjo un error desconocido durante la traducción"
|
||||
|
||||
@@ -3612,8 +3612,10 @@
|
||||
},
|
||||
"empty": "Le contenu à traduire est vide",
|
||||
"error": {
|
||||
"detected_unknown": "Langue inconnue non échangeable",
|
||||
"empty": "Le résultat de la traduction est un contenu vide",
|
||||
"failed": "échec de la traduction",
|
||||
"invalid_source": "Langue source invalide",
|
||||
"not_configured": "le modèle de traduction n'est pas configuré",
|
||||
"not_supported": "Langue non prise en charge {{language}}",
|
||||
"unknown": "Une erreur inconnue s'est produite lors de la traduction"
|
||||
|
||||
@@ -3612,8 +3612,10 @@
|
||||
},
|
||||
"empty": "O conteúdo de tradução está vazio",
|
||||
"error": {
|
||||
"detected_unknown": "Idioma desconhecido não pode ser trocado",
|
||||
"empty": "Resultado da tradução está vazio",
|
||||
"failed": "Tradução falhou",
|
||||
"invalid_source": "Idioma de origem inválido",
|
||||
"not_configured": "Modelo de tradução não configurado",
|
||||
"not_supported": "Idioma não suportado {{language}}",
|
||||
"unknown": "Ocorreu um erro desconhecido durante a tradução"
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import AiProvider from '@renderer/aiCore'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||
import { useCodeTools } from '@renderer/hooks/useCodeTools'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Alert, Button, Checkbox, Select, Space } from 'antd'
|
||||
import { Download, Terminal, X } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
// CLI 工具选项
|
||||
const CLI_TOOLS = [
|
||||
{ value: 'qwen-code', label: 'Qwen Code' },
|
||||
{ value: 'claude-code', label: 'Claude Code' },
|
||||
{ value: 'gemini-cli', label: 'Gemini CLI' }
|
||||
]
|
||||
|
||||
const logger = loggerService.withContext('CodeToolsPage')
|
||||
|
||||
const CodeToolsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const dispatch = useAppDispatch()
|
||||
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
|
||||
const {
|
||||
selectedCliTool,
|
||||
selectedModel,
|
||||
directories,
|
||||
currentDirectory,
|
||||
canLaunch,
|
||||
setCliTool,
|
||||
setModel,
|
||||
setCurrentDir,
|
||||
removeDir,
|
||||
selectFolder
|
||||
} = useCodeTools()
|
||||
|
||||
// 状态管理
|
||||
const [isLaunching, setIsLaunching] = useState(false)
|
||||
const [isInstallingBun, setIsInstallingBun] = useState(false)
|
||||
const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false)
|
||||
|
||||
// 处理 CLI 工具选择
|
||||
const handleCliToolChange = (value: string) => {
|
||||
setCliTool(value)
|
||||
// 不再清空模型选择,因为每个工具都会记住自己的模型
|
||||
}
|
||||
|
||||
const openAiProviders = providers.filter((p) => p.type.includes('openai'))
|
||||
const geminiProviders = providers.filter((p) => p.type === 'gemini')
|
||||
const claudeProviders = providers.filter((p) => p.type === 'anthropic')
|
||||
|
||||
const modelPredicate = useCallback(
|
||||
(m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) && !isTextToImageModel(m),
|
||||
[]
|
||||
)
|
||||
|
||||
const availableProviders =
|
||||
selectedCliTool === 'claude-code'
|
||||
? claudeProviders
|
||||
: selectedCliTool === 'gemini-cli'
|
||||
? geminiProviders
|
||||
: openAiProviders
|
||||
|
||||
// 处理模型选择
|
||||
const handleModelChange = (value: string) => {
|
||||
if (!value) {
|
||||
setModel(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 从所有 providers 中查找选中的模型
|
||||
for (const provider of providers || []) {
|
||||
const model = provider.models.find((m) => getModelUniqId(m) === value)
|
||||
if (model) {
|
||||
setModel(model)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件夹选择
|
||||
const handleFolderSelect = async () => {
|
||||
try {
|
||||
await selectFolder()
|
||||
} catch (error) {
|
||||
logger.error('选择文件夹失败:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理目录选择
|
||||
const handleDirectoryChange = (value: string) => {
|
||||
setCurrentDir(value)
|
||||
}
|
||||
|
||||
// 处理删除目录
|
||||
const handleRemoveDirectory = (directory: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
removeDir(directory)
|
||||
}
|
||||
|
||||
// 检查 bun 是否安装
|
||||
const checkBunInstallation = useCallback(async () => {
|
||||
try {
|
||||
const bunExists = await window.api.isBinaryExist('bun')
|
||||
dispatch(setIsBunInstalled(bunExists))
|
||||
} catch (error) {
|
||||
logger.error('检查 bun 安装状态失败:', error as Error)
|
||||
dispatch(setIsBunInstalled(false))
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
// 安装 bun
|
||||
const handleInstallBun = async () => {
|
||||
try {
|
||||
setIsInstallingBun(true)
|
||||
await window.api.installBunBinary()
|
||||
dispatch(setIsBunInstalled(true))
|
||||
window.message.success({
|
||||
content: t('settings.mcp.installSuccess'),
|
||||
key: 'bun-install-message'
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('安装 bun 失败:', error as Error)
|
||||
window.message.error({
|
||||
content: `${t('settings.mcp.installError')}: ${error.message}`,
|
||||
key: 'bun-install-message'
|
||||
})
|
||||
} finally {
|
||||
setIsInstallingBun(false)
|
||||
// 重新检查安装状态
|
||||
setTimeout(checkBunInstallation, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理启动
|
||||
const handleLaunch = async () => {
|
||||
if (!canLaunch || !isBunInstalled) {
|
||||
if (!isBunInstalled) {
|
||||
window.message.warning({
|
||||
content: t('code.launch.bun_required'),
|
||||
key: 'code-launch-message'
|
||||
})
|
||||
} else {
|
||||
window.message.warning({
|
||||
content: t('code.launch.validation_error'),
|
||||
key: 'code-launch-message'
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsLaunching(true)
|
||||
|
||||
if (!selectedModel) {
|
||||
window.message.error({
|
||||
content: t('code.model_required'),
|
||||
key: 'code-launch-message'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const modelProvider = getProviderByModel(selectedModel)
|
||||
const aiProvider = new AiProvider(modelProvider)
|
||||
const baseUrl = await aiProvider.getBaseURL()
|
||||
const apiKey = await aiProvider.getApiKey()
|
||||
|
||||
let env: Record<string, string> = {}
|
||||
if (selectedCliTool === 'claude-code') {
|
||||
env = {
|
||||
ANTHROPIC_API_KEY: apiKey,
|
||||
ANTHROPIC_MODEL: selectedModel.id
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCliTool === 'gemini-cli') {
|
||||
env = {
|
||||
GEMINI_API_KEY: apiKey
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCliTool === 'qwen-code') {
|
||||
env = {
|
||||
OPENAI_API_KEY: apiKey,
|
||||
OPENAI_BASE_URL: baseUrl,
|
||||
OPENAI_MODEL: selectedModel.id
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 这里可以添加实际的启动逻辑
|
||||
logger.info('启动配置:', {
|
||||
cliTool: selectedCliTool,
|
||||
model: selectedModel,
|
||||
folder: currentDirectory
|
||||
})
|
||||
|
||||
window.api.codeTools.run(selectedCliTool, selectedModel?.id, currentDirectory, env, {
|
||||
autoUpdateToLatest
|
||||
})
|
||||
|
||||
window.message.success({
|
||||
content: t('code.launch.success'),
|
||||
key: 'code-launch-message'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('启动失败:', error as Error)
|
||||
window.message.error({
|
||||
content: t('code.launch.error'),
|
||||
key: 'code-launch-message'
|
||||
})
|
||||
} finally {
|
||||
setIsLaunching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检查 bun 安装状态
|
||||
useEffect(() => {
|
||||
checkBunInstallation()
|
||||
}, [checkBunInstallation])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>{t('code.title')}</Title>
|
||||
<Description>{t('code.description')}</Description>
|
||||
|
||||
{/* Bun 安装状态提示 */}
|
||||
{!isBunInstalled && (
|
||||
<BunInstallAlert>
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)' }}
|
||||
message={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t('code.bun_required_message')}</span>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<Download size={14} />}
|
||||
onClick={handleInstallBun}
|
||||
loading={isInstallingBun}
|
||||
disabled={isInstallingBun}>
|
||||
{isInstallingBun ? t('code.installing_bun') : t('code.install_bun')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</BunInstallAlert>
|
||||
)}
|
||||
|
||||
<SettingsPanel>
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.cli_tool')}</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('code.cli_tool_placeholder')}
|
||||
value={selectedCliTool}
|
||||
onChange={handleCliToolChange}
|
||||
options={CLI_TOOLS}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.model')}</div>
|
||||
<ModelSelector
|
||||
providers={availableProviders}
|
||||
predicate={modelPredicate}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('code.model_placeholder')}
|
||||
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
|
||||
onChange={handleModelChange}
|
||||
allowClear
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.working_directory')}</div>
|
||||
<Space.Compact style={{ width: '100%', display: 'flex' }}>
|
||||
<Select
|
||||
style={{ flex: 1, width: 480 }}
|
||||
placeholder={t('code.folder_placeholder')}
|
||||
value={currentDirectory || undefined}
|
||||
onChange={handleDirectoryChange}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
const label = typeof option?.label === 'string' ? option.label : String(option?.value || '')
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
options={directories.map((dir) => ({
|
||||
value: dir,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
|
||||
<X
|
||||
size={14}
|
||||
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
|
||||
onClick={(e) => handleRemoveDirectory(dir, e)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
<Button onClick={handleFolderSelect} style={{ width: 120 }}>
|
||||
{t('code.select_folder')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.update_options')}</div>
|
||||
<Checkbox checked={autoUpdateToLatest} onChange={(e) => setAutoUpdateToLatest(e.target.checked)}>
|
||||
{t('code.auto_update_to_latest')}
|
||||
</Checkbox>
|
||||
</SettingsItem>
|
||||
</SettingsPanel>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Terminal size={16} />}
|
||||
size="large"
|
||||
onClick={handleLaunch}
|
||||
loading={isLaunching}
|
||||
disabled={!canLaunch || !isBunInstalled}
|
||||
block>
|
||||
{isLaunching ? t('code.launching') : t('code.launch.label')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件
|
||||
const Container = styled.div`
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
`
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
margin-top: -50px;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const Description = styled.p`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
`
|
||||
|
||||
const SettingsPanel = styled.div`
|
||||
margin-bottom: 32px;
|
||||
`
|
||||
|
||||
const SettingsItem = styled.div`
|
||||
margin-bottom: 24px;
|
||||
|
||||
.settings-label {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
`
|
||||
|
||||
const BunInstallAlert = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
export default CodeToolsPage
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CodeToolsPage'
|
||||
@@ -46,11 +46,15 @@ const FilesPage: FC = () => {
|
||||
const dataSource = sortedFiles?.map((file) => {
|
||||
return {
|
||||
key: file.id,
|
||||
file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>,
|
||||
file: (
|
||||
<span onClick={() => window.api.file.openPath(FileManager.getFilePath(file))}>
|
||||
{FileManager.formatFileName(file)}
|
||||
</span>
|
||||
),
|
||||
size: formatFileSize(file.size),
|
||||
size_bytes: file.size,
|
||||
count: file.count,
|
||||
path: file.path,
|
||||
path: FileManager.getFilePath(file),
|
||||
ext: file.ext,
|
||||
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
|
||||
created_at_unix: dayjs(file.created_at).unix(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
|
||||
@@ -10,6 +11,7 @@ import NavigationService from '@renderer/services/NavigationService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Divider, Empty } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Forward } from 'lucide-react'
|
||||
@@ -26,6 +28,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
|
||||
const dispatch = useAppDispatch()
|
||||
const { messageStyle } = useSettings()
|
||||
|
||||
useEffect(() => {
|
||||
topic && dispatch(loadTopicMessagesThunk(topic.id))
|
||||
@@ -48,9 +51,9 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
return (
|
||||
<MessageEditingProvider>
|
||||
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll}>
|
||||
<ContainerWrapper>
|
||||
<ContainerWrapper className={messageStyle}>
|
||||
{topic?.messages.map((message) => (
|
||||
<div key={message.id} style={{ position: 'relative' }}>
|
||||
<MessageWrapper key={message.id} className={classNames([messageStyle, message.role])}>
|
||||
<MessageItem message={message} topic={topic} hideMenuBar={true} />
|
||||
<Button
|
||||
type="text"
|
||||
@@ -60,7 +63,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
icon={<Forward size={16} />}
|
||||
/>
|
||||
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
|
||||
</div>
|
||||
</MessageWrapper>
|
||||
))}
|
||||
{isEmpty && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{!isEmpty && (
|
||||
@@ -91,4 +94,11 @@ const ContainerWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const MessageWrapper = styled.div`
|
||||
position: relative;
|
||||
&.bubble.user {
|
||||
padding-top: 26px;
|
||||
}
|
||||
`
|
||||
|
||||
export default TopicMessages
|
||||
|
||||
@@ -35,7 +35,7 @@ import { setSearching } from '@renderer/store/runtime'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { classNames, delay, formatFileSize } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import {
|
||||
getFilesFromDropEvent,
|
||||
@@ -590,7 +590,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
let supportedFiles = 0
|
||||
|
||||
files.forEach((file) => {
|
||||
if (supportedExts.includes(getFileExtension(file.path))) {
|
||||
if (supportedExts.includes(file.ext)) {
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
supportedFiles++
|
||||
}
|
||||
|
||||
@@ -397,6 +397,7 @@ const InputbarTools = ({
|
||||
ToolbarButton={ToolbarButton}
|
||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||
files={files}
|
||||
setText={setText}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Props {
|
||||
couldMentionNotVisionModel: boolean
|
||||
files: FileType[]
|
||||
ToolbarButton: any
|
||||
setText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const MentionModelsButton: FC<Props> = ({
|
||||
@@ -35,13 +36,17 @@ const MentionModelsButton: FC<Props> = ({
|
||||
onMentionModel,
|
||||
couldMentionNotVisionModel,
|
||||
files,
|
||||
ToolbarButton
|
||||
ToolbarButton,
|
||||
setText
|
||||
}) => {
|
||||
const { providers } = useProviders()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
// 记录是否有模型被选择的动作发生
|
||||
const hasModelActionRef = useRef<boolean>(false)
|
||||
|
||||
const pinnedModels = useLiveQuery(
|
||||
async () => {
|
||||
const setting = await db.settings.get('pinned:models')
|
||||
@@ -74,7 +79,10 @@ const MentionModelsButton: FC<Props> = ({
|
||||
</Avatar>
|
||||
),
|
||||
filterText: getFancyProviderName(p) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
action: () => {
|
||||
hasModelActionRef.current = true // 标记有模型动作发生
|
||||
onMentionModel(m)
|
||||
},
|
||||
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
)
|
||||
@@ -107,7 +115,10 @@ const MentionModelsButton: FC<Props> = ({
|
||||
</Avatar>
|
||||
),
|
||||
filterText: getFancyProviderName(p) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
action: () => {
|
||||
hasModelActionRef.current = true // 标记有模型动作发生
|
||||
onMentionModel(m)
|
||||
},
|
||||
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
|
||||
@@ -127,6 +138,9 @@ const MentionModelsButton: FC<Props> = ({
|
||||
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
// 重置模型动作标记
|
||||
hasModelActionRef.current = false
|
||||
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
@@ -134,9 +148,25 @@ const MentionModelsButton: FC<Props> = ({
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
},
|
||||
onClose({ action }) {
|
||||
// ESC或Backspace关闭时的特殊处理
|
||||
if (action === 'esc' || action === 'delete-symbol') {
|
||||
// 如果有模型选择动作发生,删除@字符
|
||||
if (hasModelActionRef.current) {
|
||||
// 使用React的setText来更新状态,而不是直接操作DOM
|
||||
setText((currentText) => {
|
||||
const lastAtIndex = currentText.lastIndexOf('@')
|
||||
if (lastAtIndex !== -1) {
|
||||
return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1)
|
||||
}
|
||||
return currentText
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [modelItems, quickPanel, t])
|
||||
}, [modelItems, quickPanel, t, setText])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
|
||||
@@ -73,7 +73,7 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
}, [currentReasoningEffort, supportedOptions, updateAssistantSettings, model.id])
|
||||
|
||||
const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => {
|
||||
const iconColor = isActive ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
switch (true) {
|
||||
case option === 'minimal':
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Assistant, WebSearchProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { FC, memo, startTransition, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface WebSearchButtonRef {
|
||||
@@ -29,23 +29,22 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
|
||||
|
||||
const updateSelectedWebSearchProvider = useCallback(
|
||||
(providerId?: WebSearchProvider['id']) => {
|
||||
async (providerId?: WebSearchProvider['id']) => {
|
||||
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
|
||||
// NOTE: 也许可以用startTransition优化卡顿问题
|
||||
setTimeout(() => {
|
||||
const currentWebSearchProviderId = assistant.webSearchProviderId
|
||||
const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId
|
||||
const currentWebSearchProviderId = assistant.webSearchProviderId
|
||||
const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId
|
||||
startTransition(() => {
|
||||
updateAssistant({ ...assistant, webSearchProviderId: newWebSearchProviderId, enableWebSearch: false })
|
||||
}, 200)
|
||||
})
|
||||
},
|
||||
[assistant, updateAssistant]
|
||||
)
|
||||
|
||||
const updateSelectedWebSearchBuiltin = useCallback(() => {
|
||||
const updateSelectedWebSearchBuiltin = useCallback(async () => {
|
||||
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
|
||||
setTimeout(() => {
|
||||
startTransition(() => {
|
||||
updateAssistant({ ...assistant, webSearchProviderId: undefined, enableWebSearch: !assistant.enableWebSearch })
|
||||
}, 200)
|
||||
})
|
||||
}, [assistant, updateAssistant])
|
||||
|
||||
const providerItems = useMemo<QuickPanelListItem[]>(() => {
|
||||
@@ -92,11 +91,13 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
if (assistant.webSearchProviderId) {
|
||||
return updateSelectedWebSearchProvider(undefined)
|
||||
updateSelectedWebSearchProvider(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (assistant.enableWebSearch) {
|
||||
return updateSelectedWebSearchBuiltin()
|
||||
updateSelectedWebSearchBuiltin()
|
||||
return
|
||||
}
|
||||
|
||||
quickPanel.open({
|
||||
@@ -137,7 +138,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
<Globe
|
||||
size={18}
|
||||
style={{
|
||||
color: enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
color: enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Pluggable } from 'unified'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import Link from './Link'
|
||||
import rehypeHeadingIds from './plugins/rehypeHeadingIds'
|
||||
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
|
||||
import Table from './Table'
|
||||
|
||||
@@ -45,7 +46,7 @@ interface Props {
|
||||
|
||||
const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
const { t } = useTranslation()
|
||||
const { mathEngine } = useSettings()
|
||||
const { mathEngine, mathEnableSingleDollar } = useSettings()
|
||||
|
||||
const isTrulyDone = 'status' in block && block.status === 'success'
|
||||
const [displayedContent, setDisplayedContent] = useState(postProcess ? postProcess(block.content) : block.content)
|
||||
@@ -97,10 +98,10 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
remarkDisableConstructs(['codeIndented'])
|
||||
]
|
||||
if (mathEngine !== 'none') {
|
||||
plugins.push(remarkMath)
|
||||
plugins.push([remarkMath, { singleDollarTextMath: mathEnableSingleDollar }])
|
||||
}
|
||||
return plugins
|
||||
}, [mathEngine])
|
||||
}, [mathEngine, mathEnableSingleDollar])
|
||||
|
||||
const messageContent = useMemo(() => {
|
||||
if ('status' in block && block.status === 'paused' && isEmpty(block.content)) {
|
||||
@@ -110,17 +111,18 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
}, [block, displayedContent, t])
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const plugins: any[] = []
|
||||
const plugins: Pluggable[] = []
|
||||
if (ALLOWED_ELEMENTS.test(messageContent)) {
|
||||
plugins.push(rehypeRaw)
|
||||
}
|
||||
plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }])
|
||||
if (mathEngine === 'KaTeX') {
|
||||
plugins.push(rehypeKatex as any)
|
||||
plugins.push(rehypeKatex)
|
||||
} else if (mathEngine === 'MathJax') {
|
||||
plugins.push(rehypeMathjax as any)
|
||||
plugins.push(rehypeMathjax)
|
||||
}
|
||||
return plugins
|
||||
}, [mathEngine, messageContent])
|
||||
}, [mathEngine, messageContent, block.id])
|
||||
|
||||
const onSaveCodeBlock = useCallback(
|
||||
(id: string, newContent: string) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CopyIcon } from '@renderer/components/Icons'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import store from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { memo, useCallback, useState } from 'react'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -18,7 +19,7 @@ interface Props {
|
||||
*/
|
||||
const Table: React.FC<Props> = ({ children, node, blockId }) => {
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copied, setCopied] = useTemporaryValue(false, 2000)
|
||||
|
||||
const handleCopyTable = useCallback(() => {
|
||||
const tableMarkdown = extractTableMarkdown(blockId ?? '', node?.position)
|
||||
@@ -28,12 +29,11 @@ const Table: React.FC<Props> = ({ children, node, blockId }) => {
|
||||
.writeText(tableMarkdown)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
.catch((error) => {
|
||||
window.message?.error({ content: `${t('message.copy.failed')}: ${error}`, key: 'copy-table-error' })
|
||||
})
|
||||
}, [node, blockId, t])
|
||||
}, [blockId, node?.position, setCopied, t])
|
||||
|
||||
return (
|
||||
<TableWrapper className="table-wrapper">
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('Markdown', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default settings
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true })
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key)
|
||||
})
|
||||
@@ -270,7 +270,7 @@ describe('Markdown', () => {
|
||||
|
||||
describe('math engine configuration', () => {
|
||||
it('should configure KaTeX when mathEngine is KaTeX', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
@@ -279,7 +279,7 @@ describe('Markdown', () => {
|
||||
})
|
||||
|
||||
it('should configure MathJax when mathEngine is MathJax', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax', mathEnableSingleDollar: true })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('Markdown', () => {
|
||||
})
|
||||
|
||||
it('should not load math plugins when mathEngine is none', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'none' })
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'none', mathEnableSingleDollar: true })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
@@ -384,12 +384,12 @@ describe('Markdown', () => {
|
||||
})
|
||||
|
||||
it('should re-render when math engine changes', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true })
|
||||
const { rerender } = render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax', mathEnableSingleDollar: true })
|
||||
rerender(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Should still render correctly with new math engine
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Element, Node, Root, Text } from 'hast'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
/**
|
||||
* 基于 GitHub 风格的标题 slug 生成器(去重逻辑)
|
||||
* - 小写
|
||||
* - 去除前后空白
|
||||
* - 移除部分标点
|
||||
* - 将空白与非字母数字字符合并为单个 '-'
|
||||
* - 多次出现的相同 slug 加上递增后缀(-1, -2...)
|
||||
*/
|
||||
export function createSlugger() {
|
||||
const seen = new Map<string, number>()
|
||||
const normalize = (text: string): string => {
|
||||
const slug = (text || 'section')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
// 移除常见分隔符和标点
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符
|
||||
.replace(/["'`(){}[\]:;!?.,]/g, '')
|
||||
// 将空白和非字母数字字符转换为 '-'
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
// 合并多余的 '-'
|
||||
.replace(/-{2,}/g, '-')
|
||||
// 去除首尾 '-'
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return slug
|
||||
}
|
||||
|
||||
const slug = (text: string): string => {
|
||||
const base = normalize(text)
|
||||
const count = seen.get(base) || 0
|
||||
seen.set(base, count + 1)
|
||||
return `${base}-${count}`
|
||||
}
|
||||
|
||||
return { slug }
|
||||
}
|
||||
|
||||
export function extractTextFromNode(node: Node | Text | Element | null | undefined): string {
|
||||
if (!node) return ''
|
||||
|
||||
if (typeof (node as Text).value === 'string') {
|
||||
return (node as Text).value
|
||||
}
|
||||
|
||||
if ((node as Element).children?.length) {
|
||||
return (node as Element).children.map(extractTextFromNode).join('')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function rehypeHeadingIds(options?: { prefix?: string }) {
|
||||
return (tree: Root) => {
|
||||
const slugger = createSlugger()
|
||||
const prefix = options?.prefix ? `${options.prefix}--` : ''
|
||||
visit(tree, 'element', (node) => {
|
||||
if (!node || typeof node.tagName !== 'string') return
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (!/^h[1-6]$/.test(tag)) return
|
||||
|
||||
const text = extractTextFromNode(node)
|
||||
const id = prefix + slugger.slug(text)
|
||||
node.properties = node.properties || {}
|
||||
if (!node.properties.id) node.properties.id = id
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ImageViewer from '@renderer/components/ImageViewer'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { type ImageMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { Skeleton } from 'antd'
|
||||
import React from 'react'
|
||||
@@ -13,8 +14,8 @@ const ImageBlock: React.FC<Props> = ({ block }) => {
|
||||
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
|
||||
const images = block.metadata?.generateImageResponse?.images?.length
|
||||
? block.metadata?.generateImageResponse?.images
|
||||
: block?.file?.path
|
||||
? [`file://${block?.file?.path}`]
|
||||
: block?.file
|
||||
? [`file://${FileManager.getFilePath(block?.file)}`]
|
||||
: []
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@@ -403,7 +403,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
onClose={handleDrawerClose}
|
||||
open={showChatHistory}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
header: { border: 'none' },
|
||||
body: {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import ContextMenu from '@renderer/components/ContextMenu'
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { Citation } from '@renderer/types'
|
||||
import { fetchWebContent } from '@renderer/utils/fetch'
|
||||
import { cleanMarkdownContent } from '@renderer/utils/formats'
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
|
||||
import { Button, message, Popover, Skeleton } from 'antd'
|
||||
import { Check, Copy, FileSearch } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -116,7 +117,7 @@ const handleLinkClick = (url: string, event: React.MouseEvent) => {
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copied, setCopied] = useTemporaryValue(false, 2000)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -126,7 +127,6 @@ const CopyButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
window.message.success(t('common.copied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
.catch(() => {
|
||||
message.error(t('message.copy.failed'))
|
||||
|
||||
@@ -23,6 +23,7 @@ import MessageEditor from './MessageEditor'
|
||||
import MessageErrorBoundary from './MessageErrorBoundary'
|
||||
import MessageHeader from './MessageHeader'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
import MessageOutline from './MessageOutline'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@@ -66,7 +67,7 @@ const MessageItem: FC<Props> = ({
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const { isMultiSelectMode } = useChatContext(topic)
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { messageFont, fontSize, messageStyle } = useSettings()
|
||||
const { messageFont, fontSize, messageStyle, showMessageOutline } = useSettings()
|
||||
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { editingMessageId, stopEditing } = useMessageEditing()
|
||||
@@ -183,6 +184,9 @@ const MessageItem: FC<Props> = ({
|
||||
)}
|
||||
{!isEditing && (
|
||||
<>
|
||||
{!isMultiSelectMode && message.role === 'assistant' && showMessageOutline && (
|
||||
<MessageOutline message={message} />
|
||||
)}
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAppSelector } from '@renderer/store'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { classNames, getFileExtension } from '@renderer/utils'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { findAllBlocks } from '@renderer/utils/messageUtils/find'
|
||||
@@ -173,7 +173,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
||||
if (files) {
|
||||
let supportedFiles = 0
|
||||
files.forEach((file) => {
|
||||
if (extensions.includes(getFileExtension(file.path))) {
|
||||
if (extensions.includes(file.ext)) {
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
supportedFiles++
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ interface MessageWrapperProps {
|
||||
|
||||
const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||
&.horizontal {
|
||||
padding-right: 1px;
|
||||
padding: 1px;
|
||||
overflow-y: auto;
|
||||
.message {
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { Message, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import React, { FC, useMemo, useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import remarkParse from 'remark-parse'
|
||||
import styled from 'styled-components'
|
||||
import { unified } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
import { createSlugger, extractTextFromNode } from '../Markdown/plugins/rehypeHeadingIds'
|
||||
|
||||
interface MessageOutlineProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
interface HeadingItem {
|
||||
id: string
|
||||
level: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const MessageOutline: FC<MessageOutlineProps> = ({ message }) => {
|
||||
const blockEntities = useSelector((state: RootState) => messageBlocksSelectors.selectEntities(state))
|
||||
|
||||
const headings: HeadingItem[] = useMemo(() => {
|
||||
const mainTextBlocks = message.blocks
|
||||
.map((blockId) => blockEntities[blockId])
|
||||
.filter((b) => b?.type === MessageBlockType.MAIN_TEXT)
|
||||
|
||||
if (!mainTextBlocks?.length) return []
|
||||
|
||||
const result: HeadingItem[] = []
|
||||
mainTextBlocks.forEach((mainTextBlock) => {
|
||||
const tree = unified().use(remarkParse).parse(mainTextBlock?.content)
|
||||
const slugger = createSlugger()
|
||||
visit(tree, ['heading', 'html'], (node) => {
|
||||
if (node.type === 'heading') {
|
||||
const level = node.depth ?? 0
|
||||
if (!level || level < 1 || level > 6) return
|
||||
const text = extractTextFromNode(node)
|
||||
if (!text) return
|
||||
const id = `heading-${mainTextBlock.id}--` + slugger.slug(text || '')
|
||||
result.push({ id, level, text: text })
|
||||
} else if (node.type === 'html') {
|
||||
// 匹配 <h1>...</h1> 到 <h6>...</h6>
|
||||
const match = node.value.match(/<h([1-6])[^>]*>(.*?)<\/h\1>/i)
|
||||
if (match) {
|
||||
const level = parseInt(match[1], 10)
|
||||
const text = match[2].replace(/<[^>]*>/g, '').trim() // 移除内部的HTML标签
|
||||
if (text) {
|
||||
const id = `heading-${mainTextBlock.id}--${slugger.slug(text || '')}`
|
||||
result.push({ id, level, text })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}, [message.blocks, blockEntities])
|
||||
|
||||
const miniLevel = useMemo(() => {
|
||||
return headings.length ? Math.min(...headings.map((heading) => heading.level)) : 1
|
||||
}, [headings])
|
||||
|
||||
const messageOutlineContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollToHeading = (id: string) => {
|
||||
const parent = messageOutlineContainerRef.current?.parentElement
|
||||
const messageContentContainer = parent?.querySelector('.message-content-container')
|
||||
if (messageContentContainer) {
|
||||
const headingElement = messageContentContainer.querySelector(`#${id}`)
|
||||
if (headingElement) {
|
||||
const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start'
|
||||
headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暂时不支持 grid,因为在锚点滚动时会导致渲染错位
|
||||
if (message.multiModelMessageStyle === 'grid' || !headings.length) return null
|
||||
|
||||
return (
|
||||
<MessageOutlineContainer ref={messageOutlineContainerRef}>
|
||||
<MessageOutlineBody $count={headings.length}>
|
||||
{headings.map((heading, index) => (
|
||||
<MessageOutlineItem key={index} onClick={() => scrollToHeading(heading.id)}>
|
||||
<MessageOutlineItemDot $level={heading.level} />
|
||||
<MessageOutlineItemText $level={heading.level} $miniLevel={miniLevel}>
|
||||
{heading.text}
|
||||
</MessageOutlineItemText>
|
||||
</MessageOutlineItem>
|
||||
))}
|
||||
</MessageOutlineBody>
|
||||
</MessageOutlineContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const MessageOutlineContainer = styled.div`
|
||||
position: absolute;
|
||||
inset: 63px 0 36px 10px;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
& ~ .message-content-container {
|
||||
padding-left: 46px !important;
|
||||
}
|
||||
& ~ .MessageFooter {
|
||||
margin-left: 46px !important;
|
||||
}
|
||||
`
|
||||
|
||||
const MessageOutlineItemDot = styled.div<{ $level: number }>`
|
||||
width: ${({ $level }) => 16 - $level * 2}px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease;
|
||||
`
|
||||
|
||||
const MessageOutlineItemText = styled.div<{ $level: number; $miniLevel: number }>`
|
||||
overflow: hidden;
|
||||
color: var(--color-text-3);
|
||||
opacity: 0;
|
||||
display: none;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 2px 8px;
|
||||
padding-left: ${({ $level, $miniLevel }) => ($level - $miniLevel) * 8}px;
|
||||
font-size: ${({ $level }) => 16 - $level}px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const MessageOutlineItem = styled.div`
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
${MessageOutlineItemText} {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
${MessageOutlineItemDot} {
|
||||
background: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MessageOutlineBody = styled(Scrollbar)<{ $count: number }>`
|
||||
max-width: 50%;
|
||||
max-height: min(100%, 70vh);
|
||||
position: sticky;
|
||||
top: max(calc(50% - ${({ $count }) => ($count * 24) / 2 + 10}px), 20px);
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0 10px 10px;
|
||||
gap: 4px;
|
||||
border-radius: 10px;
|
||||
pointer-events: auto;
|
||||
&:hover {
|
||||
padding: 10px 10px 10px 10px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-background);
|
||||
box-shadow: 0 0 10px 0 rgba(128, 128, 128, 0.2);
|
||||
${MessageOutlineItemText} {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default React.memo(MessageOutline)
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
setEnableBackspaceDeleteModel,
|
||||
setEnableQuickPanelTriggers,
|
||||
setFontSize,
|
||||
setMathEnableSingleDollar,
|
||||
setMathEngine,
|
||||
setMessageFont,
|
||||
setMessageNavigation,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
setPasteLongTextThreshold,
|
||||
setRenderInputMessageAsMarkdown,
|
||||
setShowInputEstimatedTokens,
|
||||
setShowMessageOutline,
|
||||
setShowPrompt,
|
||||
setShowTranslateConfirm,
|
||||
setThoughtAutoCollapse
|
||||
@@ -96,6 +98,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
codeImageTools,
|
||||
codeExecution,
|
||||
mathEngine,
|
||||
mathEnableSingleDollar,
|
||||
autoTranslateWithSpace,
|
||||
pasteLongTextThreshold,
|
||||
multiModelMessageStyle,
|
||||
@@ -103,7 +106,8 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
messageNavigation,
|
||||
enableQuickPanelTriggers,
|
||||
enableBackspaceDeleteModel,
|
||||
showTranslateConfirm
|
||||
showTranslateConfirm,
|
||||
showMessageOutline
|
||||
} = useSettings()
|
||||
|
||||
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
||||
@@ -332,6 +336,15 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.show_message_outline')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showMessageOutline}
|
||||
onChange={(checked) => dispatch(setShowMessageOutline(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.style.label')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
@@ -371,19 +384,6 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.math_engine.label')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={mathEngine}
|
||||
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
|
||||
options={[
|
||||
{ value: 'KaTeX', label: 'KaTeX' },
|
||||
{ value: 'MathJax', label: 'MathJax' },
|
||||
{ value: 'none', label: t('settings.messages.math_engine.none') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.font_size.title')}</SettingRowTitleSmall>
|
||||
</SettingRow>
|
||||
@@ -407,6 +407,37 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
</SettingGroup>
|
||||
</CollapsibleSettingGroup>
|
||||
<CollapsibleSettingGroup title={t('settings.math.title')} defaultExpanded={true}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.math.engine.label')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={mathEngine}
|
||||
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
|
||||
options={[
|
||||
{ value: 'KaTeX', label: 'KaTeX' },
|
||||
{ value: 'MathJax', label: 'MathJax' },
|
||||
{ value: 'none', label: t('settings.math.engine.none') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('settings.math.single_dollar.label')}{' '}
|
||||
<Tooltip title={t('settings.math.single_dollar.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={mathEnableSingleDollar}
|
||||
onChange={(checked) => dispatch(setMathEnableSingleDollar(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</SettingGroup>
|
||||
</CollapsibleSettingGroup>
|
||||
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={true}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({ panels,
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import tabsService from '@renderer/services/TabsService'
|
||||
import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle } from 'lucide-react'
|
||||
import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle, Terminal } from 'lucide-react'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -52,6 +52,12 @@ const LaunchpadPage: FC = () => {
|
||||
text: t('title.files'),
|
||||
path: '/files',
|
||||
bgColor: 'linear-gradient(135deg, #F59E0B, #FBBF24)' // 文件:金色,代表资源和重要性
|
||||
},
|
||||
{
|
||||
icon: <Terminal size={32} className="icon" />,
|
||||
text: t('title.code'),
|
||||
path: '/code',
|
||||
bgColor: 'linear-gradient(135deg, #1F2937, #374151)' // Code CLI:高级暗黑色,代表专业和技术
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -577,7 +577,7 @@ const DataSettings: FC = () => {
|
||||
)
|
||||
)}
|
||||
</MenuList>
|
||||
<SettingContainer theme={theme} style={{ display: 'flex', flex: 1 }}>
|
||||
<SettingContainer theme={theme} style={{ display: 'flex', flex: 1, height: '100%' }}>
|
||||
{menu === 'data' && (
|
||||
<>
|
||||
<SettingGroup theme={theme}>
|
||||
|
||||
@@ -113,12 +113,6 @@ const GeneralSettings: FC = () => {
|
||||
|
||||
const onProxyModeChange = (mode: 'system' | 'custom' | 'none') => {
|
||||
dispatch(setProxyMode(mode))
|
||||
if (mode === 'system') {
|
||||
dispatch(_setProxyUrl(undefined))
|
||||
} else if (mode === 'none') {
|
||||
dispatch(_setProxyUrl(undefined))
|
||||
dispatch(_setProxyBypassRules(undefined))
|
||||
}
|
||||
}
|
||||
|
||||
const languagesOptions: { value: LanguageVarious; label: string; flag: string }[] = [
|
||||
|
||||
@@ -274,7 +274,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
||||
onClose()
|
||||
}}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
width={600}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RedoOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@@ -19,6 +18,7 @@ import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDescription, SettingGroup, SettingTitle } from '..'
|
||||
import TranslateSettingsPopup from '../TranslateSettingsPopup/TranslateSettingsPopup'
|
||||
import DefaultAssistantSettings from './DefaultAssistantSettings'
|
||||
import TopicNamingModalPopup from './TopicNamingModalPopup'
|
||||
|
||||
@@ -53,21 +53,6 @@ const ModelSettings: FC = () => {
|
||||
[translateModel]
|
||||
)
|
||||
|
||||
const onUpdateTranslateModel = async () => {
|
||||
const prompt = await PromptPopup.show({
|
||||
title: t('settings.models.translate_model_prompt_title'),
|
||||
message: t('settings.models.translate_model_prompt_message'),
|
||||
defaultValue: translateModelPrompt,
|
||||
inputProps: {
|
||||
rows: 10,
|
||||
onPressEnter: () => {}
|
||||
}
|
||||
})
|
||||
if (prompt) {
|
||||
dispatch(setTranslateModelPrompt(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
const onResetTranslatePrompt = () => {
|
||||
dispatch(setTranslateModelPrompt(TRANSLATE_PROMPT))
|
||||
}
|
||||
@@ -133,7 +118,11 @@ const ModelSettings: FC = () => {
|
||||
onChange={(value) => setTranslateModel(find(allModels, JSON.parse(value)) as Model)}
|
||||
placeholder={t('settings.models.empty')}
|
||||
/>
|
||||
<Button icon={<Settings2 size={16} />} style={{ marginLeft: 8 }} onClick={onUpdateTranslateModel} />
|
||||
<Button
|
||||
icon={<Settings2 size={16} />}
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => TranslateSettingsPopup.show()}
|
||||
/>
|
||||
{translateModelPrompt !== TRANSLATE_PROMPT && (
|
||||
<Tooltip title={t('common.reset')}>
|
||||
<Button icon={<RedoOutlined />} style={{ marginLeft: 8 }} onClick={onResetTranslatePrompt}></Button>
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import {
|
||||
SettingRowTitle,
|
||||
SettingSubtitle,
|
||||
SettingTitle
|
||||
} from '../..'
|
||||
} from '..'
|
||||
|
||||
interface Props {
|
||||
provider: PreprocessProvider
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { Select } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import PreprocessProviderSettings from './PreprocessSettings'
|
||||
|
||||
const PreprocessSettings: FC = () => {
|
||||
+31
-65
@@ -1,8 +1,8 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { isSystemProvider, Provider } from '@renderer/types'
|
||||
import { Collapse, Flex, Switch } from 'antd'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { Flex, Switch } from 'antd'
|
||||
import { startTransition, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -55,17 +55,6 @@ const ApiOptionsSettings = ({ providerId }: Props) => {
|
||||
},
|
||||
checked: !provider.apiOptions?.isNotSupportStreamOptions
|
||||
},
|
||||
{
|
||||
key: 'openai_array_content',
|
||||
label: t('settings.provider.api.options.array_content.label'),
|
||||
tip: t('settings.provider.api.options.array_content.help'),
|
||||
onChange: (checked: boolean) => {
|
||||
updateProviderTransition({
|
||||
apiOptions: { ...provider.apiOptions, isNotSupportArrayContent: !checked }
|
||||
})
|
||||
},
|
||||
checked: !provider.apiOptions?.isNotSupportArrayContent
|
||||
},
|
||||
{
|
||||
key: 'openai_service_tier',
|
||||
label: t('settings.provider.api.options.service_tier.label'),
|
||||
@@ -93,64 +82,41 @@ const ApiOptionsSettings = ({ providerId }: Props) => {
|
||||
)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const items: OptionType[] = []
|
||||
const items: OptionType[] = [
|
||||
{
|
||||
key: 'openai_array_content',
|
||||
label: t('settings.provider.api.options.array_content.label'),
|
||||
tip: t('settings.provider.api.options.array_content.help'),
|
||||
onChange: (checked: boolean) => {
|
||||
updateProviderTransition({
|
||||
apiOptions: { ...provider.apiOptions, isNotSupportArrayContent: !checked }
|
||||
})
|
||||
},
|
||||
checked: !provider.apiOptions?.isNotSupportArrayContent
|
||||
}
|
||||
]
|
||||
|
||||
if (provider.type === 'openai' || provider.type === 'openai-response' || provider.type === 'azure-openai') {
|
||||
items.push(...openAIOptions)
|
||||
}
|
||||
return items
|
||||
}, [openAIOptions, provider.type])
|
||||
|
||||
if (options.length === 0 || isSystemProvider(provider)) {
|
||||
return null
|
||||
}
|
||||
return items
|
||||
}, [openAIOptions, provider.apiOptions, provider.type, t, updateProviderTransition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'settings',
|
||||
styles: {
|
||||
header: {
|
||||
paddingLeft: 0,
|
||||
paddingRight: 6
|
||||
},
|
||||
body: {
|
||||
padding: 0
|
||||
}
|
||||
},
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text-1)',
|
||||
userSelect: 'none',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{t('settings.provider.api.options.label')}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<Flex vertical gap="middle">
|
||||
{options.map((item) => (
|
||||
<HStack key={item.key} justifyContent="space-between">
|
||||
<HStack alignItems="center" gap={6}>
|
||||
<label style={{ cursor: 'pointer' }} htmlFor={item.key}>
|
||||
{item.label}
|
||||
</label>
|
||||
<InfoTooltip title={item.tip}></InfoTooltip>
|
||||
</HStack>
|
||||
<Switch id={item.key} checked={item.checked} onChange={item.onChange} />
|
||||
</HStack>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
]}
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
/>
|
||||
</>
|
||||
<Flex vertical gap="middle">
|
||||
{options.map((item) => (
|
||||
<HStack key={item.key} justifyContent="space-between">
|
||||
<HStack alignItems="center" gap={6}>
|
||||
<label style={{ cursor: 'pointer' }} htmlFor={item.key}>
|
||||
{item.label}
|
||||
</label>
|
||||
<InfoTooltip title={item.tip}></InfoTooltip>
|
||||
</HStack>
|
||||
<Switch id={item.key} checked={item.checked} onChange={item.onChange} />
|
||||
</HStack>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { Modal } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ApiOptionsSettings from './ApiOptionsSettings'
|
||||
|
||||
interface ShowParams {
|
||||
providerId: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
ApiOptionsSettingsPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.provider.api.options.label')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
styles={{ body: { padding: '20px 16px' } }}
|
||||
footer={null}
|
||||
centered>
|
||||
<ApiOptionsSettings providerId={providerId} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'ApiOptionsSettingsPopup'
|
||||
|
||||
export default class ApiOptionsSettingsPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import ManageModelsPopup from '@renderer/pages/settings/ProviderSettings/ModelLi
|
||||
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup'
|
||||
import { Model } from '@renderer/types'
|
||||
import { filterModelsByKeywords } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Spin, Tooltip } from 'antd'
|
||||
import { Button, Flex, Spin, Tooltip } from 'antd'
|
||||
import { groupBy, isEmpty, sortBy, toPairs } from 'lodash'
|
||||
import { ListCheck, Plus } from 'lucide-react'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -120,7 +120,7 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
|
||||
</HStack>
|
||||
</SettingSubtitle>
|
||||
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
|
||||
{displayedModelGroups && !isEmpty(displayedModelGroups) ? (
|
||||
{displayedModelGroups && !isEmpty(displayedModelGroups) && (
|
||||
<Flex gap={12} vertical>
|
||||
{Object.keys(displayedModelGroups).map((group, i) => (
|
||||
<ModelListGroup
|
||||
@@ -135,12 +135,6 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('settings.models.empty')}
|
||||
style={{ visibility: isLoading ? 'hidden' : 'visible' }}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
<Flex justify="space-between" align="center">
|
||||
|
||||
@@ -10,13 +10,14 @@ import i18n from '@renderer/i18n'
|
||||
import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList'
|
||||
import { checkApi } from '@renderer/services/ApiService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { isSystemProvider } from '@renderer/types'
|
||||
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip, Typography } from 'antd'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react'
|
||||
import { Bolt, Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
SettingSubtitle,
|
||||
SettingTitle
|
||||
} from '..'
|
||||
import ApiOptionsSettings from './ApiOptionsSettings'
|
||||
import ApiOptionsSettingsPopup from './ApiOptionsSettings/ApiOptionsSettingsPopup'
|
||||
import AwsBedrockSettings from './AwsBedrockSettings'
|
||||
import CustomHeaderPopup from './CustomHeaderPopup'
|
||||
import DMXAPISettings from './DMXAPISettings'
|
||||
@@ -229,13 +230,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
return (
|
||||
<SettingContainer theme={theme} style={{ background: 'var(--color-background)' }}>
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={5} flex={1} style={{ overflow: 'hidden' }}>
|
||||
<ProviderName ellipsis>{fancyProviderName}</ProviderName>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderName>{fancyProviderName}</ProviderName>
|
||||
{officialWebsite && (
|
||||
<Link target="_blank" href={providerConfig.websites.official} style={{ display: 'flex' }}>
|
||||
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
|
||||
</Link>
|
||||
)}
|
||||
{!isSystemProvider(provider) && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Bolt size={14} />}
|
||||
size="small"
|
||||
onClick={() => ApiOptionsSettingsPopup.show({ providerId: provider.id })}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Switch
|
||||
value={provider.enabled}
|
||||
@@ -366,13 +375,12 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
|
||||
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings providerId={provider.id} />}
|
||||
<ApiOptionsSettings providerId={provider.id} />
|
||||
<ModelList providerId={provider.id} />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderName = styled(Typography.Text)`
|
||||
const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: -2px;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
matchKeywordsInProvider,
|
||||
uuid
|
||||
} from '@renderer/utils'
|
||||
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Splitter, Tag } from 'antd'
|
||||
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -455,77 +455,70 @@ const ProvidersList: FC = () => {
|
||||
|
||||
return (
|
||||
<Container className="selectable">
|
||||
<Splitter>
|
||||
<Splitter.Panel min={250} defaultSize={250}>
|
||||
<ProviderListContainer>
|
||||
<AddButtonWrapper>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('settings.provider.search')}
|
||||
value={searchText}
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35 }}
|
||||
suffix={<Search size={14} />}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchText('')
|
||||
}
|
||||
}}
|
||||
allowClear
|
||||
disabled={dragging}
|
||||
/>
|
||||
</AddButtonWrapper>
|
||||
<DraggableVirtualList
|
||||
list={filteredProviders}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
estimateSize={useCallback(() => 40, [])}
|
||||
itemKey={itemKey}
|
||||
overscan={3}
|
||||
style={{
|
||||
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
|
||||
}}
|
||||
scrollerStyle={{
|
||||
padding: 8,
|
||||
paddingRight: 5
|
||||
}}
|
||||
itemContainerStyle={{ paddingBottom: 5 }}>
|
||||
{(provider) => (
|
||||
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
|
||||
<ProviderListItem
|
||||
key={provider.id}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
<DragHandle>
|
||||
<GripVertical size={12} />
|
||||
</DragHandle>
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
|
||||
{provider.enabled && (
|
||||
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
|
||||
ON
|
||||
</Tag>
|
||||
)}
|
||||
</ProviderListItem>
|
||||
</Dropdown>
|
||||
)}
|
||||
</DraggableVirtualList>
|
||||
<AddButtonWrapper>
|
||||
<Button
|
||||
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
|
||||
icon={<PlusIcon size={16} />}
|
||||
onClick={onAddProvider}
|
||||
disabled={dragging}>
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
</AddButtonWrapper>
|
||||
</ProviderListContainer>
|
||||
</Splitter.Panel>
|
||||
|
||||
<Splitter.Panel min={'50%'}>
|
||||
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
<ProviderListContainer>
|
||||
<AddButtonWrapper>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('settings.provider.search')}
|
||||
value={searchText}
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35 }}
|
||||
suffix={<Search size={14} />}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchText('')
|
||||
}
|
||||
}}
|
||||
allowClear
|
||||
disabled={dragging}
|
||||
/>
|
||||
</AddButtonWrapper>
|
||||
<DraggableVirtualList
|
||||
list={filteredProviders}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
estimateSize={useCallback(() => 40, [])}
|
||||
itemKey={itemKey}
|
||||
overscan={3}
|
||||
style={{
|
||||
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
|
||||
}}
|
||||
scrollerStyle={{
|
||||
padding: 8,
|
||||
paddingRight: 5
|
||||
}}
|
||||
itemContainerStyle={{ paddingBottom: 5 }}>
|
||||
{(provider) => (
|
||||
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
|
||||
<ProviderListItem
|
||||
key={provider.id}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
<DragHandle>
|
||||
<GripVertical size={12} />
|
||||
</DragHandle>
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
|
||||
{provider.enabled && (
|
||||
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
|
||||
ON
|
||||
</Tag>
|
||||
)}
|
||||
</ProviderListItem>
|
||||
</Dropdown>
|
||||
)}
|
||||
</DraggableVirtualList>
|
||||
<AddButtonWrapper>
|
||||
<Button
|
||||
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
|
||||
icon={<PlusIcon size={16} />}
|
||||
onClick={onAddProvider}
|
||||
disabled={dragging}>
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
</AddButtonWrapper>
|
||||
</ProviderListContainer>
|
||||
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -540,7 +533,10 @@ const Container = styled.div`
|
||||
const ProviderListContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: calc(var(--settings-width) + 10px);
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
padding-bottom: 5px;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ProviderListItem = styled.div`
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '..'
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '.'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const MacProcessTrustHintModal: FC<MacProcessTrustHintModalProps> = ({ open, onC
|
||||
</div>
|
||||
}
|
||||
centered
|
||||
destroyOnClose>
|
||||
destroyOnHidden>
|
||||
<ContentContainer>
|
||||
<Paragraph>
|
||||
<Text>
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ const SelectionActionSearchModal: FC<SelectionActionSearchModalProps> = ({
|
||||
open={isModalOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
centered>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ const SelectionFilterListModal: FC<SelectionFilterListModalProps> = ({ open, onC
|
||||
onCancel={onClose}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
destroyOnClose={true}
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="modal-cancel" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { GlobalOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||
import { Divider as AntDivider } from 'antd'
|
||||
import {
|
||||
Brain,
|
||||
Cloud,
|
||||
Command,
|
||||
FolderCog,
|
||||
FileCode,
|
||||
HardDrive,
|
||||
Info,
|
||||
Languages,
|
||||
MonitorCog,
|
||||
Package,
|
||||
PictureInPicture2,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
TextCursorInput
|
||||
TextCursorInput,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -26,12 +29,13 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'
|
||||
import GeneralSettings from './GeneralSettings'
|
||||
import MCPSettings from './MCPSettings'
|
||||
import MemorySettings from './MemorySettings'
|
||||
import PreprocessSettings from './PreprocessSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings'
|
||||
import ShortcutSettings from './ShortcutSettings'
|
||||
import ToolSettings from './ToolSettings'
|
||||
import TranslateSettings from './TranslateSettings/TranslateSettings'
|
||||
import WebSearchSettings from './WebSearchSettings'
|
||||
|
||||
const SettingsPage: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
@@ -58,6 +62,7 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.model')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<Divider />
|
||||
<MenuItemLink to="/settings/general">
|
||||
<MenuItem className={isRoute('/settings/general')}>
|
||||
<Settings2 size={18} />
|
||||
@@ -76,16 +81,17 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.data.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<Divider />
|
||||
<MenuItemLink to="/settings/mcp">
|
||||
<MenuItem className={isRoute('/settings/mcp')}>
|
||||
<SquareTerminal size={18} />
|
||||
{t('settings.mcp.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/translate">
|
||||
<MenuItem className={isRoute('/settings/translate')}>
|
||||
<Languages size={18} />
|
||||
{t('settings.translate.title')}
|
||||
<MenuItemLink to="/settings/websearch">
|
||||
<MenuItem className={isRoute('/settings/websearch')}>
|
||||
<GlobalOutlined style={{ fontSize: 18 }} />
|
||||
{t('settings.tool.websearch.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/memory">
|
||||
@@ -94,18 +100,25 @@ const SettingsPage: FC = () => {
|
||||
{t('memory.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/preprocess">
|
||||
<MenuItem className={isRoute('/settings/preprocess')}>
|
||||
<FileCode size={18} />
|
||||
{t('settings.tool.preprocess.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/quickphrase">
|
||||
<MenuItem className={isRoute('/settings/quickphrase')}>
|
||||
<Zap size={18} />
|
||||
{t('settings.quickPhrase.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/shortcut">
|
||||
<MenuItem className={isRoute('/settings/shortcut')}>
|
||||
<Command size={18} />
|
||||
{t('settings.shortcuts.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/tool">
|
||||
<MenuItem className={isRoute('/settings/tool')}>
|
||||
<FolderCog size={18} />
|
||||
{t('settings.tool.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<Divider />
|
||||
<MenuItemLink to="/settings/quickAssistant">
|
||||
<MenuItem className={isRoute('/settings/quickAssistant')}>
|
||||
<PictureInPicture2 size={18} />
|
||||
@@ -118,6 +131,7 @@ const SettingsPage: FC = () => {
|
||||
{t('selection.name')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<Divider />
|
||||
<MenuItemLink to="/settings/about">
|
||||
<MenuItem className={isRoute('/settings/about')}>
|
||||
<Info size={18} />
|
||||
@@ -129,9 +143,10 @@ const SettingsPage: FC = () => {
|
||||
<Routes>
|
||||
<Route path="provider" element={<ProvidersList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="tool/*" element={<ToolSettings />} />
|
||||
<Route path="websearch" element={<WebSearchSettings />} />
|
||||
<Route path="preprocess" element={<PreprocessSettings />} />
|
||||
<Route path="quickphrase" element={<QuickPhraseSettings />} />
|
||||
<Route path="mcp/*" element={<MCPSettings />} />
|
||||
<Route path="translate" element={<TranslateSettings />} />
|
||||
<Route path="memory" element={<MemorySettings />} />
|
||||
<Route path="general/*" element={<GeneralSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
@@ -158,21 +173,22 @@ const ContentContainer = styled.div`
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
padding: 1px 0;
|
||||
`
|
||||
|
||||
const SettingMenus = styled.ul`
|
||||
const SettingMenus = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: var(--settings-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
padding: 10px;
|
||||
user-select: none;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const MenuItemLink = styled(Link)`
|
||||
text-decoration: none;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const MenuItem = styled.li`
|
||||
@@ -191,12 +207,6 @@ const MenuItem = styled.li`
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0.7;
|
||||
margin-left: -1px;
|
||||
}
|
||||
&:hover {
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
@@ -212,4 +222,8 @@ const SettingContent = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const Divider = styled(AntDivider)`
|
||||
margin: 3px 0;
|
||||
`
|
||||
|
||||
export default SettingsPage
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { GlobalOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import { FileCode, Zap } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreprocessSettings from './PreprocessSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
import WebSearchSettings from './WebSearchSettings'
|
||||
|
||||
const ToolSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
const menuItems = [
|
||||
{ key: 'web-search', title: 'settings.tool.websearch.title', icon: <GlobalOutlined style={{ fontSize: 16 }} /> },
|
||||
{ key: 'preprocess', title: 'settings.tool.preprocess.title', icon: <FileCode size={16} /> },
|
||||
{ key: 'quick-phrase', title: 'settings.quickPhrase.title', icon: <Zap size={16} /> }
|
||||
]
|
||||
|
||||
const isActive = (key: string): boolean => {
|
||||
const basePath = '/settings/tool'
|
||||
if (key === 'web-search') {
|
||||
return pathname === basePath || pathname === `${basePath}/` || pathname === `${basePath}/${key}`
|
||||
}
|
||||
return pathname === `${basePath}/${key}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MenuList>
|
||||
{menuItems.map((item) => (
|
||||
<Link key={item.key} to={`/settings/tool/${item.key}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<ListItem
|
||||
title={t(item.title)}
|
||||
active={isActive(item.key)}
|
||||
titleStyle={{ fontWeight: 500 }}
|
||||
icon={item.icon}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</MenuList>
|
||||
<ContentArea>
|
||||
<Routes>
|
||||
<Route path="/" element={<WebSearchSettings />} />
|
||||
<Route path="/web-search" element={<WebSearchSettings />} />
|
||||
<Route path="/preprocess" element={<PreprocessSettings />} />
|
||||
<Route path="/quick-phrase" element={<QuickPhraseSettings />} />
|
||||
</Routes>
|
||||
</ContentArea>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(HStack)`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const MenuList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: var(--settings-width);
|
||||
padding: 12px;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
height: 100%;
|
||||
.iconfont {
|
||||
line-height: 16px;
|
||||
}
|
||||
`
|
||||
|
||||
const ContentArea = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
`
|
||||
export default ToolSettings
|
||||
@@ -1,56 +0,0 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { find } from 'lodash'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDescription, SettingGroup, SettingTitle } from '..'
|
||||
|
||||
const TranslateModelSettings = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { providers } = useProviders()
|
||||
const { translateModel, setTranslateModel } = useDefaultModel()
|
||||
|
||||
const allModels = useMemo(() => providers.map((p) => p.models).flat(), [providers])
|
||||
|
||||
const modelPredicate = useCallback(
|
||||
(m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) && !isTextToImageModel(m),
|
||||
[]
|
||||
)
|
||||
|
||||
const defaultTranslateModel = useMemo(
|
||||
() => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined),
|
||||
[translateModel]
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle style={{ marginBottom: 12 }}>
|
||||
<HStack alignItems="center" gap={10}>
|
||||
{t('settings.models.translate_model')}
|
||||
</HStack>
|
||||
</SettingTitle>
|
||||
<HStack alignItems="center">
|
||||
<ModelSelector
|
||||
providers={providers}
|
||||
predicate={modelPredicate}
|
||||
value={defaultTranslateModel}
|
||||
defaultValue={defaultTranslateModel}
|
||||
style={{ width: 360 }}
|
||||
onChange={(value) => setTranslateModel(find(allModels, JSON.parse(value)) as Model)}
|
||||
placeholder={t('settings.models.empty')}
|
||||
/>
|
||||
</HStack>
|
||||
<SettingDescription>{t('settings.models.translate_model_description')}</SettingDescription>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default TranslateModelSettings
|
||||
@@ -1,51 +0,0 @@
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import CustomLanguageSettings from '@renderer/pages/settings/TranslateSettings/CustomLanguageSettings'
|
||||
import { getAllCustomLanguages } from '@renderer/services/TranslateService'
|
||||
import { CustomTranslateLanguage } from '@renderer/types'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
|
||||
import { SettingContainer, SettingGroup } from '..'
|
||||
import TranslateModelSettings from './TranslateModelSettings'
|
||||
import TranslatePromptSettings from './TranslatePromptSettings'
|
||||
|
||||
const TranslateSettings = () => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [dataPromise, setDataPromise] = useState<Promise<CustomTranslateLanguage[]>>(Promise.resolve([]))
|
||||
|
||||
useEffect(() => {
|
||||
setDataPromise(getAllCustomLanguages())
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingContainer theme={theme}>
|
||||
<TranslateModelSettings />
|
||||
<TranslatePromptSettings />
|
||||
<SettingGroup theme={theme} style={{ flex: 1 }}>
|
||||
<Suspense fallback={<CustomLanguagesSettingsFallback />}>
|
||||
<CustomLanguageSettings dataPromise={dataPromise} />
|
||||
</Suspense>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomLanguagesSettingsFallback = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 250,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<SvgSpinners180Ring />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TranslateSettings
|
||||
+2
@@ -98,7 +98,9 @@ const CustomLanguageModal = ({ isOpen, editingCustomLanguage, onAdd, onEdit, onC
|
||||
footer={footer}
|
||||
onCancel={onCancel}
|
||||
maskClosable={false}
|
||||
transitionName="animation-move-down"
|
||||
forceRender
|
||||
centered
|
||||
styles={{
|
||||
body: {
|
||||
padding: '20px'
|
||||
+20
-11
@@ -1,20 +1,19 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { deleteCustomLanguage } from '@renderer/services/TranslateService'
|
||||
import { deleteCustomLanguage, getAllCustomLanguages } from '@renderer/services/TranslateService'
|
||||
import { CustomTranslateLanguage } from '@renderer/types'
|
||||
import { Button, Popconfirm, Space, Table, TableProps } from 'antd'
|
||||
import { memo, startTransition, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingRowTitle } from '..'
|
||||
import CustomLanguageModal from './CustomLanguageModal'
|
||||
|
||||
type Props = {
|
||||
dataPromise: Promise<CustomTranslateLanguage[]>
|
||||
}
|
||||
const logger = loggerService.withContext('CustomLanguageSettings')
|
||||
|
||||
const CustomLanguageSettings = ({ dataPromise }: Props) => {
|
||||
const CustomLanguageSettings = () => {
|
||||
const { t } = useTranslation()
|
||||
const [displayedItems, setDisplayedItems] = useState<CustomTranslateLanguage[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -104,18 +103,28 @@ const CustomLanguageSettings = ({ dataPromise }: Props) => {
|
||||
[onDelete, t]
|
||||
)
|
||||
|
||||
const data = use(dataPromise)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedItems(data)
|
||||
}, [data])
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const data = await getAllCustomLanguages()
|
||||
setDisplayedItems(data)
|
||||
} catch (error) {
|
||||
logger.error('Failed to load custom languages:', error as Error)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomLanguageSettingsContainer>
|
||||
<HStack justifyContent="space-between" style={{ padding: '4px 0' }}>
|
||||
<SettingRowTitle>{t('translate.custom.label')}</SettingRowTitle>
|
||||
<Button type="primary" icon={<PlusOutlined size={16} />} onClick={onClickAdd}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined size={16} />}
|
||||
onClick={onClickAdd}
|
||||
style={{ marginBottom: 5, marginTop: -5 }}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</HStack>
|
||||
+2
-1
@@ -45,7 +45,8 @@ const TranslatePromptSettings = () => {
|
||||
onChange={(e) => setLocalPrompt(e.target.value)}
|
||||
onBlur={(e) => dispatch(setTranslateModelPrompt(e.target.value))}
|
||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
||||
placeholder={t('settings.models.translate_model_prompt_message')}></Input.TextArea>
|
||||
placeholder={t('settings.models.translate_model_prompt_message')}
|
||||
/>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { Modal } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingGroup } from '..'
|
||||
import CustomLanguageSettings from './CustomLanguageSettings'
|
||||
import TranslatePromptSettings from './TranslatePromptSettings'
|
||||
|
||||
interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
TranslateSettingsPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.translate.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
width="80vw"
|
||||
centered>
|
||||
<SettingContainer theme={theme} style={{ padding: '10px 0' }}>
|
||||
<TranslatePromptSettings />
|
||||
<SettingGroup theme={theme} style={{ flex: 1 }}>
|
||||
<CustomLanguageSettings />
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'TranslateSettingsPopup'
|
||||
|
||||
export default class TranslateSettingsPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show() {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { t } from 'i18next'
|
||||
import { Info } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const BasicSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
+1
-1
@@ -10,7 +10,7 @@ import TextArea from 'antd/es/input/TextArea'
|
||||
import { t } from 'i18next'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import AddSubscribePopup from './AddSubscribePopup'
|
||||
|
||||
type TableRowSelection<T extends object = object> = TableProps<T>['rowSelection']
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user