Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a25f4e90dd | ||
|
|
3d701b98aa | ||
|
|
dcaac54c75 | ||
|
|
b2b89a1339 | ||
|
|
4692f98770 | ||
|
|
86a3a108a7 | ||
|
|
5ec4403bfb | ||
|
|
ec0be1ff27 | ||
|
|
516315ac45 | ||
|
|
ff55739376 | ||
|
|
1e24b7bc45 | ||
|
|
dd92dca34b | ||
|
|
a592fdc550 | ||
|
|
846e7ca097 | ||
|
|
93c2a94658 | ||
|
|
309b66e4df | ||
|
|
4d9476e99b | ||
|
|
46f796a74c | ||
|
|
00bf28b999 | ||
|
|
640d3783a0 | ||
|
|
0e4f06e86a | ||
|
|
886a7ec1e9 | ||
|
|
37cf7427f9 | ||
|
|
e69d0c89a6 | ||
|
|
581e2fb786 | ||
|
|
13b465fe73 | ||
|
|
a12d10f4f7 | ||
|
|
e8bfb2b49b | ||
|
|
ae995182b2 | ||
|
|
59c69e065c | ||
|
|
4ca2d61ccc | ||
|
|
d62ff69351 | ||
|
|
012e79a7e2 | ||
|
|
97dc80a07f | ||
|
|
b974f8537f | ||
|
|
c32e17968e | ||
|
|
cf09d1d44d | ||
|
|
ad39d8774d | ||
|
|
687f140a5c | ||
|
|
1b09bb47bf | ||
|
|
808b457503 | ||
|
|
92e054569c | ||
|
|
2f22e68559 | ||
|
|
53a8628fab | ||
|
|
df04503674 | ||
|
|
1fe74fa753 | ||
|
|
55a9be2fa5 | ||
|
|
fdf4821d56 | ||
|
|
84e6caa846 | ||
|
|
3bc8dfdf8c | ||
|
|
ed96940e82 | ||
|
|
1c60375d71 | ||
|
|
c9699609ed | ||
|
|
f91caff7ec | ||
|
|
11bd55701c | ||
|
|
efa9c6c546 | ||
|
|
92ab67eb3d | ||
|
|
ae11490f87 | ||
|
|
956c2f683d | ||
|
|
d01f793558 | ||
|
|
94d9b79957 | ||
|
|
78a7b2759e | ||
|
|
27c0edfb79 | ||
|
|
59b1d8bcc4 | ||
|
|
741d84b4d3 | ||
|
|
5bacf048f2 | ||
|
|
1d4916c516 | ||
|
|
8e1207c2a2 | ||
|
|
ac92f1a783 | ||
|
|
28c59ea436 | ||
|
|
9e808208ab | ||
|
|
feefaaf3e3 | ||
|
|
31078b8ec5 | ||
|
|
f3f32cc591 | ||
|
|
f489b034b5 | ||
|
|
3d9d5b6263 | ||
|
|
89440c9c10 | ||
|
|
4c0f358323 | ||
|
|
ad01fc43e5 | ||
|
|
9b17416f9c | ||
|
|
cda4edfb7f | ||
|
|
acc803aa43 | ||
|
|
2ab8f325df | ||
|
|
a68cbe4438 | ||
|
|
646d0e4ccb | ||
|
|
a7a82be083 | ||
|
|
c0117c25ac | ||
|
|
d51da99b8f | ||
|
|
d4848faa5a | ||
|
|
bfeca0b383 | ||
|
|
79c7c3dc1c | ||
|
|
b2ebbc1e30 | ||
|
|
50e2dd0ec0 | ||
|
|
5e753de71c | ||
|
|
6bc6dab879 |
6
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 🐛 Bug Report
|
||||
name: 🐛 Bug Report (English)
|
||||
description: Create a report to help us improve
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
@@ -6,8 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this bug report!
|
||||
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
|
||||
Thank you for taking the time to fill out this bug report!
|
||||
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 💡 Feature Request
|
||||
name: 💡 Feature Request (English)
|
||||
description: Suggest an idea for this project
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
|
||||
@@ -65,8 +65,9 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
- [x] Quick popup (read clipboard, quick question, explain, translate, summarize)
|
||||
- [x] Comparison of multi-model answers
|
||||
- [x] Support login using SSO provided by service providers
|
||||
- [ ] All models support networking (in development...)
|
||||
- [ ] Launch of the first official version
|
||||
- [x] All models support networking
|
||||
- [x] Launch of the first official version
|
||||
- [x] Bug fixes and improvements (In progress...)
|
||||
- [ ] Plugin functionality (JavaScript)
|
||||
- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base)
|
||||
- [ ] iOS & Android client
|
||||
|
||||
@@ -66,8 +66,9 @@ Cherry Studioは、複数のLLMプロバイダーをサポートするデスク
|
||||
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
|
||||
- [x] 複数モデルの回答の比較
|
||||
- [x] サービスプロバイダーが提供するSSOを使用したログインをサポート
|
||||
- [ ] すべてのモデルがネットワークをサポート(開発中...)
|
||||
- [ ] 最初の公式バージョンのリリース
|
||||
- [x] すべてのモデルがネットワークをサポート
|
||||
- [x] 最初の公式バージョンのリリース
|
||||
- [ ] 錯誤修復と改善 (開発中...)
|
||||
- [ ] プラグイン機能(JavaScript)
|
||||
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
|
||||
- [ ] iOS & Android クライアント
|
||||
|
||||
@@ -63,17 +63,18 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
# 📝 待辦事項
|
||||
|
||||
- [x] 快捷彈窗 (讀取剪貼簿、快速提問、解釋、翻譯、總結)
|
||||
- [x] 多模型回答對比
|
||||
- [x] 支援使用服務供應商提供的 SSO 進行登入
|
||||
- [ ] 全部模型支援連網(開發中...)
|
||||
- [ ] 推出第一個正式版
|
||||
- [x] 快捷弹窗 (读取剪贴板、快速提问、解释、翻译、总结)
|
||||
- [x] 多模型回答对比
|
||||
- [x] 支持使用服务供应商提供的 SSO 进行登入
|
||||
- [x] 全部模型支持连网(开发中...)
|
||||
- [x] 推出第一个正式版
|
||||
- [x] 错误修复和改进(开发中...)
|
||||
- [ ] 插件功能(JavaScript)
|
||||
- [ ] 瀏覽器插件(劃詞翻譯、總結、新增至知識庫)
|
||||
- [ ] iOS & Android 客戶端
|
||||
- [ ] AI 筆記
|
||||
- [ ] 語音輸入輸出(AI 通話)
|
||||
- [ ] 資料備份支援自訂備份內容
|
||||
- [ ] 浏览器插件(划词翻译、总结、新增至知识库)
|
||||
- [ ] iOS & Android 客户端
|
||||
- [ ] AI 笔记
|
||||
- [ ] 语音输入输出(AI 通话)
|
||||
- [ ] 数据备份支持自定义备份内容
|
||||
|
||||
# 🖥️ 开发
|
||||
|
||||
|
||||
174
docs/technical/KnowledgeService.md
Normal file
@@ -0,0 +1,174 @@
|
||||
我来详细介绍 KnowledgeService 中控制并发处理的项目数量和工作负载的部分。
|
||||
|
||||
## 并发控制与工作负载管理机制
|
||||
|
||||
KnowledgeService 实现了一个精细的任务队列系统,用于控制并发处理的项目数量和工作负载。这个系统主要通过以下几个关键部分实现:
|
||||
|
||||
### 1. 关键变量和限制
|
||||
|
||||
```typescript
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80 // 约80MB
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
```
|
||||
|
||||
- `workload`: 跟踪当前正在处理的总工作量(以字节为单位)
|
||||
- `processingItemCount`: 跟踪当前正在处理的项目数量
|
||||
- `MAXIMUM_WORKLOAD`: 设置最大工作负载为80MB
|
||||
- `MAXIMUM_PROCESSING_ITEM_COUNT`: 设置最大并发处理项目数为30个
|
||||
|
||||
### 2. 工作负载评估
|
||||
|
||||
每个任务都有一个评估工作负载的机制,通过 `evaluateTaskWorkload` 属性来表示:
|
||||
|
||||
```typescript
|
||||
interface EvaluateTaskWorkload {
|
||||
workload: number
|
||||
}
|
||||
```
|
||||
|
||||
不同类型的任务有不同的工作负载评估方式:
|
||||
|
||||
- 文件任务:使用文件大小作为工作负载 `{ workload: file.size }`
|
||||
- URL任务:使用固定值 `{ workload: 1024 * 1024 * 2 }` (约2MB)
|
||||
- 网站地图任务:使用固定值 `{ workload: 1024 * 1024 * 20 }` (约20MB)
|
||||
- 笔记任务:使用文本内容的字节长度 `{ workload: contentBytes.length }`
|
||||
|
||||
### 3. 任务状态管理
|
||||
|
||||
任务通过状态枚举来跟踪其生命周期:
|
||||
|
||||
```typescript
|
||||
enum LoaderTaskItemState {
|
||||
PENDING, // 等待处理
|
||||
PROCESSING, // 正在处理
|
||||
DONE // 已完成
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 任务队列处理核心逻辑
|
||||
|
||||
核心的队列处理逻辑在 `processingQueueHandle` 方法中:
|
||||
|
||||
```typescript
|
||||
private processingQueueHandle() {
|
||||
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
|
||||
const queueTaskList: QueueTaskItem[] = []
|
||||
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
|
||||
for (const item of task.loaderTasks) {
|
||||
if (this.maximumLoad()) {
|
||||
break that
|
||||
}
|
||||
|
||||
const { state, task: taskPromise, evaluateTaskWorkload } = item
|
||||
|
||||
if (state !== LoaderTaskItemState.PENDING) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { workload } = evaluateTaskWorkload
|
||||
this.workload += workload
|
||||
this.processingItemCount += 1
|
||||
item.state = LoaderTaskItemState.PROCESSING
|
||||
queueTaskList.push({
|
||||
taskPromise: () =>
|
||||
taskPromise().then(() => {
|
||||
this.workload -= workload
|
||||
this.processingItemCount -= 1
|
||||
task.loaderTasks.delete(item)
|
||||
if (task.loaderTasks.size === 0) {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
|
||||
resolve()
|
||||
}
|
||||
this.processingQueueHandle()
|
||||
}),
|
||||
resolve: () => {},
|
||||
evaluateTaskWorkload
|
||||
})
|
||||
}
|
||||
}
|
||||
return queueTaskList
|
||||
}
|
||||
|
||||
const subTasks = getSubtasksUntilMaximumLoad()
|
||||
if (subTasks.length > 0) {
|
||||
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
|
||||
Promise.all(subTaskPromises).then(() => {
|
||||
subTasks.forEach(({ resolve }) => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个方法的工作流程是:
|
||||
|
||||
1. 遍历所有待处理的任务集合
|
||||
2. 对于每个任务集合中的每个子任务:
|
||||
- 检查是否已达到最大负载(通过 `maximumLoad()` 方法)
|
||||
- 如果任务状态为 PENDING,则:
|
||||
- 增加当前工作负载和处理项目计数
|
||||
- 将任务状态更新为 PROCESSING
|
||||
- 将任务添加到待执行队列
|
||||
3. 执行所有收集到的子任务
|
||||
4. 当子任务完成时:
|
||||
- 减少工作负载和处理项目计数
|
||||
- 从任务集合中移除已完成的任务
|
||||
- 如果任务集合为空,则解析相应的 Promise
|
||||
- 递归调用 `processingQueueHandle()` 以处理更多任务
|
||||
|
||||
### 5. 负载检查
|
||||
|
||||
```typescript
|
||||
private maximumLoad() {
|
||||
return (
|
||||
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
这个方法检查当前是否已达到最大负载,通过两个条件:
|
||||
|
||||
- 处理项目数量达到上限(30个)
|
||||
- 总工作负载达到上限(80MB)
|
||||
|
||||
### 6. 任务添加与执行流程
|
||||
|
||||
当添加新任务时,流程如下:
|
||||
|
||||
1. 创建任务(根据类型不同创建不同的任务)
|
||||
2. 通过 `appendProcessingQueue` 将任务添加到队列
|
||||
3. 调用 `processingQueueHandle` 开始处理队列中的任务
|
||||
|
||||
```typescript
|
||||
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
|
||||
return new Promise((resolve) => {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 并发控制的优势
|
||||
|
||||
这种并发控制机制有几个重要优势:
|
||||
|
||||
1. **资源使用优化**:通过限制同时处理的项目数量和总工作负载,避免系统资源过度使用
|
||||
2. **自动调节**:当任务完成时,会自动从队列中获取新任务,保持资源的高效利用
|
||||
3. **灵活性**:不同类型的任务有不同的工作负载评估,更准确地反映实际资源需求
|
||||
4. **可靠性**:通过状态管理和Promise解析机制,确保任务正确完成并通知调用者
|
||||
|
||||
## 实际应用场景
|
||||
|
||||
这种并发控制在处理大量数据时特别有用,例如:
|
||||
|
||||
- 导入大型目录时,可能包含数百个文件
|
||||
- 处理大型网站地图,可能包含大量URL
|
||||
- 处理多个用户同时添加知识库项目的请求
|
||||
|
||||
通过这种机制,系统可以平滑地处理大量请求,避免资源耗尽,同时保持良好的响应性。
|
||||
|
||||
总结来说,KnowledgeService 实现了一个复杂而高效的任务队列系统,通过精确控制并发处理的项目数量和工作负载,确保系统在处理大量数据时保持稳定和高效。
|
||||
@@ -27,7 +27,6 @@ files:
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/html2canvas/dist/{html2canvas.min.js,html2canvas.esm.js}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{node,dll,metal,exp,lib}'
|
||||
@@ -81,8 +80,9 @@ afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
Web 搜索增加更多配置选项
|
||||
用户消息支持快速重新发送
|
||||
知识库网址支持右键配置别名
|
||||
支持更多类型的思考内容显示
|
||||
知识库错误修复
|
||||
数据备份和恢复支持进度显示
|
||||
支持快捷引用模型回复内容
|
||||
输入框可以手动调整大小
|
||||
知识库文件支持一键删除
|
||||
服务商列表支持查询(拖拽可排序)
|
||||
增加代码块换行功能
|
||||
|
||||
@@ -43,7 +43,24 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react(), ...visualizerPlugin('renderer')],
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
[
|
||||
'styled-components',
|
||||
{
|
||||
displayName: true, // 开发环境下启用组件名称
|
||||
fileName: false, // 不在类名中包含文件名
|
||||
pure: true, // 优化性能
|
||||
ssr: false // 不需要服务端渲染
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}),
|
||||
...visualizerPlugin('renderer')
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -51,6 +51,7 @@
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@llm-tools/embedjs": "patch:@llm-tools/embedjs@npm%3A0.1.28#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.28-8e4393fa2d.patch",
|
||||
"@llm-tools/embedjs-libsql": "^0.1.28",
|
||||
@@ -73,14 +74,13 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "^1.3.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"tokenx": "^0.4.1",
|
||||
"webdav": "4.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24.3",
|
||||
"@anthropic-ai/sdk": "^0.38.0",
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^1.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
@@ -104,6 +104,7 @@
|
||||
"antd": "^5.22.5",
|
||||
"applescript": "^1.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
@@ -121,6 +122,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.0.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^23.11.5",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.4",
|
||||
|
||||
@@ -20,25 +20,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.whenReady().then(async () => {
|
||||
await updateUserDataPath()
|
||||
|
||||
// Register custom protocol
|
||||
if (!app.isDefaultProtocolClient('cherrystudio')) {
|
||||
app.setAsDefaultProtocolClient('cherrystudio')
|
||||
}
|
||||
|
||||
// Handle protocol open
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
const parsedUrl = new URL(url)
|
||||
if (parsedUrl.pathname === 'siliconflow.oauth.login') {
|
||||
const code = parsedUrl.searchParams.get('code')
|
||||
if (code) {
|
||||
// Handle the OAuth code here
|
||||
console.log('OAuth code received:', code)
|
||||
// You can send this code to your renderer process via IPC if needed
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||
|
||||
@@ -53,6 +34,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
|
||||
registerShortcuts(mainWindow)
|
||||
|
||||
registerIpc(mainWindow, app)
|
||||
|
||||
@@ -27,7 +27,7 @@ const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const { autoUpdater } = new AppUpdater(mainWindow)
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
|
||||
ipcMain.handle('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
@@ -48,6 +48,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle('app:reload', () => mainWindow.reload())
|
||||
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// Update
|
||||
ipcMain.handle('app:show-update-dialog', () => appUpdater.showUpdateDialog(mainWindow))
|
||||
|
||||
// language
|
||||
ipcMain.handle('app:set-language', (_, language) => {
|
||||
configManager.setLanguage(language)
|
||||
@@ -99,9 +102,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// check for update
|
||||
ipcMain.handle('app:check-for-update', async () => {
|
||||
const update = await autoUpdater.checkForUpdates()
|
||||
const update = await appUpdater.autoUpdater.checkForUpdates()
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
currentVersion: appUpdater.autoUpdater.currentVersion,
|
||||
updateInfo: update?.updateInfo
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
logger.transports.file.level = 'info'
|
||||
@@ -37,34 +39,40 @@ export default class AppUpdater {
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
mainWindow.webContents.send('update-downloaded')
|
||||
|
||||
logger.info('下载完成,询问用户是否更新', releaseInfo)
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '安装更新',
|
||||
icon,
|
||||
message: `新版本 ${releaseInfo.version} 已准备就绪`,
|
||||
detail: this.formatReleaseNotes(releaseInfo.releaseNotes),
|
||||
buttons: ['稍后安装', '立即安装'],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send('update-downloaded-cancelled')
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.send('update-downloaded', releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('下载完成', releaseInfo)
|
||||
})
|
||||
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '安装更新',
|
||||
icon,
|
||||
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
|
||||
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
|
||||
buttons: ['稍后安装', '立即安装'],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send('update-downloaded-cancelled')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return '暂无更新说明'
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { exec } from 'child_process'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
import { exec } from 'child_process'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
class BackupManager {
|
||||
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
|
||||
@@ -21,25 +22,25 @@ class BackupManager {
|
||||
|
||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name);
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
|
||||
// 先处理子目录
|
||||
if (item.isDirectory()) {
|
||||
await this.setWritableRecursive(fullPath);
|
||||
await this.setWritableRecursive(fullPath)
|
||||
}
|
||||
|
||||
// 统一设置权限(Windows需要特殊处理)
|
||||
await this.forceSetWritable(fullPath);
|
||||
await this.forceSetWritable(fullPath)
|
||||
}
|
||||
|
||||
// 确保根目录权限
|
||||
await this.forceSetWritable(dirPath);
|
||||
await this.forceSetWritable(dirPath)
|
||||
} catch (error) {
|
||||
Logger.error(`权限设置失败:${dirPath}`, error);
|
||||
throw error;
|
||||
Logger.error(`权限设置失败:${dirPath}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,20 +49,20 @@ class BackupManager {
|
||||
try {
|
||||
// Windows系统需要先取消只读属性
|
||||
if (process.platform === 'win32') {
|
||||
await fs.chmod(targetPath, 0o666); // Windows会忽略权限位但能移除只读
|
||||
await fs.chmod(targetPath, 0o666) // Windows会忽略权限位但能移除只读
|
||||
} else {
|
||||
const stats = await fs.stat(targetPath);
|
||||
const mode = stats.isDirectory() ? 0o777 : 0o666;
|
||||
await fs.chmod(targetPath, mode);
|
||||
const stats = await fs.stat(targetPath)
|
||||
const mode = stats.isDirectory() ? 0o777 : 0o666
|
||||
await fs.chmod(targetPath, mode)
|
||||
}
|
||||
|
||||
// 双重保险:使用文件属性命令(Windows专用)
|
||||
if (process.platform === 'win32') {
|
||||
await exec(`attrib -R "${targetPath}" /L /D`);
|
||||
await exec(`attrib -R "${targetPath}" /L /D`)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
Logger.warn(`权限设置警告:${targetPath}`, error);
|
||||
Logger.warn(`权限设置警告:${targetPath}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,18 +73,39 @@ class BackupManager {
|
||||
data: string,
|
||||
destinationPath: string = this.backupDir
|
||||
): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send('backup-progress', processData)
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(this.tempDir)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
// 将 data 写入临时文件
|
||||
const tempDataPath = path.join(this.tempDir, 'data.json')
|
||||
await fs.writeFile(tempDataPath, data)
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
await fs.copy(sourcePath, tempDataDir)
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
onProgress({ stage: 'compressing', progress: 80, total: 100 })
|
||||
|
||||
// 使用 adm-zip 创建压缩文件
|
||||
const zip = new AdmZip()
|
||||
@@ -93,6 +115,7 @@ class BackupManager {
|
||||
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
||||
|
||||
Logger.log('Backup completed successfully')
|
||||
return backupedFilePath
|
||||
@@ -103,35 +126,54 @@ class BackupManager {
|
||||
}
|
||||
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send('restore-progress', processData)
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
|
||||
|
||||
// 使用 adm-zip 解压
|
||||
const zip = new AdmZip(backupPath)
|
||||
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
|
||||
onProgress({ stage: 'extracting', progress: 20, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 2: read data.json')
|
||||
|
||||
// 读取 data.json
|
||||
const dataPath = path.join(this.tempDir, 'data.json')
|
||||
const data = await fs.readFile(dataPath, 'utf-8')
|
||||
onProgress({ stage: 'reading_data', progress: 40, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
Logger.log('[backup] step 4: clean up temp directory')
|
||||
|
||||
// 清理临时目录
|
||||
await this.setWritableRecursive(this.tempDir)
|
||||
await fs.remove(this.tempDir)
|
||||
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 5: Restore completed successfully')
|
||||
|
||||
@@ -166,6 +208,44 @@ class BackupManager {
|
||||
|
||||
return await this.restore(_, backupedFilePath)
|
||||
}
|
||||
|
||||
private async getDirSize(dirPath: string): Promise<number> {
|
||||
let size = 0
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
size += await this.getDirSize(fullPath)
|
||||
} else {
|
||||
const stats = await fs.stat(fullPath)
|
||||
size += stats.size
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
private async copyDirWithProgress(
|
||||
source: string,
|
||||
destination: string,
|
||||
onProgress: (size: number) => void
|
||||
): Promise<void> {
|
||||
const items = await fs.readdir(source, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(source, item.name)
|
||||
const destPath = path.join(destination, item.name)
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
onProgress(stats.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
// ExportService
|
||||
|
||||
import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, TextRun } from 'docx'
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
VerticalAlign,
|
||||
WidthType
|
||||
} from 'docx'
|
||||
import { dialog } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
@@ -21,13 +36,54 @@ export class ExportService {
|
||||
const tokens = this.md.parse(markdown, {})
|
||||
const elements: any[] = []
|
||||
let listLevel = 0
|
||||
let currentTable: Table | null = null
|
||||
let currentRowCells: TableCell[] = []
|
||||
let isHeaderRow = false
|
||||
let tableColumnCount = 0
|
||||
let tableRows: TableRow[] = [] // Store rows temporarily
|
||||
|
||||
const processInlineTokens = (tokens: any[]): TextRun[] => {
|
||||
const runs: TextRun[] = []
|
||||
for (const token of tokens) {
|
||||
const processInlineTokens = (tokens: any[], isHeaderRow: boolean): (TextRun | ExternalHyperlink)[] => {
|
||||
const runs: (TextRun | ExternalHyperlink)[] = []
|
||||
let linkText = ''
|
||||
let linkUrl = ''
|
||||
let insideLink = false
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
switch (token.type) {
|
||||
case 'link_open':
|
||||
insideLink = true
|
||||
linkUrl = token.attrs.find((attr: [string, string]) => attr[0] === 'href')[1]
|
||||
linkText = tokens[i + 1].content
|
||||
i += 1
|
||||
break
|
||||
case 'link_close':
|
||||
if (insideLink && linkUrl && linkText) {
|
||||
// Handle any accumulated link text with the ExternalHyperlink
|
||||
runs.push(
|
||||
new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: linkText,
|
||||
style: 'Hyperlink',
|
||||
color: '0000FF',
|
||||
underline: {
|
||||
type: 'single'
|
||||
}
|
||||
})
|
||||
],
|
||||
link: linkUrl
|
||||
})
|
||||
)
|
||||
|
||||
// Reset link variables
|
||||
linkText = ''
|
||||
linkUrl = ''
|
||||
insideLink = false
|
||||
}
|
||||
break
|
||||
case 'text':
|
||||
runs.push(new TextRun(token.content))
|
||||
runs.push(new TextRun({ text: token.content, bold: isHeaderRow ? true : false }))
|
||||
break
|
||||
case 'strong':
|
||||
runs.push(new TextRun({ text: token.content, bold: true }))
|
||||
@@ -45,7 +101,6 @@ export class ExportService {
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
|
||||
switch (token.type) {
|
||||
case 'heading_open':
|
||||
// 获取标题级别 (h1 -> h6)
|
||||
@@ -68,7 +123,7 @@ export class ExportService {
|
||||
const inlineTokens = tokens[i + 1].children || []
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: processInlineTokens(inlineTokens),
|
||||
children: processInlineTokens(inlineTokens, false),
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
@@ -93,7 +148,7 @@ export class ExportService {
|
||||
children: [
|
||||
new TextRun({ text: '•', bold: true }),
|
||||
new TextRun({ text: '\t' }),
|
||||
...processInlineTokens(itemInlineTokens)
|
||||
...processInlineTokens(itemInlineTokens, false)
|
||||
],
|
||||
indent: {
|
||||
left: listLevel * 720
|
||||
@@ -171,6 +226,116 @@ export class ExportService {
|
||||
)
|
||||
i += 3
|
||||
break
|
||||
|
||||
// 表格处理
|
||||
case 'table_open':
|
||||
tableRows = [] // Reset table rows for new table
|
||||
break
|
||||
|
||||
case 'thead_open':
|
||||
isHeaderRow = true
|
||||
break
|
||||
|
||||
case 'tbody_open':
|
||||
isHeaderRow = false
|
||||
break
|
||||
|
||||
case 'tr_open':
|
||||
currentRowCells = []
|
||||
break
|
||||
|
||||
case 'tr_close':
|
||||
const row = new TableRow({
|
||||
children: currentRowCells,
|
||||
tableHeader: isHeaderRow
|
||||
})
|
||||
tableRows.push(row)
|
||||
// 计算表格有多少列(针对第一行)
|
||||
if (tableColumnCount === 0) {
|
||||
tableColumnCount = currentRowCells.length
|
||||
}
|
||||
break
|
||||
|
||||
case 'th_open':
|
||||
case 'td_open':
|
||||
const isFirstColumn = currentRowCells.length === 0 // 判断是否是第一列
|
||||
const borders = {
|
||||
top: {
|
||||
style: BorderStyle.NONE
|
||||
},
|
||||
bottom: isHeaderRow
|
||||
? {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 0.5,
|
||||
color: '000000'
|
||||
}
|
||||
: {
|
||||
style: BorderStyle.NONE
|
||||
},
|
||||
left: {
|
||||
style: BorderStyle.NONE
|
||||
},
|
||||
right: {
|
||||
style: BorderStyle.NONE
|
||||
}
|
||||
}
|
||||
const cellContent = tokens[i + 1]
|
||||
const cellOptions = {
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: cellContent.children
|
||||
? processInlineTokens(cellContent.children, isHeaderRow || isFirstColumn)
|
||||
: [new TextRun({ text: cellContent.content || '', bold: isHeaderRow || isFirstColumn })],
|
||||
alignment: AlignmentType.CENTER
|
||||
})
|
||||
],
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: borders
|
||||
}
|
||||
currentRowCells.push(new TableCell(cellOptions))
|
||||
i += 2 // 跳过内容和结束标记
|
||||
break
|
||||
case 'table_close':
|
||||
// Create table with the collected rows - avoid using protected properties
|
||||
// Create the table with all rows
|
||||
currentTable = new Table({
|
||||
width: {
|
||||
size: 100,
|
||||
type: WidthType.PERCENTAGE
|
||||
},
|
||||
rows: tableRows,
|
||||
borders: {
|
||||
top: {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 1,
|
||||
color: '000000'
|
||||
},
|
||||
bottom: {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 1,
|
||||
color: '000000'
|
||||
},
|
||||
left: {
|
||||
style: BorderStyle.NONE
|
||||
},
|
||||
right: {
|
||||
style: BorderStyle.NONE
|
||||
},
|
||||
insideHorizontal: {
|
||||
style: BorderStyle.NONE
|
||||
},
|
||||
insideVertical: {
|
||||
style: BorderStyle.NONE
|
||||
}
|
||||
}
|
||||
})
|
||||
elements.push(currentTable)
|
||||
currentTable = null
|
||||
tableColumnCount = 0
|
||||
tableRows = []
|
||||
currentRowCells = []
|
||||
isHeaderRow = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* Knowledge Service - Manages knowledge bases using RAG (Retrieval-Augmented Generation)
|
||||
*
|
||||
* This service handles creation, management, and querying of knowledge bases from various sources
|
||||
* including files, directories, URLs, sitemaps, and notes.
|
||||
*
|
||||
* Features:
|
||||
* - Concurrent task processing with workload management
|
||||
* - Multiple data source support
|
||||
* - Vector database integration
|
||||
*
|
||||
* For detailed documentation, see:
|
||||
* @see {@link ../../../docs/technical/KnowledgeService.md}
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
@@ -8,17 +23,77 @@ import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@llm-tools/embedjs-loader-web'
|
||||
import { AzureOpenAiEmbeddings, OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { windowService } from './WindowService'
|
||||
export interface KnowledgeBaseAddItemOptions {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
}
|
||||
|
||||
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload: boolean
|
||||
}
|
||||
|
||||
interface EvaluateTaskWorkload {
|
||||
workload: number
|
||||
}
|
||||
|
||||
type LoaderDoneReturn = LoaderReturn | null
|
||||
|
||||
enum LoaderTaskItemState {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
DONE
|
||||
}
|
||||
|
||||
interface LoaderTaskItem {
|
||||
state: LoaderTaskItemState
|
||||
task: () => Promise<unknown>
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
interface LoaderTask {
|
||||
loaderTasks: LoaderTaskItem[]
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
interface LoaderTaskOfSet {
|
||||
loaderTasks: Set<LoaderTaskItem>
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
interface QueueTaskItem {
|
||||
taskPromise: () => Promise<unknown>
|
||||
resolve: () => void
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
return {
|
||||
loaderTasks: new Set(loaderTask.loaderTasks),
|
||||
loaderDoneReturn: loaderTask.loaderDoneReturn
|
||||
}
|
||||
}
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
// Byte based
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
|
||||
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
@@ -79,11 +154,52 @@ class KnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
public add = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ base, item, forceReload = false }: { base: KnowledgeBaseParams; item: KnowledgeItem; forceReload: boolean }
|
||||
): Promise<LoaderReturn> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
private maximumLoad() {
|
||||
return (
|
||||
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
|
||||
private fileTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const file = item.content as FileType
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () =>
|
||||
addFileLoader(ragApplication, file, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private directoryTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const directory = item.content as string
|
||||
const files = getAllFiles(directory)
|
||||
const totalFiles = files.length
|
||||
let processedFiles = 0
|
||||
|
||||
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
@@ -93,86 +209,257 @@ class KnowledgeService {
|
||||
})
|
||||
}
|
||||
|
||||
if (item.type === 'directory') {
|
||||
const directory = item.content as string
|
||||
const files = getAllFiles(directory)
|
||||
const totalFiles = files.length
|
||||
let processedFiles = 0
|
||||
|
||||
const loaderPromises = files.map(async (file) => {
|
||||
const result = await addFileLoader(ragApplication, file, base, forceReload)
|
||||
processedFiles++
|
||||
sendDirectoryProcessingPercent(totalFiles, processedFiles)
|
||||
return result
|
||||
const loaderDoneReturn: LoaderDoneReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: `DirectoryLoader_${uuidv4()}`,
|
||||
uniqueIds: [],
|
||||
loaderType: 'DirectoryLoader'
|
||||
}
|
||||
const loaderTasks: LoaderTaskItem[] = []
|
||||
for (const file of files) {
|
||||
loaderTasks.push({
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () =>
|
||||
addFileLoader(ragApplication, file, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderDoneReturn.entriesAdded += 1
|
||||
processedFiles += 1
|
||||
sendDirectoryProcessingPercent(totalFiles, processedFiles)
|
||||
loaderDoneReturn.uniqueIds.push(result.uniqueId)
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
})
|
||||
|
||||
const loaderResults = await Promise.allSettled(loaderPromises)
|
||||
// @ts-ignore uniqueId
|
||||
const uniqueIds = loaderResults
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value.uniqueId)
|
||||
|
||||
return {
|
||||
entriesAdded: loaderResults.length,
|
||||
uniqueId: `DirectoryLoader_${uuidv4()}`,
|
||||
uniqueIds,
|
||||
loaderType: 'DirectoryLoader'
|
||||
} as LoaderReturn
|
||||
}
|
||||
|
||||
if (item.type === 'url') {
|
||||
const content = item.content as string
|
||||
if (content.startsWith('http')) {
|
||||
const loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({ urlOrContent: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
|
||||
forceReload
|
||||
)
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
} as LoaderReturn
|
||||
return {
|
||||
loaderTasks,
|
||||
loaderDoneReturn
|
||||
}
|
||||
}
|
||||
|
||||
private urlTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const content = item.content as string
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: content,
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}),
|
||||
forceReload
|
||||
) as Promise<LoaderReturn>
|
||||
|
||||
return loaderReturn
|
||||
.then((result) => {
|
||||
const { entriesAdded, uniqueId, loaderType } = result
|
||||
loaderTask.loaderDoneReturn = {
|
||||
entriesAdded: entriesAdded,
|
||||
uniqueId: uniqueId,
|
||||
uniqueIds: [uniqueId],
|
||||
loaderType: loaderType
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: 1024 * 1024 * 2 }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private sitemapTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const content = item.content as string
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () =>
|
||||
ragApplication
|
||||
.addLoader(
|
||||
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
|
||||
forceReload
|
||||
)
|
||||
.then((result) => {
|
||||
const { entriesAdded, uniqueId, loaderType } = result
|
||||
loaderTask.loaderDoneReturn = {
|
||||
entriesAdded: entriesAdded,
|
||||
uniqueId: uniqueId,
|
||||
uniqueIds: [uniqueId],
|
||||
loaderType: loaderType
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: 1024 * 1024 * 20 }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private noteTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const content = item.content as string
|
||||
console.debug('chunkSize', base.chunkSize)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const contentBytes = encoder.encode(content)
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
|
||||
forceReload
|
||||
) as Promise<LoaderReturn>
|
||||
|
||||
return loaderReturn
|
||||
.then(({ entriesAdded, uniqueId, loaderType }) => {
|
||||
loaderTask.loaderDoneReturn = {
|
||||
entriesAdded: entriesAdded,
|
||||
uniqueId: uniqueId,
|
||||
uniqueIds: [uniqueId],
|
||||
loaderType: loaderType
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: contentBytes.length }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private processingQueueHandle() {
|
||||
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
|
||||
const queueTaskList: QueueTaskItem[] = []
|
||||
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
|
||||
for (const item of task.loaderTasks) {
|
||||
if (this.maximumLoad()) {
|
||||
break that
|
||||
}
|
||||
|
||||
const { state, task: taskPromise, evaluateTaskWorkload } = item
|
||||
|
||||
if (state !== LoaderTaskItemState.PENDING) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { workload } = evaluateTaskWorkload
|
||||
this.workload += workload
|
||||
this.processingItemCount += 1
|
||||
item.state = LoaderTaskItemState.PROCESSING
|
||||
queueTaskList.push({
|
||||
taskPromise: () =>
|
||||
taskPromise().then(() => {
|
||||
this.workload -= workload
|
||||
this.processingItemCount -= 1
|
||||
task.loaderTasks.delete(item)
|
||||
if (task.loaderTasks.size === 0) {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
|
||||
resolve()
|
||||
}
|
||||
this.processingQueueHandle()
|
||||
}),
|
||||
resolve: () => {},
|
||||
evaluateTaskWorkload
|
||||
})
|
||||
}
|
||||
}
|
||||
return queueTaskList
|
||||
}
|
||||
|
||||
if (item.type === 'sitemap') {
|
||||
const content = item.content as string
|
||||
// @ts-ignore loader type
|
||||
const loaderReturn = await ragApplication.addLoader(
|
||||
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
|
||||
forceReload
|
||||
)
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
} as LoaderReturn
|
||||
const subTasks = getSubtasksUntilMaximumLoad()
|
||||
if (subTasks.length > 0) {
|
||||
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
|
||||
Promise.all(subTaskPromises).then(() => {
|
||||
subTasks.forEach(({ resolve }) => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'note') {
|
||||
const content = item.content as string
|
||||
console.debug('chunkSize', base.chunkSize)
|
||||
const loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
|
||||
forceReload
|
||||
)
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
} as LoaderReturn
|
||||
}
|
||||
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
|
||||
return new Promise((resolve) => {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
const file = item.content as FileType
|
||||
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
return new Promise((resolve) => {
|
||||
const { base, item, forceReload = false } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload }
|
||||
this.getRagApplication(base)
|
||||
.then((ragApplication) => {
|
||||
const task = (() => {
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return this.fileTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'directory':
|
||||
return this.directoryTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'url':
|
||||
return this.urlTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'sitemap':
|
||||
return this.sitemapTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'note':
|
||||
return this.noteTask(ragApplication, optionsNonNullableAttribute)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
return await addFileLoader(ragApplication, file, base, forceReload)
|
||||
}
|
||||
|
||||
return { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
|
||||
if (task) {
|
||||
this.appendProcessingQueue(task).then(() => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
this.processingQueueHandle()
|
||||
} else {
|
||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public remove = async (
|
||||
|
||||
@@ -51,7 +51,7 @@ export class WindowService {
|
||||
show: false, // 初始不显示
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: isLinux ? 'default' : 'hidden',
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
@@ -150,6 +150,17 @@ export class WindowService {
|
||||
this.wasFullScreen = false
|
||||
mainWindow.webContents.send('fullscreen-status-changed', false)
|
||||
})
|
||||
|
||||
// 添加Escape键退出全屏的支持
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
||||
if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
|
||||
if (mainWindow.isFullScreen()) {
|
||||
event.preventDefault()
|
||||
mainWindow.setFullScreen(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
||||
@@ -241,11 +252,16 @@ export class WindowService {
|
||||
return app.quit()
|
||||
}
|
||||
|
||||
// 如果是全屏状态,直接退出
|
||||
// 如果是Windows或Linux,且处于全屏状态,则退出应用
|
||||
if (this.wasFullScreen) {
|
||||
return app.quit()
|
||||
if (isWin || isLinux) {
|
||||
return app.quit()
|
||||
} else {
|
||||
event.preventDefault()
|
||||
mainWindow.setFullScreen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
})
|
||||
|
||||
1
src/preload/index.d.ts
vendored
@@ -15,6 +15,7 @@ declare global {
|
||||
api: {
|
||||
getAppInfo: () => Promise<AppInfo>
|
||||
checkForUpdate: () => Promise<{ currentVersion: string; updateInfo: UpdateInfo | null }>
|
||||
showUpdateDialog: () => Promise<void>
|
||||
openWebsite: (url: string) => void
|
||||
setProxy: (proxy: string | undefined) => void
|
||||
setLanguage: (theme: LanguageVarious) => void
|
||||
|
||||
@@ -8,6 +8,7 @@ const api = {
|
||||
reload: () => ipcRenderer.invoke('app:reload'),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
|
||||
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
|
||||
showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
|
||||
restartTray: () => ipcRenderer.invoke('app:restart-tray'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PersistGate } from 'redux-persist/integration/react'
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
@@ -23,31 +24,32 @@ import TranslatePage from './pages/translate/TranslatePage'
|
||||
function App(): JSX.Element {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<SyntaxHighlighterProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
{/* 添加导航处理组件 */}
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings" element={<PaintingsPage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</SyntaxHighlighterProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<SyntaxHighlighterProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings" element={<PaintingsPage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</SyntaxHighlighterProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/cici-app-logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/renderer/src/assets/images/apps/cici.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/renderer/src/assets/images/apps/you.jpg
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/renderer/src/assets/images/apps/zhihu.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/renderer/src/assets/images/models/xirang.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/renderer/src/assets/images/models/xirang_dark.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/renderer/src/assets/images/providers/tencent-cloud-ti.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/renderer/src/assets/images/providers/xirang.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -1,3 +1,5 @@
|
||||
@use './container.scss';
|
||||
|
||||
#inputbar {
|
||||
resize: none;
|
||||
}
|
||||
@@ -10,6 +12,10 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ant-segmented-group {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
7
src/renderer/src/assets/styles/container.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
box-shadow: -2px 0px 20px -4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import './markdown.scss';
|
||||
@import './ant.scss';
|
||||
@import './scrollbar.scss';
|
||||
@use './markdown.scss';
|
||||
@use './ant.scss';
|
||||
@use './scrollbar.scss';
|
||||
@use './container.scss';
|
||||
@import '../fonts/icon-fonts/iconfont.css';
|
||||
@import '../fonts/ubuntu/ubuntu.css';
|
||||
|
||||
@@ -88,7 +89,7 @@ body[theme-mode='light'] {
|
||||
--color-background: var(--color-white);
|
||||
--color-background-soft: var(--color-white-soft);
|
||||
--color-background-mute: var(--color-white-mute);
|
||||
--color-background-opacity: rgba(255, 255, 255, 0.7);
|
||||
--color-background-opacity: rgba(235, 235, 235, 0.7);
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
@@ -176,14 +177,6 @@ body,
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
box-shadow: -2px 0px 20px -4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@@ -14,7 +14,15 @@ const ModelAvatar: FC<Props> = ({ model, size, props }) => {
|
||||
return (
|
||||
<Avatar
|
||||
src={getModelLogo(model?.id || '')}
|
||||
style={{ width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
{...props}>
|
||||
{first(model?.name)}
|
||||
</Avatar>
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
type Props = {
|
||||
text: string | number
|
||||
maxLine?: number
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
} & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const Ellipsis = (props: Props) => {
|
||||
const { text, maxLine = 1, ...rest } = props
|
||||
const { maxLine = 1, children, ...rest } = props
|
||||
return (
|
||||
<EllipsisContainer maxLine={maxLine} {...rest}>
|
||||
{text}
|
||||
<EllipsisContainer $maxLine={maxLine} {...rest}>
|
||||
{children}
|
||||
</EllipsisContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const EllipsisContainer = styled.div<{ maxLine: number }>`
|
||||
const multiLineEllipsis = css<{ $maxLine: number }>`
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: ${({ $maxLine }) => $maxLine};
|
||||
overflow-wrap: break-word;
|
||||
`
|
||||
|
||||
const singleLineEllipsis = css`
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const EllipsisContainer = styled.div<{ $maxLine: number }>`
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: ${({ maxLine }) => maxLine};
|
||||
text-overflow: ellipsis;
|
||||
${({ $maxLine }) => ($maxLine > 1 ? multiLineEllipsis : singleLineEllipsis)}
|
||||
`
|
||||
|
||||
export default Ellipsis
|
||||
|
||||
134
src/renderer/src/components/Icons/FallbackFavicon.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
// FallbackFavicon component that tries multiple favicon sources
|
||||
interface FallbackFaviconProps {
|
||||
hostname: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
|
||||
type FaviconState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'failed' }
|
||||
| { status: 'loaded'; src: string }
|
||||
|
||||
const [faviconState, setFaviconState] = useState<FaviconState>({ status: 'idle' })
|
||||
|
||||
useEffect(() => {
|
||||
// Reset state when hostname changes
|
||||
setFaviconState({ status: 'loading' })
|
||||
|
||||
// Generate all possible favicon URLs
|
||||
const faviconUrls = [
|
||||
`https://favicon.splitbee.io/?url=${hostname}`,
|
||||
`https://${hostname}/favicon.ico`,
|
||||
`https://icon.horse/icon/${hostname}`,
|
||||
`https://favicon.cccyun.cc/${hostname}`,
|
||||
`https://favicon.im/${hostname}`,
|
||||
`https://www.google.com/s2/favicons?domain=${hostname}`
|
||||
]
|
||||
|
||||
// Main controller to abort all requests when needed
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
|
||||
// Create a promise for each favicon URL
|
||||
const faviconPromises = faviconUrls.map((url) =>
|
||||
fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal,
|
||||
credentials: 'omit'
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return url
|
||||
}
|
||||
throw new Error(`Failed to fetch ${url}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
// Rethrow aborted errors but silence other failures
|
||||
if (error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
console.debug(`Failed to fetch favicon from ${url}:`, error)
|
||||
return null // Return null for failed requests
|
||||
})
|
||||
)
|
||||
|
||||
// Create a timeout promise
|
||||
const timeoutPromise = new Promise<string>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(faviconUrls[0]) // Default to first URL after timeout
|
||||
}, 2000)
|
||||
|
||||
// Clear timeout if signal is aborted
|
||||
signal.addEventListener('abort', () => clearTimeout(timer))
|
||||
})
|
||||
|
||||
// Use Promise.race to get the first successful result
|
||||
Promise.race([
|
||||
// Filter out failed requests (null results)
|
||||
Promise.any(faviconPromises)
|
||||
.then((result) => result || faviconUrls[0]) // Ensure we always have a string, not null
|
||||
.catch(() => faviconUrls[0]),
|
||||
timeoutPromise
|
||||
])
|
||||
.then((url) => {
|
||||
setFaviconState({ status: 'loaded', src: url })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug('All favicon requests failed:', error)
|
||||
setFaviconState({ status: 'loaded', src: faviconUrls[0] })
|
||||
})
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [hostname]) // Only depend on hostname
|
||||
|
||||
const handleError = () => {
|
||||
setFaviconState({ status: 'failed' })
|
||||
}
|
||||
|
||||
// Render based on current state
|
||||
if (faviconState.status === 'failed') {
|
||||
return <FaviconPlaceholder>{hostname.charAt(0).toUpperCase()}</FaviconPlaceholder>
|
||||
}
|
||||
|
||||
if (faviconState.status === 'loaded') {
|
||||
return <Favicon src={faviconState.src} alt={alt} onError={handleError} />
|
||||
}
|
||||
|
||||
return <FaviconLoading />
|
||||
}
|
||||
|
||||
const FaviconLoading = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background-mute);
|
||||
`
|
||||
|
||||
const FaviconPlaceholder = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-primary-1);
|
||||
color: var(--color-primary-6);
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
`
|
||||
const Favicon = styled.img`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background-mute);
|
||||
`
|
||||
|
||||
export default FallbackFavicon
|
||||
17
src/renderer/src/components/Icons/UnWrapIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
const UnWrapIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
className="unwrap_svg__lucide unwrap_svg__lucide-text unwrap_svg__size-4"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<path d="M17 6.1H3M21 12.1H3M15.1 18H3" />
|
||||
</svg>
|
||||
)
|
||||
export default UnWrapIcon
|
||||
20
src/renderer/src/components/Icons/WrapIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
|
||||
const WrapIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
className="wrap_svg__lucide wrap_svg__lucide-wrap-text wrap_svg__size-4"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<path d="M3 6h18M3 12h15a3 3 0 1 1 0 6h-4" />
|
||||
<path d="m16 16-2 2 2 2M3 18h7" />
|
||||
</svg>
|
||||
)
|
||||
export default WrapIcon
|
||||
63
src/renderer/src/components/MarkdownShadowDOMRenderer.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { StyleProvider } from '@ant-design/cssinjs'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { StyleSheetManager } from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [shadowRoot, setShadowRoot] = React.useState<ShadowRoot | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
|
||||
// 创建 shadow root
|
||||
const shadow = host.shadowRoot || host.attachShadow({ mode: 'open' })
|
||||
|
||||
// 获取原始样式表
|
||||
const markdownStyleSheet = Array.from(document.styleSheets).find((sheet) => {
|
||||
try {
|
||||
return Array.from(sheet.cssRules).some((rule: CSSRule) => {
|
||||
return rule.cssText?.includes('.markdown')
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (markdownStyleSheet) {
|
||||
const style = document.createElement('style')
|
||||
const cssRules = Array.from(markdownStyleSheet.cssRules)
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n')
|
||||
|
||||
style.textContent = cssRules
|
||||
shadow.appendChild(style)
|
||||
}
|
||||
|
||||
setShadowRoot(shadow)
|
||||
}, [])
|
||||
|
||||
if (!shadowRoot) {
|
||||
return <div ref={hostRef} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={hostRef}>
|
||||
{createPortal(
|
||||
<StyleSheetManager target={shadowRoot}>
|
||||
<StyleProvider container={shadowRoot} layer>
|
||||
{children}
|
||||
</StyleProvider>
|
||||
</StyleSheetManager>,
|
||||
shadowRoot
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShadowDOMRenderer
|
||||
@@ -110,7 +110,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ paddingLeft: 0 }}
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
100
src/renderer/src/components/Popups/BackupPopup.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
stage: string
|
||||
progress: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [progressData, setProgressData] = useState<ProgressData>()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on('backup-progress', (_, data: ProgressData) => {
|
||||
setProgressData(data)
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onOk = async () => {
|
||||
await backup()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const getProgressText = () => {
|
||||
if (!progressData) return ''
|
||||
|
||||
if (progressData.stage === 'copying_files') {
|
||||
return t(`backup.progress.${progressData.stage}`, {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return t(`backup.progress.${progressData.stage}`)
|
||||
}
|
||||
|
||||
BackupPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('backup.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
okText={t('backup.confirm.button')}
|
||||
centered>
|
||||
{!progressData && <div>{t('backup.content')}</div>}
|
||||
{progressData && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Progress percent={Math.floor(progressData.progress)} strokeColor="var(--color-primary)" />
|
||||
<div style={{ marginTop: 16 }}>{getProgressText()}</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'BackupPopup'
|
||||
|
||||
export default class BackupPopup {
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
|
||||
|
||||
const AppsContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
||||
grid-template-columns: repeat(8, minmax(90px, 1fr));
|
||||
gap: 18px;
|
||||
`
|
||||
|
||||
|
||||
100
src/renderer/src/components/Popups/RestorePopup.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { restore } from '@renderer/services/BackupService'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
stage: string
|
||||
progress: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [progressData, setProgressData] = useState<ProgressData>()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on('restore-progress', (_, data: ProgressData) => {
|
||||
setProgressData(data)
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onOk = async () => {
|
||||
await restore()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const getProgressText = () => {
|
||||
if (!progressData) return ''
|
||||
|
||||
if (progressData.stage === 'copying_files') {
|
||||
return t(`restore.progress.${progressData.stage}`, {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return t(`restore.progress.${progressData.stage}`)
|
||||
}
|
||||
|
||||
RestorePopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('restore.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
okText={t('restore.confirm.button')}
|
||||
centered>
|
||||
{!progressData && <div>{t('restore.content')}</div>}
|
||||
{progressData && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Progress percent={Math.floor(progressData.progress)} strokeColor="var(--color-primary)" />
|
||||
<div style={{ marginTop: 16 }}>{getProgressText()}</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'RestorePopup'
|
||||
|
||||
export default class RestorePopup {
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -33,6 +33,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
const { providers } = useProviders()
|
||||
const [pinnedModels, setPinnedModels] = useState<string[]>([])
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const loadPinnedModels = async () => {
|
||||
@@ -62,41 +63,59 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
setPinnedModels(sortBy(newPinnedModels, ['group', 'name']))
|
||||
}
|
||||
|
||||
// 根据输入的文本筛选模型
|
||||
const getFilteredModels = useCallback(
|
||||
(provider) => {
|
||||
const nonEmbeddingModels = provider.models.filter((m) => !isEmbeddingModel(m))
|
||||
|
||||
if (!searchText.trim()) {
|
||||
return sortBy(nonEmbeddingModels, ['group', 'name'])
|
||||
}
|
||||
|
||||
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
|
||||
return sortBy(nonEmbeddingModels, ['group', 'name']).filter((m) => {
|
||||
const fullName = provider.isSystem
|
||||
? `${m.name}${m.provider}${t('provider.' + provider.id)}`
|
||||
: `${m.name}${m.provider}`
|
||||
|
||||
const lowerFullName = fullName.toLowerCase()
|
||||
return keywords.every((keyword) => lowerFullName.includes(keyword))
|
||||
})
|
||||
},
|
||||
[searchText, t]
|
||||
)
|
||||
|
||||
const filteredItems: MenuItem[] = providers
|
||||
.filter((p) => p.models && p.models.length > 0)
|
||||
.map((p) => {
|
||||
const filteredModels = sortBy(p.models, ['group', 'name'])
|
||||
.filter((m) => !isEmbeddingModel(m))
|
||||
.filter((m) =>
|
||||
[m.name + m.provider + t('provider.' + p.id)].join('').toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTags model={m} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m))
|
||||
}}
|
||||
isPinned={pinnedModels.includes(getModelUniqId(m))}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
const filteredModels = getFilteredModels(p).map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTags model={m} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m))
|
||||
}}
|
||||
isPinned={pinnedModels.includes(getModelUniqId(m))}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
|
||||
// Only return the group if it has filtered models
|
||||
return filteredModels.length > 0
|
||||
@@ -153,10 +172,12 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setKeyboardSelectedId('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = async () => {
|
||||
setKeyboardSelectedId('')
|
||||
resolve(undefined)
|
||||
SelectModelPopup.hide()
|
||||
}
|
||||
@@ -176,6 +197,85 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
}
|
||||
}, [open, model])
|
||||
|
||||
// 获取所有可见的模型项
|
||||
const getVisibleModelItems = useCallback(() => {
|
||||
const items: { key: string; model: Model }[] = []
|
||||
|
||||
// 如果有置顶模型且没有搜索文本,添加置顶模型
|
||||
if (pinnedModels.length > 0 && searchText.length === 0) {
|
||||
providers
|
||||
.flatMap((p) => p.models || [])
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.forEach((m) => items.push({ key: getModelUniqId(m) + '_pinned', model: m }))
|
||||
}
|
||||
|
||||
// 添加其他过滤后的模型
|
||||
providers.forEach((p) => {
|
||||
if (p.models) {
|
||||
getFilteredModels(p).forEach((m) => {
|
||||
const modelId = getModelUniqId(m)
|
||||
const isPinned = pinnedModels.includes(modelId)
|
||||
// 如果是搜索状态,或者不是固定模型,才添加到列表中
|
||||
if (searchText.length > 0 || !isPinned) {
|
||||
items.push({
|
||||
key: isPinned ? modelId + '_pinned' : modelId,
|
||||
model: m
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}, [pinnedModels, searchText, providers, getFilteredModels])
|
||||
|
||||
// 处理键盘导航
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const items = getVisibleModelItems()
|
||||
if (items.length === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const currentIndex = items.findIndex((item) => item.key === keyboardSelectedId)
|
||||
let nextIndex
|
||||
|
||||
if (currentIndex === -1) {
|
||||
nextIndex = e.key === 'ArrowDown' ? 0 : items.length - 1
|
||||
} else {
|
||||
nextIndex =
|
||||
e.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length
|
||||
}
|
||||
|
||||
const nextItem = items[nextIndex]
|
||||
setKeyboardSelectedId(nextItem.key)
|
||||
|
||||
const element = document.querySelector(`[data-menu-id="${nextItem.key}"]`)
|
||||
element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault() // 阻止回车的默认行为
|
||||
if (keyboardSelectedId) {
|
||||
const selectedItem = items.find((item) => item.key === keyboardSelectedId)
|
||||
if (selectedItem) {
|
||||
resolve(selectedItem.model)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[keyboardSelectedId, getVisibleModelItems, resolve, setOpen]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
// 搜索文本改变时重置键盘选中状态
|
||||
useEffect(() => {
|
||||
setKeyboardSelectedId('')
|
||||
}, [searchText])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
@@ -208,20 +308,21 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ paddingLeft: 0 }}
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
onKeyDown={(e) => {
|
||||
// 防止上下键移动光标
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
|
||||
<Container>
|
||||
{filteredItems.length > 0 ? (
|
||||
<StyledMenu
|
||||
items={filteredItems}
|
||||
selectedKeys={model ? [getModelUniqId(model)] : []}
|
||||
mode="inline"
|
||||
inlineIndent={6}
|
||||
/>
|
||||
<StyledMenu items={filteredItems} selectedKeys={[keyboardSelectedId]} mode="inline" inlineIndent={6} />
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import DefaultAvatar from '@renderer/assets/images/avatar.png'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
import { compressImage } from '@renderer/utils'
|
||||
import { Avatar, Input, Modal, Upload } from 'antd'
|
||||
import { compressImage, isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { Center, HStack } from '../Layout'
|
||||
import EmojiPicker from '../EmojiPicker'
|
||||
import { Center, HStack, VStack } from '../Layout'
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +21,8 @@ interface Props {
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { userName } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -36,6 +40,85 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const handleEmojiClick = async (emoji: string) => {
|
||||
try {
|
||||
// set emoji string
|
||||
await ImageStorage.set('avatar', emoji)
|
||||
// update avatar display
|
||||
dispatch(setAvatar(emoji))
|
||||
setEmojiPickerOpen(false)
|
||||
} catch (error: any) {
|
||||
window.message.error(error.message)
|
||||
}
|
||||
}
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await ImageStorage.set('avatar', DefaultAvatar)
|
||||
dispatch(setAvatar(DefaultAvatar))
|
||||
setDropdownOpen(false)
|
||||
} catch (error: any) {
|
||||
window.message.error(error.message)
|
||||
}
|
||||
}
|
||||
const items = [
|
||||
{
|
||||
key: 'upload',
|
||||
label: (
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<Upload
|
||||
customRequest={() => {}}
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
itemRender={() => null}
|
||||
maxCount={1}
|
||||
onChange={async ({ file }) => {
|
||||
try {
|
||||
const _file = file.originFileObj as File
|
||||
if (_file.type === 'image/gif') {
|
||||
await ImageStorage.set('avatar', _file)
|
||||
} else {
|
||||
const compressedFile = await compressImage(_file)
|
||||
await ImageStorage.set('avatar', compressedFile)
|
||||
}
|
||||
dispatch(setAvatar(await ImageStorage.get('avatar')))
|
||||
setDropdownOpen(false)
|
||||
} catch (error: any) {
|
||||
window.message.error(error.message)
|
||||
}
|
||||
}}>
|
||||
{t('settings.general.image_upload')}
|
||||
</Upload>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'emoji',
|
||||
label: (
|
||||
<div
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEmojiPickerOpen(true)
|
||||
setDropdownOpen(false)
|
||||
}}>
|
||||
{t('settings.general.emoji_picker')}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
label: (
|
||||
<div
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleReset()
|
||||
}}>
|
||||
{t('settings.general.avatar.reset')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width="300px"
|
||||
@@ -47,29 +130,40 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
transitionName="ant-move-down"
|
||||
centered>
|
||||
<Center mt="30px">
|
||||
<Upload
|
||||
customRequest={() => {}}
|
||||
accept="image/png, image/jpeg"
|
||||
itemRender={() => null}
|
||||
maxCount={1}
|
||||
onChange={async ({ file }) => {
|
||||
try {
|
||||
const _file = file.originFileObj as File
|
||||
const compressedFile = await compressImage(_file)
|
||||
await ImageStorage.set('avatar', compressedFile)
|
||||
dispatch(setAvatar(await ImageStorage.get('avatar')))
|
||||
} catch (error: any) {
|
||||
window.message.error(error.message)
|
||||
}
|
||||
}}>
|
||||
<UserAvatar src={avatar} />
|
||||
</Upload>
|
||||
<VStack alignItems="center" gap="10px">
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
open={dropdownOpen}
|
||||
align={{ offset: [0, 4] }}
|
||||
placement="bottom"
|
||||
onOpenChange={(visible) => {
|
||||
setDropdownOpen(visible)
|
||||
if (visible) {
|
||||
setEmojiPickerOpen(false)
|
||||
}
|
||||
}}>
|
||||
<Popover
|
||||
content={<EmojiPicker onEmojiClick={handleEmojiClick} />}
|
||||
trigger="click"
|
||||
open={emojiPickerOpen}
|
||||
onOpenChange={(visible) => {
|
||||
setEmojiPickerOpen(visible)
|
||||
if (visible) {
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}}
|
||||
placement="bottom">
|
||||
{isEmoji(avatar) ? <EmojiAvatar>{avatar}</EmojiAvatar> : <UserAvatar src={avatar} />}
|
||||
</Popover>
|
||||
</Dropdown>
|
||||
</VStack>
|
||||
</Center>
|
||||
<HStack alignItems="center" gap="10px" p="20px">
|
||||
<Input
|
||||
placeholder={t('settings.general.user_name.placeholder')}
|
||||
value={userName}
|
||||
onChange={(e) => dispatch(setUserName(e.target.value))}
|
||||
onChange={(e) => dispatch(setUserName(e.target.value.trim()))}
|
||||
style={{ flex: 1, textAlign: 'center', width: '100%' }}
|
||||
maxLength={30}
|
||||
/>
|
||||
@@ -88,6 +182,23 @@ const UserAvatar = styled(Avatar)`
|
||||
}
|
||||
`
|
||||
|
||||
const EmojiAvatar = styled.div`
|
||||
cursor: pointer;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20%;
|
||||
background-color: var(--color-background-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
transition: opacity 0.3s ease;
|
||||
border: 0.5px solid var(--color-border);
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
|
||||
export default class UserPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Avatar } from 'antd'
|
||||
@@ -64,7 +65,11 @@ const Sidebar: FC = () => {
|
||||
backgroundColor: sidebarBgColor,
|
||||
zIndex: minappShow ? 10000 : 'initial'
|
||||
}}>
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar onClick={onEditUser}>{avatar}</EmojiAvatar>
|
||||
) : (
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
)}
|
||||
<MainMenusContainer>
|
||||
<Menus onClick={MinApp.onClose}>
|
||||
<MainMenus />
|
||||
@@ -220,6 +225,24 @@ const AvatarImg = styled(Avatar)`
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const EmojiAvatar = styled.div`
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: ${isMac ? '12px' : '12px'};
|
||||
margin-top: ${isMac ? '0px' : '2px'};
|
||||
border-radius: 20%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: none;
|
||||
border: 0.5px solid var(--color-border);
|
||||
font-size: 20px;
|
||||
`
|
||||
|
||||
const MainMenusContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -5,6 +5,7 @@ import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
|
||||
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
|
||||
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
|
||||
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
|
||||
import CiciAppLogo from '@renderer/assets/images/apps/cici.webp?url'
|
||||
import CozeAppLogo from '@renderer/assets/images/apps/coze.webp?url'
|
||||
import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
|
||||
import DifyAppLogo from '@renderer/assets/images/apps/dify.svg?url'
|
||||
@@ -37,8 +38,10 @@ import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
||||
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
||||
import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
|
||||
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
||||
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
|
||||
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
|
||||
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
|
||||
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
||||
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
|
||||
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png?url'
|
||||
import QwenModelLogo from '@renderer/assets/images/models/qwen.png?url'
|
||||
@@ -119,6 +122,12 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
url: 'https://www.doubao.com/chat/',
|
||||
logo: DoubaoAppLogo
|
||||
},
|
||||
{
|
||||
id: 'cici',
|
||||
name: 'Cici',
|
||||
url: 'https://www.cici.com/chat/',
|
||||
logo: CiciAppLogo
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: '海螺',
|
||||
@@ -371,6 +380,19 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
logo: MonicaLogo,
|
||||
url: 'https://monica.im/home/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'you',
|
||||
name: 'You',
|
||||
logo: YouLogo,
|
||||
url: 'https://you.com/'
|
||||
},
|
||||
{
|
||||
id: 'zhihu',
|
||||
name: '知乎直答',
|
||||
logo: ZhihuAppLogo,
|
||||
url: 'https://zhida.zhihu.com/',
|
||||
bodered: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ import ViduModelLogo from '@renderer/assets/images/models/vidu.png'
|
||||
import ViduModelLogoDark from '@renderer/assets/images/models/vidu_dark.png'
|
||||
import WenxinModelLogo from '@renderer/assets/images/models/wenxin.png'
|
||||
import WenxinModelLogoDark from '@renderer/assets/images/models/wenxin_dark.png'
|
||||
import XirangModelLogo from '@renderer/assets/images/models/xirang.png'
|
||||
import XirangModelLogoDark from '@renderer/assets/images/models/xirang_dark.png'
|
||||
import YiModelLogo from '@renderer/assets/images/models/yi.png'
|
||||
import YiModelLogoDark from '@renderer/assets/images/models/yi_dark.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
@@ -189,17 +191,14 @@ export function getModelLogo(modelId: string) {
|
||||
'text-moderation': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'babbage-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'sora-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'omni-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'(^|/)omni-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'Embedding-V1': isLight ? WenxinModelLogo : WenxinModelLogoDark,
|
||||
'text-embedding-v': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
'text-embedding': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'davinci-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
glm: isLight ? ChatGLMModelLogo : ChatGLMModelLogoDark,
|
||||
deepseek: isLight ? DeepSeekModelLogo : DeepSeekModelLogoDark,
|
||||
qwen: isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
'qwq-': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
'qvq-': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
Omni: isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
'(qwen|qwq-|qvq-)': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
gemma: isLight ? GemmaModelLogo : GemmaModelLogoDark,
|
||||
'yi-': isLight ? YiModelLogo : YiModelLogoDark,
|
||||
llama: isLight ? LlamaModelLogo : LlamaModelLogoDark,
|
||||
@@ -276,6 +275,7 @@ export function getModelLogo(modelId: string) {
|
||||
rakutenai: isLight ? RakutenaiModelLogo : RakutenaiModelLogoDark,
|
||||
ibm: isLight ? IbmModelLogo : IbmModelLogoDark,
|
||||
'google/': isLight ? GoogleModelLogo : GoogleModelLogoDark,
|
||||
xirang: isLight ? XirangModelLogo : XirangModelLogoDark,
|
||||
hugging: isLight ? HuggingfaceModelLogo : HuggingfaceModelLogoDark,
|
||||
embedding: isLight ? EmbeddingModelLogo : EmbeddingModelLogoDark,
|
||||
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
@@ -284,7 +284,8 @@ export function getModelLogo(modelId: string) {
|
||||
}
|
||||
|
||||
for (const key in logoMap) {
|
||||
if (modelId.toLowerCase().includes(key)) {
|
||||
const regex = new RegExp(key, 'i')
|
||||
if (regex.test(modelId)) {
|
||||
return logoMap[key]
|
||||
}
|
||||
}
|
||||
@@ -556,6 +557,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
}
|
||||
],
|
||||
openai: [
|
||||
{ id: 'gpt-4.5-preview', provider: 'openai', name: ' gpt-4.5-preview', group: 'gpt-4.5' },
|
||||
{ id: 'gpt-4o', provider: 'openai', name: ' GPT-4o', group: 'GPT 4o' },
|
||||
{ id: 'gpt-4o-mini', provider: 'openai', name: ' GPT-4o-mini', group: 'GPT 4o' },
|
||||
{ id: 'o1-mini', provider: 'openai', name: ' o1-mini', group: 'o1' },
|
||||
@@ -617,7 +619,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
{
|
||||
id: 'claude-3-5-haiku-20241022',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Haiku',
|
||||
name: 'Claude 3.5 Haiku',
|
||||
group: 'Claude 3.5'
|
||||
},
|
||||
{
|
||||
@@ -1404,6 +1406,18 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'hunyuan',
|
||||
name: 'hunyuan-turbo',
|
||||
group: 'Hunyuan'
|
||||
},
|
||||
{
|
||||
id: 'hunyuan-turbos-latest',
|
||||
provider: 'hunyuan',
|
||||
name: 'hunyuan-turbos-latest',
|
||||
group: 'Hunyuan'
|
||||
},
|
||||
{
|
||||
id: 'hunyuan-embedding',
|
||||
provider: 'hunyuan',
|
||||
name: 'hunyuan-embedding',
|
||||
group: 'Embedding'
|
||||
}
|
||||
],
|
||||
nvidia: [
|
||||
@@ -1713,6 +1727,21 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'jina-embeddings-v2-base-code',
|
||||
group: 'Jina'
|
||||
}
|
||||
],
|
||||
xirang: [],
|
||||
'tencent-cloud-ti': [
|
||||
{
|
||||
id: 'deepseek-r1',
|
||||
provider: 'tencent-cloud-ti',
|
||||
name: 'DeepSeek R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3',
|
||||
provider: 'tencent-cloud-ti',
|
||||
name: 'DeepSeek V3',
|
||||
group: 'DeepSeek'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1822,7 +1851,11 @@ export function isVisionModel(model: Model): boolean {
|
||||
return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
}
|
||||
|
||||
export function isReasoningModel(model: Model): boolean {
|
||||
export function isOpenAIoSeries(model: Model): boolean {
|
||||
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
|
||||
}
|
||||
|
||||
export function isReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
@@ -1831,6 +1864,10 @@ export function isReasoningModel(model: Model): boolean {
|
||||
return REASONING_REGEX.test(model.name) || model.type?.includes('reasoning') || false
|
||||
}
|
||||
|
||||
if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
|
||||
}
|
||||
|
||||
@@ -1853,6 +1890,12 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const isEmbedding = isEmbeddingModel(model)
|
||||
|
||||
if (isEmbedding) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (model?.id?.includes('gemini-2.0-flash-exp')) {
|
||||
return true
|
||||
|
||||
@@ -33,8 +33,10 @@ import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
|
||||
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
|
||||
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -67,6 +69,8 @@ export function getProviderLogo(providerId: string) {
|
||||
return BailianProviderLogo
|
||||
case 'modelscope':
|
||||
return ModelScopeProviderLogo
|
||||
case 'xirang':
|
||||
return XirangProviderLogo
|
||||
case 'anthropic':
|
||||
return AnthropicProviderLogo
|
||||
case 'aihubmix':
|
||||
@@ -117,6 +121,8 @@ export function getProviderLogo(providerId: string) {
|
||||
return InfiniProviderLogo
|
||||
case 'o3':
|
||||
return O3ProviderLogo
|
||||
case 'tencent-cloud-ti':
|
||||
return TencentCloudProviderLogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -323,6 +329,17 @@ export const PROVIDER_CONFIG = {
|
||||
models: 'https://modelscope.cn/models'
|
||||
}
|
||||
},
|
||||
xirang: {
|
||||
api: {
|
||||
url: 'https://wishub-x1.ctyun.cn'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.ctyun.cn',
|
||||
apiKey: 'https://huiju.ctyun.cn/service/serviceGroup',
|
||||
docs: 'https://www.ctyun.cn/products/ctxirang',
|
||||
models: 'https://huiju.ctyun.cn/modelSquare/'
|
||||
}
|
||||
},
|
||||
dashscope: {
|
||||
api: {
|
||||
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
@@ -544,5 +561,16 @@ export const PROVIDER_CONFIG = {
|
||||
docs: 'https://cloud.baidu.com/doc/index.html',
|
||||
models: 'https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu'
|
||||
}
|
||||
},
|
||||
'tencent-cloud-ti': {
|
||||
api: {
|
||||
url: 'https://api.lkeap.cloud.tencent.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cloud.tencent.com/product/ti',
|
||||
apiKey: 'https://console.cloud.tencent.com/lkeap/api',
|
||||
docs: 'https://cloud.tencent.com/document/product/1772',
|
||||
models: 'https://console.cloud.tencent.com/tione/v2/aimarket'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useTheme } from './ThemeProvider'
|
||||
const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { language } = useSettings()
|
||||
const { theme: _theme } = useTheme()
|
||||
const isDarkTheme = _theme === 'dark'
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
@@ -21,14 +20,6 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
theme={{
|
||||
algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm],
|
||||
components: {
|
||||
Segmented: {
|
||||
trackBg: 'transparent',
|
||||
itemSelectedBg: isDarkTheme ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
boxShadowTertiary: undefined,
|
||||
borderRadiusLG: 16,
|
||||
borderRadiusSM: 16,
|
||||
borderRadiusXS: 16
|
||||
},
|
||||
Menu: {
|
||||
activeBarBorderWidth: 0,
|
||||
darkItemBg: 'transparent'
|
||||
|
||||
25
src/renderer/src/context/StyleSheetManager.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import isPropValid from '@emotion/is-prop-valid'
|
||||
import { ReactNode } from 'react'
|
||||
import { StyleSheetManager as StyledComponentsStyleSheetManager } from 'styled-components'
|
||||
|
||||
interface StyleSheetManagerProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const StyleSheetManager = ({ children }: StyleSheetManagerProps): JSX.Element => {
|
||||
return (
|
||||
<StyledComponentsStyleSheetManager
|
||||
shouldForwardProp={(prop, element) => {
|
||||
// 对于 HTML 元素,使用 isPropValid 检查
|
||||
if (typeof element === 'string') {
|
||||
return isPropValid(prop)
|
||||
}
|
||||
// 对于自定义组件,允许所有非特殊属性通过
|
||||
return prop !== '$' && !prop.startsWith('$')
|
||||
}}>
|
||||
{children}
|
||||
</StyledComponentsStyleSheetManager>
|
||||
)
|
||||
}
|
||||
|
||||
export default StyleSheetManager
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isMiniWindow } from '@renderer/utils'
|
||||
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
@@ -40,9 +39,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('theme-mode', _theme)
|
||||
if (!isMiniWindow()) {
|
||||
window.api?.setTheme(_theme === ThemeMode.dark ? 'dark' : 'light')
|
||||
}
|
||||
// 移除迷你窗口的条件判断,让所有窗口都能设置主题
|
||||
window.api?.setTheme(_theme === ThemeMode.dark ? 'dark' : 'light')
|
||||
}, [_theme])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
const NavigationHandler: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const showSettingsShortcutEnabled = useAppSelector(
|
||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'meta+, ! ctrl+,',
|
||||
function () {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
return
|
||||
}
|
||||
navigate('/settings/provider')
|
||||
},
|
||||
{ splitKey: '!' }
|
||||
{
|
||||
splitKey: '!',
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
enabled: showSettingsShortcutEnabled
|
||||
}
|
||||
)
|
||||
|
||||
return null
|
||||
|
||||
48
src/renderer/src/hooks/useKnowledgeFiles.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useKnowledgeBases } from './useKnowledge'
|
||||
|
||||
export const useKnowledgeFiles = () => {
|
||||
const [knowledgeFiles, setKnowledgeFiles] = useState<FileType[]>([])
|
||||
const { bases, updateKnowledgeBases } = useKnowledgeBases()
|
||||
|
||||
useEffect(() => {
|
||||
const items = bases.map((kb) => kb.items).flat()
|
||||
|
||||
const fileItems = items
|
||||
.filter((item) => item.type === 'file')
|
||||
.filter((item) => item.processingStatus === 'completed')
|
||||
|
||||
const files = fileItems.map((item) => item.content as FileType)
|
||||
|
||||
!isEmpty(files) && setKnowledgeFiles(files)
|
||||
}, [bases])
|
||||
|
||||
const removeAllFiles = async () => {
|
||||
console.debug('removeAllFiles', knowledgeFiles)
|
||||
await FileManager.deleteFiles(knowledgeFiles)
|
||||
|
||||
const newBases = bases.map((kb) => ({
|
||||
...kb,
|
||||
items: kb.items.map((item) =>
|
||||
item.type === 'file'
|
||||
? {
|
||||
...item,
|
||||
content: {
|
||||
...(item.content as FileType),
|
||||
size: 0
|
||||
}
|
||||
}
|
||||
: item
|
||||
)
|
||||
}))
|
||||
updateKnowledgeBases(newBases)
|
||||
}
|
||||
|
||||
const size = knowledgeFiles.reduce((acc, file) => acc + file.size, 0)
|
||||
|
||||
return { knowledgeFiles, size, removeAllFiles }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
addProvider,
|
||||
removeModel,
|
||||
removeProvider,
|
||||
updateModel,
|
||||
updateProvider,
|
||||
updateProviders
|
||||
} from '@renderer/store/llm'
|
||||
@@ -51,7 +52,8 @@ export function useProvider(id: string) {
|
||||
models: provider?.models || [],
|
||||
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),
|
||||
addModel: (model: Model) => dispatch(addModel({ providerId: id, model })),
|
||||
removeModel: (model: Model) => dispatch(removeModel({ providerId: id, model }))
|
||||
removeModel: (model: Model) => dispatch(removeModel({ providerId: id, model })),
|
||||
updateModel: (model: Model) => dispatch(updateModel({ providerId: id, model }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
SendMessageShortcut,
|
||||
setSendMessageShortcut as _setSendMessageShortcut,
|
||||
setShowAssistantIcon,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTheme,
|
||||
@@ -45,6 +46,9 @@ export function useSettings() {
|
||||
},
|
||||
updateSidebarDisabledIcons(icons: SidebarIcon[]) {
|
||||
dispatch(setSidebarIcons({ disabled: icons }))
|
||||
},
|
||||
setShowAssistantIcon(showAssistantIcon: boolean) {
|
||||
dispatch(setShowAssistantIcon(showAssistantIcon))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setUpdateState } from '@renderer/store/runtime'
|
||||
import type { ProgressInfo, UpdateInfo } from 'electron-updater'
|
||||
import type { ProgressInfo, UpdateInfo } from 'builder-util-runtime'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -46,8 +46,14 @@ export default function useUpdateHandler() {
|
||||
})
|
||||
)
|
||||
}),
|
||||
ipcRenderer.on('update-downloaded', () => {
|
||||
dispatch(setUpdateState({ downloading: false }))
|
||||
ipcRenderer.on('update-downloaded', (_, releaseInfo: UpdateInfo) => {
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
downloading: false,
|
||||
info: releaseInfo,
|
||||
downloaded: true
|
||||
})
|
||||
)
|
||||
}),
|
||||
ipcRenderer.on('update-error', (_, error) => {
|
||||
dispatch(
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"settings.reasoning_effort.low": "low",
|
||||
"settings.reasoning_effort.medium": "medium",
|
||||
"settings.reasoning_effort.off": "off",
|
||||
"settings.reasoning_effort.tip": "Only supports reasoning models",
|
||||
"settings.reasoning_effort.tip": "Only supports OpenAI o-series and Anthropic reasoning models",
|
||||
"title": "Assistants"
|
||||
},
|
||||
"auth": {
|
||||
@@ -68,7 +68,8 @@
|
||||
"collapse": "Collapse",
|
||||
"manage": "Manage",
|
||||
"select_model": "Select Model",
|
||||
"show.all": "Show All"
|
||||
"show.all": "Show All",
|
||||
"update_available": "Update Available"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "Add Assistant",
|
||||
@@ -104,14 +105,17 @@
|
||||
"input.web_search.button.ok": "Go to Settings",
|
||||
"input.web_search.enable": "Enable web search",
|
||||
"input.web_search.enable_content": "Enable web search in Settings",
|
||||
"input.auto_resize": "Auto resize height",
|
||||
"message.new.branch": "New Branch",
|
||||
"message.new.branch.created": "New Branch Created",
|
||||
"message.new.context": "New Context",
|
||||
"message.regenerate.model": "Switch Model",
|
||||
"message.useful": "Helpful",
|
||||
"message.quote": "Quote",
|
||||
"resend": "Resend",
|
||||
"save": "Save",
|
||||
"settings.code_collapsible": "Code block collapsible",
|
||||
"settings.code_wrappable": "Code block wrappable",
|
||||
"settings.context_count": "Context",
|
||||
"settings.context_count.tip": "The number of previous messages to keep in the context.",
|
||||
"settings.max": "Max",
|
||||
@@ -384,6 +388,7 @@
|
||||
"error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
|
||||
"error.yuque.export": "Failed to export to Yuque. Please check connection status and configuration according to documentation",
|
||||
"error.yuque.no_config": "Yuque Token or Yuque Url is not configured",
|
||||
"error.dimension_too_large": "Content size is too large",
|
||||
"group.delete.content": "Deleting a group message will delete the user's question and all assistant's answers",
|
||||
"group.delete.title": "Delete Group Message",
|
||||
"ignore.knowledge.base": "Web search mode is enabled, ignore knowledge base",
|
||||
@@ -392,11 +397,11 @@
|
||||
"message.code_style": "Code style",
|
||||
"message.delete.content": "Are you sure you want to delete this message?",
|
||||
"message.delete.title": "Delete Message",
|
||||
"message.multi_model_style": "Group style",
|
||||
"message.multi_model_style.fold": "Fold",
|
||||
"message.multi_model_style.grid": "Grid",
|
||||
"message.multi_model_style.horizontal": "Horizontal",
|
||||
"message.multi_model_style.vertical": "Vertical",
|
||||
"message.multi_model_style": "Multi-model response style",
|
||||
"message.multi_model_style.fold": "Fold view",
|
||||
"message.multi_model_style.grid": "Grid layout",
|
||||
"message.multi_model_style.horizontal": "Side by side",
|
||||
"message.multi_model_style.vertical": "Stacked view",
|
||||
"message.style": "Message style",
|
||||
"message.style.bubble": "Bubble",
|
||||
"message.style.plain": "Plain",
|
||||
@@ -405,6 +410,7 @@
|
||||
"reset.double.confirm.content": "All data will be lost, do you want to continue?",
|
||||
"reset.double.confirm.title": "DATA LOST !!!",
|
||||
"restore.success": "Restored successfully",
|
||||
"restore.failed": "Restore failed",
|
||||
"save.success.title": "Saved successfully",
|
||||
"searching": "Searching the internet...",
|
||||
"success.notion.export": "Successfully exported to Notion",
|
||||
@@ -472,7 +478,8 @@
|
||||
"vision": "Vision"
|
||||
},
|
||||
"vision": "Vision",
|
||||
"websearch": "WebSearch"
|
||||
"websearch": "WebSearch",
|
||||
"edit": "Edit Model"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
@@ -558,7 +565,9 @@
|
||||
"together": "Together",
|
||||
"yi": "Yi",
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI"
|
||||
"zhipu": "ZHIPU AI",
|
||||
"xirang": "State Cloud Xirang",
|
||||
"tencent-cloud-ti": "Tencent Cloud TI"
|
||||
},
|
||||
"settings": {
|
||||
"about": "About & Feedback",
|
||||
@@ -590,6 +599,11 @@
|
||||
"data": {
|
||||
"app_data": "App Data",
|
||||
"app_logs": "App Logs",
|
||||
"app_knowledge": "Knowledge Base Files",
|
||||
"app_knowledge.button.delete": "Delete File",
|
||||
"app_knowledge.remove_all": "Remove Knowledge Base Files",
|
||||
"app_knowledge.remove_all_confirm": "Deleting knowledge base files will reduce the storage space occupied, but will not delete the knowledge base vector data, after deletion, the source file will no longer be able to be opened. Continue?",
|
||||
"app_knowledge.remove_all_success": "Files removed successfully",
|
||||
"clear_cache": {
|
||||
"button": "Clear Cache",
|
||||
"confirm": "Clearing the cache will delete application cache data, including minapp data. This action is irreversible, continue?",
|
||||
@@ -665,6 +679,7 @@
|
||||
},
|
||||
"display.custom.css": "Custom CSS",
|
||||
"display.custom.css.placeholder": "/* Put custom CSS here */",
|
||||
"display.custom.css.cherrycss": "Get from cherrycss.com",
|
||||
"display.minApp.disabled": "Hidden MinApp",
|
||||
"display.minApp.empty": "Drag minApp from the left to hide them here",
|
||||
"display.minApp.title": "MinApp Settings",
|
||||
@@ -681,6 +696,7 @@
|
||||
"display.sidebar.visible": "Show icons",
|
||||
"display.title": "Display Settings",
|
||||
"display.topic.title": "Topic Settings",
|
||||
"display.assistant.title": "Assistant Settings",
|
||||
"font_size.title": "Message font size",
|
||||
"general": "General Settings",
|
||||
"general.backup.button": "Backup",
|
||||
@@ -693,6 +709,9 @@
|
||||
"general.title": "General Settings",
|
||||
"general.user_name": "User Name",
|
||||
"general.user_name.placeholder": "Enter your name",
|
||||
"general.image_upload": "Image Upload",
|
||||
"general.emoji_picker": "Emoji Picker",
|
||||
"general.avatar.reset": "Reset Avatar",
|
||||
"general.view_webdav_settings": "View WebDAV settings",
|
||||
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
|
||||
"input.target_language": "Target language",
|
||||
@@ -739,6 +758,10 @@
|
||||
"models.translate_model_description": "Model used for translation service",
|
||||
"models.translate_model_prompt_message": "Please enter the translate model prompt",
|
||||
"models.translate_model_prompt_title": "Translate Model Prompt",
|
||||
"moresetting": "More Settings",
|
||||
"moresetting.warn": "Risk Warning",
|
||||
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",
|
||||
"moresetting.check.confirm": "Confirm Selection",
|
||||
"provider": {
|
||||
"add.name": "Provider Name",
|
||||
"add.name.placeholder": "Example: OpenAI",
|
||||
@@ -765,7 +788,8 @@
|
||||
"remove_duplicate_keys": "Remove Duplicate Keys",
|
||||
"remove_invalid_keys": "Remove Invalid Keys",
|
||||
"search_placeholder": "Search model id or name",
|
||||
"title": "Model Provider"
|
||||
"title": "Model Provider",
|
||||
"search": "Search Providers..."
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -785,7 +809,6 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "Action",
|
||||
"alt_warning": "On Mac, Option key combinations only work with the Space key",
|
||||
"clear_shortcut": "Clear Shortcut",
|
||||
"clear_topic": "Clear Messages",
|
||||
"copy_last_message": "Copy Last Message",
|
||||
@@ -819,6 +842,7 @@
|
||||
"topic.position.left": "Left",
|
||||
"topic.position.right": "Right",
|
||||
"topic.show.time": "Show topic time",
|
||||
"assistant.show.icon": "Show model icon",
|
||||
"tray.title": "Enable System Tray Icon",
|
||||
"websearch": {
|
||||
"get_api_key": "Get API Key",
|
||||
@@ -870,6 +894,38 @@
|
||||
"quit": "Quit",
|
||||
"show_window": "Show Window",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"code_block": {
|
||||
"enable_wrap": "Wrap",
|
||||
"disable_wrap": "Unwrap"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Data Backup",
|
||||
"confirm": "Are you sure you want to backup data?",
|
||||
"confirm.button": "Select Backup Location",
|
||||
"content": "Backup all data, including chat history, settings, and knowledge base. Please note that the backup process may take some time, thank you for your patience.",
|
||||
"progress": {
|
||||
"title": "Backup Progress",
|
||||
"preparing": "Preparing backup...",
|
||||
"writing_data": "Writing data...",
|
||||
"copying_files": "Copying files... {{progress}}%",
|
||||
"compressing": "Compressing files...",
|
||||
"completed": "Backup completed"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "Data Restore",
|
||||
"confirm": "Are you sure you want to restore data?",
|
||||
"confirm.button": "Select Backup File",
|
||||
"content": "Restore operation will overwrite all current application data with the backup data. Please note that the restore process may take some time, thank you for your patience.",
|
||||
"progress": {
|
||||
"title": "Restore Progress",
|
||||
"preparing": "Preparing restore...",
|
||||
"extracting": "Extracting backup...",
|
||||
"reading_data": "Reading data...",
|
||||
"copying_files": "Copying files... {{progress}}%",
|
||||
"completed": "Restore completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"settings.reasoning_effort.low": "短い",
|
||||
"settings.reasoning_effort.medium": "中程度",
|
||||
"settings.reasoning_effort.off": "オフ",
|
||||
"settings.reasoning_effort.tip": "この設定は推論モデルのみサポートしています",
|
||||
"settings.reasoning_effort.tip": "OpenAIのoシリーズとAnthropicの推論モデルのみサポートしています",
|
||||
"title": "アシスタント"
|
||||
},
|
||||
"auth": {
|
||||
@@ -68,7 +68,8 @@
|
||||
"collapse": "折りたたむ",
|
||||
"manage": "管理",
|
||||
"select_model": "モデルを選択",
|
||||
"show.all": "すべて表示"
|
||||
"show.all": "すべて表示",
|
||||
"update_available": "更新可能"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "アシスタントを追加",
|
||||
@@ -104,14 +105,17 @@
|
||||
"input.web_search.button.ok": "設定に移動",
|
||||
"input.web_search.enable": "ウェブ検索を有効にする",
|
||||
"input.web_search.enable_content": "ウェブ検索を有効にするには、設定でウェブ検索を有効にする必要があります",
|
||||
"input.auto_resize": "高さを自動調整",
|
||||
"message.new.branch": "新しいブランチ",
|
||||
"message.new.branch.created": "新しいブランチが作成されました",
|
||||
"message.new.context": "新しいコンテキスト",
|
||||
"message.regenerate.model": "モデルを切り替え",
|
||||
"message.useful": "役立つ",
|
||||
"message.quote": "引用",
|
||||
"resend": "再送信",
|
||||
"save": "保存",
|
||||
"settings.code_collapsible": "コードブロックを折りたたむ",
|
||||
"settings.code_collapsible": "コードブロック折り畳み",
|
||||
"settings.code_wrappable": "コードブロック折り返し",
|
||||
"settings.context_count": "コンテキスト",
|
||||
"settings.context_count.tip": "コンテキストに保持する以前のメッセージの数",
|
||||
"settings.max": "最大",
|
||||
@@ -384,6 +388,7 @@
|
||||
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
|
||||
"error.yuque.export": "語雀へのエクスポートに失敗しました。接続状態と設定を確認してください",
|
||||
"error.yuque.no_config": "語雀Token または 知識ベースID が設定されていません",
|
||||
"error.dimension_too_large": "内容のサイズが大きすぎます",
|
||||
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
|
||||
"group.delete.title": "分組メッセージを削除",
|
||||
"ignore.knowledge.base": "インターネットモードが有効になっています。ナレッジベースを無視します",
|
||||
@@ -393,10 +398,10 @@
|
||||
"message.delete.content": "このメッセージを削除してもよろしいですか?",
|
||||
"message.delete.title": "メッセージを削除",
|
||||
"message.multi_model_style": "複数モデル回答スタイル",
|
||||
"message.multi_model_style.fold": "折りたたむ",
|
||||
"message.multi_model_style.grid": "グリッド",
|
||||
"message.multi_model_style.horizontal": "水平",
|
||||
"message.multi_model_style.vertical": "垂直",
|
||||
"message.multi_model_style.fold": "タブ表示",
|
||||
"message.multi_model_style.grid": "カード表示",
|
||||
"message.multi_model_style.horizontal": "横並び",
|
||||
"message.multi_model_style.vertical": "縦積み",
|
||||
"message.style": "メッセージスタイル",
|
||||
"message.style.bubble": "バブル",
|
||||
"message.style.plain": "プレーン",
|
||||
@@ -405,6 +410,7 @@
|
||||
"reset.double.confirm.content": "すべてのデータが失われます。続行しますか?",
|
||||
"reset.double.confirm.title": "データが失われます!!!",
|
||||
"restore.success": "復元に成功しました",
|
||||
"restore.failed": "復元に失敗しました",
|
||||
"save.success.title": "保存に成功しました",
|
||||
"searching": "インターネットで検索中...",
|
||||
"success.notion.export": "Notionへのエクスポートに成功しました",
|
||||
@@ -472,7 +478,8 @@
|
||||
"vision": "画像"
|
||||
},
|
||||
"vision": "画像",
|
||||
"websearch": "ウェブ検索"
|
||||
"websearch": "ウェブ検索",
|
||||
"edit": "モデルを編集"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -558,7 +565,9 @@
|
||||
"together": "Together",
|
||||
"yi": "零一万物",
|
||||
"zhinao": "360智脳",
|
||||
"zhipu": "智譜AI"
|
||||
"zhipu": "智譜AI",
|
||||
"xirang": "天翼クラウド 息壤",
|
||||
"tencent-cloud-ti": "Tencent Cloud TI"
|
||||
},
|
||||
"settings": {
|
||||
"about": "について",
|
||||
@@ -590,6 +599,11 @@
|
||||
"data": {
|
||||
"app_data": "アプリデータ",
|
||||
"app_logs": "アプリログ",
|
||||
"app_knowledge": "ナレッジベースファイル",
|
||||
"app_knowledge.button.delete": "ファイルを削除",
|
||||
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
|
||||
"app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?",
|
||||
"app_knowledge.remove_all_success": "ファイル削除成功",
|
||||
"clear_cache": {
|
||||
"button": "キャッシュをクリア",
|
||||
"confirm": "キャッシュをクリアすると、アプリのキャッシュデータ(ミニアプリデータを含む)が削除されます。この操作は元に戻せません。続行しますか?",
|
||||
@@ -665,6 +679,7 @@
|
||||
},
|
||||
"display.custom.css": "カスタムCSS",
|
||||
"display.custom.css.placeholder": "/* ここにカスタムCSSを入力 */",
|
||||
"display.custom.css.cherrycss": "cherrycss.comから取得",
|
||||
"display.minApp.disabled": "非表示ミニプログラム",
|
||||
"display.minApp.empty": "非表示にしたいアプレットを左からここまでドラッグします",
|
||||
"display.minApp.title": "ミニプログラム表示設定",
|
||||
@@ -681,6 +696,7 @@
|
||||
"display.sidebar.visible": "アイコンを表示",
|
||||
"display.title": "表示設定",
|
||||
"display.topic.title": "トピック設定",
|
||||
"display.assistant.title": "アシスタント設定",
|
||||
"font_size.title": "メッセージのフォントサイズ",
|
||||
"general": "一般設定",
|
||||
"general.backup.button": "バックアップ",
|
||||
@@ -693,6 +709,9 @@
|
||||
"general.title": "一般設定",
|
||||
"general.user_name": "ユーザー名",
|
||||
"general.user_name.placeholder": "ユーザー名を入力",
|
||||
"general.image_upload": "画像アップロード",
|
||||
"general.emoji_picker": "絵文字ピッカー",
|
||||
"general.avatar.reset": "アバターをリセット",
|
||||
"general.view_webdav_settings": "WebDAV設定を表示",
|
||||
"input.auto_translate_with_space": "スペースを3回押して翻訳",
|
||||
"input.target_language": "目標言語",
|
||||
@@ -739,6 +758,10 @@
|
||||
"models.translate_model_description": "翻訳サービスに使用されるモデル",
|
||||
"models.translate_model_prompt_message": "翻訳モデルのプロンプトを入力してください",
|
||||
"models.translate_model_prompt_title": "翻訳モデルのプロンプト",
|
||||
"moresetting": "詳細設定",
|
||||
"moresetting.warn": "リスク警告",
|
||||
"moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
|
||||
"moresetting.check.confirm": "選択を確認",
|
||||
"provider": {
|
||||
"add.name": "プロバイダー名",
|
||||
"add.name.placeholder": "例:OpenAI",
|
||||
@@ -765,7 +788,8 @@
|
||||
"remove_duplicate_keys": "重複キーを削除",
|
||||
"remove_invalid_keys": "無効なキーを削除",
|
||||
"search_placeholder": "モデルIDまたは名前を検索",
|
||||
"title": "モデルプロバイダー"
|
||||
"title": "モデルプロバイダー",
|
||||
"search": "プロバイダーを検索..."
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -785,7 +809,6 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"alt_warning": "MacではOptionキーとの組み合わせは、スペースキーのみ使用可能です",
|
||||
"clear_shortcut": "ショートカットをクリア",
|
||||
"clear_topic": "メッセージを消去",
|
||||
"copy_last_message": "最後のメッセージをコピー",
|
||||
@@ -808,8 +831,8 @@
|
||||
"zoom_reset": "ズームをリセット"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.dark": "ダークテーマ",
|
||||
"theme.light": "ライトテーマ",
|
||||
"theme.dark": "ダーク",
|
||||
"theme.light": "ライト",
|
||||
"theme.title": "テーマ",
|
||||
"theme.window.style.opaque": "不透明ウィンドウ",
|
||||
"theme.window.style.title": "ウィンドウスタイル",
|
||||
@@ -819,6 +842,7 @@
|
||||
"topic.position.left": "左",
|
||||
"topic.position.right": "右",
|
||||
"topic.show.time": "トピックの時間を表示",
|
||||
"assistant.show.icon": "モデルアイコンを表示",
|
||||
"tray.title": "システムトレイアイコンを有効にする",
|
||||
"websearch": {
|
||||
"get_api_key": "APIキーを取得",
|
||||
@@ -870,6 +894,38 @@
|
||||
"quit": "終了",
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"code_block": {
|
||||
"enable_wrap": "改行",
|
||||
"disable_wrap": "改行解除"
|
||||
},
|
||||
"backup": {
|
||||
"title": "データバックアップ",
|
||||
"confirm": "データをバックアップしますか?",
|
||||
"confirm.button": "バックアップ位置を選択",
|
||||
"content": "バックアップ操作はすべてのアプリデータを含むため、時間がかかる場合があります。",
|
||||
"progress": {
|
||||
"title": "バックアップ進捗",
|
||||
"preparing": "バックアップ準備中...",
|
||||
"writing_data": "データ書き込み中...",
|
||||
"copying_files": "ファイルコピー中... {{progress}}%",
|
||||
"compressing": "圧縮中...",
|
||||
"completed": "バックアップ完了"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "データ復元",
|
||||
"confirm": "データを復元しますか?",
|
||||
"confirm.button": "バックアップファイルを選択",
|
||||
"content": "復元操作は現在のアプリデータをバックアップデータで上書きします。復元処理には時間がかかる場合があります。",
|
||||
"progress": {
|
||||
"title": "復元進捗",
|
||||
"preparing": "復元準備中...",
|
||||
"extracting": "バックアップ解凍中...",
|
||||
"reading_data": "データ読み込み中...",
|
||||
"copying_files": "ファイルコピー中... {{progress}}%",
|
||||
"completed": "復元完了"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"settings.reasoning_effort.low": "Короткая",
|
||||
"settings.reasoning_effort.medium": "Средняя",
|
||||
"settings.reasoning_effort.off": "Выключено",
|
||||
"settings.reasoning_effort.tip": "Эта настройка поддерживается только моделями с рассуждением",
|
||||
"settings.reasoning_effort.tip": "Поддерживается только моделями с рассуждением OpenAI o-series и Anthropic",
|
||||
"title": "Ассистенты"
|
||||
},
|
||||
"auth": {
|
||||
@@ -68,7 +68,8 @@
|
||||
"collapse": "Свернуть",
|
||||
"manage": "Редактировать",
|
||||
"select_model": "Выбрать модель",
|
||||
"show.all": "Показать все"
|
||||
"show.all": "Показать все",
|
||||
"update_available": "Доступно обновление"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "Добавить ассистента",
|
||||
@@ -104,14 +105,17 @@
|
||||
"input.web_search.button.ok": "Перейти в Настройки",
|
||||
"input.web_search.enable": "Включить веб-поиск",
|
||||
"input.web_search.enable_content": "Необходимо включить веб-поиск в Настройки",
|
||||
"input.auto_resize": "Автоматическая высота",
|
||||
"message.new.branch": "Новая ветка",
|
||||
"message.new.branch.created": "Новая ветка создана",
|
||||
"message.new.context": "Новый контекст",
|
||||
"message.regenerate.model": "Переключить модель",
|
||||
"message.useful": "Полезно",
|
||||
"message.quote": "Цитата",
|
||||
"resend": "Переотправить",
|
||||
"save": "Сохранить",
|
||||
"settings.code_collapsible": "Блок кода свернут",
|
||||
"settings.code_wrappable": "Блок кода можно переносить",
|
||||
"settings.context_count": "Контекст",
|
||||
"settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.",
|
||||
"settings.max": "Максимум",
|
||||
@@ -384,6 +388,7 @@
|
||||
"error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
|
||||
"error.yuque.export": "Ошибка экспорта в Yuque, пожалуйста, проверьте состояние подключения и настройки в документации",
|
||||
"error.yuque.no_config": "Yuque Token или Yuque Url не настроен",
|
||||
"error.dimension_too_large": "Размер содержимого слишком велик",
|
||||
"group.delete.content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
|
||||
"group.delete.title": "Удалить группу сообщений",
|
||||
"ignore.knowledge.base": "Режим сети включен, игнорировать базу знаний",
|
||||
@@ -393,10 +398,10 @@
|
||||
"message.delete.content": "Вы уверены, что хотите удалить это сообщение?",
|
||||
"message.delete.title": "Удалить сообщение",
|
||||
"message.multi_model_style": "Стиль ответов от нескольких моделей",
|
||||
"message.multi_model_style.fold": "Свернуть",
|
||||
"message.multi_model_style.grid": "клетчатый вид",
|
||||
"message.multi_model_style.horizontal": "Горизонтальный",
|
||||
"message.multi_model_style.vertical": "Вертикальный",
|
||||
"message.multi_model_style.fold": "Вкладки",
|
||||
"message.multi_model_style.grid": "Карточки",
|
||||
"message.multi_model_style.horizontal": "Горизонтальное расположение",
|
||||
"message.multi_model_style.vertical": "Вертикальное расположение",
|
||||
"message.style": "Стиль сообщения",
|
||||
"message.style.bubble": "Пузырь",
|
||||
"message.style.plain": "Простой",
|
||||
@@ -405,6 +410,7 @@
|
||||
"reset.double.confirm.content": "Все данные будут утеряны, хотите продолжить?",
|
||||
"reset.double.confirm.title": "ДАННЫЕ БУДУТ УТЕРЯНЫ !!!",
|
||||
"restore.success": "Успешно восстановлено",
|
||||
"restore.failed": "Восстановление не удалось",
|
||||
"save.success.title": "Успешно сохранено",
|
||||
"searching": "Поиск в Интернете...",
|
||||
"success.notion.export": "Успешный экспорт в Notion",
|
||||
@@ -472,7 +478,8 @@
|
||||
"vision": "Изображение"
|
||||
},
|
||||
"vision": "Визуальные",
|
||||
"websearch": "Веб-поисковые"
|
||||
"websearch": "Веб-поисковые",
|
||||
"edit": "Редактировать модель"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -558,7 +565,9 @@
|
||||
"together": "Together",
|
||||
"yi": "Yi",
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI"
|
||||
"zhipu": "ZHIPU AI",
|
||||
"xirang": "State Cloud Xirang",
|
||||
"tencent-cloud-ti": "Tencent Cloud TI"
|
||||
},
|
||||
"settings": {
|
||||
"about": "О программе и обратная связь",
|
||||
@@ -590,6 +599,11 @@
|
||||
"data": {
|
||||
"app_data": "Данные приложения",
|
||||
"app_logs": "Логи приложения",
|
||||
"app_knowledge": "База знаний",
|
||||
"app_knowledge.button.delete": "Удалить файл",
|
||||
"app_knowledge.remove_all": "Удалить файлы базы знаний",
|
||||
"app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?",
|
||||
"app_knowledge.remove_all_success": "Файлы удалены успешно",
|
||||
"clear_cache": {
|
||||
"button": "Очистка кэша",
|
||||
"confirm": "Очистка кэша удалит данные приложения. Это действие необратимо, продолжить?",
|
||||
@@ -665,6 +679,7 @@
|
||||
},
|
||||
"display.custom.css": "Пользовательский CSS",
|
||||
"display.custom.css.placeholder": "/* Здесь введите пользовательский CSS */",
|
||||
"display.custom.css.cherrycss": "Получить из cherrycss.com",
|
||||
"display.minApp.disabled": "скрытый апплет",
|
||||
"display.minApp.empty": "Перетащите апплет, который хотите скрыть, слева сюда",
|
||||
"display.minApp.title": "Настройки отображения мини программы",
|
||||
@@ -681,6 +696,7 @@
|
||||
"display.sidebar.visible": "Показывать иконки",
|
||||
"display.title": "Настройки отображения",
|
||||
"display.topic.title": "Настройки топиков",
|
||||
"display.assistant.title": "Настройки ассистентов",
|
||||
"font_size.title": "Размер шрифта сообщений",
|
||||
"general": "Общие настройки",
|
||||
"general.backup.button": "Резервное копирование",
|
||||
@@ -693,6 +709,9 @@
|
||||
"general.title": "Общие настройки",
|
||||
"general.user_name": "Имя пользователя",
|
||||
"general.user_name.placeholder": "Введите ваше имя",
|
||||
"general.image_upload": "Загрузка изображений",
|
||||
"general.emoji_picker": "Выбор эмодзи",
|
||||
"general.avatar.reset": "Сброс аватара",
|
||||
"general.view_webdav_settings": "Просмотр настроек WebDAV",
|
||||
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
|
||||
"input.target_language": "Целевой язык",
|
||||
@@ -739,6 +758,10 @@
|
||||
"models.translate_model_description": "Модель, используемая для сервиса перевода",
|
||||
"models.translate_model_prompt_message": "Введите модель перевода",
|
||||
"models.translate_model_prompt_title": "Модель перевода",
|
||||
"moresetting": "Дополнительные настройки",
|
||||
"moresetting.warn": "Предупреждение о риске",
|
||||
"moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
|
||||
"moresetting.check.confirm": "Подтвердить выбор",
|
||||
"provider": {
|
||||
"add.name": "Имя провайдера",
|
||||
"add.name.placeholder": "Пример: OpenAI",
|
||||
@@ -765,7 +788,8 @@
|
||||
"remove_duplicate_keys": "Удалить дубликаты ключей",
|
||||
"remove_invalid_keys": "Удалить недействительные ключи",
|
||||
"search_placeholder": "Поиск по ID или имени модели",
|
||||
"title": "Провайдеры моделей"
|
||||
"title": "Провайдеры моделей",
|
||||
"search": "Поиск поставщиков..."
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -785,7 +809,6 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "Действие",
|
||||
"alt_warning": "В Mac сочетания с клавишей Option работают только с пробелом",
|
||||
"clear_shortcut": "Очистить сочетание клавиш",
|
||||
"clear_topic": "Очистить все сообщения",
|
||||
"copy_last_message": "Копировать последнее сообщение",
|
||||
@@ -819,6 +842,7 @@
|
||||
"topic.position.left": "Слева",
|
||||
"topic.position.right": "Справа",
|
||||
"topic.show.time": "Показывать время топика",
|
||||
"assistant.show.icon": "Показывать модельный иконки",
|
||||
"tray.title": "Включить значок системного трея",
|
||||
"websearch": {
|
||||
"get_api_key": "Получить ключ API",
|
||||
@@ -870,6 +894,38 @@
|
||||
"quit": "Выйти",
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"code_block": {
|
||||
"enable_wrap": "Перенос строки",
|
||||
"disable_wrap": "Отменить перенос строки"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Резервное копирование данных",
|
||||
"confirm": "Вы уверены, что хотите создать резервную копию?",
|
||||
"confirm.button": "Выбрать папку для резервной копии",
|
||||
"content": "Резервная копия будет содержать все данные приложения, включая чаты, настройки и базу знаний. Это может занять некоторое время.",
|
||||
"progress": {
|
||||
"title": "Прогресс резервного копирования",
|
||||
"preparing": "Подготовка резервной копии...",
|
||||
"writing_data": "Запись данных...",
|
||||
"copying_files": "Копирование файлов... {{progress}}%",
|
||||
"compressing": "Сжатие файлов...",
|
||||
"completed": "Резервная копия создана"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "Восстановление данных",
|
||||
"confirm": "Вы уверены, что хотите восстановить данные?",
|
||||
"confirm.button": "Выбрать файл резервной копии",
|
||||
"content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.",
|
||||
"progress": {
|
||||
"title": "Прогресс восстановления",
|
||||
"preparing": "Подготовка к восстановлению...",
|
||||
"extracting": "Распаковка резервной копии...",
|
||||
"reading_data": "Чтение данных...",
|
||||
"copying_files": "Копирование файлов... {{progress}}%",
|
||||
"completed": "Восстановление завершено"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"settings.reasoning_effort.low": "短",
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "关",
|
||||
"settings.reasoning_effort.tip": "该设置仅支持推理模型",
|
||||
"settings.reasoning_effort.tip": "仅支持 OpenAI o-series 和 Anthropic 推理模型",
|
||||
"title": "助手"
|
||||
},
|
||||
"auth": {
|
||||
@@ -68,7 +68,8 @@
|
||||
"collapse": "收起",
|
||||
"manage": "管理",
|
||||
"select_model": "选择模型",
|
||||
"show.all": "显示全部"
|
||||
"show.all": "显示全部",
|
||||
"update_available": "有可用更新"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "添加助手",
|
||||
@@ -104,14 +105,17 @@
|
||||
"input.web_search.button.ok": "去设置",
|
||||
"input.web_search.enable": "开启网络搜索",
|
||||
"input.web_search.enable_content": "需要先在设置中开启网络搜索",
|
||||
"input.auto_resize": "自动调整高度",
|
||||
"message.new.branch": "分支",
|
||||
"message.new.branch.created": "新分支已创建",
|
||||
"message.new.context": "清除上下文",
|
||||
"message.regenerate.model": "切换模型",
|
||||
"message.useful": "有用",
|
||||
"message.quote": "引用",
|
||||
"resend": "重新发送",
|
||||
"save": "保存",
|
||||
"settings.code_collapsible": "代码块可折叠",
|
||||
"settings.code_wrappable": "代码块可换行",
|
||||
"settings.context_count": "上下文数",
|
||||
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
|
||||
"settings.max": "不限",
|
||||
@@ -384,6 +388,7 @@
|
||||
"error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID",
|
||||
"error.yuque.export": "导出语雀错误,请检查连接状态并对照文档检查配置",
|
||||
"error.yuque.no_config": "未配置语雀 Token 或 知识库 URL",
|
||||
"error.dimension_too_large": "内容尺寸过大",
|
||||
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
|
||||
"group.delete.title": "删除分组消息",
|
||||
"ignore.knowledge.base": "联网模式开启,忽略知识库",
|
||||
@@ -393,10 +398,10 @@
|
||||
"message.delete.content": "确定要删除此消息吗?",
|
||||
"message.delete.title": "删除消息",
|
||||
"message.multi_model_style": "多模型回答样式",
|
||||
"message.multi_model_style.fold": "折叠",
|
||||
"message.multi_model_style.grid": "网格",
|
||||
"message.multi_model_style.horizontal": "水平",
|
||||
"message.multi_model_style.vertical": "垂直",
|
||||
"message.multi_model_style.fold": "标签模式",
|
||||
"message.multi_model_style.grid": "卡片布局",
|
||||
"message.multi_model_style.horizontal": "横向排列",
|
||||
"message.multi_model_style.vertical": "纵向堆叠",
|
||||
"message.style": "消息样式",
|
||||
"message.style.bubble": "气泡",
|
||||
"message.style.plain": "简洁",
|
||||
@@ -405,6 +410,7 @@
|
||||
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
|
||||
"reset.double.confirm.title": "数据丢失!!!",
|
||||
"restore.success": "恢复成功",
|
||||
"restore.failed": "恢复失败",
|
||||
"save.success.title": "保存成功",
|
||||
"searching": "正在联网搜索...",
|
||||
"success.notion.export": "成功导出到Notion",
|
||||
@@ -472,7 +478,8 @@
|
||||
"vision": "图像"
|
||||
},
|
||||
"vision": "视觉",
|
||||
"websearch": "联网"
|
||||
"websearch": "联网",
|
||||
"edit": "编辑模型"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||
@@ -558,7 +565,9 @@
|
||||
"together": "Together",
|
||||
"yi": "零一万物",
|
||||
"zhinao": "360智脑",
|
||||
"zhipu": "智谱AI"
|
||||
"zhipu": "智谱AI",
|
||||
"xirang": "天翼云息壤",
|
||||
"tencent-cloud-ti": "腾讯云TI"
|
||||
},
|
||||
"settings": {
|
||||
"about": "关于我们",
|
||||
@@ -590,6 +599,11 @@
|
||||
"data": {
|
||||
"app_data": "应用数据",
|
||||
"app_logs": "应用日志",
|
||||
"app_knowledge": "知识库文件",
|
||||
"app_knowledge.button.delete": "删除文件",
|
||||
"app_knowledge.remove_all": "删除知识库文件",
|
||||
"app_knowledge.remove_all_confirm": "删除知识库文件可以减少存储空间占用,但不会删除知识库向量化数据,删除之后将无法打开源文件,是否删除?",
|
||||
"app_knowledge.remove_all_success": "文件删除成功",
|
||||
"clear_cache": {
|
||||
"button": "清除缓存",
|
||||
"confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?",
|
||||
@@ -665,6 +679,7 @@
|
||||
},
|
||||
"display.custom.css": "自定义 CSS",
|
||||
"display.custom.css.placeholder": "/* 这里写自定义CSS */",
|
||||
"display.custom.css.cherrycss": "从 cherrycss.com 获取",
|
||||
"display.minApp.disabled": "隐藏的小程序",
|
||||
"display.minApp.empty": "把要隐藏的小程序从左侧拖拽到这里",
|
||||
"display.minApp.title": "小程序显示设置",
|
||||
@@ -681,6 +696,7 @@
|
||||
"display.sidebar.visible": "显示的图标",
|
||||
"display.title": "显示设置",
|
||||
"display.topic.title": "话题设置",
|
||||
"display.assistant.title": "助手设置",
|
||||
"font_size.title": "消息字体大小",
|
||||
"general": "常规设置",
|
||||
"general.backup.button": "备份",
|
||||
@@ -693,6 +709,9 @@
|
||||
"general.title": "常规设置",
|
||||
"general.user_name": "用户名",
|
||||
"general.user_name.placeholder": "请输入用户名",
|
||||
"general.image_upload": "图片上传",
|
||||
"general.emoji_picker": "表情选择器",
|
||||
"general.avatar.reset": "重置头像",
|
||||
"general.view_webdav_settings": "查看 WebDAV 设置",
|
||||
"input.auto_translate_with_space": "快速敲击3次空格翻译",
|
||||
"input.target_language": "目标语言",
|
||||
@@ -739,6 +758,10 @@
|
||||
"models.translate_model_description": "翻译服务使用的模型",
|
||||
"models.translate_model_prompt_message": "请输入翻译模型提示词",
|
||||
"models.translate_model_prompt_title": "翻译模型提示词",
|
||||
"moresetting": "更多设置",
|
||||
"moresetting.warn": "风险警告",
|
||||
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
|
||||
"moresetting.check.confirm": "确认勾选",
|
||||
"provider": {
|
||||
"add.name": "提供商名称",
|
||||
"add.name.placeholder": "例如 OpenAI",
|
||||
@@ -765,7 +788,8 @@
|
||||
"remove_duplicate_keys": "移除重复密钥",
|
||||
"remove_invalid_keys": "删除无效密钥",
|
||||
"search_placeholder": "搜索模型 ID 或名称",
|
||||
"title": "模型服务"
|
||||
"title": "模型服务",
|
||||
"search": "搜索模型平台..."
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -785,7 +809,6 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"alt_warning": "Mac 系统中 Option 键只能与空格键组合使用",
|
||||
"clear_shortcut": "清除快捷键",
|
||||
"clear_topic": "清空消息",
|
||||
"copy_last_message": "复制上一条消息",
|
||||
@@ -807,9 +830,9 @@
|
||||
"zoom_out": "缩小界面",
|
||||
"zoom_reset": "重置缩放"
|
||||
},
|
||||
"theme.auto": "跟随系统",
|
||||
"theme.dark": "深色主题",
|
||||
"theme.light": "浅色主题",
|
||||
"theme.auto": "自动",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "浅色",
|
||||
"theme.title": "主题",
|
||||
"theme.window.style.opaque": "不透明窗口",
|
||||
"theme.window.style.title": "窗口样式",
|
||||
@@ -819,6 +842,7 @@
|
||||
"topic.position.left": "左侧",
|
||||
"topic.position.right": "右侧",
|
||||
"topic.show.time": "显示话题时间",
|
||||
"assistant.show.icon": "显示模型图标",
|
||||
"tray.title": "启用系统托盘图标",
|
||||
"websearch": {
|
||||
"blacklist": "黑名单",
|
||||
@@ -870,6 +894,38 @@
|
||||
"quit": "退出",
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"code_block": {
|
||||
"enable_wrap": "换行",
|
||||
"disable_wrap": "取消换行"
|
||||
},
|
||||
"backup": {
|
||||
"title": "数据备份",
|
||||
"confirm": "确定要备份数据吗?",
|
||||
"confirm.button": "选择备份位置",
|
||||
"content": "备份全部数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"progress": {
|
||||
"title": "备份进度",
|
||||
"preparing": "准备备份...",
|
||||
"writing_data": "写入数据...",
|
||||
"copying_files": "复制文件... {{progress}}%",
|
||||
"compressing": "压缩文件...",
|
||||
"completed": "备份完成"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "数据恢复",
|
||||
"confirm": "确定要恢复数据吗?",
|
||||
"confirm.button": "选择备份文件",
|
||||
"content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"progress": {
|
||||
"title": "恢复进度",
|
||||
"preparing": "准备恢复...",
|
||||
"extracting": "解压备份...",
|
||||
"reading_data": "读取数据...",
|
||||
"copying_files": "复制文件... {{progress}}%",
|
||||
"completed": "恢复完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"settings.reasoning_effort.low": "短",
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "關",
|
||||
"settings.reasoning_effort.tip": "該設置僅支持推理模型",
|
||||
"settings.reasoning_effort.tip": "僅支持OpenAI o系列和Anthropic推理模型",
|
||||
"title": "助手"
|
||||
},
|
||||
"auth": {
|
||||
@@ -68,7 +68,8 @@
|
||||
"collapse": "收起",
|
||||
"manage": "管理",
|
||||
"select_model": "選擇模型",
|
||||
"show.all": "顯示全部"
|
||||
"show.all": "顯示全部",
|
||||
"update_available": "有可用更新"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "添加助手",
|
||||
@@ -104,14 +105,17 @@
|
||||
"input.web_search.button.ok": "去設定",
|
||||
"input.web_search.enable": "開啟網路搜索",
|
||||
"input.web_search.enable_content": "需要先在設定中開啟網路搜索",
|
||||
"input.auto_resize": "自動調整高度",
|
||||
"message.new.branch": "分支",
|
||||
"message.new.branch.created": "新分支已建立",
|
||||
"message.new.context": "新上下文",
|
||||
"message.regenerate.model": "切換模型",
|
||||
"message.useful": "有用",
|
||||
"message.quote": "引用",
|
||||
"resend": "重新發送",
|
||||
"save": "保存",
|
||||
"settings.code_collapsible": "代码块可折叠",
|
||||
"settings.code_collapsible": "代碼區塊可折疊",
|
||||
"settings.code_wrappable": "代碼區塊可換行",
|
||||
"settings.context_count": "上下文",
|
||||
"settings.context_count.tip": "在上下文中保留的前幾則訊息。",
|
||||
"settings.max": "最大",
|
||||
@@ -384,6 +388,7 @@
|
||||
"error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID",
|
||||
"error.yuque.export": "導出語雀錯誤,請檢查連接狀態並對照文檔檢查配置",
|
||||
"error.yuque.no_config": "未配置語雀 Token 或知識庫 Url",
|
||||
"error.dimension_too_large": "內容尺寸過大",
|
||||
"group.delete.content": "刪除分組消息會刪除用戶提問和所有助手的回答",
|
||||
"group.delete.title": "刪除分組消息",
|
||||
"ignore.knowledge.base": "網路模式開啟,忽略知識庫",
|
||||
@@ -393,10 +398,10 @@
|
||||
"message.delete.content": "確定要刪除此訊息嗎?",
|
||||
"message.delete.title": "刪除訊息",
|
||||
"message.multi_model_style": "多模型回答樣式",
|
||||
"message.multi_model_style.fold": "折疊",
|
||||
"message.multi_model_style.grid": "网格",
|
||||
"message.multi_model_style.horizontal": "水平",
|
||||
"message.multi_model_style.vertical": "垂直",
|
||||
"message.multi_model_style.fold": "標籤模式",
|
||||
"message.multi_model_style.grid": "卡片佈局",
|
||||
"message.multi_model_style.horizontal": "橫向排列",
|
||||
"message.multi_model_style.vertical": "縱向堆疊",
|
||||
"message.style": "消息樣式",
|
||||
"message.style.bubble": "氣泡",
|
||||
"message.style.plain": "簡潔",
|
||||
@@ -405,6 +410,7 @@
|
||||
"reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?",
|
||||
"reset.double.confirm.title": "資料將會丟失!!!",
|
||||
"restore.success": "恢復成功",
|
||||
"restore.failed": "恢復失敗",
|
||||
"save.success.title": "保存成功",
|
||||
"searching": "正在網路搜索...",
|
||||
"success.notion.export": "成功導出到 Notion",
|
||||
@@ -472,7 +478,8 @@
|
||||
"vision": "圖像"
|
||||
},
|
||||
"vision": "視覺",
|
||||
"websearch": "網路搜索"
|
||||
"websearch": "網路搜索",
|
||||
"edit": "編輯模型"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。",
|
||||
@@ -524,7 +531,7 @@
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
"baichuan": "百川",
|
||||
"baidu-cloud": "百度云千帆",
|
||||
"baidu-cloud": "百度雲千帆",
|
||||
"dashscope": "阿里雲百鍊",
|
||||
"deepseek": "深度求索",
|
||||
"dmxapi": "DMXAPI",
|
||||
@@ -558,7 +565,9 @@
|
||||
"together": "Together",
|
||||
"yi": "零一萬物",
|
||||
"zhinao": "360智腦",
|
||||
"zhipu": "智譜AI"
|
||||
"zhipu": "智譜AI",
|
||||
"xirang": "天翼雲息壤",
|
||||
"tencent-cloud-ti": "騰訊雲TI"
|
||||
},
|
||||
"settings": {
|
||||
"about": "關於與回饋",
|
||||
@@ -590,6 +599,11 @@
|
||||
"data": {
|
||||
"app_data": "應用數據",
|
||||
"app_logs": "應用日誌",
|
||||
"app_knowledge": "知識庫文件",
|
||||
"app_knowledge.button.delete": "刪除文件",
|
||||
"app_knowledge.remove_all": "刪除知識庫文件",
|
||||
"app_knowledge.remove_all_confirm": "刪除知識庫文件可以减少存储空间占用,但不会删除知识库向量化数据,删除之后将无法打开源文件,是否删除?",
|
||||
"app_knowledge.remove_all_success": "文件刪除成功",
|
||||
"clear_cache": {
|
||||
"button": "清除緩存",
|
||||
"confirm": "清除緩存將刪除應用緩存數據,包括小程序數據。此操作不可恢復,是否繼續?",
|
||||
@@ -665,6 +679,7 @@
|
||||
},
|
||||
"display.custom.css": "自定義 CSS",
|
||||
"display.custom.css.placeholder": "/* 這裡寫自定義 CSS */",
|
||||
"display.custom.css.cherrycss": "從 cherrycss.com 獲取",
|
||||
"display.minApp.disabled": "隱藏的小程序",
|
||||
"display.minApp.empty": "把要隱藏的小程序從左側拖拽到這裡",
|
||||
"display.minApp.title": "小程序顯示設定",
|
||||
@@ -693,6 +708,9 @@
|
||||
"general.title": "一般設定",
|
||||
"general.user_name": "使用者名稱",
|
||||
"general.user_name.placeholder": "輸入您的名稱",
|
||||
"general.image_upload": "圖片上傳",
|
||||
"general.emoji_picker": "表情選擇器",
|
||||
"general.avatar.reset": "重置頭像",
|
||||
"general.view_webdav_settings": "查看 WebDAV 設定",
|
||||
"input.auto_translate_with_space": "快速敲擊3次空格翻譯",
|
||||
"input.target_language": "目標語言",
|
||||
@@ -739,6 +757,10 @@
|
||||
"models.translate_model_description": "翻譯服務使用的模型",
|
||||
"models.translate_model_prompt_message": "請輸入翻譯模型提示詞",
|
||||
"models.translate_model_prompt_title": "翻譯模型提示詞",
|
||||
"moresetting": "更多設置",
|
||||
"moresetting.warn": "風險警告",
|
||||
"moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
|
||||
"moresetting.check.confirm": "確認勾選",
|
||||
"provider": {
|
||||
"add.name": "提供者名稱",
|
||||
"add.name.placeholder": "例如:OpenAI",
|
||||
@@ -765,7 +787,8 @@
|
||||
"remove_duplicate_keys": "移除重複密鑰",
|
||||
"remove_invalid_keys": "刪除無效密鑰",
|
||||
"search_placeholder": "搜尋模型 ID 或名稱",
|
||||
"title": "模型提供者"
|
||||
"title": "模型提供者",
|
||||
"search": "搜尋模型平台..."
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -785,7 +808,6 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"alt_warning": "Mac 系統中 Option 鍵只能與空白鍵組合使用",
|
||||
"clear_shortcut": "清除快捷鍵",
|
||||
"clear_topic": "清除所有訊息",
|
||||
"copy_last_message": "複製上一条消息",
|
||||
@@ -808,8 +830,8 @@
|
||||
"zoom_reset": "重置縮放"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.dark": "深色主題",
|
||||
"theme.light": "淺色主題",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "淺色",
|
||||
"theme.title": "主題",
|
||||
"theme.window.style.opaque": "不透明視窗",
|
||||
"theme.window.style.title": "視窗樣式",
|
||||
@@ -819,6 +841,7 @@
|
||||
"topic.position.left": "左側",
|
||||
"topic.position.right": "右側",
|
||||
"topic.show.time": "顯示話題時間",
|
||||
"assistant.show.icon": "顯示模型圖標",
|
||||
"tray.title": "啟用系統托盤圖標",
|
||||
"websearch": {
|
||||
"get_api_key": "點擊這裡獲取密鑰",
|
||||
@@ -835,7 +858,8 @@
|
||||
"blacklist_tooltip": "請使用以下格式(換行分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||
"search_max_result": "搜索結果個數",
|
||||
"search_result_default": "預設"
|
||||
}
|
||||
},
|
||||
"display.assistant.title": "助手設定"
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意語言",
|
||||
@@ -870,6 +894,38 @@
|
||||
"quit": "退出",
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"code_block": {
|
||||
"enable_wrap": "換行",
|
||||
"disable_wrap": "取消換行"
|
||||
},
|
||||
"backup": {
|
||||
"title": "資料備份",
|
||||
"confirm": "確定要備份資料嗎?",
|
||||
"confirm.button": "選擇備份位置",
|
||||
"content": "備份全部資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待。",
|
||||
"progress": {
|
||||
"title": "備份進度",
|
||||
"preparing": "準備備份...",
|
||||
"writing_data": "寫入資料...",
|
||||
"copying_files": "複製文件... {{progress}}%",
|
||||
"compressing": "壓縮文件...",
|
||||
"completed": "備份完成"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "資料復原",
|
||||
"confirm": "確定要復原資料嗎?",
|
||||
"confirm.button": "選擇備份檔案",
|
||||
"content": "復原操作將使用備份資料覆蓋當前所有應用資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待。",
|
||||
"progress": {
|
||||
"title": "復原進度",
|
||||
"preparing": "準備復原...",
|
||||
"extracting": "解壓備份...",
|
||||
"reading_data": "讀取資料...",
|
||||
"copying_files": "複製文件... {{progress}}%",
|
||||
"completed": "復原完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const ContentView: React.FC<ContentViewProps> = ({ id, files, dataSource, column
|
||||
}}
|
||||
/>
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file)}</div>
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
</ImageInfo>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
|
||||
@@ -111,7 +111,7 @@ const FilesPage: FC = () => {
|
||||
{file.origin_name}
|
||||
</FileNameText>
|
||||
),
|
||||
size: formatFileSize(file),
|
||||
size: formatFileSize(file.size),
|
||||
size_bytes: file.size,
|
||||
count: file.count,
|
||||
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { Upload } from 'antd'
|
||||
import { Upload as AntdUpload, UploadFile } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -19,12 +19,15 @@ const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
|
||||
<ContentContainer>
|
||||
<Upload
|
||||
listType={files.length > 20 ? 'text' : 'picture-card'}
|
||||
fileList={files.map((file) => ({
|
||||
uid: file.id,
|
||||
url: 'file://' + FileManager.getSafePath(file),
|
||||
status: 'done',
|
||||
name: file.name
|
||||
}))}
|
||||
fileList={files.map(
|
||||
(file) =>
|
||||
({
|
||||
uid: file.id,
|
||||
url: 'file://' + FileManager.getSafePath(file),
|
||||
status: 'done',
|
||||
name: file.name
|
||||
}) as UploadFile
|
||||
)}
|
||||
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
|
||||
/>
|
||||
</ContentContainer>
|
||||
@@ -38,4 +41,10 @@ const ContentContainer = styled.div`
|
||||
padding: 10px 15px 0;
|
||||
`
|
||||
|
||||
const Upload = styled(AntdUpload)`
|
||||
.ant-upload-list-item {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
`
|
||||
|
||||
export default AttachmentPreview
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ClearOutlined,
|
||||
ColumnHeightOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
GlobalOutlined,
|
||||
HolderOutlined,
|
||||
PauseCircleOutlined,
|
||||
PicCenterOutlined,
|
||||
QuestionCircleOutlined
|
||||
@@ -87,6 +89,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
||||
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
const currentMessageId = useRef<string>()
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
@@ -94,11 +100,24 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
|
||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||
|
||||
const estimateTextTokens = useCallback(debounce(estimateTxtTokens, 1000), [])
|
||||
const inputTokenCount = useMemo(
|
||||
() => (showInputEstimatedTokens ? estimateTextTokens(text) || 0 : 0),
|
||||
[estimateTextTokens, showInputEstimatedTokens, text]
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
|
||||
const debouncedEstimate = useCallback(
|
||||
debounce((newText) => {
|
||||
if (showInputEstimatedTokens) {
|
||||
const count = estimateTxtTokens(newText) || 0
|
||||
setTokenCount(count)
|
||||
}
|
||||
}, 500),
|
||||
[showInputEstimatedTokens]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedEstimate(text)
|
||||
}, [text, debouncedEstimate])
|
||||
|
||||
const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const newContextShortcut = useShortcutDisplay('toggle_new_context')
|
||||
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
@@ -292,6 +311,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
const resizeTextArea = () => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
// 如果已经手动设置了高度,则不自动调整
|
||||
if (textareaHeight) {
|
||||
return
|
||||
}
|
||||
textArea.style.height = 'auto'
|
||||
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
|
||||
}
|
||||
@@ -306,7 +329,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
if (isExpended) {
|
||||
textArea.style.height = '70vh'
|
||||
} else {
|
||||
resizeTextArea()
|
||||
resetHeight()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +366,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (file.path === '') {
|
||||
if (file.type.startsWith('image/')) {
|
||||
if (file.type.startsWith('image/') && isVisionModel(model)) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
@@ -415,6 +438,50 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
startDragY.current = e.clientY
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
startHeight.current = textArea.offsetHeight
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrag = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const delta = startDragY.current - e.clientY // 改变计算方向
|
||||
const viewportHeight = window.innerHeight
|
||||
const maxHeightInPixels = viewportHeight * 0.7
|
||||
|
||||
const newHeight = Math.min(maxHeightInPixels, Math.max(startHeight.current + delta, 30))
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.style.height = `${newHeight}px`
|
||||
setExpend(newHeight == maxHeightInPixels)
|
||||
setTextareaHeight(newHeight)
|
||||
}
|
||||
},
|
||||
[isDragging]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleDrag)
|
||||
document.addEventListener('mouseup', handleDragEnd)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleDrag)
|
||||
document.removeEventListener('mouseup', handleDragEnd)
|
||||
}
|
||||
}, [isDragging, handleDrag, handleDragEnd])
|
||||
|
||||
useShortcut('new_topic', () => {
|
||||
if (!generating) {
|
||||
addNewTopic()
|
||||
@@ -443,7 +510,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
_setEstimateTokenCount(tokensCount)
|
||||
setContextCount(contextCount)
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic)
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic),
|
||||
EventEmitter.on(EVENT_NAMES.QUOTE_TEXT, (quotedText: string) => {
|
||||
setText((prevText) => {
|
||||
const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n`
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
return newText
|
||||
})
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
]
|
||||
return () => unsubscribes.forEach((unsub) => unsub())
|
||||
}, [addNewTopic])
|
||||
@@ -531,6 +606,21 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
}
|
||||
}, [assistant, model, updateAssistant])
|
||||
|
||||
const resetHeight = () => {
|
||||
if (expended) {
|
||||
setExpend(false)
|
||||
}
|
||||
setTextareaHeight(undefined)
|
||||
requestAnimationFrame(() => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.style.height = 'auto'
|
||||
const contentHeight = textArea.scrollHeight
|
||||
textArea.style.height = contentHeight > 400 ? '400px' : `${contentHeight}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
@@ -551,7 +641,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
spellCheck={false}
|
||||
rows={textareaRows}
|
||||
ref={textareaRef}
|
||||
style={{ fontSize }}
|
||||
style={{
|
||||
fontSize,
|
||||
height: textareaHeight ? `${textareaHeight}px` : undefined
|
||||
}}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
setInputFocus(true)
|
||||
@@ -567,6 +660,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
onClick={() => searching && dispatch(setSearching(false))}
|
||||
/>
|
||||
<DragHandle onMouseDown={handleDragStart}>
|
||||
<HolderOutlined />
|
||||
</DragHandle>
|
||||
<Toolbar>
|
||||
<ToolbarMenu>
|
||||
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
|
||||
@@ -595,7 +691,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
|
||||
okText={t('chat.input.clear.title')}>
|
||||
<ToolbarButton type="text">
|
||||
<ClearOutlined />
|
||||
<ClearOutlined style={{ fontSize: 17 }} />
|
||||
</ToolbarButton>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
@@ -618,6 +714,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{textareaHeight && (
|
||||
<Tooltip placement="top" title={t('chat.input.auto_resize')} arrow>
|
||||
<ToolbarButton type="text" onClick={resetHeight}>
|
||||
<ColumnHeightOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TokenCount
|
||||
estimateTokenCount={estimateTokenCount}
|
||||
inputTokenCount={inputTokenCount}
|
||||
@@ -644,22 +747,51 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Add these styled components at the bottom
|
||||
const DragHandle = styled.div`
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: row-resize;
|
||||
color: var(--color-icon);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
transform: rotate(90deg);
|
||||
font-size: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 1px solid var(--color-border);
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
margin: 0 20px 15px 20px;
|
||||
border-radius: 10px;
|
||||
margin: 14px 20px;
|
||||
margin-top: 12px;
|
||||
border-radius: 15px;
|
||||
padding-top: 6px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '10px 15px 8px'
|
||||
padding: '4px 15px 8px' // 减小顶部padding
|
||||
}
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
@@ -697,7 +829,7 @@ const ToolbarMenu = styled.div`
|
||||
const ToolbarButton = styled(Button)`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
@@ -712,7 +844,7 @@ const ToolbarButton = styled(Button)`
|
||||
color: var(--color-icon);
|
||||
}
|
||||
.icon-a-addchat {
|
||||
font-size: 19px;
|
||||
font-size: 18px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
&:hover {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { CheckOutlined, DownloadOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import UnWrapIcon from '@renderer/components/Icons/UnWrapIcon'
|
||||
import WrapIcon from '@renderer/components/Icons/WrapIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -20,12 +23,13 @@ interface CodeBlockProps {
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible } = useSettings()
|
||||
const match = /language-(\w+)/.exec(className || '') || children?.includes('\n')
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const language = match?.[1] ?? 'text'
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const { codeToHtml } = useSyntaxHighlighter()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
|
||||
const codeContentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -59,6 +63,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
}
|
||||
}, [codeCollapsible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeWrappable) {
|
||||
// 如果未启动代码块换行功能
|
||||
setIsUnwrapped(true)
|
||||
} else {
|
||||
setIsUnwrapped(!codeWrappable) // 被换行
|
||||
}
|
||||
}, [codeWrappable])
|
||||
|
||||
if (language === 'mermaid') {
|
||||
return <Mermaid chart={children} />
|
||||
}
|
||||
@@ -86,16 +99,19 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
{codeCollapsible && shouldShowExpandButton && (
|
||||
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
|
||||
)}
|
||||
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
|
||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||
</div>
|
||||
<HStack gap={12} alignItems="center">
|
||||
{showDownloadButton && <DownloadButton language={language} data={children} />}
|
||||
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
||||
<CopyButton text={children} />
|
||||
</HStack>
|
||||
</CodeHeader>
|
||||
<CodeContent
|
||||
ref={codeContentRef}
|
||||
isShowLineNumbers={codeShowLineNumbers}
|
||||
isUnwrapped={isUnwrapped}
|
||||
isCodeWrappable={codeWrappable}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{
|
||||
border: '0.5px solid var(--color-code-background)',
|
||||
@@ -149,6 +165,22 @@ const ExpandButton: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const UnwrapButton: React.FC<{ unwrapped: boolean; onClick: () => void }> = ({ unwrapped, onClick }) => {
|
||||
const { t } = useTranslation()
|
||||
const unwrapLabel = unwrapped ? t('code_block.enable_wrap') : t('code_block.disable_wrap')
|
||||
return (
|
||||
<Tooltip title={unwrapLabel}>
|
||||
<UnwrapButtonWrapper onClick={onClick} title={unwrapLabel}>
|
||||
{unwrapped ? (
|
||||
<UnWrapIcon style={{ width: '100%', height: '100%' }} />
|
||||
) : (
|
||||
<WrapIcon style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</UnwrapButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
@@ -183,9 +215,19 @@ const DownloadButton = ({ language, data }: { language: string; data: string })
|
||||
|
||||
const CodeBlockWrapper = styled.div``
|
||||
|
||||
const CodeContent = styled.div<{ isShowLineNumbers: boolean }>`
|
||||
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
|
||||
code {
|
||||
display: table;
|
||||
width: 100%;
|
||||
|
||||
.line {
|
||||
display: table-row;
|
||||
height: 1.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
@@ -200,14 +242,23 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean }>`
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
width: 1rem;
|
||||
margin-right: 1rem;
|
||||
display: inline-block;
|
||||
padding-right: 1rem;
|
||||
display: table-cell;
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
${(props) =>
|
||||
props.isCodeWrappable &&
|
||||
!props.isUnwrapped &&
|
||||
`
|
||||
code .line * {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`}
|
||||
`
|
||||
const CodeHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -290,6 +341,23 @@ const CollapseIconWrapper = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const UnwrapButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
const DownloadWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'katex/dist/contrib/copy-tex'
|
||||
|
||||
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Message } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types'
|
||||
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown, { Components } from 'react-markdown'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
// @ts-ignore next-line
|
||||
import rehypeMathjax from 'rehype-mathjax'
|
||||
@@ -14,6 +16,8 @@ import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import 'katex/dist/contrib/mhchem'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import Link from './Link'
|
||||
@@ -49,11 +53,12 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className="markdown"
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
className="markdown"
|
||||
components={
|
||||
{
|
||||
style: MarkdownShadowDOMRenderer,
|
||||
a: Link,
|
||||
code: CodeBlock,
|
||||
img: ImagePreview
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import { Message, Topic } from '@renderer/types'
|
||||
import { classNames, runAsyncFunction } from '@renderer/utils'
|
||||
import { Divider } from 'antd'
|
||||
import { Divider, Dropdown } from 'antd'
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -35,14 +35,6 @@ interface Props {
|
||||
onDeleteMessage?: (message: Message) => Promise<void>
|
||||
}
|
||||
|
||||
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => {
|
||||
return isBubbleStyle
|
||||
? isAssistantMessage
|
||||
? 'var(--chat-background-assistant)'
|
||||
: 'var(--chat-background-user)'
|
||||
: undefined
|
||||
}
|
||||
|
||||
const MessageItem: FC<Props> = ({
|
||||
message: _message,
|
||||
topic: _topic,
|
||||
@@ -62,6 +54,9 @@ const MessageItem: FC<Props> = ({
|
||||
const { showMessageDivider, messageFont, fontSize } = useSettings()
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const topic = useTopic(assistant, _topic?.id)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
@@ -75,10 +70,37 @@ const MessageItem: FC<Props> = ({
|
||||
const messageBorder = showMessageDivider ? undefined : 'none'
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onEditMessage = useCallback(
|
||||
async (msg: Message) => {
|
||||
const usage = await estimateMessageUsage(msg)
|
||||
msg.usage = usage
|
||||
if (msg.role === 'user') {
|
||||
const usage = await estimateMessageUsage(msg)
|
||||
msg.usage = usage
|
||||
}
|
||||
|
||||
setMessage(msg)
|
||||
const messages = onGetMessages?.()?.map((m) => (m.id === message.id ? msg : m))
|
||||
@@ -185,7 +207,18 @@ const MessageItem: FC<Props> = ({
|
||||
'message-user': !isAssistantMessage
|
||||
})}
|
||||
ref={messageContainerRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}>
|
||||
{contextMenuPosition && (
|
||||
<ContextMenuOverlay style={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}>
|
||||
<Dropdown
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
</ContextMenuOverlay>
|
||||
)}
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
@@ -221,6 +254,32 @@ const MessageItem: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => {
|
||||
return isBubbleStyle
|
||||
? isAssistantMessage
|
||||
? 'var(--chat-background-assistant)'
|
||||
: 'var(--chat-background-user)'
|
||||
: undefined
|
||||
}
|
||||
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(selectedText)
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const MessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -270,4 +329,8 @@ const NewContextMessage = styled.div`
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const ContextMenuOverlay = styled.div`
|
||||
position: fixed;
|
||||
`
|
||||
|
||||
export default memo(MessageItem)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InfoCircleOutlined, SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
@@ -135,7 +136,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
{message.metadata.tavily.results.map((result, index) => (
|
||||
<HStack key={result.url} style={{ alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-2)' }}>{index + 1}.</span>
|
||||
<Favicon src={`https://favicon.splitbee.io/?url=${new URL(result.url).hostname}`} alt={result.title} />
|
||||
<Favicon hostname={new URL(result.url).hostname} alt={result.title} />
|
||||
<CitationLink href={result.url} target="_blank" rel="noopener noreferrer">
|
||||
{result.title}
|
||||
</CitationLink>
|
||||
@@ -214,11 +215,4 @@ const SearchingText = styled.div`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const Favicon = styled.img`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background-mute);
|
||||
`
|
||||
|
||||
export default React.memo(MessageContent)
|
||||
|
||||
@@ -11,8 +11,9 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { Button, Segmented as AntdSegmented } from 'antd'
|
||||
import { Button, Segmented as AntdSegmented, Tooltip } from 'antd'
|
||||
import { FC, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageGroupSettings from './MessageGroupSettings'
|
||||
@@ -34,25 +35,29 @@ const MessageGroupMenuBar: FC<Props> = ({
|
||||
setSelectedIndex,
|
||||
onDelete
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<GroupMenuBar $layout={multiModelMessageStyle} className="group-menu-bar">
|
||||
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
|
||||
<LayoutContainer>
|
||||
{['fold', 'vertical', 'horizontal', 'grid'].map((layout) => (
|
||||
<LayoutOption
|
||||
<Tooltip
|
||||
key={layout}
|
||||
$active={multiModelMessageStyle === layout}
|
||||
onClick={() => setMultiModelMessageStyle(layout as MultiModelMessageStyle)}>
|
||||
{layout === 'fold' ? (
|
||||
<FolderOutlined />
|
||||
) : layout === 'horizontal' ? (
|
||||
<ColumnWidthOutlined />
|
||||
) : layout === 'vertical' ? (
|
||||
<ColumnHeightOutlined />
|
||||
) : (
|
||||
<NumberOutlined />
|
||||
)}
|
||||
</LayoutOption>
|
||||
title={t(`message.message.multi_model_style`) + ': ' + t(`message.message.multi_model_style.${layout}`)}>
|
||||
<LayoutOption
|
||||
$active={multiModelMessageStyle === layout}
|
||||
onClick={() => setMultiModelMessageStyle(layout as MultiModelMessageStyle)}>
|
||||
{layout === 'fold' ? (
|
||||
<FolderOutlined />
|
||||
) : layout === 'horizontal' ? (
|
||||
<ColumnWidthOutlined />
|
||||
) : layout === 'vertical' ? (
|
||||
<ColumnHeightOutlined />
|
||||
) : (
|
||||
<NumberOutlined />
|
||||
)}
|
||||
</LayoutOption>
|
||||
</Tooltip>
|
||||
))}
|
||||
</LayoutContainer>
|
||||
{multiModelMessageStyle === 'fold' && (
|
||||
@@ -131,9 +136,14 @@ const ModelsContainer = styled(Scrollbar)`
|
||||
`
|
||||
|
||||
const Segmented = styled(AntdSegmented)`
|
||||
&.ant-segmented {
|
||||
background: transparent !important;
|
||||
}
|
||||
.ant-segmented-item {
|
||||
background-color: transparent !important;
|
||||
transition: none !important;
|
||||
border-radius: var(--list-item-border-radius) !important;
|
||||
box-shadow: none !important;
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -143,6 +153,8 @@ const Segmented = styled(AntdSegmented)`
|
||||
background-color: transparent !important;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: none !important;
|
||||
border-radius: var(--list-item-border-radius) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelName } from '@renderer/services/ModelService'
|
||||
import { Assistant, Message, Model } from '@renderer/types'
|
||||
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { firstLetter, isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { Avatar } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { CSSProperties, FC, memo, useCallback, useMemo } from 'react'
|
||||
@@ -81,12 +81,18 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
||||
{avatarName}
|
||||
</Avatar>
|
||||
) : (
|
||||
<Avatar
|
||||
src={avatar}
|
||||
size={35}
|
||||
style={{ borderRadius: '20%', cursor: 'pointer' }}
|
||||
onClick={() => UserPopup.show()}
|
||||
/>
|
||||
<>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar onClick={() => UserPopup.show()}>{avatar}</EmojiAvatar>
|
||||
) : (
|
||||
<Avatar
|
||||
src={avatar}
|
||||
size={35}
|
||||
style={{ borderRadius: '20%', cursor: 'pointer' }}
|
||||
onClick={() => UserPopup.show()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<UserWrap>
|
||||
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
|
||||
@@ -101,6 +107,20 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
||||
|
||||
MessageHeader.displayName = 'MessageHeader'
|
||||
|
||||
const EmojiAvatar = styled.div`
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-radius: 20%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid var(--color-border);
|
||||
font-size: 20px;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
} from '@renderer/utils'
|
||||
import {
|
||||
exportMarkdownToNotion,
|
||||
exportMarkdownToYuque,
|
||||
exportMessageAsMarkdown,
|
||||
messageToMarkdown,
|
||||
exportMarkdownToYuque
|
||||
messageToMarkdown
|
||||
} from '@renderer/utils/export'
|
||||
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -282,7 +282,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||
e?.stopPropagation?.()
|
||||
await modelGenerating()
|
||||
const selectedModel = message.model || (isGrouped ? model : assistantModel)
|
||||
const selectedModel = isGrouped ? model : assistantModel
|
||||
const _message = resetAssistantMessage(message, selectedModel)
|
||||
onEditMessage?.(_message)
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ const Prompt: FC<Props> = ({ assistant, topic }) => {
|
||||
|
||||
const Container = styled.div<{ $isDark: boolean }>`
|
||||
padding: 10px 20px;
|
||||
margin: 4px 20px 0 20px;
|
||||
margin: 5px 20px 0 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid var(--color-border);
|
||||
background-color: ${({ $isDark }) => ($isDark ? 'var(--color-background-soft)' : 'transparent')};
|
||||
background-color: ${({ $isDark }) => ($isDark ? 'var(--color-background-opacity)' : 'transparent')};
|
||||
`
|
||||
|
||||
const Text = styled.div`
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelButton from './components/SelectModelButton'
|
||||
import UpdateAppButton from './components/UpdateAppButton'
|
||||
|
||||
interface Props {
|
||||
activeAssistant: Assistant
|
||||
@@ -83,6 +84,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<UpdateAppButton />
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<SearchOutlined />
|
||||
</NarrowIcon>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DeleteOutlined, EditOutlined, MinusCircleOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { getDefaultModel, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
@@ -28,7 +29,8 @@ interface AssistantItemProps {
|
||||
const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch, onDelete, addAgent, addAssistant }) => {
|
||||
const { t } = useTranslation()
|
||||
const { removeAllTopics } = useAssistant(assistant.id) // 使用当前助手的ID
|
||||
const { clickAssistantToShowTopic, topicPosition } = useSettings()
|
||||
const { clickAssistantToShowTopic, topicPosition, showAssistantIcon } = useSettings()
|
||||
const defaultModel = getDefaultModel()
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(assistant: Assistant): ItemType[] => [
|
||||
@@ -108,13 +110,15 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
|
||||
|
||||
const assistantName = assistant.name || t('chat.default.name')
|
||||
const fullAssistantName = assistant.emoji ? `${assistant.emoji} ${assistantName}` : assistantName
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
|
||||
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
|
||||
<AssistantName className="name">
|
||||
{assistant.emoji ? `${assistant.emoji} ${assistantName}` : assistantName}
|
||||
</AssistantName>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
{showAssistantIcon && <ModelAvatar model={assistant.model || defaultModel} size={22} />}
|
||||
<AssistantName className="text-nowrap">{showAssistantIcon ? assistantName : fullAssistantName}</AssistantName>
|
||||
</AssistantNameRow>
|
||||
{isActive && (
|
||||
<MenuButton onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
|
||||
<TopicCount className="topics-count">{assistant.topics.length}</TopicCount>
|
||||
@@ -129,13 +133,13 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
padding: 7px 10px;
|
||||
position: relative;
|
||||
margin: 0 10px;
|
||||
padding-right: 35px;
|
||||
font-family: Ubuntu;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
cursor: pointer;
|
||||
.iconfont {
|
||||
opacity: 0;
|
||||
@@ -152,15 +156,17 @@ const Container = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const AssistantName = styled.div`
|
||||
const AssistantNameRow = styled.div`
|
||||
color: var(--color-text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const AssistantName = styled.div``
|
||||
|
||||
const MenuButton = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -175,6 +181,8 @@ const MenuButton = styled.div`
|
||||
background-color: var(--color-background);
|
||||
right: 9px;
|
||||
top: 6px;
|
||||
padding: 0 5px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const TopicCount = styled.div`
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isMac,
|
||||
isWindows
|
||||
} from '@renderer/config/constant'
|
||||
import { isReasoningModel } from '@renderer/config/models'
|
||||
import { codeThemes } from '@renderer/context/SyntaxHighlighterProvider'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
setCodeCollapsible,
|
||||
setCodeShowLineNumbers,
|
||||
setCodeStyle,
|
||||
setCodeWrappable,
|
||||
setFontSize,
|
||||
setMathEngine,
|
||||
setMessageFont,
|
||||
@@ -32,7 +34,7 @@ import {
|
||||
} from '@renderer/store/settings'
|
||||
import { Assistant, AssistantSettings, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
import { Col, InputNumber, Row, Segmented, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -51,6 +53,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
|
||||
const [reasoningEffort, setReasoningEffort] = useState(assistant?.settings?.reasoning_effort)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -67,6 +70,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
renderInputMessageAsMarkdown,
|
||||
codeShowLineNumbers,
|
||||
codeCollapsible,
|
||||
codeWrappable,
|
||||
mathEngine,
|
||||
autoTranslateWithSpace,
|
||||
pasteLongTextThreshold,
|
||||
@@ -96,9 +100,14 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onReasoningEffortChange = (value) => {
|
||||
updateAssistantSettings({ reasoning_effort: value })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
setTemperature(DEFAULT_TEMPERATURE)
|
||||
setContextCount(DEFAULT_CONTEXTCOUNT)
|
||||
setReasoningEffort(undefined)
|
||||
updateAssistant({
|
||||
...assistant,
|
||||
settings: {
|
||||
@@ -109,6 +118,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
streamOutput: true,
|
||||
hideMessages: false,
|
||||
reasoning_effort: undefined,
|
||||
customParameters: []
|
||||
}
|
||||
})
|
||||
@@ -120,6 +130,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
setEnableMaxTokens(assistant?.settings?.enableMaxTokens ?? false)
|
||||
setMaxTokens(assistant?.settings?.maxTokens ?? DEFAULT_MAX_TOKENS)
|
||||
setStreamOutput(assistant?.settings?.streamOutput ?? true)
|
||||
setReasoningEffort(assistant?.settings?.reasoning_effort)
|
||||
}, [assistant])
|
||||
|
||||
return (
|
||||
@@ -160,7 +171,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Col span={24}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
max={10}
|
||||
onChange={setContextCount}
|
||||
onChangeComplete={onContextCountChange}
|
||||
value={typeof contextCount === 'number' ? contextCount : 0}
|
||||
@@ -223,6 +234,39 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isReasoningModel(assistant?.model) && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<Row align="middle">
|
||||
<Label>{t('assistants.settings.reasoning_effort')}</Label>
|
||||
<Tooltip title={t('assistants.settings.reasoning_effort.tip')}>
|
||||
<QuestionIcon />
|
||||
</Tooltip>
|
||||
</Row>
|
||||
<Row align="middle" gutter={10}>
|
||||
<Col span={24}>
|
||||
<SegmentedContainer>
|
||||
<Segmented
|
||||
value={reasoningEffort || 'off'}
|
||||
onChange={(value) => {
|
||||
const typedValue = value === 'off' ? undefined : (value as 'low' | 'medium' | 'high')
|
||||
setReasoningEffort(typedValue)
|
||||
onReasoningEffortChange(typedValue)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'low', label: t('assistants.settings.reasoning_effort.low') },
|
||||
{ value: 'medium', label: t('assistants.settings.reasoning_effort.medium') },
|
||||
{ value: 'high', label: t('assistants.settings.reasoning_effort.high') },
|
||||
{ value: 'off', label: t('assistants.settings.reasoning_effort.off') }
|
||||
]}
|
||||
name="group"
|
||||
block
|
||||
/>
|
||||
</SegmentedContainer>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
<SettingGroup>
|
||||
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.messages.title')}</SettingSubtitle>
|
||||
@@ -263,6 +307,11 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
|
||||
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.thought_auto_collapse')}
|
||||
@@ -455,8 +504,8 @@ const Container = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 0 10px;
|
||||
padding-right: 5px;
|
||||
padding: 0 8px;
|
||||
padding-right: 0;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
@@ -485,4 +534,25 @@ export const SettingGroup = styled.div<{ theme?: ThemeMode }>`
|
||||
margin-bottom: 10px;
|
||||
`
|
||||
|
||||
// Define the styled component with hover state styling
|
||||
const SegmentedContainer = styled.div`
|
||||
margin-top: 5px;
|
||||
.ant-segmented-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ant-segmented-item-selected {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.ant-segmented-item:hover:not(.ant-segmented-item-selected) {
|
||||
background-color: var(--color-primary-bg) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.ant-segmented-thumb {
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
`
|
||||
|
||||
export default SettingsTab
|
||||
|
||||
@@ -293,16 +293,21 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
<DragableList list={assistant.topics} onUpdate={updateTopics}>
|
||||
{(topic) => {
|
||||
const isActive = topic.id === activeTopic?.id
|
||||
const topicName = topic.name.replace('`', '')
|
||||
const topicPrompt = topic.prompt
|
||||
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}>
|
||||
<TopicListItem
|
||||
className={isActive ? 'active' : ''}
|
||||
onClick={() => onSwitchTopic(topic)}
|
||||
style={{ borderRadius }}>
|
||||
<TopicName className="name">{topic.name.replace('`', '')}</TopicName>
|
||||
{topic.prompt && (
|
||||
<TopicPromptText className="prompt">
|
||||
{t('common.prompt')}: {topic.prompt}
|
||||
<TopicName className="name" title={topicName}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
{topicPrompt && (
|
||||
<TopicPromptText className="prompt" title={fullTopicPrompt}>
|
||||
{fullTopicPrompt}
|
||||
</TopicPromptText>
|
||||
)}
|
||||
{showTopicTime && (
|
||||
|
||||
@@ -98,14 +98,7 @@ const HomeTabs: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant,
|
||||
{showTab && (
|
||||
<Segmented
|
||||
value={tab}
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
padding: '10px 0',
|
||||
margin: '0 10px',
|
||||
paddingBottom: 10,
|
||||
borderBottom: '0.5px solid var(--color-border)',
|
||||
gap: 2
|
||||
}}
|
||||
style={{ borderRadius: 16, paddingTop: 10, margin: '0 10px', gap: 2 }}
|
||||
options={
|
||||
[
|
||||
position === 'left' && topicPosition === 'left' ? assistantTab : undefined,
|
||||
@@ -166,6 +159,12 @@ const TabContent = styled.div`
|
||||
`
|
||||
|
||||
const Segmented = styled(AntSegmented)`
|
||||
&.ant-segmented {
|
||||
background-color: transparent;
|
||||
border-radius: 0 !important;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.ant-segmented-item {
|
||||
overflow: hidden;
|
||||
transition: none !important;
|
||||
@@ -173,6 +172,8 @@ const Segmented = styled(AntSegmented)`
|
||||
line-height: 34px;
|
||||
background-color: transparent;
|
||||
user-select: none;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ant-segmented-item-selected {
|
||||
background-color: var(--color-background-soft);
|
||||
@@ -204,7 +205,12 @@ const Segmented = styled(AntSegmented)`
|
||||
transition: none !important;
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* These styles ensure the same appearance as before */
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
`
|
||||
|
||||
export default HomeTabs
|
||||
|
||||
45
src/renderer/src/pages/home/components/UpdateAppButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { SyncOutlined } from '@ant-design/icons'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const UpdateAppButton: FC = () => {
|
||||
const { update } = useRuntime()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!update) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!update.downloaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<UpdateButton
|
||||
className="nodrag"
|
||||
onClick={() => window.api.showUpdateDialog()}
|
||||
icon={<SyncOutlined />}
|
||||
color="orange"
|
||||
variant="outlined"
|
||||
size="small">
|
||||
{t('button.update_available')}
|
||||
</UpdateButton>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div``
|
||||
|
||||
const UpdateButton = styled(Button)`
|
||||
border-radius: 24px;
|
||||
font-size: 12px;
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default UpdateAppButton
|
||||
@@ -263,9 +263,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemInfo>
|
||||
<FileIcon />
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
|
||||
<Tooltip title={file.origin_name}>
|
||||
<Ellipsis text={file.origin_name} />
|
||||
</Tooltip>
|
||||
<Ellipsis>
|
||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
@@ -295,9 +295,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemInfo>
|
||||
<FolderOutlined />
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis text={item.content as string} />
|
||||
</Tooltip>
|
||||
<Ellipsis>
|
||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
@@ -353,11 +353,15 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
]
|
||||
}}
|
||||
trigger={['contextMenu']}>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis text={item.remark || (item.content as string)} />
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.remark || (item.content as string)}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</ClickableSpan>
|
||||
</Dropdown>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
@@ -386,11 +390,15 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemContent>
|
||||
<ItemInfo>
|
||||
<GlobalOutlined />
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis text={item.content as string} />
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
@@ -530,13 +538,6 @@ const ItemInfo = styled.div`
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 600px;
|
||||
}
|
||||
`
|
||||
|
||||
const IndexSection = styled.div`
|
||||
@@ -570,6 +571,8 @@ const FlexAlignCenter = styled.div`
|
||||
|
||||
const ClickableSpan = styled.span`
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
`
|
||||
|
||||
const FileIcon = styled(FileTextOutlined)`
|
||||
|
||||
@@ -36,6 +36,11 @@ const AboutSettings: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (update.downloaded) {
|
||||
window.api.showUpdateDialog()
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(setUpdateState({ checking: true }))
|
||||
|
||||
try {
|
||||
|
||||
@@ -154,6 +154,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
setMaxTokens(0)
|
||||
setStreamOutput(true)
|
||||
setTopP(1)
|
||||
setReasoningEffort(undefined)
|
||||
setCustomParameters([])
|
||||
updateAssistantSettings({
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
@@ -162,6 +163,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
maxTokens: 0,
|
||||
streamOutput: true,
|
||||
topP: 1,
|
||||
reasoning_effort: undefined,
|
||||
customParameters: []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { FileSearchOutlined, FolderOpenOutlined, InfoCircleOutlined, SaveOutline
|
||||
import { Client } from '@notionhq/client'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import BackupPopup from '@renderer/components/Popups/BackupPopup'
|
||||
import RestorePopup from '@renderer/components/Popups/RestorePopup'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { backup, reset, restore } from '@renderer/services/BackupService'
|
||||
import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles'
|
||||
import { reset } from '@renderer/services/BackupService'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setNotionApiKey,
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
setYuqueUrl
|
||||
} from '@renderer/store/settings'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, InputNumber, Modal, Switch, Tooltip, Typography } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
@@ -296,6 +300,7 @@ const YuqueSettings: FC = () => {
|
||||
const DataSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>()
|
||||
const { size, removeAllFiles } = useKnowledgeFiles()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -332,6 +337,22 @@ const DataSettings: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveAllFiles = () => {
|
||||
Modal.confirm({
|
||||
centered: true,
|
||||
title: t('settings.data.app_knowledge.remove_all') + ` (${formatFileSize(size)}) `,
|
||||
content: t('settings.data.app_knowledge.remove_all_confirm'),
|
||||
onOk: async () => {
|
||||
await removeAllFiles()
|
||||
window.message.success(t('settings.data.app_knowledge.remove_all_success'))
|
||||
},
|
||||
okText: t('common.delete'),
|
||||
okButtonProps: {
|
||||
danger: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
@@ -340,10 +361,10 @@ const DataSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button onClick={backup} icon={<SaveOutlined />}>
|
||||
<Button onClick={BackupPopup.show} icon={<SaveOutlined />}>
|
||||
{t('settings.general.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={restore} icon={<FolderOpenOutlined />}>
|
||||
<Button onClick={RestorePopup.show} icon={<FolderOpenOutlined />}>
|
||||
{t('settings.general.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@@ -382,6 +403,15 @@ const DataSettings: FC = () => {
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.app_knowledge')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px">
|
||||
<Button onClick={handleRemoveAllFiles} danger>
|
||||
{t('settings.data.app_knowledge.button.delete')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.clear_cache.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SyncOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
setSidebarIcons
|
||||
} from '@renderer/store/settings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { Button, Input, Select, Switch } from 'antd'
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { Button, Input, Segmented, Switch } from 'antd'
|
||||
import { FC, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -32,7 +33,9 @@ const DisplaySettings: FC = () => {
|
||||
clickAssistantToShowTopic,
|
||||
showTopicTime,
|
||||
customCss,
|
||||
sidebarIcons
|
||||
sidebarIcons,
|
||||
showAssistantIcon,
|
||||
setShowAssistantIcon
|
||||
} = useSettings()
|
||||
const { minapps, disabled, updateMinapps, updateDisabledMinapps } = useMinapps()
|
||||
const { theme: themeMode } = useTheme()
|
||||
@@ -65,6 +68,39 @@ const DisplaySettings: FC = () => {
|
||||
updateDisabledMinapps([])
|
||||
}, [updateDisabledMinapps, updateMinapps])
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: ThemeMode.light,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<i className="iconfont icon-theme icon-theme-light" />
|
||||
<span>{t('settings.theme.light')}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: ThemeMode.dark,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<i className="iconfont icon-theme icon-dark1" />
|
||||
<span>{t('settings.theme.dark')}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: ThemeMode.auto,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<SyncOutlined />
|
||||
<span>{t('settings.theme.auto')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingContainer theme={themeMode}>
|
||||
<SettingGroup theme={theme}>
|
||||
@@ -72,16 +108,7 @@ const DisplaySettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.title')}</SettingRowTitle>
|
||||
<Select
|
||||
value={theme}
|
||||
style={{ width: 120 }}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: ThemeMode.light, label: t('settings.theme.light') },
|
||||
{ value: ThemeMode.dark, label: t('settings.theme.dark') },
|
||||
{ value: ThemeMode.auto, label: t('settings.theme.auto') }
|
||||
]}
|
||||
/>
|
||||
<Segmented value={theme} onChange={setTheme} options={themeOptions} />
|
||||
</SettingRow>
|
||||
{isMac && (
|
||||
<>
|
||||
@@ -93,14 +120,22 @@ const DisplaySettings: FC = () => {
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.assistant.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.assistant.show.icon')}</SettingRowTitle>
|
||||
<Switch checked={showAssistantIcon} onChange={(checked) => setShowAssistantIcon(checked)} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.topic.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>
|
||||
<Select
|
||||
<Segmented
|
||||
value={topicPosition || 'right'}
|
||||
style={{ width: 120 }}
|
||||
shape="round"
|
||||
onChange={setTopicPosition}
|
||||
options={[
|
||||
{ value: 'left', label: t('settings.topic.position.left') },
|
||||
@@ -159,7 +194,12 @@ const DisplaySettings: FC = () => {
|
||||
/>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.custom.css')}</SettingTitle>
|
||||
<SettingTitle>
|
||||
{t('settings.display.custom.css')}
|
||||
<TitleExtra onClick={() => window.api.openWebsite('https://cherrycss.com/')}>
|
||||
{t('settings.display.custom.css.cherrycss')}
|
||||
</TitleExtra>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Input.TextArea
|
||||
value={customCss}
|
||||
@@ -175,6 +215,12 @@ const DisplaySettings: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const TitleExtra = styled.div`
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
opacity: 0.7;
|
||||
`
|
||||
const ResetButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -52,10 +52,8 @@ const GeneralSettings: FC = () => {
|
||||
dispatch(setProxyMode(mode))
|
||||
if (mode === 'system') {
|
||||
window.api.setProxy('system')
|
||||
dispatch(_setProxyUrl(undefined))
|
||||
} else if (mode === 'none') {
|
||||
window.api.setProxy(undefined)
|
||||
dispatch(_setProxyUrl(undefined))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +62,7 @@ const GeneralSettings: FC = () => {
|
||||
{ value: 'zh-TW', label: '中文(繁体)', flag: '🇭🇰' },
|
||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
|
||||
{ value: 'ja-JP', label: '日本語', flag: '🇯🇵' },
|
||||
{ value: 'ru-RU', label: 'Russian', flag: '🇷🇺' }
|
||||
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,7 @@ const ModelSettings: FC = () => {
|
||||
options: sortBy(p.models, 'name')
|
||||
.filter((m) => !isEmbeddingModel(m))
|
||||
.map((m) => ({
|
||||
label: m.name,
|
||||
label: `${m.name} | ${p.isSystem ? t(`provider.${p.id}`) : p.name}`,
|
||||
value: getModelUniqId(m)
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TopView } from '@renderer/components/TopView'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { getDefaultGroupName } from '@renderer/utils'
|
||||
import { Button, Form, FormProps, Input, Modal } from 'antd'
|
||||
import { Button, Flex, Form, FormProps, Input, Modal } from 'antd'
|
||||
import { find } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -120,10 +120,14 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
|
||||
tooltip={t('settings.models.add.group_name.tooltip')}>
|
||||
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('settings.models.add.add_model')}
|
||||
</Button>
|
||||
<Form.Item style={{ marginBottom: 15, textAlign: 'center' }}>
|
||||
<Flex justify="center" align="center" style={{ position: 'relative' }}>
|
||||
<div>
|
||||
<Button type="primary" htmlType="submit" size="middle">
|
||||
{t('settings.models.add.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons'
|
||||
import { isEmbeddingModel, isReasoningModel, isVisionModel } from '@renderer/config/models'
|
||||
import { Model, ModelType } from '@renderer/types'
|
||||
import { getDefaultGroupName } from '@renderer/utils'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, Modal } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ModelEditContentProps {
|
||||
model: Model
|
||||
onUpdateModel: (model: Model) => void
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, open, onClose }) => {
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const [showModelTypes, setShowModelTypes] = useState(false)
|
||||
const onFinish = (values: any) => {
|
||||
const updatedModel = {
|
||||
...model,
|
||||
id: values.id || model.id,
|
||||
name: values.name || model.name,
|
||||
group: values.group || model.group
|
||||
}
|
||||
onUpdateModel(updatedModel)
|
||||
setShowModelTypes(false)
|
||||
onClose()
|
||||
}
|
||||
const handleClose = () => {
|
||||
setShowModelTypes(false)
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
title={t('models.edit')}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
centered
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) {
|
||||
form.getFieldInstance('id')?.focus()
|
||||
} else {
|
||||
setShowModelTypes(false)
|
||||
}
|
||||
}}>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{ flex: '110px' }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
style={{ marginTop: 15 }}
|
||||
initialValues={{
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
group: model.group
|
||||
}}
|
||||
onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="id"
|
||||
label={t('settings.models.add.model_id')}
|
||||
tooltip={t('settings.models.add.model_id.tooltip')}
|
||||
rules={[{ required: true }]}>
|
||||
<Input
|
||||
placeholder={t('settings.models.add.model_id.placeholder')}
|
||||
spellCheck={false}
|
||||
maxLength={200}
|
||||
disabled={true}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
form.setFieldValue('name', value)
|
||||
form.setFieldValue('group', getDefaultGroupName(value))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.models.add.model_name')}
|
||||
tooltip={t('settings.models.add.model_name.tooltip')}>
|
||||
<Input placeholder={t('settings.models.add.model_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="group"
|
||||
label={t('settings.models.add.group_name')}
|
||||
tooltip={t('settings.models.add.group_name.tooltip')}>
|
||||
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 15, textAlign: 'center' }}>
|
||||
<Flex justify="center" align="center" style={{ position: 'relative' }}>
|
||||
<div>
|
||||
<Button type="primary" htmlType="submit" size="middle">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
<MoreSettingsRow
|
||||
onClick={() => setShowModelTypes(!showModelTypes)}
|
||||
style={{ position: 'absolute', right: 0 }}>
|
||||
{t('settings.moresetting')}
|
||||
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
|
||||
</MoreSettingsRow>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
{showModelTypes && (
|
||||
<div>
|
||||
<Divider style={{ margin: '0 0 15px 0' }} />
|
||||
<TypeTitle>{t('models.type.select')}:</TypeTitle>
|
||||
{(() => {
|
||||
const defaultTypes = [
|
||||
...(isVisionModel(model) ? ['vision'] : []),
|
||||
...(isEmbeddingModel(model) ? ['embedding'] : []),
|
||||
...(isReasoningModel(model) ? ['reasoning'] : [])
|
||||
] as ModelType[]
|
||||
|
||||
// 合并现有选择和默认类型
|
||||
const selectedTypes = [...new Set([...(model.type || []), ...defaultTypes])]
|
||||
|
||||
const showTypeConfirmModal = (type: string) => {
|
||||
Modal.confirm({
|
||||
title: t('settings.moresetting.warn'),
|
||||
content: t('settings.moresetting.check.warn'),
|
||||
okText: t('settings.moresetting.check.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelButtonProps: { type: 'primary' },
|
||||
onOk: () => onUpdateModel({ ...model, type: [...selectedTypes, type] as ModelType[] }),
|
||||
onCancel: () => {},
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
|
||||
const handleTypeChange = (types: string[]) => {
|
||||
const newType = types.find((type) => !selectedTypes.includes(type as ModelType))
|
||||
|
||||
if (newType) {
|
||||
showTypeConfirmModal(newType)
|
||||
} else {
|
||||
onUpdateModel({ ...model, type: types as ModelType[] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox.Group
|
||||
value={selectedTypes}
|
||||
onChange={handleTypeChange}
|
||||
options={[
|
||||
{
|
||||
label: t('models.type.vision'),
|
||||
value: 'vision',
|
||||
disabled: isVisionModel(model) && !selectedTypes.includes('vision')
|
||||
},
|
||||
{
|
||||
label: t('models.type.embedding'),
|
||||
value: 'embedding',
|
||||
disabled: isEmbeddingModel(model) && !selectedTypes.includes('embedding')
|
||||
},
|
||||
{
|
||||
label: t('models.type.reasoning'),
|
||||
value: 'reasoning',
|
||||
disabled: isReasoningModel(model) && !selectedTypes.includes('reasoning')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeTitle = styled.div`
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
`
|
||||
|
||||
const ExpandIcon = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const MoreSettingsRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
`
|
||||
|
||||
export default ModelEditContent
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelTags from '@renderer/components/ModelTags'
|
||||
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
|
||||
import { getModelLogo, isEmbeddingModel, isReasoningModel, isVisionModel } from '@renderer/config/models'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
@@ -21,12 +21,12 @@ import { checkApi } from '@renderer/services/ApiService'
|
||||
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setModel } from '@renderer/store/assistants'
|
||||
import { Model, ModelType, Provider } from '@renderer/types'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { providerCharge } from '@renderer/utils/oauth'
|
||||
import { Avatar, Button, Card, Checkbox, Divider, Flex, Input, Popover, Space, Switch } from 'antd'
|
||||
import { Avatar, Button, Card, Divider, Flex, Input, Space, Switch } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { groupBy, isEmpty } from 'lodash'
|
||||
import { groupBy, isEmpty, sortBy, toPairs } from 'lodash'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -44,6 +44,7 @@ import ApiCheckPopup from './ApiCheckPopup'
|
||||
import EditModelsPopup from './EditModelsPopup'
|
||||
import GraphRAGSettings from './GraphRAGSettings'
|
||||
import LMStudioSettings from './LMStudioSettings'
|
||||
import ModelEditContent from './ModelEditContent'
|
||||
import OllamSettings from './OllamaSettings'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
|
||||
@@ -67,6 +68,11 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
const { defaultModel, setDefaultModel } = useDefaultModel()
|
||||
|
||||
const modelGroups = groupBy(models, 'group')
|
||||
const sortedModelGroups = sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
|
||||
|
||||
const providerConfig = PROVIDER_CONFIG[provider.id]
|
||||
@@ -76,6 +82,8 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
const modelsWebsite = providerConfig?.websites?.models
|
||||
const configedApiHost = providerConfig?.api?.url
|
||||
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null)
|
||||
|
||||
const onUpdateApiKey = () => {
|
||||
if (apiKey !== provider.apiKey) {
|
||||
updateProvider({ ...provider, apiKey })
|
||||
@@ -164,70 +172,34 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
return formatApiHost(apiHost) + 'chat/completions'
|
||||
}
|
||||
|
||||
const onUpdateModelTypes = (model: Model, types: ModelType[]) => {
|
||||
const onUpdateModel = (updatedModel: Model) => {
|
||||
const updatedModels = models.map((m) => {
|
||||
if (m.id === model.id) {
|
||||
return { ...m, type: types }
|
||||
if (m.id === updatedModel.id) {
|
||||
return updatedModel
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
updateProvider({ ...provider, models: updatedModels })
|
||||
|
||||
// Update assistants using this model
|
||||
assistants.forEach((assistant) => {
|
||||
if (assistant?.model?.id === model.id && assistant.model.provider === provider.id) {
|
||||
if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) {
|
||||
dispatch(
|
||||
setModel({
|
||||
assistantId: assistant.id,
|
||||
model: { ...model, type: types }
|
||||
model: updatedModel
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (defaultModel?.id === model.id && defaultModel?.provider === provider.id) {
|
||||
setDefaultModel({ ...defaultModel, type: types })
|
||||
// Update default model if needed
|
||||
if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) {
|
||||
setDefaultModel(updatedModel)
|
||||
}
|
||||
}
|
||||
|
||||
const modelTypeContent = (model: Model) => {
|
||||
// 获取默认选中的类型
|
||||
const defaultTypes = [
|
||||
...(isVisionModel(model) ? ['vision'] : []),
|
||||
...(isEmbeddingModel(model) ? ['embedding'] : []),
|
||||
...(isReasoningModel(model) ? ['reasoning'] : [])
|
||||
] as ModelType[]
|
||||
|
||||
// 合并现有选择和默认类型
|
||||
const selectedTypes = [...new Set([...(model.type || []), ...defaultTypes])]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Checkbox.Group
|
||||
value={selectedTypes}
|
||||
onChange={(types) => onUpdateModelTypes(model, types as ModelType[])}
|
||||
options={[
|
||||
{
|
||||
label: t('models.type.vision'),
|
||||
value: 'vision',
|
||||
disabled: isVisionModel(model) && !selectedTypes.includes('vision')
|
||||
},
|
||||
{
|
||||
label: t('models.type.embedding'),
|
||||
value: 'embedding',
|
||||
disabled: isEmbeddingModel(model) && !selectedTypes.includes('embedding')
|
||||
},
|
||||
{
|
||||
label: t('models.type.reasoning'),
|
||||
value: 'reasoning',
|
||||
disabled: isReasoningModel(model) && !selectedTypes.includes('reasoning')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatApiKeys = (value: string) => {
|
||||
return value.replaceAll(',', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',')
|
||||
}
|
||||
@@ -338,14 +310,14 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
<GraphRAGSettings provider={provider} />
|
||||
)}
|
||||
<SettingSubtitle style={{ marginBottom: 5 }}>{t('common.models')}</SettingSubtitle>
|
||||
{Object.keys(modelGroups).map((group) => (
|
||||
{Object.keys(sortedModelGroups).map((group) => (
|
||||
<Card
|
||||
key={group}
|
||||
type="inner"
|
||||
title={group}
|
||||
style={{ marginBottom: '10px', border: '0.5px solid var(--color-border)' }}
|
||||
size="small">
|
||||
{modelGroups[group].map((model) => (
|
||||
{sortedModelGroups[group].map((model) => (
|
||||
<ModelListItem key={model.id}>
|
||||
<ModelListHeader>
|
||||
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
|
||||
@@ -355,9 +327,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
<span>{model?.name}</span>
|
||||
<ModelTags model={model} />
|
||||
</ModelNameRow>
|
||||
<Popover content={modelTypeContent(model)} title={t('models.type.select')} trigger="click">
|
||||
<SettingIcon />
|
||||
</Popover>
|
||||
<SettingIcon onClick={() => setEditingModel(model)} />
|
||||
</ModelListHeader>
|
||||
<RemoveIcon onClick={() => removeModel(model)} />
|
||||
</ModelListItem>
|
||||
@@ -386,6 +356,15 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
</Flex>
|
||||
{models.map((model) => (
|
||||
<ModelEditContent
|
||||
model={model}
|
||||
onUpdateModel={onUpdateModel}
|
||||
open={editingModel?.id === model.id}
|
||||
onClose={() => setEditingModel(null)}
|
||||
key={model.id}
|
||||
/>
|
||||
))}
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
|
||||
import { Avatar, Button, Dropdown, MenuProps, Tag } from 'antd'
|
||||
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -18,6 +18,7 @@ const ProvidersList: FC = () => {
|
||||
const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders()
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider>(providers[0])
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
@@ -95,17 +96,60 @@ const ProvidersList: FC = () => {
|
||||
return menus
|
||||
}
|
||||
|
||||
//will match the providers and the models that provider provides
|
||||
const filteredProviders = providers.filter((provider) => {
|
||||
// 获取 provider 的名称
|
||||
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
|
||||
|
||||
// 检查 provider 的 id 和 name 是否匹配搜索条件
|
||||
const isProviderMatch =
|
||||
provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
providerName.toLowerCase().includes(searchText.toLowerCase())
|
||||
|
||||
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
|
||||
const isModelMatch = provider.models.some((model) => {
|
||||
return (
|
||||
model.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
model.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// 如果 provider 或 model 匹配,则保留该 provider
|
||||
return isProviderMatch || isModelMatch
|
||||
})
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ProviderListContainer>
|
||||
<AddButtonWrapper>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('settings.provider.search')}
|
||||
value={searchText}
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35 }}
|
||||
suffix={<SearchOutlined style={{ color: 'var(--color-text-3)' }} />}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchText('')
|
||||
}
|
||||
}}
|
||||
allowClear
|
||||
disabled={dragging}
|
||||
/>
|
||||
</AddButtonWrapper>
|
||||
<Scrollbar>
|
||||
<ProviderList>
|
||||
<DragDropContext onDragStart={() => setDragging(true)} onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{providers.map((provider, index) => (
|
||||
<Draggable key={`draggable_${provider.id}_${index}`} draggableId={provider.id} index={index}>
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<Draggable
|
||||
key={`draggable_${provider.id}_${index}`}
|
||||
draggableId={provider.id}
|
||||
index={index}
|
||||
isDragDisabled={searchText.length > 0}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
@@ -148,13 +192,15 @@ const ProvidersList: FC = () => {
|
||||
</DragDropContext>
|
||||
</ProviderList>
|
||||
</Scrollbar>
|
||||
{!dragging && (
|
||||
<AddButtonWrapper>
|
||||
<Button style={{ width: '100%' }} icon={<PlusOutlined />} onClick={onAddProvider}>
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
</AddButtonWrapper>
|
||||
)}
|
||||
<AddButtonWrapper>
|
||||
<Button
|
||||
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAddProvider}
|
||||
disabled={dragging}>
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
</AddButtonWrapper>
|
||||
</ProviderListContainer>
|
||||
<ProviderSetting provider={selectedProvider} key={JSON.stringify(selectedProvider)} />
|
||||
</Container>
|
||||
@@ -166,7 +212,6 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
`
|
||||
|
||||
const ProviderListContainer = styled.div`
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||
@@ -19,7 +20,6 @@ import AboutSettings from './AboutSettings'
|
||||
import DataSettings from './DataSettings/DataSettings'
|
||||
import DisplaySettings from './DisplaySettings/DisplaySettings'
|
||||
import GeneralSettings from './GeneralSettings'
|
||||
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import ShortcutSettings from './ShortcutSettings'
|
||||
|
||||
@@ -59,15 +59,6 @@ const ShortcutSettings: FC = () => {
|
||||
const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
|
||||
const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
|
||||
|
||||
// only allows option + space
|
||||
if (isMac && keys[0] === 'Alt' && !['Space', undefined].includes(keys[1])) {
|
||||
window.message.warning({
|
||||
content: t('settings.shortcuts.alt_warning'),
|
||||
key: 'shortcut-alt-warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return hasModifier && hasNonModifier && keys.length >= 2
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setExcludeDomains, setMaxResult, setSearchWithTime } from '@renderer/store/websearch'
|
||||
import { formatDomains } from '@renderer/utils/blacklist'
|
||||
import { Alert, Input, Slider, Switch, Typography } from 'antd'
|
||||
import { Alert, Button, Input, Slider, Switch, Typography } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -113,11 +113,13 @@ const WebSearchSettings: FC = () => {
|
||||
<TextArea
|
||||
value={blacklistInput}
|
||||
onChange={(e) => setBlacklistInput(e.target.value)}
|
||||
onBlur={() => updateManualBlacklist(blacklistInput)}
|
||||
placeholder={t('settings.websearch.blacklist_tooltip')}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
rows={4}
|
||||
/>
|
||||
<Button onClick={() => updateManualBlacklist(blacklistInput)} style={{ marginTop: 10 }}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{errFormat && <Alert message={t('settings.websearch.blacklist_tooltip')} type="error" />}
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
|
||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||
import { isReasoningModel } from '@renderer/config/models'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages } from '@renderer/services/MessagesService'
|
||||
import { filterContextMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileTypes, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { removeSpecialCharacters } from '@renderer/utils'
|
||||
import { first, flatten, sum, takeRight } from 'lodash'
|
||||
@@ -13,12 +14,24 @@ import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
import BaseProvider from './BaseProvider'
|
||||
|
||||
type ReasoningEffort = 'high' | 'medium' | 'low'
|
||||
|
||||
interface ReasoningConfig {
|
||||
type: 'enabled' | 'disabled'
|
||||
budget_tokens?: number
|
||||
}
|
||||
|
||||
export default class AnthropicProvider extends BaseProvider {
|
||||
private sdk: Anthropic
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
this.sdk = new Anthropic({ apiKey: this.apiKey, baseURL: this.getBaseURL() })
|
||||
this.sdk = new Anthropic({
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
public getBaseURL(): string {
|
||||
@@ -60,13 +73,58 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private getTemperature(assistant: Assistant, model: Model) {
|
||||
if (isReasoningModel(model)) return undefined
|
||||
|
||||
return assistant?.settings?.temperature
|
||||
}
|
||||
|
||||
private getTopP(assistant: Assistant, model: Model) {
|
||||
if (isReasoningModel(model)) return undefined
|
||||
|
||||
return assistant?.settings?.topP
|
||||
}
|
||||
|
||||
private getReasoningEffort(assistant: Assistant, model: Model): ReasoningConfig | undefined {
|
||||
if (!isReasoningModel(model)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const effortRatios: Record<ReasoningEffort, number> = {
|
||||
high: 0.8,
|
||||
medium: 0.5,
|
||||
low: 0.2
|
||||
}
|
||||
|
||||
const effort = assistant?.settings?.reasoning_effort as ReasoningEffort
|
||||
const effortRatio = effortRatios[effort]
|
||||
|
||||
if (!effortRatio) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isClaude37Sonnet = model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet')
|
||||
|
||||
if (!isClaude37Sonnet) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const maxTokens = assistant?.settings?.maxTokens || DEFAULT_MAX_TOKENS
|
||||
const budgetTokens = Math.trunc(Math.max(Math.min(maxTokens * effortRatio, 32000), 1024))
|
||||
|
||||
return {
|
||||
type: 'enabled',
|
||||
budget_tokens: budgetTokens
|
||||
}
|
||||
}
|
||||
|
||||
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
|
||||
const defaultModel = getDefaultModel()
|
||||
const model = assistant.model || defaultModel
|
||||
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
|
||||
|
||||
const userMessagesParams: MessageParam[] = []
|
||||
const _messages = filterContextMessages(takeRight(messages, contextCount + 2))
|
||||
const _messages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
|
||||
|
||||
onFilterMessages(_messages)
|
||||
|
||||
@@ -76,28 +134,44 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
|
||||
const userMessages = flatten(userMessagesParams)
|
||||
|
||||
if (first(userMessages)?.role === 'assistant') {
|
||||
userMessages.shift()
|
||||
}
|
||||
|
||||
const body: MessageCreateParamsNonStreaming = {
|
||||
model: model.id,
|
||||
messages: userMessages,
|
||||
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
|
||||
temperature: assistant?.settings?.temperature,
|
||||
top_p: assistant?.settings?.topP,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
system: assistant.prompt,
|
||||
// @ts-ignore thinking
|
||||
thinking: this.getReasoningEffort(assistant, model),
|
||||
...this.getCustomParameters(assistant)
|
||||
}
|
||||
|
||||
let time_first_token_millsec = 0
|
||||
let time_first_content_millsec = 0
|
||||
const start_time_millsec = new Date().getTime()
|
||||
|
||||
if (!streamOutput) {
|
||||
const message = await this.sdk.messages.create({ ...body, stream: false })
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
|
||||
let text = ''
|
||||
let reasoning_content = ''
|
||||
|
||||
if (message.content && message.content.length > 0) {
|
||||
const thinkingBlock = message.content.find((block) => block.type === 'thinking')
|
||||
const textBlock = message.content.find((block) => block.type === 'text')
|
||||
|
||||
if (thinkingBlock && 'thinking' in thinkingBlock) {
|
||||
reasoning_content = thinkingBlock.thinking
|
||||
}
|
||||
|
||||
if (textBlock && 'text' in textBlock) {
|
||||
text = textBlock.text
|
||||
}
|
||||
}
|
||||
return onChunk({
|
||||
text: message.content[0].type === 'text' ? message.content[0].text : '',
|
||||
text,
|
||||
reasoning_content,
|
||||
usage: message.usage,
|
||||
metrics: {
|
||||
completion_tokens: message.usage.output_tokens,
|
||||
@@ -113,6 +187,7 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
const { signal } = abortController
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let hasThinkingContent = false
|
||||
const stream = this.sdk.messages
|
||||
.stream({ ...body, stream: true }, { signal })
|
||||
.on('text', (text) => {
|
||||
@@ -123,9 +198,34 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
if (time_first_token_millsec == 0) {
|
||||
time_first_token_millsec = new Date().getTime() - start_time_millsec
|
||||
}
|
||||
|
||||
if (hasThinkingContent && time_first_content_millsec === 0) {
|
||||
time_first_content_millsec = new Date().getTime()
|
||||
}
|
||||
|
||||
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
|
||||
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
onChunk({
|
||||
text,
|
||||
metrics: {
|
||||
completion_tokens: undefined,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
}
|
||||
})
|
||||
})
|
||||
.on('thinking', (thinking) => {
|
||||
hasThinkingContent = true
|
||||
if (time_first_token_millsec == 0) {
|
||||
time_first_token_millsec = new Date().getTime() - start_time_millsec
|
||||
}
|
||||
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
onChunk({
|
||||
reasoning_content: thinking,
|
||||
text: '',
|
||||
metrics: {
|
||||
completion_tokens: undefined,
|
||||
time_completion_millsec,
|
||||
@@ -134,6 +234,8 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
})
|
||||
})
|
||||
.on('finalMessage', (message) => {
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
|
||||
onChunk({
|
||||
text: '',
|
||||
usage: {
|
||||
@@ -143,8 +245,9 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
},
|
||||
metrics: {
|
||||
completion_tokens: message.usage.output_tokens,
|
||||
time_completion_millsec: new Date().getTime() - start_time_millsec,
|
||||
time_first_token_millsec
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
}
|
||||
})
|
||||
resolve()
|
||||
|
||||
@@ -15,11 +15,11 @@ import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages } from '@renderer/services/MessagesService'
|
||||
import { filterContextMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileType, FileTypes, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { removeSpecialCharacters } from '@renderer/utils'
|
||||
import axios from 'axios'
|
||||
import { first, isEmpty, takeRight } from 'lodash'
|
||||
import { isEmpty, takeRight } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
@@ -146,13 +146,9 @@ export default class GeminiProvider extends BaseProvider {
|
||||
const model = assistant.model || defaultModel
|
||||
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
|
||||
|
||||
const userMessages = filterContextMessages(takeRight(messages, contextCount + 2))
|
||||
const userMessages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
|
||||
onFilterMessages(userMessages)
|
||||
|
||||
if (first(userMessages)?.role === 'assistant') {
|
||||
userMessages.shift()
|
||||
}
|
||||
|
||||
const userLastMessage = userMessages.pop()
|
||||
|
||||
const history: Content[] = []
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { getOpenAIWebSearchParams, isReasoningModel, isSupportedModel, isVisionModel } from '@renderer/config/models'
|
||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||
import {
|
||||
getOpenAIWebSearchParams,
|
||||
isOpenAIoSeries,
|
||||
isReasoningModel,
|
||||
isSupportedModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages } from '@renderer/services/MessagesService'
|
||||
import { filterContextMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileTypes, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { removeSpecialCharacters } from '@renderer/utils'
|
||||
import { takeRight } from 'lodash'
|
||||
@@ -17,6 +24,8 @@ import {
|
||||
import { CompletionsParams } from '.'
|
||||
import BaseProvider from './BaseProvider'
|
||||
|
||||
type ReasoningEffort = 'high' | 'medium' | 'low'
|
||||
|
||||
export default class OpenAIProvider extends BaseProvider {
|
||||
private sdk: OpenAI
|
||||
|
||||
@@ -42,7 +51,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
private get isNotSupportFiles() {
|
||||
const providers = ['deepseek', 'baichuan', 'minimax', 'doubao']
|
||||
const providers = ['deepseek', 'baichuan', 'minimax', 'doubao', 'xirang']
|
||||
return providers.includes(this.provider.id)
|
||||
}
|
||||
|
||||
@@ -156,9 +165,45 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
if (isReasoningModel(model)) {
|
||||
return {
|
||||
reasoning_effort: assistant?.settings?.reasoning_effort
|
||||
if (model.provider === 'openrouter') {
|
||||
return {
|
||||
reasoning: {
|
||||
effort: assistant?.settings?.reasoning_effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpenAIoSeries(model)) {
|
||||
return {
|
||||
reasoning_effort: assistant?.settings?.reasoning_effort
|
||||
}
|
||||
}
|
||||
|
||||
if (model.id.includes('claude-3.7-sonnet') || model.id.includes('claude-3-7-sonnet')) {
|
||||
const effortRatios: Record<ReasoningEffort, number> = {
|
||||
high: 0.8,
|
||||
medium: 0.5,
|
||||
low: 0.2
|
||||
}
|
||||
|
||||
const effort = assistant?.settings?.reasoning_effort as ReasoningEffort
|
||||
const effortRatio = effortRatios[effort]
|
||||
|
||||
if (!effortRatio) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const maxTokens = assistant?.settings?.maxTokens || DEFAULT_MAX_TOKENS
|
||||
const budgetTokens = Math.trunc(Math.max(Math.min(maxTokens * effortRatio, 32000), 1024))
|
||||
|
||||
return {
|
||||
thinking: {
|
||||
budget_tokens: budgetTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
return {}
|
||||
@@ -175,7 +220,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
|
||||
let systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
|
||||
|
||||
if (['o1', 'o1-2024-12-17'].includes(model.id) || model.id.startsWith('o3')) {
|
||||
if (isOpenAIoSeries(model)) {
|
||||
systemMessage = {
|
||||
role: 'developer',
|
||||
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
|
||||
@@ -184,15 +229,9 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
|
||||
const userMessages: ChatCompletionMessageParam[] = []
|
||||
|
||||
const _messages = filterContextMessages(takeRight(messages, contextCount + 1))
|
||||
const _messages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
|
||||
onFilterMessages(_messages)
|
||||
|
||||
if (model.id === 'deepseek-reasoner') {
|
||||
if (_messages[0]?.role !== 'user') {
|
||||
userMessages.push({ role: 'user', content: '' })
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of _messages) {
|
||||
userMessages.push(await this.getMessageParam(message, model))
|
||||
}
|
||||
@@ -212,6 +251,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta & {
|
||||
reasoning_content?: string
|
||||
reasoning?: string
|
||||
thinking?: string
|
||||
}
|
||||
) => {
|
||||
if (!delta?.content) return false
|
||||
@@ -226,7 +266,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
// 如果有reasoning_content或reasoning,说明是在思考中
|
||||
if (delta?.reasoning_content || delta?.reasoning) {
|
||||
if (delta?.reasoning_content || delta?.reasoning || delta?.thinking) {
|
||||
hasReasoningContent = true
|
||||
}
|
||||
|
||||
|
||||
@@ -51,20 +51,24 @@ class KnowledgeQueue {
|
||||
throw new Error('Knowledge base not found')
|
||||
}
|
||||
|
||||
const processableItems = base.items.filter((item) => {
|
||||
if (item.processingStatus === 'failed') {
|
||||
return !item.retryCount || item.retryCount < this.MAX_RETRIES
|
||||
}
|
||||
return item.processingStatus === 'pending'
|
||||
})
|
||||
const findProcessableItem = () => {
|
||||
const state = store.getState()
|
||||
const base = state.knowledge.bases.find((b) => b.id === baseId)
|
||||
return (
|
||||
base?.items.find((item) => {
|
||||
if (item.processingStatus === 'failed') {
|
||||
return !item.retryCount || item.retryCount < this.MAX_RETRIES
|
||||
} else {
|
||||
return item.processingStatus === 'pending'
|
||||
}
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of processableItems) {
|
||||
if (!this.processing.get(baseId)) {
|
||||
console.log(`[KnowledgeQueue] Processing interrupted for base ${baseId}`)
|
||||
break
|
||||
}
|
||||
|
||||
this.processItem(baseId, item)
|
||||
let processableItem = findProcessableItem()
|
||||
while (processableItem) {
|
||||
this.processItem(baseId, processableItem).then()
|
||||
processableItem = findProcessableItem()
|
||||
}
|
||||
} finally {
|
||||
console.log(`[KnowledgeQueue] Finished processing queue for base ${baseId}`)
|
||||
@@ -153,7 +157,7 @@ class KnowledgeQueue {
|
||||
}
|
||||
console.debug(`[KnowledgeQueue] Updated uniqueId for item ${item.id} in base ${baseId} `)
|
||||
|
||||
setTimeout(() => store.dispatch(clearCompletedProcessing({ baseId })), 1000)
|
||||
store.dispatch(clearCompletedProcessing({ baseId }))
|
||||
} catch (error) {
|
||||
console.error(`[KnowledgeQueue] Error processing item ${item.id}: `, error)
|
||||
store.dispatch(
|
||||
|
||||
@@ -114,6 +114,12 @@ export async function fetchChatCompletion({
|
||||
assistant,
|
||||
messages: [..._messages, message]
|
||||
})
|
||||
// Set metrics.completion_tokens
|
||||
if (message.metrics && message?.usage?.completion_tokens) {
|
||||
if (!message.metrics?.completion_tokens) {
|
||||
message.metrics.completion_tokens = message.usage.completion_tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.status = 'error'
|
||||
|
||||