Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f502049f4 | ||
|
|
c68ad4febb | ||
|
|
2ebcec9f59 | ||
|
|
7bc74a5b86 | ||
|
|
75152421d9 | ||
|
|
3326074076 | ||
|
|
362d82bdcc | ||
|
|
fcce241c82 | ||
|
|
693b06c126 | ||
|
|
c310c71576 | ||
|
|
bea95fc52f | ||
|
|
969cf8ea21 | ||
|
|
5b357f14e5 | ||
|
|
de5db4f805 | ||
|
|
1ccb5edda7 | ||
|
|
97b8749dd1 | ||
|
|
a6d7ecae81 | ||
|
|
938efb5aef | ||
|
|
9baf0f772e | ||
|
|
ff5de3625e | ||
|
|
f1cfdb29f8 | ||
|
|
26e48f07fd | ||
|
|
8bb5fb9811 | ||
|
|
d41667b599 | ||
|
|
85152cbcd7 | ||
|
|
b80863111f | ||
|
|
6cd88fa51d | ||
|
|
3619e8f47b | ||
|
|
5b41dd24d4 | ||
|
|
91dd2f233a | ||
|
|
7e651f9abc | ||
|
|
e44f666c5c |
@@ -1,5 +0,0 @@
|
||||
# Sponsor
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/user-attachments/assets/4665f07f-5ecc-4bd8-8727-ae00f35d6d98" alt="Buy Me a Coffee" width="280"/>
|
||||
</div>
|
||||
@@ -1,95 +0,0 @@
|
||||
# FAQ 文档
|
||||
本文档适用于:产品手册、官网页面、课程测验、现场 Q&A。
|
||||
|
||||
## 问题1:Cherry Studio 支持哪些操作系统?
|
||||
- **答案**:Cherry Studio 支持 Windows、Mac 和 Linux 操作系统。
|
||||
|
||||
## 问题2:Cherry Studio 的主要功能有哪些?
|
||||
- **答案**:Cherry Studio 的主要功能包括:
|
||||
1. 支持多个 LLM 提供商
|
||||
2. 允许创建多个助手
|
||||
3. 支持创建多个主题
|
||||
4. 允许在同一对话中使用多个模型来回答问题
|
||||
5. 支持拖放排序
|
||||
6. 代码高亮
|
||||
7. Mermaid 图表支持
|
||||
|
||||
## 问题3:Cherry Studio 的主要目录结构是怎样的?
|
||||
- **答案**:Cherry Studio 的主要目录结构如下:
|
||||
- `/src`: 主要源代码目录
|
||||
- `/build`: 构建相关文件
|
||||
- `/docs`: 文档目录
|
||||
- `/resources`: 资源文件目录
|
||||
- `/scripts`: 脚本文件目录
|
||||
|
||||
## 问题4:如何在 Windows 环境下 fork Cherry Studio 并修改部分功能?
|
||||
- **答案**:在 Windows 环境下 fork Cherry Studio 并修改部分功能的步骤如下:
|
||||
1. 在 GitHub 上 fork Cherry Studio 仓库
|
||||
2. 克隆 fork 的仓库到本地:`git clone https://github.com/your-username/cherry-studio.git`
|
||||
3. 进入项目目录:`cd cherry-studio`
|
||||
4. 安装依赖:`yarn install`
|
||||
5. 修改所需的功能代码
|
||||
6. 测试修改:`yarn dev`
|
||||
7. 提交修改:`git add .` 和 `git commit -m "描述你的修改"`
|
||||
8. 推送到你的 fork 仓库:`git push origin main`
|
||||
|
||||
## 问题5:Cherry Studio 使用了哪些主要技术栈?
|
||||
- **答案**:Cherry Studio 主要使用了以下技术栈:
|
||||
- TypeScript
|
||||
- SCSS
|
||||
- Electron
|
||||
- Vite
|
||||
- Sequelize
|
||||
|
||||
## 问题6:如何贡献代码到 Cherry Studio 项目?
|
||||
- **答案**:贡献代码到 Cherry Studio 项目的步骤如下:
|
||||
1. Fork 项目仓库
|
||||
2. 创建你的特性分支:`git checkout -b feature/AmazingFeature`
|
||||
3. 提交你的修改:`git commit -m 'Add some AmazingFeature'`
|
||||
4. 推送到分支:`git push origin feature/AmazingFeature`
|
||||
5. 打开一个 Pull Request
|
||||
|
||||
## 问题7:Cherry Studio 的 `/src` 目录主要包含哪些内容?
|
||||
- **答案**:Cherry Studio 的 `/src` 目录主要包含以下内容:
|
||||
- 主进程代码(Electron 主进程)
|
||||
- 渲染进程代码(用户界面)
|
||||
- 组件
|
||||
- 工具函数
|
||||
- 状态管理
|
||||
- 样式文件
|
||||
|
||||
## 问题8:如何在 Cherry Studio 中添加新的 LLM 提供商?
|
||||
- **答案**:要在 Cherry Studio 中添加新的 LLM 提供商,你需要:
|
||||
1. 在 `/src/services` 或类似目录下创建新的服务文件
|
||||
2. 实现与新 LLM 提供商 API 的集成
|
||||
3. 在用户界面中添加新提供商的选项
|
||||
4. 更新配置和状态管理以支持新提供商
|
||||
|
||||
## 问题9:Cherry Studio 的构建过程是怎样的?
|
||||
- **答案**:Cherry Studio 的构建过程主要包括:
|
||||
1. 使用 Vite 构建前端资源
|
||||
2. 使用 Electron Builder 打包桌面应用
|
||||
3. 根据不同平台(Windows、Mac、Linux)生成相应的安装包
|
||||
|
||||
## 问题10:如何在 Cherry Studio 中实现新的 UI 主题?
|
||||
- **答案**:在 Cherry Studio 中实现新的 UI 主题的步骤:
|
||||
1. 在 `/src/styles` 目录下创建新的主题 SCSS 文件
|
||||
2. 定义新主题的颜色变量和样式
|
||||
3. 在主样式文件中导入新主题
|
||||
4. 更新主题切换逻辑以包含新主题
|
||||
5. 在用户界面中添加新主题的选项
|
||||
|
||||
## 问题11:Cherry Studio 如何处理多语言支持?
|
||||
- **答案**:Cherry Studio 可能通过以下方式处理多语言支持:
|
||||
1. 使用 i18n 库进行国际化
|
||||
2. 在 `/src/locales` 或类似目录下存储不同语言的翻译文件
|
||||
3. 实现语言切换功能
|
||||
4. 在组件中使用翻译函数或组件来显示多语言文本
|
||||
|
||||
## 问题12:如何为 Cherry Studio 编写单元测试?
|
||||
- **答案**:为 Cherry Studio 编写单元测试的步骤:
|
||||
1. 在 `/tests` 目录下创建测试文件
|
||||
2. 使用测试框架(如 Jest)编写测试用例
|
||||
3. 模拟 Electron 环境和其他依赖
|
||||
4. 运行测试命令:`yarn test`
|
||||
5. 确保测试覆盖主要功能和组件
|
||||
@@ -65,9 +65,12 @@ afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
本次更新:
|
||||
增加 Azure OpenAI 服务商
|
||||
修复表格换行问题
|
||||
增加 Artifacts 网页预览功能
|
||||
内置助理新增网页生成助理
|
||||
DashScope 服务商修改为阿里云百炼
|
||||
修复粘贴长文本后不能自动清除的问题
|
||||
话题右键菜单增加删除消息功能
|
||||
修复选择模型弹窗滚动条消失问题
|
||||
近期更新:
|
||||
增加 WebDAV 备份功能 by @DrayChou
|
||||
增加话题历史记录
|
||||
增加消息搜索功能
|
||||
支持 PDF, DOC等办公文件格式
|
||||
支持图片的预览和下载
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "0.7.13",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -39,6 +39,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"fs-extra": "^11.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"officeparser": "^4.1.1",
|
||||
"unzipper": "^0.12.3",
|
||||
"webdav": "4.11.4"
|
||||
},
|
||||
@@ -96,7 +97,7 @@
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"rehype-mathjax": "^6.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
|
||||
94
src/main/constant.ts
Normal file
94
src/main/constant.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
'.mdx', // Markdown 文件
|
||||
'.html', // HTML 文件
|
||||
'.htm', // HTML 文件的另一种扩展名
|
||||
'.xml', // XML 文件
|
||||
'.json', // JSON 文件
|
||||
'.yaml', // YAML 文件
|
||||
'.yml', // YAML 文件的另一种扩展名
|
||||
'.csv', // 逗号分隔值文件
|
||||
'.tsv', // 制表符分隔值文件
|
||||
'.ini', // 配置文件
|
||||
'.log', // 日志文件
|
||||
'.rtf', // 富文本格式文件
|
||||
'.tex', // LaTeX 文件
|
||||
'.srt', // 字幕文件
|
||||
'.xhtml', // XHTML 文件
|
||||
'.nfo', // 信息文件(主要用于场景发布)
|
||||
'.conf', // 配置文件
|
||||
'.config', // 配置文件
|
||||
'.env', // 环境变量文件
|
||||
'.properties', // 配置属性文件
|
||||
'.latex', // LaTeX 文档文件
|
||||
'.rst', // reStructuredText 文件
|
||||
'.php', // PHP 脚本文件,包含嵌入的 HTML
|
||||
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
|
||||
'.ts', // TypeScript 文件
|
||||
'.jsp', // JavaServer Pages 文件
|
||||
'.aspx', // ASP.NET 文件
|
||||
'.bat', // Windows 批处理文件
|
||||
'.sh', // Unix/Linux Shell 脚本文件
|
||||
'.py', // Python 脚本文件
|
||||
'.rb', // Ruby 脚本文件
|
||||
'.pl', // Perl 脚本文件
|
||||
'.sql', // SQL 脚本文件
|
||||
'.css', // Cascading Style Sheets 文件
|
||||
'.less', // Less CSS 预处理器文件
|
||||
'.scss', // Sass CSS 预处理器文件
|
||||
'.sass', // Sass 文件
|
||||
'.styl', // Stylus CSS 预处理器文件
|
||||
'.coffee', // CoffeeScript 文件
|
||||
'.ino', // Arduino 代码文件
|
||||
'.ino', // Arduino 代码文件
|
||||
'.asm', // Assembly 语言文件
|
||||
'.go', // Go 语言文件
|
||||
'.scala', // Scala 语言文件
|
||||
'.swift', // Swift 语言文件
|
||||
'.kt', // Kotlin 语言文件
|
||||
'.rs', // Rust 语言文件
|
||||
'.lua', // Lua 语言文件
|
||||
'.groovy', // Groovy 语言文件
|
||||
'.dart', // Dart 语言文件
|
||||
'.hs', // Haskell 语言文件
|
||||
'.clj', // Clojure 语言文件
|
||||
'.cljs', // ClojureScript 语言文件
|
||||
'.elm', // Elm 语言文件
|
||||
'.erl', // Erlang 语言文件
|
||||
'.ex', // Elixir 语言文件
|
||||
'.exs', // Elixir 脚本文件
|
||||
'.pug', // Pug (formerly Jade) 模板文件
|
||||
'.haml', // Haml 模板文件
|
||||
'.slim', // Slim 模板文件
|
||||
'.tpl', // 模板文件(通用)
|
||||
'.ejs', // Embedded JavaScript 模板文件
|
||||
'.hbs', // Handlebars 模板文件
|
||||
'.mustache', // Mustache 模板文件
|
||||
'.jade', // Jade 模板文件 (已重命名为 Pug)
|
||||
'.twig', // Twig 模板文件
|
||||
'.blade', // Blade 模板文件 (Laravel)
|
||||
'.vue', // Vue.js 单文件组件
|
||||
'.jsx', // React JSX 文件
|
||||
'.tsx', // React TSX 文件
|
||||
'.graphql', // GraphQL 查询语言文件
|
||||
'.gql', // GraphQL 查询语言文件
|
||||
'.proto', // Protocol Buffers 文件
|
||||
'.thrift', // Thrift 文件
|
||||
'.toml', // TOML 配置文件
|
||||
'.edn', // Clojure 数据表示文件
|
||||
'.cake', // CakePHP 配置文件
|
||||
'.ctp', // CakePHP 视图文件
|
||||
'.cfm', // ColdFusion 标记语言文件
|
||||
'.cfc', // ColdFusion 组件文件
|
||||
'.m', // Objective-C 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.kts' // Kotlin Script 文件
|
||||
]
|
||||
@@ -4,6 +4,7 @@ import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import FileManager from './services/FileManager'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { createMinappWindow } from './window'
|
||||
|
||||
const fileManager = new FileManager()
|
||||
@@ -29,6 +30,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
ipcMain.handle('reload', () => mainWindow.reload())
|
||||
|
||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||
ipcMain.handle('backup:backup', backupManager.backup)
|
||||
ipcMain.handle('backup:restore', backupManager.restore)
|
||||
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { documentExts } from '@main/constant'
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { FileType } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
@@ -13,11 +14,14 @@ import logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import officeParser from 'officeparser'
|
||||
import * as path from 'path'
|
||||
import { chdir } from 'process'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
class FileManager {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
private tempDir = path.join(app.getPath('temp'), 'CherryStudio')
|
||||
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
@@ -27,6 +31,9 @@ class FileManager {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private getFileHash = async (filePath: string): Promise<string> => {
|
||||
@@ -173,15 +180,29 @@ class FileManager {
|
||||
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
if (documentExts.includes(path.extname(filePath))) {
|
||||
const originalCwd = process.cwd()
|
||||
try {
|
||||
chdir(this.tempDir)
|
||||
const data = await officeParser.parseOfficeAsync(filePath)
|
||||
chdir(originalCwd)
|
||||
return data
|
||||
} catch (error) {
|
||||
chdir(originalCwd)
|
||||
logger.error(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
}
|
||||
|
||||
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
|
||||
const tempDir = path.join(app.getPath('temp'), 'CherryStudio')
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
const tempFilePath = path.join(tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||
const tempFilePath = path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||
return tempFilePath
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +1,8 @@
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@main/constant'
|
||||
|
||||
import { FileTypes } from '../../renderer/src/types'
|
||||
|
||||
export function getFileType(ext: string): FileTypes {
|
||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
const documentExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
|
||||
const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
'.mdx', // Markdown 文件
|
||||
'.html', // HTML 文件
|
||||
'.htm', // HTML 文件的另一种扩展名
|
||||
'.xml', // XML 文件
|
||||
'.json', // JSON 文件
|
||||
'.yaml', // YAML 文件
|
||||
'.yml', // YAML 文件的另一种扩展名
|
||||
'.csv', // 逗号分隔值文件
|
||||
'.tsv', // 制表符分隔值文件
|
||||
'.ini', // 配置文件
|
||||
'.log', // 日志文件
|
||||
'.rtf', // 富文本格式文件
|
||||
'.tex', // LaTeX 文件
|
||||
'.srt', // 字幕文件
|
||||
'.xhtml', // XHTML 文件
|
||||
'.nfo', // 信息文件(主要用于场景发布)
|
||||
'.conf', // 配置文件
|
||||
'.config', // 配置文件
|
||||
'.env', // 环境变量文件
|
||||
'.properties', // 配置属性文件
|
||||
'.latex', // LaTeX 文档文件
|
||||
'.rst', // reStructuredText 文件
|
||||
'.php', // PHP 脚本文件,包含嵌入的 HTML
|
||||
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
|
||||
'.ts', // TypeScript 文件
|
||||
'.jsp', // JavaServer Pages 文件
|
||||
'.aspx', // ASP.NET 文件
|
||||
'.bat', // Windows 批处理文件
|
||||
'.sh', // Unix/Linux Shell 脚本文件
|
||||
'.py', // Python 脚本文件
|
||||
'.rb', // Ruby 脚本文件
|
||||
'.pl', // Perl 脚本文件
|
||||
'.sql', // SQL 脚本文件
|
||||
'.css', // Cascading Style Sheets 文件
|
||||
'.less', // Less CSS 预处理器文件
|
||||
'.scss', // Sass CSS 预处理器文件
|
||||
'.sass', // Sass 文件
|
||||
'.styl', // Stylus CSS 预处理器文件
|
||||
'.coffee', // CoffeeScript 文件
|
||||
'.ino', // Arduino 代码文件
|
||||
'.ino', // Arduino 代码文件
|
||||
'.asm', // Assembly 语言文件
|
||||
'.go', // Go 语言文件
|
||||
'.scala', // Scala 语言文件
|
||||
'.swift', // Swift 语言文件
|
||||
'.kt', // Kotlin 语言文件
|
||||
'.rs', // Rust 语言文件
|
||||
'.lua', // Lua 语言文件
|
||||
'.groovy', // Groovy 语言文件
|
||||
'.dart', // Dart 语言文件
|
||||
'.hs', // Haskell 语言文件
|
||||
'.clj', // Clojure 语言文件
|
||||
'.cljs', // ClojureScript 语言文件
|
||||
'.elm', // Elm 语言文件
|
||||
'.erl', // Erlang 语言文件
|
||||
'.ex', // Elixir 语言文件
|
||||
'.exs', // Elixir 脚本文件
|
||||
'.pug', // Pug (formerly Jade) 模板文件
|
||||
'.haml', // Haml 模板文件
|
||||
'.slim', // Slim 模板文件
|
||||
'.tpl', // 模板文件(通用)
|
||||
'.ejs', // Embedded JavaScript 模板文件
|
||||
'.hbs', // Handlebars 模板文件
|
||||
'.mustache', // Mustache 模板文件
|
||||
'.jade', // Jade 模板文件 (已重命名为 Pug)
|
||||
'.twig', // Twig 模板文件
|
||||
'.blade', // Blade 模板文件 (Laravel)
|
||||
'.vue', // Vue.js 单文件组件
|
||||
'.jsx', // React JSX 文件
|
||||
'.tsx', // React TSX 文件
|
||||
'.graphql', // GraphQL 查询语言文件
|
||||
'.gql', // GraphQL 查询语言文件
|
||||
'.proto', // Protocol Buffers 文件
|
||||
'.thrift', // Thrift 文件
|
||||
'.toml', // TOML 配置文件
|
||||
'.edn', // Clojure 数据表示文件
|
||||
'.cake', // CakePHP 配置文件
|
||||
'.ctp', // CakePHP 视图文件
|
||||
'.cfm', // ColdFusion 标记语言文件
|
||||
'.cfc', // ColdFusion 组件文件
|
||||
'.m', // Objective-C 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.kts' // Kotlin Script 文件
|
||||
]
|
||||
|
||||
ext = ext.toLowerCase()
|
||||
if (imageExts.includes(ext)) return FileTypes.IMAGE
|
||||
if (videoExts.includes(ext)) return FileTypes.VIDEO
|
||||
|
||||
39
src/main/utils/zip.ts
Normal file
39
src/main/utils/zip.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import util from 'node:util'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
import logger from 'electron-log'
|
||||
|
||||
// 将 zlib 的 gzip 和 gunzip 方法转换为 Promise 版本
|
||||
const gzipPromise = util.promisify(zlib.gzip)
|
||||
const gunzipPromise = util.promisify(zlib.gunzip)
|
||||
|
||||
/**
|
||||
* 压缩字符串
|
||||
* @param {string} string - 要压缩的 JSON 字符串
|
||||
* @returns {Promise<Buffer>} 压缩后的 Buffer
|
||||
*/
|
||||
export async function compress(str) {
|
||||
try {
|
||||
const buffer = Buffer.from(str, 'utf-8')
|
||||
const compressedBuffer = await gzipPromise(buffer)
|
||||
return compressedBuffer
|
||||
} catch (error) {
|
||||
logger.error('Compression failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩 Buffer 到 JSON 字符串
|
||||
* @param {Buffer} compressedBuffer - 压缩的 Buffer
|
||||
* @returns {Promise<string>} 解压缩后的 JSON 字符串
|
||||
*/
|
||||
export async function decompress(compressedBuffer) {
|
||||
try {
|
||||
const buffer = await gunzipPromise(compressedBuffer)
|
||||
return buffer.toString('utf-8')
|
||||
} catch (error) {
|
||||
logger.error('Decompression failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export function createMainWindow() {
|
||||
const theme = appConfig.get('theme') || 'light'
|
||||
|
||||
// Create the browser window.
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
@@ -25,11 +27,12 @@ export function createMainWindow() {
|
||||
minHeight: 600,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
transparent: process.platform === 'darwin',
|
||||
transparent: isMac,
|
||||
vibrancy: 'fullscreen-ui',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
@@ -45,11 +48,9 @@ export function createMainWindow() {
|
||||
|
||||
mainWindow.webContents.on('context-menu', () => {
|
||||
const menu = new Menu()
|
||||
menu.append(new MenuItem({ label: '复制', role: 'copy', sublabel: '⌘ + C' }))
|
||||
menu.append(new MenuItem({ label: '粘贴', role: 'paste', sublabel: '⌘ + V' }))
|
||||
menu.append(new MenuItem({ label: '剪切', role: 'cut', sublabel: '⌘ + X' }))
|
||||
menu.append(new MenuItem({ type: 'separator' }))
|
||||
menu.append(new MenuItem({ label: '全选', role: 'selectAll', sublabel: '⌘ + A' }))
|
||||
menu.append(new MenuItem({ label: '复制', role: 'copy' }))
|
||||
menu.append(new MenuItem({ label: '粘贴', role: 'paste' }))
|
||||
menu.append(new MenuItem({ label: '剪切', role: 'cut' }))
|
||||
menu.popup()
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const api = {
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
reload: () => ipcRenderer.invoke('reload'),
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo/cherry-text.svg" />
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
18
src/renderer/src/assets/images/apps/bolt.svg
Normal file
18
src/renderer/src/assets/images/apps/bolt.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="4" fill="black"/>
|
||||
<g filter="url(#filter0_i_2119_154)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.64368 11.7731C7.91976 11.7731 7.20901 11.5147 6.80099 10.9591L6.65707 11.6143L4 13L4.28684 11.6143L6.22186 3H8.59103L7.9066 6.03634C8.45941 5.44199 8.97273 5.22234 9.63083 5.22234C11.0523 5.22234 12 6.1397 12 7.81938C12 9.55074 10.9076 11.7731 8.64368 11.7731ZM9.55186 8.31036C9.55186 9.11144 8.97273 9.71871 8.22249 9.71871C7.8013 9.71871 7.4196 9.56366 7.16952 9.29233L7.53806 7.70309C7.81447 7.43176 8.13036 7.27671 8.49889 7.27671C9.06486 7.27671 9.55186 7.69017 9.55186 8.31036Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_2119_154" x="4" y="3" width="8" height="10" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.0192413"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.95 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2119_154"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/renderer/src/assets/images/providers/bailian.png
Normal file
BIN
src/renderer/src/assets/images/providers/bailian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -2,10 +2,8 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-nav-dropdown {
|
||||
.ant-dropdown-menu {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.ant-btn:not(:disabled):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ant-segmented-group {
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* 全局初始化滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
width: 4px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ const Container = styled.div`
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 10px;
|
||||
padding: 5px;
|
||||
`
|
||||
|
||||
const Label = styled.p`
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { syncAsistantToAgent } from '@renderer/services/assistant'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Input } from 'antd'
|
||||
import { Button, Input } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Box, VStack } from '../Layout'
|
||||
import { Box, HStack, VStack } from '../Layout'
|
||||
|
||||
const AssistantPromptSettings: React.FC<{ assistant: Assistant }> = (props) => {
|
||||
const AssistantPromptSettings: React.FC<{ assistant: Assistant; onOk: () => void }> = (props) => {
|
||||
const { assistant, updateAssistant } = useAssistant(props.assistant.id)
|
||||
const [name, setName] = useState(assistant.name)
|
||||
const [prompt, setPrompt] = useState(assistant.prompt)
|
||||
@@ -22,14 +22,16 @@ const AssistantPromptSettings: React.FC<{ assistant: Assistant }> = (props) => {
|
||||
|
||||
return (
|
||||
<VStack flex={1}>
|
||||
<Box mb={8}>{t('common.name')}</Box>
|
||||
<Box mb={8} style={{ fontWeight: 'bold' }}>
|
||||
{t('common.name')}
|
||||
</Box>
|
||||
<Input
|
||||
placeholder={t('common.assistant') + t('common.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={onUpdate}
|
||||
/>
|
||||
<Box mt={8} mb={8}>
|
||||
<Box mt={8} mb={8} style={{ fontWeight: 'bold' }}>
|
||||
{t('common.prompt')}
|
||||
</Box>
|
||||
<TextArea
|
||||
@@ -38,8 +40,13 @@ const AssistantPromptSettings: React.FC<{ assistant: Assistant }> = (props) => {
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onBlur={onUpdate}
|
||||
style={{ minHeight: 'calc(80vh - 150px)', maxHeight: 'calc(80vh - 150px)' }}
|
||||
style={{ minHeight: 'calc(80vh - 200px)', maxHeight: 'calc(80vh - 150px)' }}
|
||||
/>
|
||||
<HStack width="100%" justifyContent="flex-end" mt="10px">
|
||||
<Button type="primary" onClick={props.onOk}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ assistant, resolve })
|
||||
/>
|
||||
</LeftMenu>
|
||||
<Settings>
|
||||
{menu === 'prompt' && <AssistantPromptSettings assistant={assistant} />}
|
||||
{menu === 'prompt' && <AssistantPromptSettings assistant={assistant} onOk={onOk} />}
|
||||
{menu === 'model' && <AssistantModelSettings assistant={assistant} />}
|
||||
</Settings>
|
||||
</HStack>
|
||||
|
||||
@@ -102,7 +102,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
size="middle"
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
<Container>
|
||||
{agents.map((agent) => (
|
||||
<AgentItem
|
||||
|
||||
180
src/renderer/src/components/Popups/SelectModelPopup.tsx
Normal file
180
src/renderer/src/components/Popups/SelectModelPopup.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { getModelLogo, isVisionModel } from '@renderer/config/models'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/model'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
||||
import { first, reverse, sortBy } from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number]
|
||||
|
||||
interface Props {
|
||||
model?: Model
|
||||
}
|
||||
|
||||
interface PopupContainerProps extends Props {
|
||||
resolve: (value: Model | undefined) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const { providers } = useProviders()
|
||||
|
||||
const filteredItems: MenuItem[] = providers
|
||||
.filter((p) => p.models && p.models.length > 0)
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
type: 'group',
|
||||
children: reverse(sortBy(p.models, 'name'))
|
||||
.filter((m) => m.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
label: (
|
||||
<ModelItem>
|
||||
{m?.name} {isVisionModel(m) && <VisionIcon />}
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
}))
|
||||
.filter((item) => item.children && item.children.length > 0) as MenuItem[]
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = async () => {
|
||||
resolve(undefined)
|
||||
SelectModelPopup.hide()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
open && setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
maskTransitionName="ant-fade"
|
||||
styles={{ content: { borderRadius: 20, padding: 0, overflow: 'hidden', paddingBottom: 20 } }}
|
||||
closeIcon={null}
|
||||
footer={null}>
|
||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<SearchOutlined />
|
||||
</SearchIcon>
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('model.search')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ paddingLeft: 0 }}
|
||||
bordered={false}
|
||||
size="middle"
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
<Container>
|
||||
{filteredItems.length > 0 ? (
|
||||
<StyledMenu
|
||||
items={filteredItems}
|
||||
selectedKeys={model ? [getModelUniqId(model)] : []}
|
||||
mode="inline"
|
||||
inlineIndent={6}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</EmptyState>
|
||||
)}
|
||||
</Container>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
height: 50vh;
|
||||
margin-top: 10px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const StyledMenu = styled(Menu)`
|
||||
background-color: transparent;
|
||||
padding: 5px;
|
||||
margin-top: -10px;
|
||||
max-height: calc(60vh - 50px);
|
||||
|
||||
.ant-menu-item-group-title {
|
||||
padding: 5px 10px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
}
|
||||
`
|
||||
|
||||
const ModelItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
`
|
||||
|
||||
const SearchIcon = styled.div`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-right: 2px;
|
||||
`
|
||||
|
||||
export default class SelectModelPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('SelectModelPopup')
|
||||
}
|
||||
static show(params: Props) {
|
||||
return new Promise<Model | undefined>((resolve) => {
|
||||
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ export const isWindows = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const imageExts = ['.jpg', '.png', '.jpeg']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import AiAssistantAppLogo from '@renderer/assets/images/apps/360-ai.png'
|
||||
import AiSearchAppLogo from '@renderer/assets/images/apps/ai-search.png'
|
||||
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
|
||||
import FeloAppLogo from '@renderer/assets/images/apps/felo.png'
|
||||
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp'
|
||||
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg'
|
||||
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
|
||||
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png'
|
||||
import FeloAppLogo from '@renderer/assets/images/apps/felo.png'
|
||||
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png'
|
||||
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg'
|
||||
import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg'
|
||||
@@ -207,6 +208,13 @@ const _apps: MinAppType[] = [
|
||||
logo: FeloAppLogo,
|
||||
url: 'https://felo.ai/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'bolt',
|
||||
name: 'bolt',
|
||||
logo: BoltAppLogo,
|
||||
url: 'https://bolt.new/',
|
||||
bodered: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -346,13 +346,13 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
'azure-openai': [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
provider: 'azure-openai',
|
||||
name: ' GPT-4o',
|
||||
group: 'GPT 4o'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini',
|
||||
provider: 'openai',
|
||||
provider: 'azure-openai',
|
||||
name: ' GPT-4o-mini',
|
||||
group: 'GPT 4o'
|
||||
}
|
||||
@@ -695,7 +695,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Baichuan3'
|
||||
}
|
||||
],
|
||||
dashscope: [
|
||||
bailian: [
|
||||
{
|
||||
id: 'qwen-turbo',
|
||||
provider: 'dashscope',
|
||||
|
||||
@@ -3,8 +3,8 @@ import AzureProviderLogo from '@renderer/assets/images/models/microsoft.png'
|
||||
import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.jpg'
|
||||
import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.png'
|
||||
import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png'
|
||||
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/bytedance.png'
|
||||
import DashScopeProviderLogo from '@renderer/assets/images/providers/dashscope.png'
|
||||
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
|
||||
import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png'
|
||||
import GithubProviderLogo from '@renderer/assets/images/providers/github.png'
|
||||
@@ -47,7 +47,7 @@ export function getProviderLogo(providerId: string) {
|
||||
case 'baichuan':
|
||||
return BaichuanProviderLogo
|
||||
case 'dashscope':
|
||||
return DashScopeProviderLogo
|
||||
return BailianProviderLogo
|
||||
case 'anthropic':
|
||||
return AnthropicProviderLogo
|
||||
case 'aihubmix':
|
||||
@@ -208,10 +208,10 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://dashscope.aliyun.com/',
|
||||
apiKey: 'https://help.aliyun.com/zh/dashscope/developer-reference/acquisition-and-configuration-of-api-key',
|
||||
docs: 'https://help.aliyun.com/zh/dashscope/',
|
||||
models: 'https://dashscope.console.aliyun.com/model'
|
||||
official: 'https://www.aliyun.com/product/bailian',
|
||||
apiKey: 'https://bailian.console.aliyun.com/?apiKey=1#/api-key',
|
||||
docs: 'https://help.aliyun.com/zh/model-studio/getting-started/',
|
||||
models: 'https://bailian.console.aliyun.com/model-market#/model-market'
|
||||
}
|
||||
},
|
||||
stepfun: {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
providers,
|
||||
providers: providers || {},
|
||||
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
|
||||
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
|
||||
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"default": "Default",
|
||||
"warning": "Warning",
|
||||
"back": "Back",
|
||||
"chat": "Chat"
|
||||
"chat": "Chat",
|
||||
"close": "Close"
|
||||
},
|
||||
"button": {
|
||||
"add": "Add",
|
||||
@@ -71,6 +72,7 @@
|
||||
"topics.auto_rename": "Auto Rename",
|
||||
"topics.edit.title": "Edit Name",
|
||||
"topics.edit.placeholder": "Enter new name",
|
||||
"topics.clear.title": "Clear Messages",
|
||||
"topics.delete.all.title": "Delete all topics",
|
||||
"topics.delete.all.content": "Are you sure you want to delete all topics?",
|
||||
"topics.move_to": "Move to",
|
||||
@@ -89,7 +91,7 @@
|
||||
"input.send": "Send",
|
||||
"input.pause": "Pause",
|
||||
"input.settings": "Settings",
|
||||
"input.upload": "Upload image or text file",
|
||||
"input.upload": "Upload image or document file",
|
||||
"input.context_count.tip": "Context Count",
|
||||
"input.estimated_tokens.tip": "Estimated tokens",
|
||||
"settings.temperature": "Temperature",
|
||||
@@ -105,7 +107,9 @@
|
||||
"add.assistant.title": "Add Assistant",
|
||||
"message.new.context": "New Context",
|
||||
"message.new.branch": "New Branch",
|
||||
"assistant.search.placeholder": "Search"
|
||||
"assistant.search.placeholder": "Search",
|
||||
"artifacts.button.preview": "Preview",
|
||||
"artifacts.button.download": "Download"
|
||||
},
|
||||
"assistants": {
|
||||
"title": "Assistants",
|
||||
@@ -115,7 +119,8 @@
|
||||
"model_settings": "Model Settings"
|
||||
},
|
||||
"model": {
|
||||
"stream_output": "Stream Output"
|
||||
"stream_output": "Stream Output",
|
||||
"search": "Search models..."
|
||||
},
|
||||
"files": {
|
||||
"title": "Files",
|
||||
@@ -167,7 +172,7 @@
|
||||
"groq": "Groq",
|
||||
"ollama": "Ollama",
|
||||
"baichuan": "Baichuan",
|
||||
"dashscope": "DashScope",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
"anthropic": "Anthropic",
|
||||
"aihubmix": "AiHubMix",
|
||||
"stepfun": "StepFun",
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"default": "默认",
|
||||
"warning": "警告",
|
||||
"back": "返回",
|
||||
"chat": "聊天"
|
||||
"chat": "聊天",
|
||||
"close": "关闭"
|
||||
},
|
||||
"button": {
|
||||
"add": "添加",
|
||||
@@ -71,6 +72,7 @@
|
||||
"topics.auto_rename": "生成话题名",
|
||||
"topics.edit.title": "编辑话题名",
|
||||
"topics.edit.placeholder": "输入新名称",
|
||||
"topics.clear.title": "清空消息",
|
||||
"topics.delete.all.title": "删除所有话题",
|
||||
"topics.delete.all.content": "确定要删除所有话题吗?",
|
||||
"topics.move_to": "移动到",
|
||||
@@ -83,13 +85,13 @@
|
||||
"input.new.context": "清除上下文",
|
||||
"input.expand": "展开",
|
||||
"input.collapse": "收起",
|
||||
"input.clear.title": "清除消息?",
|
||||
"input.clear.title": "清空消息",
|
||||
"input.clear.content": "确定要清除当前会话所有消息吗?",
|
||||
"input.placeholder": "在这里输入消息...",
|
||||
"input.send": "发送",
|
||||
"input.pause": "暂停",
|
||||
"input.settings": "设置",
|
||||
"input.upload": "上传图片或纯文本文件",
|
||||
"input.upload": "上传图片或文档",
|
||||
"input.context_count.tip": "上下文数",
|
||||
"input.estimated_tokens.tip": "预估 token 数",
|
||||
"settings.temperature": "模型温度",
|
||||
@@ -105,7 +107,9 @@
|
||||
"add.assistant.title": "添加助手",
|
||||
"message.new.context": "清除上下文",
|
||||
"message.new.branch": "新分支",
|
||||
"assistant.search.placeholder": "搜索"
|
||||
"assistant.search.placeholder": "搜索",
|
||||
"artifacts.button.preview": "预览",
|
||||
"artifacts.button.download": "下载"
|
||||
},
|
||||
"assistants": {
|
||||
"title": "助手",
|
||||
@@ -115,7 +119,8 @@
|
||||
"model_settings": "模型设置"
|
||||
},
|
||||
"model": {
|
||||
"stream_output": "流式输出"
|
||||
"stream_output": "流式输出",
|
||||
"search": "搜索模型..."
|
||||
},
|
||||
"files": {
|
||||
"title": "文件",
|
||||
@@ -167,7 +172,7 @@
|
||||
"groq": "Groq",
|
||||
"ollama": "Ollama",
|
||||
"baichuan": "百川",
|
||||
"dashscope": "阿里云灵积",
|
||||
"dashscope": "阿里云百炼",
|
||||
"anthropic": "Anthropic",
|
||||
"aihubmix": "AiHubMix",
|
||||
"stepfun": "阶跃星辰",
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"default": "預設",
|
||||
"warning": "警告",
|
||||
"back": "返回",
|
||||
"chat": "聊天"
|
||||
"chat": "聊天",
|
||||
"close": "關閉"
|
||||
},
|
||||
"button": {
|
||||
"add": "添加",
|
||||
@@ -71,6 +72,7 @@
|
||||
"topics.auto_rename": "自動重新命名",
|
||||
"topics.edit.title": "編輯名稱",
|
||||
"topics.edit.placeholder": "輸入新名稱",
|
||||
"topics.clear.title": "清空消息",
|
||||
"topics.delete.all.title": "刪除所有話題",
|
||||
"topics.delete.all.content": "確定要刪除所有話題嗎?",
|
||||
"topics.move_to": "移動到",
|
||||
@@ -89,7 +91,7 @@
|
||||
"input.send": "發送",
|
||||
"input.pause": "暫停",
|
||||
"input.settings": "設定",
|
||||
"input.upload": "上傳圖片或文字檔",
|
||||
"input.upload": "上傳圖片或文檔",
|
||||
"input.context_count.tip": "上下文數量",
|
||||
"input.estimated_tokens.tip": "預估 Token 數",
|
||||
"settings.temperature": "溫度",
|
||||
@@ -105,7 +107,9 @@
|
||||
"add.assistant.title": "添加助手",
|
||||
"message.new.context": "新上下文",
|
||||
"message.new.branch": "新分支",
|
||||
"assistant.search.placeholder": "搜尋"
|
||||
"assistant.search.placeholder": "搜尋",
|
||||
"artifacts.button.preview": "預覽",
|
||||
"artifacts.button.download": "下載"
|
||||
},
|
||||
"assistants": {
|
||||
"title": "助手",
|
||||
@@ -115,7 +119,8 @@
|
||||
"model_settings": "模型設定"
|
||||
},
|
||||
"model": {
|
||||
"stream_output": "串流輸出"
|
||||
"stream_output": "串流輸出",
|
||||
"search": "搜尋模型..."
|
||||
},
|
||||
"files": {
|
||||
"title": "檔案",
|
||||
@@ -162,12 +167,12 @@
|
||||
"moonshot": "月之暗面",
|
||||
"silicon": "SiliconFlow",
|
||||
"openrouter": "OpenRouter",
|
||||
"yi": "零一万物",
|
||||
"zhipu": "智谱AI",
|
||||
"yi": "零一萬物",
|
||||
"zhipu": "智譜AI",
|
||||
"groq": "Groq",
|
||||
"ollama": "Ollama",
|
||||
"baichuan": "百川",
|
||||
"dashscope": "DashScope",
|
||||
"dashscope": "阿里雲百鍊",
|
||||
"anthropic": "Anthropic",
|
||||
"aihubmix": "AiHubMix",
|
||||
"stepfun": "StepFun",
|
||||
|
||||
@@ -61,22 +61,24 @@ const AppsPage: FC = () => {
|
||||
{agents.length > 0 && <ManageIcon onClick={ManageAgentsPopup.show} />}
|
||||
</HStack>
|
||||
<UserAgents onAdd={onAddAgentConfirm} />
|
||||
{Object.keys(agentGroups).map((group) => (
|
||||
<div key={group}>
|
||||
<Title level={4} key={group} style={{ marginBottom: 16 }}>
|
||||
{group}
|
||||
</Title>
|
||||
<Row gutter={16}>
|
||||
{agentGroups[group].map((agent, index) => {
|
||||
return (
|
||||
<Col span={8} key={group + index}>
|
||||
<AgentCard onClick={() => onAddAgentConfirm(agent)} agent={agent as any} />
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(agentGroups)
|
||||
.reverse()
|
||||
.map((group) => (
|
||||
<div key={group}>
|
||||
<Title level={4} key={group} style={{ marginBottom: 16 }}>
|
||||
{group}
|
||||
</Title>
|
||||
<Row gutter={16}>
|
||||
{agentGroups[group].map((agent, index) => {
|
||||
return (
|
||||
<Col span={8} key={group + index}>
|
||||
<AgentCard onClick={() => onAddAgentConfirm(agent)} agent={agent as any} />
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ minHeight: 20 }} />
|
||||
</AssistantsContainer>
|
||||
</ContentContainer>
|
||||
|
||||
@@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
import Inputbar from './Inputbar/Inputbar'
|
||||
import Messages from './Messages/Messages'
|
||||
import RightSidebar from './RightSidebar'
|
||||
import Tabs from './Tabs'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
@@ -29,7 +29,7 @@ const Chat: FC<Props> = (props) => {
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} />
|
||||
</Main>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<RightSidebar
|
||||
<Tabs
|
||||
activeAssistant={assistant}
|
||||
activeTopic={props.activeTopic}
|
||||
setActiveAssistant={props.setActiveAssistant}
|
||||
|
||||
@@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
import Chat from './Chat'
|
||||
import Navbar from './Navbar'
|
||||
import RightSidebar from './RightSidebar'
|
||||
import HomeTabs from './Tabs'
|
||||
|
||||
let _activeAssistant: Assistant
|
||||
|
||||
@@ -29,7 +29,7 @@ const HomePage: FC = () => {
|
||||
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
|
||||
<ContentContainer id="content-container">
|
||||
{showAssistants && (
|
||||
<RightSidebar
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PaperClipOutlined } from '@ant-design/icons'
|
||||
import { imageExts, textExts } from '@renderer/config/constant'
|
||||
import { documentExts, imageExts, textExts } from '@renderer/config/constant'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { FileType, Model } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
@@ -15,7 +15,9 @@ interface Props {
|
||||
|
||||
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => {
|
||||
const { t } = useTranslation()
|
||||
const extensions = isVisionModel(model) ? [...imageExts, ...textExts] : [...textExts]
|
||||
const extensions = isVisionModel(model)
|
||||
? [...imageExts, ...documentExts, ...textExts]
|
||||
: [...documentExts, ...textExts]
|
||||
|
||||
const onSelectFile = async () => {
|
||||
if (files.length > 0) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PauseCircleOutlined,
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { imageExts, textExts } from '@renderer/config/constant'
|
||||
import { documentExts, imageExts, textExts } from '@renderer/config/constant'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
@@ -16,7 +16,7 @@ import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { getDefaultTopic } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import FileManager from '@renderer/services/file'
|
||||
import { estimateTextTokens } from '@renderer/services/tokens'
|
||||
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/tokens'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setGenerating, setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant, FileType, Message, Topic } from '@renderer/types'
|
||||
@@ -46,7 +46,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
const [inputFocus, setInputFocus] = useState(false)
|
||||
const { addTopic, model } = useAssistant(assistant.id)
|
||||
const { sendMessageShortcut, fontSize, pasteLongTextAsFile } = useSettings()
|
||||
const { sendMessageShortcut, fontSize, pasteLongTextAsFile, showInputEstimatedTokens } = useSettings()
|
||||
const [expended, setExpend] = useState(false)
|
||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||
const [contextCount, setContextCount] = useState(0)
|
||||
@@ -60,8 +60,13 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const inputTokenCount = useMemo(() => estimateTextTokens(text), [text])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
|
||||
const estimateTextTokens = useCallback(debounce(estimateTxtTokens, 1000), [])
|
||||
const inputTokenCount = useMemo(
|
||||
() => (showInputEstimatedTokens ? estimateTextTokens(text) || 0 : 0),
|
||||
[estimateTextTokens, showInputEstimatedTokens, text]
|
||||
)
|
||||
|
||||
_text = text
|
||||
_files = files
|
||||
@@ -207,14 +212,14 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
await window.api.file.write(tempFilePath, pasteText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
setText((prevText) => prevText.replace(pasteText, ''))
|
||||
setText(text)
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[pasteLongTextAsFile, supportExts]
|
||||
[pasteLongTextAsFile, supportExts, text]
|
||||
)
|
||||
|
||||
// Command or Ctrl + N create new topic
|
||||
@@ -243,10 +248,13 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
|
||||
_setEstimateTokenCount(tokensCount)
|
||||
setContextCount(contextCount)
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, () => {
|
||||
addNewTopic()
|
||||
})
|
||||
]
|
||||
return () => unsubscribes.forEach((unsub) => unsub())
|
||||
}, [])
|
||||
}, [addNewTopic])
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
|
||||
51
src/renderer/src/pages/home/Markdown/Artifacts.tsx
Normal file
51
src/renderer/src/pages/home/Markdown/Artifacts.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { extractTitle } from '@renderer/utils/formula'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'Artifacts' + ' ' + t('chat.artifacts.button.preview')
|
||||
|
||||
const onPreview = async () => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
|
||||
MinApp.start({
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: `file://${path}`
|
||||
})
|
||||
}
|
||||
|
||||
const onDownload = () => {
|
||||
window.api.file.save(`${title}.html`, html)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button type="primary" size="middle" onClick={onPreview}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button size="middle" onClick={onDownload}>
|
||||
{t('chat.artifacts.button.download')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
export default Artifacts
|
||||
@@ -9,6 +9,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Artifacts from './Artifacts'
|
||||
import Mermaid from './Mermaid'
|
||||
|
||||
interface CodeBlockProps {
|
||||
@@ -21,8 +22,9 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const showFooterCopyButton = children && children.length > 500
|
||||
const { theme } = useTheme()
|
||||
const language = match?.[1]
|
||||
|
||||
if (match && match[1] === 'mermaid') {
|
||||
if (language === 'mermaid') {
|
||||
initMermaid(theme)
|
||||
return <Mermaid chart={children} />
|
||||
}
|
||||
@@ -50,6 +52,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
|
||||
</CodeFooter>
|
||||
)}
|
||||
{language === 'html' && children?.includes('</html>') && <Artifacts html={children} />}
|
||||
</div>
|
||||
) : (
|
||||
<code className={className}>{children}</code>
|
||||
|
||||
62
src/renderer/src/pages/home/Markdown/ImagePreview.tsx
Normal file
62
src/renderer/src/pages/home/Markdown/ImagePreview.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
DownloadOutlined,
|
||||
RotateLeftOutlined,
|
||||
RotateRightOutlined,
|
||||
SwapOutlined,
|
||||
UndoOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Image, Space } from 'antd'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ImagePreviewProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string
|
||||
}
|
||||
|
||||
const ImagePreview: React.FC<ImagePreviewProps> = ({ src }) => {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
preview={{
|
||||
toolbarRender: (
|
||||
_,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}
|
||||
) => (
|
||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<DownloadOutlined onClick={() => download(src)} />
|
||||
</ToobarWrapper>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ToobarWrapper = styled(Space)`
|
||||
padding: 0px 24px;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 100px;
|
||||
.anticon {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anticon:hover {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`
|
||||
|
||||
export default ImagePreview
|
||||
@@ -7,26 +7,20 @@ import { isEmpty } from 'lodash'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown, { Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
// @ts-ignore next-line
|
||||
import rehypeMathjax from 'rehype-mathjax'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import Link from './Link'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
|
||||
const rehypePlugins = [rehypeRaw, rehypeKatex]
|
||||
const remarkPlugins = [remarkMath, remarkGfm]
|
||||
|
||||
const components = {
|
||||
code: CodeBlock,
|
||||
a: Link
|
||||
}
|
||||
|
||||
const Markdown: FC<Props> = ({ message }) => {
|
||||
const { t } = useTranslation()
|
||||
const { renderInputMessageAsMarkdown } = useSettings()
|
||||
@@ -38,6 +32,11 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
return escapeBrackets(content)
|
||||
}, [message.content, message.status, t])
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const hasUnsafeElements = /<(input|textarea|select)/i.test(messageContent)
|
||||
return hasUnsafeElements ? [rehypeMathjax] : [rehypeRaw, rehypeMathjax]
|
||||
}, [messageContent])
|
||||
|
||||
if (message.role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
}
|
||||
@@ -46,8 +45,14 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
<ReactMarkdown
|
||||
className="markdown"
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components as Partial<Components>}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
components={
|
||||
{
|
||||
a: Link,
|
||||
code: CodeBlock,
|
||||
img: ImagePreview
|
||||
} as Partial<Components>
|
||||
}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
|
||||
@@ -42,15 +42,17 @@ const MessageItem: FC<Props> = ({ message, index, lastMessage, showMenu = true,
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribes = [
|
||||
EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, () => {
|
||||
EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, (highlight: boolean = true) => {
|
||||
if (messageRef.current) {
|
||||
messageRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
setTimeout(() => {
|
||||
messageRef.current?.classList.add('message-highlight')
|
||||
if (highlight) {
|
||||
setTimeout(() => {
|
||||
messageRef.current?.classList.remove('message-highlight')
|
||||
}, 2500)
|
||||
}, 500)
|
||||
messageRef.current?.classList.add('message-highlight')
|
||||
setTimeout(() => {
|
||||
messageRef.current?.classList.remove('message-highlight')
|
||||
}, 2500)
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Assistant, Message, Model } from '@renderer/types'
|
||||
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { Avatar } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { upperFirst } from 'lodash'
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -36,7 +35,7 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
|
||||
|
||||
const getUserName = useCallback(() => {
|
||||
if (isLocalAi && message.role !== 'user') return APP_NAME
|
||||
if (message.role === 'assistant') return upperFirst(model?.name || model?.id)
|
||||
if (message.role === 'assistant') return model?.name || model?.id || ''
|
||||
return userName || t('common.you')
|
||||
}, [message.role, model?.id, model?.name, t, userName])
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SaveOutlined,
|
||||
SyncOutlined
|
||||
} from '@ant-design/icons'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
@@ -17,8 +18,6 @@ import { FC, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelDropdown from '../components/SelectModelDropdown'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
model?: Model
|
||||
@@ -83,6 +82,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
[message.content, message.createdAt, onEdit, t]
|
||||
)
|
||||
|
||||
const onSelectModel = async () => {
|
||||
const selectedModel = await SelectModelPopup.show({ model })
|
||||
selectedModel && onRegenerate(selectedModel)
|
||||
}
|
||||
|
||||
return (
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
{message.role === 'user' && (
|
||||
@@ -99,13 +103,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{canRegenerate && (
|
||||
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton>
|
||||
<SyncOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</SelectModelDropdown>
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onSelectModel}>
|
||||
<SyncOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useRuntime } from '@renderer/hooks/useStore'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Message } from '@renderer/types'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const MessgeTokens: React.FC<{ message: Message }> = ({ message }) => {
|
||||
const { generating } = useRuntime()
|
||||
|
||||
const locateMessage = () => {
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
||||
}
|
||||
|
||||
if (!message.usage) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (message.role === 'user') {
|
||||
return <MessageMetadata>Tokens: {message?.usage?.total_tokens}</MessageMetadata>
|
||||
return <MessageMetadata onClick={locateMessage}>Tokens: {message?.usage?.total_tokens}</MessageMetadata>
|
||||
}
|
||||
|
||||
if (generating) {
|
||||
@@ -19,7 +24,7 @@ const MessgeTokens: React.FC<{ message: Message }> = ({ message }) => {
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
return (
|
||||
<MessageMetadata>
|
||||
<MessageMetadata onClick={locateMessage}>
|
||||
Tokens: {message?.usage?.total_tokens} | ↑{message?.usage?.prompt_tokens} | ↓{message?.usage?.completion_tokens}
|
||||
</MessageMetadata>
|
||||
)
|
||||
@@ -33,6 +38,7 @@ const MessageMetadata = styled.div`
|
||||
color: var(--color-text-2);
|
||||
user-select: text;
|
||||
margin: 2px 0;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
export default MessgeTokens
|
||||
|
||||
@@ -131,7 +131,8 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
||||
EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic),
|
||||
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => {
|
||||
setMessages([])
|
||||
updateTopic({ ...topic, messages: [] })
|
||||
const defaultTopic = getDefaultTopic(assistant.id)
|
||||
updateTopic({ ...topic, name: defaultTopic.name, messages: [] })
|
||||
TopicManager.clearTopicMessages(topic.id)
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||
|
||||
@@ -4,11 +4,9 @@ import AssistantSettingPopup from '@renderer/components/AssistantSettings'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { getDefaultTopic } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Switch } from 'antd'
|
||||
@@ -24,8 +22,8 @@ interface Props {
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
}
|
||||
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveTopic }) => {
|
||||
const { assistant, addTopic } = useAssistant(activeAssistant.id)
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { topicPosition } = useSettings()
|
||||
@@ -33,13 +31,10 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveTopic }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const addNewTopic = useCallback(() => {
|
||||
const topic = getDefaultTopic(assistant.id)
|
||||
addTopic(topic)
|
||||
setActiveTopic(topic)
|
||||
db.topics.add({ id: topic.id, messages: [] })
|
||||
EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)
|
||||
window.message.success({ content: t('message.topic.added'), key: 'topic-added' })
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
||||
}, [addTopic, assistant.id, setActiveTopic, t])
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Navbar>
|
||||
|
||||
@@ -214,12 +214,12 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
onChange={(value) => setFontSizeValue(value)}
|
||||
onChangeComplete={(value) => dispatch(setFontSize(value))}
|
||||
min={12}
|
||||
max={18}
|
||||
max={22}
|
||||
step={1}
|
||||
marks={{
|
||||
12: <span style={{ fontSize: '12px' }}>A</span>,
|
||||
14: <span style={{ fontSize: '14px' }}>{t('common.default')}</span>,
|
||||
18: <span style={{ fontSize: '18px' }}>A</span>
|
||||
22: <span style={{ fontSize: '18px' }}>A</span>
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -1,11 +1,19 @@
|
||||
import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
ClearOutlined,
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { TopicManager } from '@renderer/hooks/useTopic'
|
||||
import { fetchMessagesSummary } from '@renderer/services/api'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
import { findIndex } from 'lodash'
|
||||
@@ -31,11 +39,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
window.message.warning({ content: t('message.switch.disabled'), key: 'generating' })
|
||||
return
|
||||
}
|
||||
if (assistant.topics.length > 1) {
|
||||
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
|
||||
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
|
||||
removeTopic(topic)
|
||||
}
|
||||
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
|
||||
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
|
||||
removeTopic(topic)
|
||||
},
|
||||
[assistant.topics, generating, removeTopic, setActiveTopic, t]
|
||||
)
|
||||
@@ -64,6 +70,12 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
[generating, setActiveTopic, t]
|
||||
)
|
||||
|
||||
const onClearMessages = useCallback(() => {
|
||||
window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, true)
|
||||
store.dispatch(setGenerating(false))
|
||||
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES)
|
||||
}, [])
|
||||
|
||||
const getTopicMenuItems = useCallback(
|
||||
(topic: Topic) => {
|
||||
const menus: MenuProps['items'] = [
|
||||
@@ -96,6 +108,18 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.clear.title'),
|
||||
key: 'clear-messages',
|
||||
icon: <ClearOutlined />,
|
||||
async onClick() {
|
||||
window.modal.confirm({
|
||||
title: t('chat.input.clear.content'),
|
||||
centered: true,
|
||||
onOk: onClearMessages
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.title'),
|
||||
key: 'export',
|
||||
@@ -138,7 +162,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
|
||||
return menus
|
||||
},
|
||||
[assistant, assistants, onDeleteTopic, onMoveTopic, t, updateTopic]
|
||||
[assistant, assistants, onClearMessages, onDeleteTopic, onMoveTopic, t, updateTopic]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -150,11 +174,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}>
|
||||
<TopicListItem className={isActive ? 'active' : ''} onClick={() => onSwitchTopic(topic)}>
|
||||
<TopicName className="name">{topic.name.replace('`', '')}</TopicName>
|
||||
{assistant.topics.length > 1 && isActive && (
|
||||
{isActive && (
|
||||
<MenuButton
|
||||
className="menu"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (assistant.topics.length === 1) {
|
||||
return onClearMessages()
|
||||
}
|
||||
onDeleteTopic(topic)
|
||||
}}>
|
||||
<CloseOutlined />
|
||||
@@ -27,7 +27,7 @@ type Tab = 'assistants' | 'topic' | 'settings'
|
||||
|
||||
let _tab: any = ''
|
||||
|
||||
const RightSidebar: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant, setActiveTopic, position }) => {
|
||||
const HomeTabs: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant, setActiveTopic, position }) => {
|
||||
const { addAssistant } = useAssistants()
|
||||
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
|
||||
const { topicPosition } = useSettings()
|
||||
@@ -164,4 +164,4 @@ const TabContent = styled.div`
|
||||
overflow-x: hidden;
|
||||
`
|
||||
|
||||
export default RightSidebar
|
||||
export default HomeTabs
|
||||
@@ -1,17 +1,15 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { upperFirst } from 'lodash'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelDropdown from './SelectModelDropdown'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
}
|
||||
@@ -24,14 +22,20 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const onSelectModel = async (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.currentTarget.blur()
|
||||
const selectedModel = await SelectModelPopup.show({ model })
|
||||
if (selectedModel) {
|
||||
setModel(selectedModel)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectModelDropdown model={model} onSelect={setModel} placement="top">
|
||||
<DropdownButton size="small" type="default">
|
||||
<ModelAvatar model={model} size={20} />
|
||||
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
|
||||
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
|
||||
</DropdownButton>
|
||||
</SelectModelDropdown>
|
||||
<DropdownButton size="small" type="default" onClick={onSelectModel}>
|
||||
<ModelAvatar model={model} size={20} />
|
||||
<ModelName>{model ? model.name : t('button.select_model')}</ModelName>
|
||||
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +43,7 @@ const DropdownButton = styled(Button)`
|
||||
font-size: 11px;
|
||||
border-radius: 15px;
|
||||
padding: 12px 8px 12px 3px;
|
||||
-webkit-app-region: none;
|
||||
`
|
||||
|
||||
const ModelName = styled.span`
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||
import { getModelLogo, isVisionModel } from '@renderer/config/models'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/model'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Dropdown, DropdownProps, MenuProps } from 'antd'
|
||||
import { first, reverse, sortBy, upperFirst } from 'lodash'
|
||||
import { FC, PropsWithChildren } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends DropdownProps {
|
||||
model?: Model
|
||||
onSelect: (model: Model) => void
|
||||
}
|
||||
|
||||
const SelectModelDropdown: FC<Props & PropsWithChildren> = ({ children, model, onSelect, ...props }) => {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
|
||||
const items: MenuProps['items'] = (providers || [])
|
||||
.filter((p) => p.models && p.models.length > 0)
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
type: 'group',
|
||||
children: reverse(sortBy(p.models, 'name')).map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
label: (
|
||||
<div>
|
||||
{upperFirst(m?.name)} {isVisionModel(m) && <VisionIcon />}
|
||||
</div>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => m && onSelect(m)
|
||||
}))
|
||||
}))
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
menu={{
|
||||
items,
|
||||
style: { maxHeight: '55vh', overflow: 'auto' },
|
||||
selectedKeys: model ? [getModelUniqId(model)] : []
|
||||
}}
|
||||
trigger={['click']}
|
||||
arrow
|
||||
placement="bottom"
|
||||
overlayClassName="chat-nav-dropdown"
|
||||
{...props}>
|
||||
{children}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenu = styled(Dropdown)`
|
||||
-webkit-app-region: none;
|
||||
`
|
||||
|
||||
export default SelectModelDropdown
|
||||
@@ -4,7 +4,7 @@ import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/model'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Select } from 'antd'
|
||||
import { find, sortBy, upperFirst } from 'lodash'
|
||||
import { find, sortBy } from 'lodash'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -23,7 +23,7 @@ const ModelSettings: FC = () => {
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
title: p.name,
|
||||
options: sortBy(p.models, 'name').map((m) => ({
|
||||
label: upperFirst(m.name),
|
||||
label: m.name,
|
||||
value: getModelUniqId(m)
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (file.type === FileTypes.TEXT) {
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
|
||||
@@ -40,7 +40,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
}
|
||||
} as InlineDataPart)
|
||||
}
|
||||
if (file.type === FileTypes.TEXT) {
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
parts.push({
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
|
||||
@@ -64,7 +64,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
|
||||
if (this.isNotSupportFiles) {
|
||||
if (message.files) {
|
||||
const textFiles = message.files.filter((file) => file.type === FileTypes.TEXT)
|
||||
const textFiles = message.files.filter((file) => [FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type))
|
||||
|
||||
if (textFiles.length > 0) {
|
||||
let text = ''
|
||||
@@ -104,7 +104,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
image_url: { url: image.data }
|
||||
})
|
||||
}
|
||||
if (file.type === FileTypes.TEXT) {
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
@@ -134,13 +134,16 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
userMessages.push(await this.getMessageParam(message, model))
|
||||
}
|
||||
|
||||
const isOpenAIo1 = model.id.includes('o1-')
|
||||
const isSupportStreamOutput = streamOutput && this.isSupportStreamOutput(model.id)
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
const stream = await this.sdk.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
|
||||
temperature: assistant?.settings?.temperature,
|
||||
messages: [isOpenAIo1 ? undefined : systemMessage, ...userMessages].filter(
|
||||
Boolean
|
||||
) as ChatCompletionMessageParam[],
|
||||
temperature: isOpenAIo1 ? 1 : assistant?.settings?.temperature,
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime,
|
||||
stream: isSupportStreamOutput
|
||||
|
||||
@@ -29,9 +29,6 @@ export async function restore() {
|
||||
data = JSON.parse(await window.api.decompress(file.content))
|
||||
}
|
||||
|
||||
// 处理文件内容
|
||||
console.log('Parsed file content:', data)
|
||||
|
||||
await handleData(data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -19,5 +19,6 @@ export const EVENT_NAMES = {
|
||||
NEW_CONTEXT: 'NEW_CONTEXT',
|
||||
NEW_BRANCH: 'NEW_BRANCH',
|
||||
EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE',
|
||||
LOCATE_MESSAGE: 'LOCATE_MESSAGE'
|
||||
LOCATE_MESSAGE: 'LOCATE_MESSAGE',
|
||||
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC'
|
||||
}
|
||||
|
||||
@@ -17,10 +17,8 @@ async function getFileContent(file: FileType) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fileId = file.id + file.ext
|
||||
|
||||
if (file.type === FileTypes.TEXT) {
|
||||
return await window.api.file.read(fileId)
|
||||
return await window.api.file.read(file.id + file.ext)
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
@@ -22,7 +22,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 30,
|
||||
version: 31,
|
||||
blacklist: ['runtime'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -143,10 +143,10 @@ const initialState: LlmState = {
|
||||
},
|
||||
{
|
||||
id: 'dashscope',
|
||||
name: 'DashScope',
|
||||
name: 'Bailian',
|
||||
apiKey: '',
|
||||
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
models: SYSTEM_MODELS.dashscope,
|
||||
models: SYSTEM_MODELS.bailian,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
|
||||
@@ -182,7 +182,7 @@ const migrateConfig = {
|
||||
name: 'DashScope',
|
||||
apiKey: '',
|
||||
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
models: SYSTEM_MODELS.dashscope,
|
||||
models: SYSTEM_MODELS.bailian,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
@@ -525,6 +525,20 @@ const migrateConfig = {
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
'31': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
llm: {
|
||||
...state.llm,
|
||||
providers: state.llm.providers.map((provider) => {
|
||||
if (provider.id === 'azure-openai') {
|
||||
provider.models = provider.models.map((model) => ({ ...model, provider: 'azure-openai' }))
|
||||
}
|
||||
return provider
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
61
src/renderer/src/utils/download.ts
Normal file
61
src/renderer/src/utils/download.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const download = (url: string) => {
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
// 尝试从Content-Disposition头获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = 'download' // 默认文件名
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 如果URL中有文件名,使用URL中的文件名
|
||||
const urlFilename = url.split('/').pop()
|
||||
if (urlFilename && urlFilename.includes('.')) {
|
||||
filename = urlFilename
|
||||
}
|
||||
|
||||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||||
if (!filename.includes('.')) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
const extension = getExtensionFromMimeType(contentType)
|
||||
filename += extension
|
||||
}
|
||||
|
||||
// 添加时间戳以确保文件名唯一
|
||||
const timestamp = Date.now()
|
||||
const finalFilename = `${timestamp}_${filename}`
|
||||
|
||||
return response.blob().then((blob) => ({ blob, finalFilename }))
|
||||
})
|
||||
.then(({ blob, finalFilename }) => {
|
||||
const blobUrl = URL.createObjectURL(new Blob([blob]))
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = finalFilename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
link.remove()
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数:根据MIME类型获取文件扩展名
|
||||
function getExtensionFromMimeType(mimeType: string | null): string {
|
||||
if (!mimeType) return '.bin' // 默认二进制文件扩展名
|
||||
|
||||
const mimeToExtension: { [key: string]: string } = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'application/pdf': '.pdf',
|
||||
'text/plain': '.txt',
|
||||
'application/msword': '.doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx'
|
||||
}
|
||||
|
||||
return mimeToExtension[mimeType] || '.bin'
|
||||
}
|
||||
@@ -21,10 +21,25 @@ export function escapeBrackets(text: string) {
|
||||
if (codeBlock) {
|
||||
return codeBlock
|
||||
} else if (squareBracket) {
|
||||
return `$$${squareBracket}$$`
|
||||
return `
|
||||
$$
|
||||
${squareBracket}
|
||||
$$
|
||||
`
|
||||
} else if (roundBracket) {
|
||||
return `$${roundBracket}$`
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
export function extractTitle(html: string): string | null {
|
||||
const titleRegex = /<title>(.*?)<\/title>/i
|
||||
const match = html.match(titleRegex)
|
||||
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user