Compare commits

..

142 Commits

Author SHA1 Message Date
suyao
fc48aa4349 feat(ProviderSettings): implement provider cleanup with conflict resolution
- Added a new cleanup process for provider data that identifies and resolves duplicates, allowing user intervention for conflicts.
- Introduced a modal for users to select which provider configurations to keep when duplicates are detected.
- Updated the cleanup function to return both cleaned providers and any conflicts that require user attention.
- Enhanced the UI to include a cleanup button and integrated the conflict resolution popup for better user experience.
2025-08-27 17:46:16 +08:00
suyao
773d8dd4c3 refactor(ProviderSettings): streamline provider cleanup logic
- Removed inline cleanup function and utilized a dedicated utility to manage provider data.
- Enhanced the cleanup process to return both cleaned providers and a change flag for better state management.
- Simplified the useEffect hook for improved readability and maintainability.
2025-08-27 14:33:53 +08:00
suyao
e7a1a43856 feat(ProviderSettings): enhance provider data management on mount
- Implemented a cleanup process for provider data to remove duplicates and ensure all system providers are included.
- Added logic to identify and eliminate duplicate providers based on their IDs.
- Integrated missing system providers into the list, ensuring comprehensive provider management upon component mount.
2025-08-27 00:19:46 +08:00
Phantom
7a0da13676 fix(qwen3): fix qwen3 thinking control by soft command (#9568)
fix(qwen3): 修复Qwen3模型思考模式处理逻辑

重构processPostsuffixQwen3Model函数,简化后缀处理逻辑
添加nvidia到不支持思考模式的提供商列表
移除postsuffix参数
2025-08-26 22:41:10 +08:00
Yricky
267b41242d fix: move topic prompt handling to message thunk and fix prompt logic (#9569) 2025-08-26 22:20:16 +08:00
beyondkmp
5bbc35695a refactor(ProxyManager): enhance bypass rule matching and logging (#9546)
* refactor(ProxyManager): enhance bypass rule matching and logging

- Updated the `isByPass` function to improve hostname and port matching against bypass rules.
- Refactored the dispatcher logic to utilize the updated `isByPass` function for better clarity.
- Enhanced logging to include bypass rules in system proxy change notifications.
- Simplified URL handling in the dispatcher to ensure consistent behavior.

* delete file

* refactor(ProxyManager): improve bypass rule handling and error logging

- Enhanced the `isByPass` function to check for bypass rules more efficiently, including improved error handling and logging for rule parsing failures.
- Added error logging for exceptions during URL processing.
- Cleaned up the logic to ensure consistent return values and better readability.
- Removed unnecessary environment variable deletions in the proxy manager cleanup process.

* feat(ProxyManager): add no_proxy environment variable support

- Introduced the `no_proxy` environment variable to allow bypassing specific hosts in proxy settings.
- The `no_proxy` value is constructed from the existing bypass rules, enhancing flexibility in proxy management.
2025-08-26 20:54:27 +08:00
beyondkmp
eac71f1f43 refactor(TranslateService): remove content parameter to reduce token (#9567)
* refactor(TranslateService): remove content parameter from fetchTranslate function

- Updated fetchTranslate function to eliminate the content parameter, simplifying its signature.
- Adjusted related calls to ensure compatibility with the new function definition, maintaining functionality while improving code clarity.

* Update src/renderer/src/services/TranslateService.ts

---------

Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>
2025-08-26 19:30:23 +08:00
Phantom
bd4ba47e61 feat(models): support qwen-flash & deepseek v3.1 (limited) (#9539)
* feat(models): 添加 qwen-flash 模型支持并更新相关配置

* feat(ai): 添加对deepseek-v3.1模型的支持(dashscope)

添加deepseek_hybrid类型到ThinkModelTypes,并实现相关推理逻辑

* refactor(openai): 移除调试日志语句以清理代码

* feat(推理模型): 增强DeepSeek混合推理模型支持并优化逻辑顺序

重构DeepSeek混合推理模型的判断逻辑,支持更多版本格式
将Doubao思考模式处理逻辑移至更合理的位置
添加对DeepSeek模型在不同provider下的思考控制处理

* fix(deepseek): 支持openrouter的deepseek-chat-v3.1模型推理控制

统一处理不同provider的DeepSeek混合推理模型控制方式,添加对openrouter的deepseek-chat-v3.1模型支持

* fix(模型): 修正函数调用模型的判断逻辑

更新函数调用模型的判断条件,明确不支持v3.1函数调用的提供商并处理openrouter的特殊情况

* feat(openai): 为silicon模型添加enable_thinking配置

* fix(模型配置): 修正深度求索混合推理模型的函数调用支持逻辑

更新深度求索混合推理模型的函数调用判断逻辑,默认支持函数调用

* feat(模型支持): 为DeepSeek V3.1添加白名单支持

添加对DeepSeek V3.1混合推理模型的白名单支持,目前仅允许openrouter、dashscope和doubao作为提供商

* feat(config): 添加silicon到DeepSeek V3.1支持的白名单中

* feat(sdk): 添加对NVIDIA推理参数的支持

在ReasoningEffortOptionalParams类型中添加chat_template_kwargs参数,用于支持NVIDIA的推理配置。同时在模型支持列表中新增nvidia提供商,并在OpenAIApiClient中实现对应的参数处理逻辑。

* refactor(openai): 使用SystemProviderIds替换硬编码的provider id字符串
2025-08-26 17:43:29 +08:00
HydrogenE7
cd2d59c6a1 fix: onprogress handler error (#9540)
Update MCPService.ts

fix onprogress error
2025-08-26 17:10:21 +08:00
Phantom
5e31c809e1 fix(openai): Gemma system message (#9561)
* fix(openai): 修复不支持系统消息的模型处理逻辑

当模型不支持系统消息时,将其转换为用户消息,确保请求消息的正确构建

* fix(openai): 处理不支持系统消息的模型时合并用户消息

当模型不支持系统消息时,将系统消息内容合并到第一条用户消息中
添加空用户消息的警告日志
2025-08-26 16:32:45 +08:00
beyondkmp
961984df24 fix: add sharp dependency for image processing and improve ocr (#9554)
build: add sharp dependency for image processing

* Added sharp as a dependency in package.json to enhance image processing capabilities.
* Removed sharp from the devDependencies section.
* Refactored OCR image preprocessing by integrating grayscale and normalization directly into the process, improving overall efficiency.
2025-08-26 14:34:16 +08:00
beyondkmp
e956a9ad35 refactor(CopilotService): streamline token file handling and improve error management (#9552)
* refactor(CopilotService): streamline token file handling and improve error management

- Consolidated token file path retrieval into a dedicated method for better clarity and maintainability.
- Updated file system operations to ensure directory existence before writing the token file.
- Enhanced error handling during token save and read operations, ensuring robust logging and user feedback.

* lint

* build: add sharp dependency for image processing

- Added sharp as a dependency in package.json to support image processing functionalities.
- Removed sharp from the devDependencies section to ensure it is available in production.
2025-08-26 14:33:47 +08:00
kangfenmao
f9869ef453 Revert "fix(hooks): type safe useAssistant (#9428)"
This reverts commit 4833f36e0b.

https://github.com/CherryHQ/cherry-studio/pull/9428#issuecomment-3222381185
2025-08-26 11:02:31 +08:00
Phantom
7bb3826cdd feat: ocr image to translate (#9423)
* build: 添加 tesseract.js 及其类型定义依赖

* feat(ocr): 添加OCR类型定义文件以支持OCR功能扩展

* feat(ocr): 添加 Tesseract OCR 提供程序配置

* feat(ocr): 添加Tesseract.js的logo

* refactor(settings): 重构文档预处理设置模块结构

将PreprocessSettings重命名为DocProcessSettings并调整文件结构
更新相关路由和组件引用以保持功能一致性

* refactor(config): 重命名OCR_PROVIDER_CONFIG为BUILTIN_OCR_PROVIDERS以更准确描述用途

* refactor(ocr): 更改文件名

* refactor(ocr): 将获取OCR提供商logo的功能移动到utils目录

将getOcrProviderLogo函数从config/ocr.ts移动到utils/ocr.ts,保持功能集中

* refactor(ocr): 重构OCR配置结构以支持默认提供者

将内置OCR提供者数组重构为单独定义的常量,并添加默认OCR提供者映射。这提高了代码的可维护性并支持未来扩展。

* feat(store): 添加OCR状态管理切片

实现OCR提供商的增删改查功能,使用Redux Toolkit管理OCR相关状态

* feat(types): 添加图片文件类型守卫函数

添加 ImageFileMetadata 类型和 isImageFile 类型守卫函数,用于检查文件是否为图片类型

* feat(ocr): 添加对OCR支持文件类型的类型定义和校验函数

添加SupportedOcrFileType类型和isSupportedOcrFileType校验函数
添加SupportedOcrFile类型和isSupportedOcrFile校验函数

* feat(ocr): 添加OCR功能支持

实现基于Tesseract的OCR功能,包括文件类型检查、服务接口和IPC通信
新增OCR相关类型定义和服务实现

* refactor(OcrService): 更新日志上下文为'main:OcrService'

* feat(ocr): 添加OCR服务基础功能

实现OCR服务的基础功能,通过调用window.api.ocr接口处理支持的文件类型

* feat(store): 添加ocr模块到redux store

* feat(ocr): 添加OCR功能支持及文件类型校验

添加OCR功能钩子useOcr,支持图片文件识别
添加不支持文件类型的错误提示国际化文案

* refactor(ocr): 重命名updatePreprocessProvider为updateOcrProvider以保持命名一致性

* feat(ocr): 添加设置图片OCR提供商的功能

* refactor(ocr): 统一OCR类型导入路径

将所有OCR相关类型从'@renderer/types/ocr'改为从'@renderer/types'或'@types'导入
优化DEFAULT_OCR_PROVIDER类型定义

* feat(store): 更新持久化存储版本并添加OCR配置迁移

添加137版本迁移逻辑,初始化OCR提供者和默认图像提供者配置

* feat(ocr): 添加OCR服务设置界面及提供商选择功能

实现OCR服务设置界面,包含图片OCR提供商的选择功能
修复ocr.ts中imageProvider的类型定义
添加相关国际化文本

* fix(ocr): 添加图像大小检查并优化错误处理

检查图像文件大小是否超过50MB限制
使用buffer读取文件替代直接路径识别
简化错误处理逻辑,直接抛出原始错误

* feat(OCR服务): 支持base64字符串作为OCR输入

扩展tesseractOcr函数以接受base64字符串或图像文件作为输入

* feat(hooks): 添加useFiles钩子用于文件选择功能

* refactor(useFiles): 移除multipleSelections参数并重构文件选择逻辑

将multipleSelections从组件props移动到onSelectFile方法参数中,简化组件接口
重构文件选择逻辑,移除不必要的useMemo,提升代码可维护性

* refactor(useFiles): 使用useMemo优化扩展名处理逻辑

将扩展名处理逻辑移至useMemo中,避免不必要的重复计算。当props.extensions未提供时默认返回['*']

* feat(文件选择): 增强文件选择功能并添加清除文件方法

- 为文件选择API添加返回类型声明
- 完善文件选择回调函数的文档注释
- 修改文件选择逻辑以返回选中的文件数组
- 添加清除文件列表的方法

* refactor(useFiles): 将参数从布尔值改为对象以增强可扩展性

* feat(hooks): 在useFiles钩子中暴露selecting状态

* feat(translate): 添加文件OCR功能支持

在翻译页面新增浮动按钮,支持通过OCR识别文件内容并自动填充到输入框。添加相关hooks和文件类型检查逻辑,提升用户输入便捷性。

* build: 将 tesseract.js 从 devDependencies 移至 dependencies

确保生产环境能正确使用 tesseract.js 功能

* refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置

* refactor(TesseractService): 添加日志记录并更新worker配置

添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger

* feat(翻译页面): 添加OCR处理中的加载状态提示

在翻译页面中添加OCR处理时的加载状态提示,提升用户体验

* fix(translate): 为OCR处理消息添加无限持续时间

防止OCR处理过程中消息自动消失,确保用户明确知道处理状态

* fix: 添加OCR未知错误的翻译并更新错误提示

在OCR处理失败时,使用翻译后的错误消息替代原始错误提示

* style(translate): 调整浮动按钮位置从右上到左下

* fix(translate): 处理未选择文件时提前返回以避免空指针异常

* feat(i18n): 添加OCR功能的多语言支持

* feat(fs): 添加自动识别编码读取文本文件功能

实现通过自动检测文件编码来读取文本文件的功能
在IPC通道、预加载API和文件服务中添加相关方法

* feat(翻译): 添加文件读取功能并改进错误处理

添加对文本文件的支持并优化文件处理流程
改进错误提示信息,包括文件过大和读取失败的场景

* fix(i18n): 更新文件大小限制错误信息并添加多语言支持

修改文件大小限制的错误信息格式,移除括号内的限制范围
为多种语言添加文件操作相关的翻译条目
在错误提示中动态显示文件大小限制范围

* refactor(AttachmentButton): 移除类型注释,使用自动类型推断

* fix(hooks): 返回变量supportedFiles

* fix(ocr): 改进OCR处理中的消息管理和错误处理

在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑
移除TranslatePage中重复的消息管理代码,简化OCR处理流程

* fix(translate): 在选择文件后清除文件状态以避免残留

在文件选择完成后调用clearFiles以清除文件状态

* refactor(preload): 移动OCR类型定义到共享类型文件

将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性

* refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本

返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑

* fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置

将错误抛出语句移至else分支

* refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义

* build: 将 tesseract.js 从 devDependencies 移至 dependencies

确保生产环境能正确使用 tesseract.js 功能

* refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置

* refactor(TesseractService): 添加日志记录并更新worker配置

添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger

* feat(i18n): 添加OCR功能的多语言支持

* refactor(preload): 移动OCR类型定义到共享类型文件

将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性

* refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本

返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑

* fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置

将错误抛出语句移至else分支

* refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义

* fix(ocr): 改进OCR处理中的消息管理和错误处理

在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑
移除TranslatePage中重复的消息管理代码,简化OCR处理流程

* feat(i18n): 添加OCR相关的错误和状态翻译文本

* fix(useOcr): 修复未支持文件类型错误抛出位置

将不支持的OCR文件类型错误抛出逻辑移至条件判断内

* refactor(ocr): ocrImage实现使用OcrService并更新日志上下文

将ocrImage函数从useOcr钩子移动到OcrService中,提高代码复用性
更新日志服务上下文从'main'改为'renderer'以更准确反映模块位置

* style(TabContainer): 移除多余的空行并保持代码整洁

* refactor(ocr): 简化OCR文件类型检查逻辑

使用现有的isImageFile函数替代冗余的类型检查逻辑,提高代码复用性

* fix: 将迁移错误日志从136更新为137

* feat(ocr): enhance Tesseract service with language support and worker management

- Added support for multiple Tesseract languages: Chinese (Simplified and Traditional) and English.
- Refactored Tesseract worker management into a class for better encapsulation and reuse.
- Introduced methods to dynamically determine language path based on IP country and manage worker lifecycle.

* update cn url

* support cn data

* change to asyn

* use register design mode

* add type

* use bind function

* refactor(ipc): 简化OCR处理程序参数

* refactor(ocr): 修改ocrProviderCapabilityRecord类型定义

允许只定义部分能力

* refactor(ocr): 将Tesseract相关配置移至服务内部

将语言列表和下载URL常量从共享配置移至Tesseract服务内部
使用常量定义图片大小阈值以提高可读性

* refactor(ocr): 统一使用 SupportedOcrFile 类型替换 FileMetadata

更新 OCR 服务及其 Tesseract 实现,使用 SupportedOcrFile 类型替代原有的 FileMetadata 类型,以提高类型安全性和一致性。同时在 OcrService 中添加重复注册的警告日志。

* refactor(ocr): 重构OCR类型定义以支持模型和API配置

将OCR提供者配置拆分为独立类型,增加模型能力记录和API配置类型检查
添加OCR处理程序类型定义,为未来扩展提供更好的类型支持

* refactor(OcrService): 移除重复的OcrHandler类型定义

已在@types中定义OcrHandler类型,移除重复定义以提高代码一致性

* refactor(ocr): 将OcrService移动到ocr目录下并更新引用路径

* feat(ocr): 添加OCR API客户端工厂及示例实现

实现OCR API客户端工厂模式,支持根据不同提供商创建对应的客户端
新增OcrBaseApiClient作为基础类,提供通用功能
添加OcrExampleApiClient作为示例实现
修改OcrService以使用新的客户端工厂

* refactor(ocr): 添加日志记录以跟踪OCR文件处理

在OCR服务中添加日志记录功能,便于跟踪文件处理过程

* fix(deps): 更新 tesseract.js 依赖并添加补丁文件

修复 tesseract.js 类型定义问题并添加语言常量支持

* refactor(ocr): 移除注释掉的tesseract语言映射代码

使用Tesseract.js的LanguageCode类型替代硬编码的语言列表,提高类型安全性

* feat(ocr): 添加 Tesseract OCR 配置类型

* refactor(OCR设置): 重命名OcrImageProviderSettings为OcrImageSettings并优化代码结构

* refactor(ocr): 将 Tesseract 相关类型移动到文件底部以改善代码组织

* feat(ocr): 添加 Tesseract OCR 提供者类型检查函数

* feat(ocr): 添加更新OCR提供者配置的功能

* feat: 添加OCR提供者钩子函数

实现useOcrProvider钩子用于获取和更新OCR提供者配置

* refactor(ocr): 修改removeOcrProvider参数为字符串id

简化removeOcrProvider方法的参数类型,直接使用字符串id进行过滤,提高代码简洁性

* refactor(ocr): 将内置OCR提供者从数组改为映射结构

重构OCR配置模块,使用映射结构存储内置OCR提供者以便于扩展和维护

* refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组

使用Object.freeze确保数组不可变,提高代码安全性

* feat(ocr): 添加OCR提供者管理功能并改进错误处理

添加useOcrProviders钩子用于管理OCR提供者的添加和删除
当内置OCR提供者不存在时自动恢复默认配置
改进错误提示信息并增加国际化支持

* Revert "refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组"

This reverts commit f23e37941a.

* feat(ocr): 为Tesseract OCR添加多语言支持配置

添加对简体中文、繁体中文和英文的语言支持配置,扩展OCR功能以满足多语言识别需求

* refactor(types): 将Tesseract.LanguageCode重命名为TesseractLangCode以提高可读性

* feat(OCR设置): 添加OCR提供商设置组件及状态管理

新增OCR提供商设置组件,支持显示当前选择的OCR提供商信息
在OCR图片设置中添加状态管理,同步提供商选择到父组件
添加Tesseract OCR设置组件,支持多语言选择(暂不可用)

* fix(DocProcessSettings): 修复OCR语言选择默认值问题

* feat(i18n): 添加OCR提供商相关错误和警告的翻译

* fix(ocr): 将 Tesseract 语言配置类型改为部分

* fix(ocr): 修复ocrImage函数未使用await导致的问题

* fix(ocr): 修复迁移配置中ocr状态的初始化方式

将分散的属性赋值改为对象整体赋值,避免潜在的属性丢失问题

* chore: 移除不再使用的@types/tesseract.js依赖

* refactor(OCR设置): 添加错误边界处理并移除无用注释

在OCR设置组件中添加ErrorBoundary以处理潜在错误
移除OcrTesseractSettings中的TODO注释

* build: 添加 sharp 依赖以支持图片处理功能

* refactor(ocr): 添加OCR图像预处理功能并优化TesseractService

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(ocr): 移除独立的灰度处理模块并改进预处理流程

将灰度处理功能直接集成到OCR预处理中,不再需要单独的image模块
添加normalise和threshold处理以提升OCR识别效果

* feat(i18n): 添加文件上传tool tip的翻译文本

* feat(hooks): 添加useDrag钩子实现拖拽功能

* feat(translate): 添加拖拽上传文件功能

实现文件拖拽上传功能,包括拖拽区域高亮显示和提示文本
添加多文件上传错误提示和未知错误处理

* feat(i18n): 添加文件拖拽和多文件上传错误提示的翻译

* refactor(PasteService): 优化粘贴服务逻辑并移除不必要的翻译依赖

将`t`参数改为布尔类型的`showMessage`参数,简化消息显示逻辑
添加默认的粘贴文本长度阈值
使文件扩展名检查变为可选参数
更新相关调用处的参数传递

* Revert "refactor(PasteService): 优化粘贴服务逻辑并移除不必要的翻译依赖"

This reverts commit 07c7ecd0cf.

* fix(preload): 为文件获取方法添加返回类型声明

添加Promise<FileMetadata | null>返回类型以明确get方法的返回值类型,提高代码可读性和类型安全性

* refactor(TopView): 移除未使用的loggerService导入和调用

* feat(TranslatePage): 添加对粘贴上传文件的支持

新增粘贴上传文件功能,处理剪贴板中的文件数据并支持图片临时文件创建
添加文件类型检查和不支持类型的错误提示
重构文件选择逻辑到通用函数 getSingleFile

* feat(i18n): 添加不支持文件类型的多语言翻译

* feat(translate): 添加翻译输入状态并优化内容更新逻辑

添加translateInput状态以存储翻译输入内容
优化setTranslatedContent reducer直接修改状态而非返回新对象

* refactor(translate): 将文本输入状态迁移至redux存储

移除本地状态_text和使用useState管理的text,改为从redux store中获取和管理输入文本

* fix(translate): 修复依赖数组中缺少setText导致的状态更新问题

* fix(store): 初始化翻译输入为空字符串

修复迁移配置时未初始化翻译输入的问题,避免潜在的undefined错误

* fix(hooks): 使 useDrag 的 onDrop 参数变为可选

处理 onDrop 未定义时的调用情况,避免运行时错误

* fix(拖拽): 修复拖拽状态未正确更新的问题

修复 handleDragOver 中未设置 isDragging 状态的问题
为输入区域添加独立的拖拽状态处理
防止容器元素意外触发文件拖放

* refactor(translate): 在文件拖放错误处理中移动错误提示位置

将文件拖放错误提示从空文件检查移动到文件读取错误捕获中

* improve image preprocess

---------

Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-26 00:21:26 +08:00
Phantom
0af5a85f67 feat: Image OCR (#9409)
* build: 添加 tesseract.js 及其类型定义依赖

* feat(ocr): 添加OCR类型定义文件以支持OCR功能扩展

* feat(ocr): 添加 Tesseract OCR 提供程序配置

* feat(ocr): 添加Tesseract.js的logo

* refactor(settings): 重构文档预处理设置模块结构

将PreprocessSettings重命名为DocProcessSettings并调整文件结构
更新相关路由和组件引用以保持功能一致性

* refactor(config): 重命名OCR_PROVIDER_CONFIG为BUILTIN_OCR_PROVIDERS以更准确描述用途

* refactor(ocr): 更改文件名

* refactor(ocr): 将获取OCR提供商logo的功能移动到utils目录

将getOcrProviderLogo函数从config/ocr.ts移动到utils/ocr.ts,保持功能集中

* refactor(ocr): 重构OCR配置结构以支持默认提供者

将内置OCR提供者数组重构为单独定义的常量,并添加默认OCR提供者映射。这提高了代码的可维护性并支持未来扩展。

* feat(store): 添加OCR状态管理切片

实现OCR提供商的增删改查功能,使用Redux Toolkit管理OCR相关状态

* feat(types): 添加图片文件类型守卫函数

添加 ImageFileMetadata 类型和 isImageFile 类型守卫函数,用于检查文件是否为图片类型

* feat(ocr): 添加对OCR支持文件类型的类型定义和校验函数

添加SupportedOcrFileType类型和isSupportedOcrFileType校验函数
添加SupportedOcrFile类型和isSupportedOcrFile校验函数

* feat(ocr): 添加OCR功能支持

实现基于Tesseract的OCR功能,包括文件类型检查、服务接口和IPC通信
新增OCR相关类型定义和服务实现

* refactor(OcrService): 更新日志上下文为'main:OcrService'

* feat(ocr): 添加OCR服务基础功能

实现OCR服务的基础功能,通过调用window.api.ocr接口处理支持的文件类型

* feat(store): 添加ocr模块到redux store

* feat(ocr): 添加OCR功能支持及文件类型校验

添加OCR功能钩子useOcr,支持图片文件识别
添加不支持文件类型的错误提示国际化文案

* refactor(ocr): 重命名updatePreprocessProvider为updateOcrProvider以保持命名一致性

* feat(ocr): 添加设置图片OCR提供商的功能

* refactor(ocr): 统一OCR类型导入路径

将所有OCR相关类型从'@renderer/types/ocr'改为从'@renderer/types'或'@types'导入
优化DEFAULT_OCR_PROVIDER类型定义

* feat(store): 更新持久化存储版本并添加OCR配置迁移

添加137版本迁移逻辑,初始化OCR提供者和默认图像提供者配置

* feat(ocr): 添加OCR服务设置界面及提供商选择功能

实现OCR服务设置界面,包含图片OCR提供商的选择功能
修复ocr.ts中imageProvider的类型定义
添加相关国际化文本

* fix(ocr): 添加图像大小检查并优化错误处理

检查图像文件大小是否超过50MB限制
使用buffer读取文件替代直接路径识别
简化错误处理逻辑,直接抛出原始错误

* feat(OCR服务): 支持base64字符串作为OCR输入

扩展tesseractOcr函数以接受base64字符串或图像文件作为输入

* build: 将 tesseract.js 从 devDependencies 移至 dependencies

确保生产环境能正确使用 tesseract.js 功能

* refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置

* refactor(TesseractService): 添加日志记录并更新worker配置

添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger

* feat(i18n): 添加OCR功能的多语言支持

* refactor(preload): 移动OCR类型定义到共享类型文件

将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性

* refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本

返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑

* fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置

将错误抛出语句移至else分支

* refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义

* fix(ocr): 改进OCR处理中的消息管理和错误处理

在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑
移除TranslatePage中重复的消息管理代码,简化OCR处理流程

* feat(i18n): 添加OCR相关的错误和状态翻译文本

* fix(useOcr): 修复未支持文件类型错误抛出位置

将不支持的OCR文件类型错误抛出逻辑移至条件判断内

* refactor(ocr): ocrImage实现使用OcrService并更新日志上下文

将ocrImage函数从useOcr钩子移动到OcrService中,提高代码复用性
更新日志服务上下文从'main'改为'renderer'以更准确反映模块位置

* style(TabContainer): 移除多余的空行并保持代码整洁

* refactor(ocr): 简化OCR文件类型检查逻辑

使用现有的isImageFile函数替代冗余的类型检查逻辑,提高代码复用性

* fix: 将迁移错误日志从136更新为137

* feat(ocr): enhance Tesseract service with language support and worker management

- Added support for multiple Tesseract languages: Chinese (Simplified and Traditional) and English.
- Refactored Tesseract worker management into a class for better encapsulation and reuse.
- Introduced methods to dynamically determine language path based on IP country and manage worker lifecycle.

* update cn url

* support cn data

* change to asyn

* use register design mode

* add type

* use bind function

* refactor(ipc): 简化OCR处理程序参数

* refactor(ocr): 修改ocrProviderCapabilityRecord类型定义

允许只定义部分能力

* refactor(ocr): 将Tesseract相关配置移至服务内部

将语言列表和下载URL常量从共享配置移至Tesseract服务内部
使用常量定义图片大小阈值以提高可读性

* refactor(ocr): 统一使用 SupportedOcrFile 类型替换 FileMetadata

更新 OCR 服务及其 Tesseract 实现,使用 SupportedOcrFile 类型替代原有的 FileMetadata 类型,以提高类型安全性和一致性。同时在 OcrService 中添加重复注册的警告日志。

* refactor(ocr): 重构OCR类型定义以支持模型和API配置

将OCR提供者配置拆分为独立类型,增加模型能力记录和API配置类型检查
添加OCR处理程序类型定义,为未来扩展提供更好的类型支持

* refactor(OcrService): 移除重复的OcrHandler类型定义

已在@types中定义OcrHandler类型,移除重复定义以提高代码一致性

* refactor(ocr): 将OcrService移动到ocr目录下并更新引用路径

* feat(ocr): 添加OCR API客户端工厂及示例实现

实现OCR API客户端工厂模式,支持根据不同提供商创建对应的客户端
新增OcrBaseApiClient作为基础类,提供通用功能
添加OcrExampleApiClient作为示例实现
修改OcrService以使用新的客户端工厂

* refactor(ocr): 添加日志记录以跟踪OCR文件处理

在OCR服务中添加日志记录功能,便于跟踪文件处理过程

* fix(deps): 更新 tesseract.js 依赖并添加补丁文件

修复 tesseract.js 类型定义问题并添加语言常量支持

* refactor(ocr): 移除注释掉的tesseract语言映射代码

使用Tesseract.js的LanguageCode类型替代硬编码的语言列表,提高类型安全性

* feat(ocr): 添加 Tesseract OCR 配置类型

* refactor(OCR设置): 重命名OcrImageProviderSettings为OcrImageSettings并优化代码结构

* refactor(ocr): 将 Tesseract 相关类型移动到文件底部以改善代码组织

* feat(ocr): 添加 Tesseract OCR 提供者类型检查函数

* feat(ocr): 添加更新OCR提供者配置的功能

* feat: 添加OCR提供者钩子函数

实现useOcrProvider钩子用于获取和更新OCR提供者配置

* refactor(ocr): 修改removeOcrProvider参数为字符串id

简化removeOcrProvider方法的参数类型,直接使用字符串id进行过滤,提高代码简洁性

* refactor(ocr): 将内置OCR提供者从数组改为映射结构

重构OCR配置模块,使用映射结构存储内置OCR提供者以便于扩展和维护

* refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组

使用Object.freeze确保数组不可变,提高代码安全性

* feat(ocr): 添加OCR提供者管理功能并改进错误处理

添加useOcrProviders钩子用于管理OCR提供者的添加和删除
当内置OCR提供者不存在时自动恢复默认配置
改进错误提示信息并增加国际化支持

* Revert "refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组"

This reverts commit f23e37941a.

* feat(ocr): 为Tesseract OCR添加多语言支持配置

添加对简体中文、繁体中文和英文的语言支持配置,扩展OCR功能以满足多语言识别需求

* refactor(types): 将Tesseract.LanguageCode重命名为TesseractLangCode以提高可读性

* feat(OCR设置): 添加OCR提供商设置组件及状态管理

新增OCR提供商设置组件,支持显示当前选择的OCR提供商信息
在OCR图片设置中添加状态管理,同步提供商选择到父组件
添加Tesseract OCR设置组件,支持多语言选择(暂不可用)

* fix(DocProcessSettings): 修复OCR语言选择默认值问题

* feat(i18n): 添加OCR提供商相关错误和警告的翻译

* fix(ocr): 将 Tesseract 语言配置类型改为部分

* fix(ocr): 修复ocrImage函数未使用await导致的问题

* fix(ocr): 修复迁移配置中ocr状态的初始化方式

将分散的属性赋值改为对象整体赋值,避免潜在的属性丢失问题

* chore: 移除不再使用的@types/tesseract.js依赖

* refactor(OCR设置): 添加错误边界处理并移除无用注释

在OCR设置组件中添加ErrorBoundary以处理潜在错误
移除OcrTesseractSettings中的TODO注释

* build: 添加 sharp 依赖以支持图片处理功能

* refactor(ocr): 添加OCR图像预处理功能并优化TesseractService

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(ocr): 移除独立的灰度处理模块并改进预处理流程

将灰度处理功能直接集成到OCR预处理中,不再需要单独的image模块
添加normalise和threshold处理以提升OCR识别效果

* improve image preprocess

---------

Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-26 00:13:24 +08:00
Phantom
3d7a64a11d fix: stream output option should not be true when undefined (#9518)
fix: 修复streamOutput默认值设置问题
2025-08-25 20:41:26 +08:00
one
548916e6e1 feat(McpServersList): add a search bar (#9520)
* feat(McpServersList): add a search bar

* refactor: show different empty tips
2025-08-25 20:35:48 +08:00
one
ffa2eb57b1 refactor(Svg): relax sanitizer rules (#9522) 2025-08-25 20:35:32 +08:00
George·Dong
fd7d2b7580 fix(codetool): quote executable path to handle spaces (#9519)
* fix(cmd): quote executable path on Windows in command string

Wrap the executable path in double quotes when running on Windows sopaths containing spaces are handled correctly. Previously the base
command used an unquoted path which could break execution for users
whose install location includes spaces. This change only alters the
Windows branch to produce a quoted executable path while keeping the
non-Windows command unchanged.

* fix(codetool): quote bun paths in shell commands to spaces
2025-08-25 20:02:13 +08:00
SuYao
57702f545d fix(OpenAIApiClient): 适配glm 4.5 toolcall (#9516)
* fix(OpenAIApiClient): update toolCalls handling to support dynamic index assignment

* refactor(OpenAIApiClient): streamline toolCalls management with reusable object structure
2025-08-25 19:49:52 +08:00
Phantom
1764be8a30 style(selection-toolbar): use primary color for selection toolbar (#9515)
* style(selection-toolbar): 统一使用主色变量并移除冗余样式

移除重复定义的颜色变量,统一使用 --color-primary 作为悬停状态的主色

* style: 移除重复的 --color-primary 变量定义
2025-08-25 19:49:23 +08:00
SuYao
e90b9a5a95 fix: unexpected anthropic model recognization (#9517)
* fix: unexpected anthropic model recognization

* refactor(RawStreamListenerMiddleware): replace model provider retrieval with API client instance check
2025-08-25 19:41:00 +08:00
Jason Young
a398010213 feat(QuickPanel): Soft hide and symbol toggle fix(#9326) (#9371)
* feat(QuickPanel): 软隐藏与符号切换;性能优化与清理

- 交互改进
  - 无匹配时“软隐藏”(不销毁、折叠且不拦截)
  - 回删修正后有结果自动展开
  - 输入新符号(/ 或 @)即切换到对应面板
- 性能优化
  - 搜索 50ms 防抖,降低高频输入开销
  - 按搜索词只构建一次模糊匹配正则
  - 使用 WeakMap 缓存每项拼音,避免重复转换
  - 折叠时不渲染列表、不注册全局键盘监听
- 代码清理
  - 删除 noMatchTimeoutRef 及其清理 effect
  - 删除未使用的 currentMessageId 引用
  - 移除重复的 setText('') 清空逻辑
- 保持不变
  - 多选/固定/清空等既有模型面板逻辑
  - ESC、外部点击、删除符号的关闭语义
  - 初始空查询直接展示可选项

* feat(quickpanel): 清除模型时同时删除@符号和搜索文本

- 在MentionModelsButton中记录触发信息
- 清除操作时根据触发类型删除@符号
- 仅处理输入触发的场景,按钮触发不需要处理

* refactor(quickpanel): 提取通用的删除@符号函数

- 创建 removeAtSymbolAndText 函数统一处理删除逻辑
- 支持两种模式:精确删除(ESC,使用searchText)和自动查找(清除)
- ESC和清除操作现在使用相同的核心逻辑
- 提高代码可维护性和一致性

* handleInput 中的 ctx.close('delete-symbol') 替换为本地 handleClose('delete-symbol'),确保 Backspace 删除触发符时同步受控输入值。

* - 统一 @ 清除逻辑:基于光标+搜索词的锚点定位
- 修复 ESC/清除误删邮箱/URL 中 @ 的问题
- 精确匹配优先:从光标左侧最近的 “@+searchText”
- 失败兜底:验证触发位 position,一致删整段,不一致仅删单个 @
- 清除按钮:未知搜索词时按光标左侧最近 @ 删至空格/换行
- 保持行为一致:ESC 与“清除模型”共用同一删除函数

* - 修复:无匹配时“清除”被过滤导致不可用的问题
- 方案:为“清除”项添加 alwaysVisible 标记,不参与过滤并始终置顶展示
- 过滤改造:QuickPanel 将列表拆分为固定项与普通项,仅对普通项执行包含/模糊/拼音过滤,最终合并渲染
- 折叠逻辑:collapsed 仅依据“非固定项”的匹配数;当仅剩“清除”时仍折叠隐藏,UI 不受影响
2025-08-25 16:06:14 +08:00
Chen Tao
c49201f365 fix: Knowledge Search Not Open Target (#9504)
* fix: #9488

* chore
2025-08-25 14:20:15 +08:00
one
070614cd3c feat: new dnd list (#9311)
* feat: add Sortable

* refactor: update SortableItem style, fix grid layout

* refactor: dragOverlay

* refactor: use Sortable grid in mcp server list

* refactor: improve style

* refactor: support custom dropAnimation for drag overlay

* fix: cursor grabbing

* fix: unexpected drag

* fix: z-index

* revert: assistants tab

* refactor: improve button layout

* docs: update comments

* fix: interaction between Sortable and portal elements

* refactor: improve McpServerCard dnd experience

* refactor: prevent pointer events on drag overlay

* refactor: rename and extraction

* refactor: simplify usage

* refactor: add showGhost
2025-08-25 14:19:56 +08:00
JwinPBE
cce88745c2 feat: add seed-36b <seed:think></seed:think> parser support (#9498)
* feat: add seed-36b thinking tag parser support

Signed-off-by: jwinpbe <jwin_pbe@proton.me>

* fix: capitalize model name for proper parsing

Signed-off-by: jwinpbe <jwin_pbe@proton.me>

* Revert "fix: capitalize model name for proper parsing"

This reverts commit dd9b45e3f4.

* fix: make seed-36b model parser case-insensitive

Signed-off-by: jwinpbe <jwin_pbe@proton.me>

* refactor(ThinkingTagExtractionMiddleware): 使用getLowerBaseModelName统一处理模型ID

简化模型ID比较逻辑,避免重复调用toLowerCase方法

---------

Signed-off-by: jwinpbe <jwin_pbe@proton.me>
Co-authored-by: jwinpbe <jwin_pbe@proton.me>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-08-25 14:12:14 +08:00
Yuhang
4b02878390 fix: follow-up to PR#9384 (#9495)
* fix: set the default text color of 'P' to change with the theme

* Update AddProviderPopup.tsx

* refactor(utils): 将 generateColorFromChar 函数从 naming 模块移动到 style 模块

移动 generateColorFromChar 函数到更合适的 style 模块,并更新相关测试文件

* feat(style): 添加十六进制颜色验证和前景色计算功能

添加颜色工具函数包括:
- 十六进制颜色格式验证
- RGB值转换
- 相对亮度计算
- 根据背景色自动选择前景色功能
这些功能用于确保颜色可访问性和文字可读性

* refactor(types): 将HexColor类型移动到types模块

将HexColor类型定义从style.ts移动到types/index.ts中,保持类型定义集中管理

* feat(ProviderSettings): 为自定义提供商添加前景色计算

添加 getForegroundColor 工具函数用于计算自定义提供商 logo 的前景色
在 ProvidersList 和 AddProviderPopup 组件中应用前景色计算
确保 logo 文字在不同背景色下保持可读性

* refactor(types): 将 isHexColor 函数从 utils/style.ts 移动到 types/index.ts

统一颜色相关类型和函数的存放位置,提高代码组织性

* feat(图标): 添加PoeLogo图标并支持自定义尺寸

在ProviderSettings页面中添加PoeLogo图标支持,并扩展getProviderAvatar函数以支持自定义尺寸参数
修复SVGIcon组件中fill-rule属性的命名错误,统一使用camelCase命名规范

* refactor(providers): 移除poe.svg并使用svg图标组件

* fix(SVGIcon): 修正SVG属性stop-color为stopColor以符合React规范

* Update src/renderer/src/types/index.ts

---------

Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>
2025-08-25 13:16:15 +08:00
George·Dong
2633a1429a chore(vscode): improve VSCode launch configurations for debugging (#9483) 2025-08-25 10:46:45 +08:00
George·Dong
b2e33f892a fix(CodeTool): Code页面显示不全 (#9492) 2025-08-25 10:46:18 +08:00
Phantom
8925d7d546 feat: translate history star (#9433)
* feat(types): 为翻译历史记录添加收藏状态字段

* feat(翻译服务): 添加更新翻译历史记录功能

新增updateTranslateHistory方法用于更新翻译历史记录,支持修改原文、译文、语言及收藏状态

* refactor(TranslateService): 简化更新翻译历史记录的参数结构

* fix(TranslateService): 添加删除翻译历史的错误处理

捕获删除翻译历史时的异常并记录日志,避免静默失败

* feat(翻译历史): 添加收藏功能并优化删除操作

- 新增翻译历史项的收藏功能
- 将删除操作从右键菜单移至显式按钮
- 增加删除失败的国际化提示
- 调整列表项高度以适应新功能

* feat(翻译历史): 添加收藏筛选功能

新增显示已收藏翻译历史的功能,用户可以通过点击星标按钮切换筛选状态

* feat(i18n): 添加翻译历史删除失败的错误消息

为翻译历史功能添加删除操作失败时的错误提示消息,支持多语言显示

* fix(翻译历史): 将删除按钮文本改为"删除翻译历史"并添加确认弹窗

修改删除按钮文本使其更明确,并添加确认弹窗防止误操作

* style(TabContainer): 移除多余的空行以保持代码整洁
2025-08-25 00:10:41 +08:00
one
56cec26858 fix: topics tab tooltip not hide (#9457) 2025-08-24 21:10:33 +08:00
Phantom
107c01913d feat: error boundary (#9462)
* build: 添加 react-error-boundary 依赖

添加 react-error-boundary 包以增强 React 应用的错误处理能力

* feat(组件): 添加ErrorBoundary组件用于错误边界处理

* feat(home): 为HomeTabs和Chat组件添加错误边界处理

* refactor(ErrorBoundary): 移除多余的ErrorContainer包装并优化结构

* feat(ErrorBoundary): 添加重新加载按钮并优化错误边界样式

添加重新加载功能按钮,方便用户快速恢复应用
调整错误边界容器的布局样式,使其居中显示

* style(ErrorBoundary): 移除ErrorContainer的固定高度以改善布局灵活性

* test(ErrorBoundary): 添加测试错误边界组件的功能按钮

添加一个用于测试错误边界组件功能的按钮组件,该按钮点击后会抛出错误以验证错误边界是否正常工作。此组件仅用于测试,合并前需要删除。

* feat(路由): 为路由组件添加错误边界处理

在Router组件中包裹ErrorBoundary以捕获并处理子组件中的错误

* fix(ErrorBoundary): 修复错误边界中翻译键的拼写错误

* feat(i18n): 添加边界错误处理和主题不存在错误的多语言支持

* refactor(ErrorBoundary): 移除用于测试的ThrowError组件
2025-08-24 18:49:14 +08:00
co63oc
6d102ccef8 chore: fix typos (#9477) 2025-08-24 17:15:35 +08:00
Phantom
fba358c0fc fix(selection): fix missing settings (#9454)
* fix(selection): 修复流式输出设置合并问题并添加调试日志

确保assistant的settings在设置streamOutput时保留原有属性
在ActionGeneral组件中添加处理消息前的调试日志

* style: 移除 TabContainer 组件中的多余空行

* fix(HomeWindow): 修复助手设置被覆盖的问题

* refactor(assistant): 优化助手设置处理逻辑,避免重复创建对象

统一处理助手设置逻辑,确保streamOutput属性存在
在多个地方避免直接修改currentAssistant,改为创建新对象

* fix: 使用cloneDeep替代对象展开并显式关闭功能

修复对象浅拷贝可能导致的问题,使用lodash的cloneDeep进行深拷贝
显式关闭web搜索、mcp服务和知识库功能以确保一致性

* refactor: 注释掉未使用的功能配置以提升代码可读性
2025-08-24 17:00:49 +08:00
Phantom
17cee98617 fix(WebSearch): fix web search condition check (#9310)
* fix(web搜索): 修正web搜索功能的条件判断和逻辑处理

修复web搜索启用条件的判断逻辑,统一使用webSearchProviderId作为启用标志
重命名相关函数以更准确表达其功能,并优化quickPanel打开逻辑

* fix(WebSearchButton): 修复快速面板点击逻辑

重构 web search provider 更新逻辑,提取为独立的 updateQuickPanelItem 方法
添加 onClick 处理函数统一管理按钮点击行为

* refactor(WebSearchButton): 更新依赖项数组

* refactor(WebSearchButton): 移除重复的颜色计算并简化图标组件

将颜色计算逻辑从WebSearchIcon组件中移出,统一在父组件中处理
2025-08-24 13:42:10 +08:00
beyondkmp
d6866052c4 fix: add copilot header to fix json error (#9456)
* add accept type in header

* add header
2025-08-23 18:59:29 +08:00
one
3be7c2e1a8 fix: HtmlArtifacts title overflow (#9434)
* fix: HtmlArtifacts title overflow

* style: fix lint errors
2025-08-23 17:31:10 +08:00
Phantom
375f966e9a fix(AttachmentPreview): ext should not be case sensitive (#9426)
fix(AttachmentPreview): 修复图片扩展名大小写敏感问题
2025-08-23 12:24:38 +08:00
Phantom
4833f36e0b fix(hooks): type safe useAssistant (#9428)
* fix(hooks): 修复useAssistant中可能存在的未定义引用

确保在访问assistant.settings前检查assistant是否存在,避免潜在的运行时错误

* fix(assistants): useAssistant 类型安全

添加助手时检查ID是否已存在,避免重复添加
为助手不存在和添加失败的情况添加多语言提示
当助手不存在时回退到默认助手并显示警告
2025-08-23 00:16:46 +08:00
one
35968f4861 chore(ci): refine pr ci steps (#9429)
* chore(ci): refine pr ci steps

* fix: line errors
2025-08-22 22:52:03 +08:00
Jason Young
e3ca927306 fix(renderer): prevent overlays from entering titlebar drag region via no-drag; fixes #9123 (#9154)
* fix(renderer): prevent overlays from entering titlebar drag region via no-drag and platform safe gap; cap modal body height; fixes #9123

* fix: modal close button intercepted by drag region in small window mode

- Set modal content as no-drag to ensure button clickability
- Use z-index layering for titlebar drag region management
- Remove redundant platform detection and CSS variables

* refine: only disable drag on modal close button instead of entire modal content

This allows users to still drag the window by clicking on modal header or other areas,
improving UX in small window scenarios while still protecting the close button interaction.
2025-08-22 22:46:33 +08:00
one
c2aff60127 refactor(CodeBlock): closed fence detection for html (#9424)
* refactor(CodeBlock): closed fence detection for html

* refactor: improve type, fix test

* doc: add comments
2025-08-22 22:37:34 +08:00
Max
ae203b5c7c fix(NewApiPage): 修复newApi图片编辑请求体没有携带model字段问题 (#9403)
Signed-off-by: hripleh <hripleh@gmail.com>
Co-authored-by: hripleh <hripleh@gmail.com>
2025-08-22 22:10:08 +08:00
one
6a4627cddc fix(Markdown): hide programmed style in MarkdownShadowDOMRenderer (#9417)
* fix(Markdown): hide programmed style in MarkdownShadowDOMRenderer

* refactor: remove redundant style
2025-08-22 22:07:44 +08:00
beyondkmp
f66cb2651f refactor: simplify NotificationService initialization and use windowService for notifications (#9411)
* refactor: simplify NotificationService initialization and use windowService for notifications

- Removed the dependency on BrowserWindow in NotificationService constructor.
- Updated the notification handling to utilize windowService for showing notifications and sending events, improving code modularity.

* refactor: remove constructor from NotificationService for cleaner initialization
2025-08-22 14:41:36 +08:00
one
a4cdb5d45f perf: history page search performance and loading state (#9344)
* refactor(HistoryPage): add loading state to search results

* refactor: add min height

* perf: speedup message search

* refactor: use cached topics map in onTopicClick

* refactor: smooth scrolling

* refactor: use MutationObserver for better scroll timing

* refactor: remove search.length restrictions

* refactor: use getTopicById in TopicMessages, improve error messages

* fix: i18n
2025-08-22 14:39:57 +08:00
亢奋猫
3501d377f6 refactor(CodeToolsPage): streamline CLI tool management and enhance p… (#9386)
* refactor(CodeToolsPage): streamline CLI tool management and enhance provider filtering logic

- Removed hardcoded CLI tool options and supported providers, replacing them with imported constants for better maintainability.
- Optimized provider filtering to include additional providers for Claude and Gemini tools.
- Updated environment variable handling for CLI tools to utilize a centralized API base URL function.

* refactor(CodeToolsPage): enhance CLI tool management and environment variable handling

- Updated provider filtering logic to utilize a centralized mapping for CLI tools, improving maintainability and extensibility.
- Refactored environment variable generation and parsing to streamline the launch process for different CLI tools.
- Simplified state management for tool selection and directory handling, enhancing code clarity.
2025-08-22 12:42:27 +08:00
beyondkmp
b4a3a483e9 fix: change title bar overlay color for windows (#9407)
* fix: update titleBarOverlayDark color for improved visibility

* refactor: import isDev and isWin constants for cleaner configuration
2025-08-22 12:30:07 +08:00
Yuhang
76c025d53b Feat/add built-in provider avatar options when adding a provider (#9350)
* Add 'builtin avatar' option to avatar dropdown

-Introduces a new 'builtin avatar' option to the avatar selection dropdown in AddProviderPopup.
-Updates i18n translation files for all supported languages to include the 'builtin' avatar label.

Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com>

* Add provider logo picker for builtin avatar selection

-Introduces a ProviderLogoPicker component for selecting a builtin provider logo as an avatar in AddProviderPopup.
-Updates provider logo handling in ProviderSettings.(If deleting the logoFile caused any issues, I sincerely apologize.)

Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com>

* Adjust ProviderLogoPicker layout dimensions and grid

Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com>

* Fix ProviderLogoPicker popover trigger behavior

Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com>

* Merge branch 'main' into feat/add-builtin-provider-avatars

* Update index.tsx

---------

Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com>
2025-08-22 09:42:24 +08:00
one
cd1b0e01a0 fix: add provider check in isMandatoryWebSearchModel (#9398)
* fix: add provider check in isMandatoryWebSearchModel

* Fix: Add provider check in isMandatoryWebSearchModel

The isMandatoryWebSearchModel function was throwing an error when the provider was undefined. This change adds a check to ensure the provider exists before accessing its properties, similar to how it's handled in isWebSearchModel.

The position of the check has also been moved to be between the provider and modelId initializations for better code flow.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-08-22 09:09:21 +08:00
Phantom
44b2d09e63 fix: throw error when translate language detection failed (#9393)
* docs(ApiService): 为语言检测函数添加详细注释并修改错误处理

移除冗余的try-catch块,改为依赖shouldThrow参数控制错误抛出

* fix(翻译动作): 添加语言检测错误处理

捕获语言检测时的异常并记录错误日志,防止未处理的异常导致应用崩溃

* docs(ApiService): 修正语言检测函数的返回注释说明

原注释说明检测失败会返回空字符串,实际实现会抛出错误,修正注释以反映实际行为

* fix: 移除语言检测中多余的或空字符串检查
2025-08-22 00:05:04 +08:00
Phantom
c7dcbdcb5b fix: gpt-oss should support temperature and topP (#9390)
* fix: 修复OpenAI推理模型温度控制判断逻辑

添加isOpenAIOpenWeightModel检查以排除开源权重模型

* fix(openai): 修正开发者角色设置条件逻辑

修改系统消息中开发者角色设置的条件判断,增加对OpenAIOpenWeightModel的检查
2025-08-21 23:51:36 +08:00
Phantom
daaf685c9e feat(TopicsTab): double click topic name to edit (#9382)
* feat(TopicsTab): 添加双击话题名称开始编辑功能

* feat(话题标签): 添加通过弹窗重命名话题的功能

* refactor(TopicsTab): 移除未使用的topicEdit参数

* style(TopicsTab): 调整主题名称容器的样式和输入框边框

移除主题编辑输入框的边框和阴影效果,并设置固定高度

* feat(i18n): 添加话题重命名提示文本并支持在弹窗中显示

为话题编辑功能添加多语言提示文本,说明双击可快速重命名
在PromptPopup组件中新增extraNode属性以支持显示额外提示信息

* docs(i18n): 为话题重命名提示添加"提示"前缀
2025-08-21 23:46:30 +08:00
one
9c2a88179b refactor: increase dropdown menu maxHeight (#9279) 2025-08-21 23:43:12 +08:00
Yuhang
a2d24a5cda fix: incorrect default avatar casing in custom provider (#9384)
* fix: incorrect default avatar casing in custom provider

* add background color to default avatar in custom provider

distinction among providers.

* set ProviderInitialsLogo text color to white

添加完背景色后发现,模型列表中默认头像字体始终为白色,而编辑提供商时默认头像字体颜色会随主题色而变,黑色字体某些背景色下不清晰(比如a),所以改成始终白色

* fix: default avatar fallback when no text is entered

-设置背景色后发现,未输入文本时的背景色是根据上一个背景色继续保持的,该情况下回退到默认背景颜色
-回退后白色字体又看不清,该情况下字体颜色回退到黑色
-最终效果就是未输入文本时显示的默认头像回退到与之前一致
2025-08-21 19:54:48 +08:00
one
4191d878f2 fix: do not reset citation block (#9383)
* fix: do not reset citation block id

* refactor: disable external websearch for mandatory websearch models

* refactor: predicate

* refactor: include openrouter perplexity
2025-08-21 16:59:04 +08:00
Chen Tao
1c0e29f029 fix: knowledge encrypted (#9385) 2025-08-21 16:58:16 +08:00
Phantom
25d3b519d9 fix(translate): fix translating state management (#9387)
* fix(translate): 修复翻译状态管理逻辑

调整翻译状态设置的位置,确保在翻译开始和结束时正确更新状态

* fix(translate): 添加缺失的setTranslating属性

* fix(translate): 去除检测语言结果中的空格

检测语言返回的结果可能包含多余空格,导致后续处理出现问题。通过trim()去除前后空格确保结果干净
2025-08-21 16:48:22 +08:00
kangfenmao
39b1332e49 feat(DraggableList): add listProps support for custom list configurations
- Enhanced DraggableList component to accept listProps, allowing for customization of the Ant Design List component.
- Updated MCPSettings to utilize the new listProps feature, providing a custom empty state message when no servers are available.
2025-08-21 15:14:27 +08:00
Phantom
0da122281e fix(AttachmentButton): Add selection state to prevent repeated file selection triggering (#9379)
fix(AttachmentButton): 添加选择状态防止重复触发文件选择

添加 selecting 状态变量以防止在文件选择过程中重复触发选择操作,避免潜在的文件选择窗口冲突
2025-08-21 15:09:39 +08:00
Phantom
4615e97ad5 fix(translate): improve auto translate language detection (#9375)
fix(translate): 调整语言检测阈值并增加回退逻辑

当文本较短时使用LLM检测语言,较长时优先使用franc检测
当franc检测失败时回退到LLM检测
同时将LLM检测的文本长度限制从50提高到100
2025-08-21 14:55:11 +08:00
beyondkmp
4dabc214f2 feat: enhance file extension handling in Inputbar (#9269)
* feat: add isTextFile functionality and improve file selection handling

- Introduced a new IPC channel for checking if a file is a text file.
- Implemented isTextFile method in FileStorage service to determine file type based on content.
- Enhanced AttachmentButton to filter selected files based on text file validation.
- Updated translations to include support for displaying unsupported file counts across multiple languages.
- Added utility functions for text file validation and filtering in file utilities.

* refactor(FileStorage): replace hardcoded buffer size with constant for improved readability

* restore yarn lock

* add isbinaryfile dep

* refactor: 整理导入顺序

* fix(preload): 为isTextFile方法添加返回类型Promise<boolean>

* refactor(FileManager): update getSafePath to use file metadata for path retrieval

- Modified getSafePath method to utilize the path from file metadata instead of a hardcoded file path.
- Enhanced handling for files not stored in the file storage system.

* refactor(FileUtilities): rename text file functions for clarity

- Updated function names from isTextFile to isSupportedFile and filterTextFiles to filterSupportedFiles to better reflect their purpose.
- Adjusted related imports and usages in AttachmentButton and PasteService components to align with the new naming conventions.

* fix drop files

* refactor(MarkdownStyles): remove last-child margin override; adjust MessageFooter margin and clean up unused code in MessageAttachments

* feat(Sidebar): add 'code_tools' icon and route; enhance CodeToolsPage layout with Navbar and improved provider filtering

* feat(CodeTools): add environment variable support for CLI tools; update UI to manage environment variables and enhance localization for related strings

* refactor(Sidebar): remove unused imports and code related to documentation; streamline sidebar functionality

* refactor(SvgPreview): use transparent container for SVG (#9294)

* refactor(SvgPreview): use transparent container for SVG

* test: fix snapshot

* refactor(CodeToolsService): replace npm package version fetching with direct API call; simplify command construction for installation

* chore: release v1.5.7-rc.1

* refactor(CodeToolsService): adjust command construction for Windows compatibility; streamline installation command handling

* refactor(Markdown): update disallowed elements to include 'script' for enhanced security

* feat: quick model (#9290)

* refactor(i18n): 将话题命名模型相关文案更新为摘要模型

更新所有语言文件中关于话题命名模型的文案,统一改为摘要模型,以反映功能的扩展和更通用的用途

* refactor(设置页面): 优化主题命名弹窗组件性能

使用useCallback和useMemo优化回调函数和渲染性能
将重复的JSX代码提取为独立组件

* feat(设置): 在模型设置中添加话题命名折叠面板

将话题命名设置从直接显示改为折叠面板形式,提升界面整洁度

* refactor(i18n): 重构话题命名相关翻译字段结构

* docs(i18n): 添加生成图像的高度、宽度和安全容忍度翻译占位符

* fix(settings): 修正主题命名弹窗中的翻译键名

* style(ui): 调整主题命名弹窗的间距和文本区域高度

移除多余的上下边距,并使用自适应高度的文本区域

* refactor(llm): 将 topicNamingModel 重命名为 summaryModel

更新相关函数、状态和测试用例以反映命名变更
增加迁移逻辑处理旧状态数据
更新持久化版本号至133

* fix(ApiService): 优先使用摘要模型替代默认模型

当获取摘要时,优先使用getSummaryModel()返回的模型,其次才是助手指定的模型或默认模型,以确保摘要生成的一致性

* docs(i18n): 更新摘要模型描述中的搜索关键词提炼

将"搜索结果摘要"修改为"搜索关键字提炼"以更准确描述功能

* fix(i18n): 更新多语言翻译文件中的摘要模型相关文本

* feat(i18n): 为摘要模型设置添加工具提示说明

添加摘要模型设置的工具提示,建议用户选择轻量模型而非思考模型

* refactor(i18n): 将摘要模型相关文案更新为快速模型

更新国际化文案和组件引用,将"摘要模型"统一改为"快速模型"以更准确描述功能用途

* feat(i18n): 将摘要模型重命名为快速模型并更新相关描述

* refactor(llm): 将summaryModel重命名为quickModel以提升语义清晰度

* test(api): 在ApiService测试中添加LlmState类型和awsBedrock配置

添加LlmState类型以满足类型检查要求,并补充awsBedrock的mock配置以完善测试覆盖

* Revert "feat(设置): 在模型设置中添加话题命名折叠面板"

This reverts commit 4d58c053da.

* refactor(settings): 重命名并移动 TopicNamingModalPopup 组件文件

将 TopicNamingModalPopup.tsx 重命名为 QuickModelPopup.tsx 并移动到相应目录

* refactor(QuickModelPopup): 优化主题命名设置布局和样式

移除 TopicNamingSettings 组件内联实现,直接整合到 Modal 中
调整间距和样式,提升视觉一致性
修复文本区域 onChange 去除换行的逻辑

* feat(模型设置): 在快速模型弹窗中添加重置按钮图标并调整布局

将重置按钮改为图标形式并内联显示,同时调整输入区域的高度样式

* docs(i18n): 更新快速模型相关翻译文本

* fix: 将迁移错误日志从133更新为134

* style(settings): 替换模型设置中的图标为Rocket图标以提升视觉一致性

* fix: unexpected quitting full screen mode (#9200)

* fix(Inputbar): 修正拼写错误,将expend改为expand

* fix: 修复Escape键事件冒泡问题并改进全屏处理

修复多个组件中Escape键事件未阻止冒泡的问题
添加全屏控制IPC通道
将全屏退出逻辑移至渲染进程处理
移除主进程中冗余的全屏退出处理代码

* fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect

将键盘事件监听从window移动到Modal容器,避免事件冒泡问题
移除无效的useEffect并更新键盘事件类型定义

* fix(QuickPanel): 拦截window上的keydown事件

* fix(QuickPanel): 修复事件监听器移除时未使用相同参数的问题

* fix(TopView): 修复左侧导航栏布局崩坏问题

* fix: 修正变量名拼写错误,将expended改为expanded

* Revert "fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect"

This reverts commit 4211780b95.

* feat: use quick model to detect translate language (#9315)

* refactor(语言检测): 移除翻译模型依赖,改用快速或默认模型

* feat(i18n): 添加希腊语翻译支持

* fix(i18n): 更新i18n

统一将翻译模型提示改为快速模型提示,优化多语言文件中的描述

* Revert "feat(i18n): 添加希腊语翻译支持"

This reverts commit 42613cb2e2.

* feat: add 'code_tools' to sidebar icons and update related components

* fix: KaTeX math engine render

* feat: 同步百炼服务器功能 (#9205)

* 同步百炼服务器功能

* cr修改

---------

Co-authored-by: yunze <yunze.wyz@alibaba-inc.com>

* fix(SelectionHook): improve validation for selected text range to handle empty strings and ensure valid extraction (#9329)

chore: update selection-hook dependency to version 1.0.10 in package.json and yarn.lock

* fix: web search references missing caused by early reset (#9328)

* feat(openai): handle special tokens for zhipu api (#9323)

* feat(openai): 添加对智谱特殊token的过滤处理

在OpenAIAPIClient中添加对智谱AI特殊token的过滤逻辑,避免不需要的token被输出

* docs(OpenAIApiClient): 添加注释

* refactor(zhipu): 重命名并更新智谱特殊token处理逻辑

将 ZHIPU_SPECIAL_TOKENS_TO_FILTER 重命名为 ZHIPU_RESULT_TOKENS 以更准确描述用途
修改智谱API特殊token处理逻辑,不再过滤而是用**标记结果token

* feat: support openai codex (#9332)

* support openai codex

* lint

* refactor: remove unused codeTools enum from constant.ts

* fix build

* fix lin

* fix: add support for qwenCode CLI tool and improve error handling in CodeToolsService

* fix: timeout memory leak (#9312)

* fix(MinappPopupContainer): 修复内存泄漏问题,清理未使用的定时器

在组件卸载时清理setTimeout定时器,避免潜在的内存泄漏

* fix(SelectModelButton): 修复模型选择后更新导致的卡顿问题

使用useRef存储定时器并在组件卸载时清理,避免内存泄漏

* fix(QuickPanel): 修复定时器未清理导致的内存泄漏问题

添加 clearSearchTimerRef 和 focusTimerRef 来管理定时器
在组件清理和状态变化时清理所有定时器

* fix(useInPlaceEdit): 修复编辑模式下定时器未清理的问题

添加清理定时器的逻辑,避免组件卸载时内存泄漏

* refactor(useKnowledge): 使用ref管理定时器并统一检查知识库逻辑

将分散的setTimeout调用统一为checkAllBases方法
使用useRef管理定时器并在组件卸载时清理

* fix(useScrollPosition): 修复滚动位置恢复时的内存泄漏问题

添加清理函数以清除未完成的定时器,防止组件卸载时内存泄漏

* fix(WebSearchProviderSetting): 清理定时器防止内存泄漏

在组件卸载时清理检查API有效性的定时器,避免潜在的内存泄漏问题

* fix(selection-toolbar): 修复选中文本时定时器未清理的问题

* fix(translate): 修复复制文本时定时器未清理的问题

添加 copyTimerRef 来管理复制操作的定时器,并在组件卸载时清理定时器

* fix(WebSearchSettings): 使用useRef管理订阅验证定时器以避免内存泄漏

* fix(MCPSettings): 修复定时器未清理导致的内存泄漏问题

添加 useRef 来存储定时器引用,并在组件卸载时清理定时器

* refactor(ThinkingBlock): 使用 useTemporaryValue 替换手动 setTimeout

移除手动设置的 setTimeout 来重置 copied 状态,改用 useTemporaryValue hook 自动处理

* refactor(ChatNavigation): 使用 useRef 替代 useState 管理定时器

简化定时器管理逻辑,避免不必要的状态更新

* fix(AddAssistantPopup): 清理创建助手时的定时器以避免内存泄漏

添加useEffect清理定时器,防止组件卸载时内存泄漏

* feat(hooks): 添加useTimer钩子管理定时器

实现一个自定义hook来集中管理setTimeout和setInterval定时器
自动在组件卸载时清理所有定时器防止内存泄漏

* refactor(Inputbar): 使用 useTimer 替换 setTimeout 实现延迟更新

将 setTimeout 替换为 useTimer 的自定义 setTimeoutTimer 方法,提高代码可维护性并统一计时器管理

* refactor(WindowFooter): 使用 useTimer 替换 setTimeout 以管理定时器

* docs(useTimer): 更新定时器hook的注释格式和描述

* feat(hooks): 为useTimer添加返回清理函数的功能

允许从setTimeoutTimer和setIntervalTimer返回清理函数,便于手动清除定时器

* refactor(ImportAgentPopup): 使用useTimer替换setTimeout以管理定时器

* refactor: 使用useTimer替代setTimeout以优化定时器管理

* refactor(SearchResults): 使用useTimer替换setTimeout以管理定时器

* refactor(消息组件): 使用useTimer替换setTimeout以管理定时器

* refactor: 使用useTimer替换setTimeout以优化定时器管理

* refactor(AssistantsDrawer): 使用useTimer替换setTimeout以优化定时器管理

* refactor(Inputbar): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MCPToolsButton): 使用useTimer优化定时器管理

* refactor(QuickPhrasesButton): 使用useTimer替换setTimeout以优化定时器管理

* refactor(ChatFlowHistory): 使用useTimer替换setTimeout以管理定时器

* refactor(Message): 使用useTimer替换setTimeout以管理定时器

* refactor(MessageAnchorLine): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MessageGroup): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MessageMenubar): 使用 useTemporaryValue 替换手动 setTimeout 逻辑

* refactor(Messages): 使用 useTimer 优化定时器管理

* refactor(MessageTools): 使用useTimer替换setTimeout以管理定时器

* fix(SelectionBox): 修复鼠标移动时未清除定时器导致的内存泄漏

在鼠标移动事件处理中增加定时器清理逻辑,避免组件卸载时未清除定时器导致的内存泄漏问题

* refactor(ErrorBlock): 使用自定义hook替换setTimeout

使用useTimer中的setTimeoutTimer替代原生setTimeout,便于统一管理定时器

* refactor(GeneralSettings): 使用useTimer替换setTimeout以实现更好的定时器管理

* refactor(ShortcutSettings): 使用useTimer替换setTimeout以优化定时器管理

* refactor(AssistantModelSettings): 使用useTimer替换setTimeout以优化定时器管理

* refactor(DataSettings): 使用useTimer替换setTimeout以增强定时器管理

统一使用useTimer hook管理所有定时器操作,提高代码可维护性

* refactor(NutstoreSettings): 使用useTimer优化setTimeout管理

替换直接使用setTimeout为useTimer hook的setTimeoutTimer方法,提升定时器管理的可维护性

* refactor(MCPSettings): 使用useTimer替换setTimeout以提升代码可维护性

* refactor(ProviderSetting): 使用useTimer优化setTimeout管理

* refactor(ProviderSettings): 使用 useTimer 替换 setTimeout 以优化定时器管理

* refactor(InputBar): 使用useTimer替换setTimeout以实现更好的定时器管理

* refactor(MessageEditor): 使用useTimer替换setTimeout以管理定时器

使用自定义hook useTimer来替代原生setTimeout,便于统一管理和清理定时器

* docs(useTimer): 添加 useTimer hook 的使用示例和详细说明

* refactor(MinappPopupContainer): 使用useTimer替换setTimeout实现

替换直接使用setTimeout为自定义hook useTimer,简化组件清理逻辑

* refactor(AddAssistantPopup): 使用useTimer替换手动定时器管理

用useTimer钩子替代手动管理定时器,简化代码并提高可维护性

* refactor(WebSearchSettings): 使用 useTimer 替换手动定时器管理

移除手动管理的定时器逻辑,改用 useTimer hook 统一处理

* refactor(WebSearchProviderSetting): 使用自定义hook替代原生定时器

用useTimer hook替换原有的setTimeout和clearTimeout逻辑,提高代码可维护性

* refactor(translate): 使用 useTemporaryValue 替换手动实现的复制状态定时器

* refactor(SelectionToolbar): 使用 useTimer 钩子替换 setTimeout 和 clearTimeout

重构 SelectionToolbar 组件,使用自定义的 useTimer 钩子来管理定时器,提升代码可维护性
清理隐藏时的定时器逻辑,避免内存泄漏

* fix(Translate): update settings into db (#9305)

* fix(翻译): 修复设置没有储存到db的错误

* fix(translate): 修复自动检测方法设置更新失败的问题

添加错误处理逻辑,当更新自动检测方法设置失败时显示错误信息

* Fix AWS Bedrock models not receiving uploaded document content (#9337)

* Initial plan

* Add file content processing to AWS Bedrock client convertMessageToSdkParam method

Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>

* Fix file content format to match other AI clients and update tests

Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>

* Update src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>
Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>

* feat(migrate): initialize default assistant settings if not present (#9303)

* feat(migrate): update migration logic for version 134; initialize default assistant settings if not present

* Update src/renderer/src/store/migrate.ts

Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>

---------

Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>

* feat: support language aliases for code editor (#9336)

* feat(CodeEditor): support language aliases

* fix: mermaid

* refactor: lookup

* chore: sort package.json

* fix(SelectionHook): [macOS] add type safety to prevent crashes (#9354)

chore: update selection-hook dependency to version 1.0.11 in package.json and yarn.lock

* fix: sidebar code icon reset bug (#9307) (#9333)

* fix: 修复侧边栏重置时 Code 图标消失的问题 (#9307)

问题原因:
- types/index.ts 中的 SidebarIcon 类型定义缺少 'code_tools'
- 存在重复的类型定义和常量定义导致不一致

修复内容:
- 在 types/index.ts 的 SidebarIcon 类型中添加 'code_tools'
- 删除 minapps.ts 中重复的 DEFAULT_SIDEBAR_ICONS 常量
- 统一从 @renderer/types 导入 SidebarIcon 类型
- 删除 settings.ts 中重复的 SidebarIcon 类型定义

这确保了在导航栏设置为左侧时,点击侧边栏设置的重置按钮后,
Code 图标能够正确显示。

* refactor: 将侧边栏配置移至 config 目录

根据 code review 建议,将侧边栏相关配置从 store/settings.ts
移动到 config/sidebar.ts,使配置管理更加清晰。

改动内容:
- 创建 config/sidebar.ts 存放侧边栏配置常量
- 更新相关文件的导入路径
- 在 settings.ts 中重新导出以保持向后兼容
- 添加 REQUIRED_SIDEBAR_ICONS 常量便于未来扩展

这个改动保持了最小化原则,不影响现有功能。

* refactor: improve locate highlight animation (#9345)

* feat(utils): show weekday in date and datetime prompt variables (#9362)

* feat(utils): 优化日期时间变量替换格式

为 {{date}} 和 {{datetime}} 变量替换添加更详细的格式选项,包括星期、年月日和时间信息

* test(prompt): 更新测试中日期时间的本地化格式

* refactor(CodeToolsPage): simplify CLI tool change handling and optimize provider filtering logic

* fix(newMessage): reduce default display count from 20 to 10

* feat(AssistantService): introduce DEFAULT_ASSISTANT_SETTINGS for consistent assistant configuration and update migration logic for version 136

* chore: release v1.5.7-rc.2

* fix(Markdown/Link): set href to undefined when it's empty (#9343)

fix(Markdown/Link): 处理空链接时设置href为undefined

* fix(Inputbar): update file handling to use functional state update for setFiles

* refactor(file): update isSupportedFile function to accept filePath instead of FileMetadata for improved clarity and consistency in file handling

---------

Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>
Co-authored-by: alickreborn0 <i@guyi.me>
Co-authored-by: yunze <yunze.wyz@alibaba-inc.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Co-authored-by: caozhiyuan <568022847@qq.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>
Co-authored-by: SuYao <sy20010504@gmail.com>
Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com>
2025-08-21 14:19:51 +08:00
Phantom
ea6a1752e7 feat: reasoning effort cache (#9357)
* feat(useAssistant): 修改模型切换时推理努力值回退逻辑

当模型切换时,确保推理努力值回退到模型支持的第一个有效值,并默认开启思考模式。使用useRef优化设置引用,避免不必要的依赖。

* feat(assistant): 添加 reasoning_effort_cache 以保留思考模型设置

当从非思考模型切换回思考模型时,恢复上次使用的 reasoning_effort 值

* fix(assistant): 修复思考模式切换时缓存未正确更新的问题

* fix(useAssistant): 修复模型选项回退逻辑以支持推理模式

当启用推理模式时,回退到支持推理的选项,否则回退到默认选项

* docs(types): 完善 AssistantSettings 类型注释中的 TODO 说明
2025-08-21 14:18:19 +08:00
Phantom
062b3b0a33 feat: search translate history (#9342)
* feat(翻译历史): 添加搜索翻译历史UI

在翻译历史页面添加搜索框

* feat(翻译历史): 优化搜索功能并添加延迟渲染

- 将搜索逻辑提取为独立函数并使用useDeferredValue优化性能
- 重构类型命名和状态管理
- 格式化日期显示并移入memo计算

* feat(i18n): 为翻译历史添加搜索框占位文本

* refactor(translate): 移除未使用的InputRef引用和inputRef变量
2025-08-21 12:48:27 +08:00
beyondkmp
c5d8ec9c1a chores: upgrade @types/node to version 22.17.1 and electron to version 37.3.1 in package.json and yarn.lock (#9364) 2025-08-21 12:48:12 +08:00
Phantom
1af4a2686b fix(Markdown/Link): set href to undefined when it's empty (#9343)
fix(Markdown/Link): 处理空链接时设置href为undefined
2025-08-21 11:02:41 +08:00
kangfenmao
174b9bdc3d chore: release v1.5.7-rc.2 2025-08-21 10:59:55 +08:00
kangfenmao
84212d0b1d feat(AssistantService): introduce DEFAULT_ASSISTANT_SETTINGS for consistent assistant configuration and update migration logic for version 136 2025-08-21 10:57:56 +08:00
kangfenmao
6e9b77a97a fix(newMessage): reduce default display count from 20 to 10 2025-08-21 10:52:12 +08:00
kangfenmao
c93b96a03f refactor(CodeToolsPage): simplify CLI tool change handling and optimize provider filtering logic 2025-08-21 10:28:44 +08:00
Phantom
a671f95bee feat(utils): show weekday in date and datetime prompt variables (#9362)
* feat(utils): 优化日期时间变量替换格式

为 {{date}} 和 {{datetime}} 变量替换添加更详细的格式选项,包括星期、年月日和时间信息

* test(prompt): 更新测试中日期时间的本地化格式
2025-08-21 10:03:07 +08:00
one
0e750c64db refactor: improve locate highlight animation (#9345) 2025-08-21 08:42:20 +08:00
Jason Young
27eef50b9f fix: sidebar code icon reset bug (#9307) (#9333)
* fix: 修复侧边栏重置时 Code 图标消失的问题 (#9307)

问题原因:
- types/index.ts 中的 SidebarIcon 类型定义缺少 'code_tools'
- 存在重复的类型定义和常量定义导致不一致

修复内容:
- 在 types/index.ts 的 SidebarIcon 类型中添加 'code_tools'
- 删除 minapps.ts 中重复的 DEFAULT_SIDEBAR_ICONS 常量
- 统一从 @renderer/types 导入 SidebarIcon 类型
- 删除 settings.ts 中重复的 SidebarIcon 类型定义

这确保了在导航栏设置为左侧时,点击侧边栏设置的重置按钮后,
Code 图标能够正确显示。

* refactor: 将侧边栏配置移至 config 目录

根据 code review 建议,将侧边栏相关配置从 store/settings.ts
移动到 config/sidebar.ts,使配置管理更加清晰。

改动内容:
- 创建 config/sidebar.ts 存放侧边栏配置常量
- 更新相关文件的导入路径
- 在 settings.ts 中重新导出以保持向后兼容
- 添加 REQUIRED_SIDEBAR_ICONS 常量便于未来扩展

这个改动保持了最小化原则,不影响现有功能。
2025-08-21 00:49:42 +08:00
fullex
8297546ed7 fix(SelectionHook): [macOS] add type safety to prevent crashes (#9354)
chore: update selection-hook dependency to version 1.0.11 in package.json and yarn.lock
2025-08-21 00:21:14 +08:00
one
4e54733d38 feat: support language aliases for code editor (#9336)
* feat(CodeEditor): support language aliases

* fix: mermaid

* refactor: lookup

* chore: sort package.json
2025-08-21 00:03:27 +08:00
SuYao
bd9b34b9a0 feat(migrate): initialize default assistant settings if not present (#9303)
* feat(migrate): update migration logic for version 134; initialize default assistant settings if not present

* Update src/renderer/src/store/migrate.ts

Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>

---------

Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>
2025-08-21 00:01:03 +08:00
caozhiyuan
b1e843973c Fix AWS Bedrock models not receiving uploaded document content (#9337)
* Initial plan

* Add file content processing to AWS Bedrock client convertMessageToSdkParam method

Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>

* Fix file content format to match other AI clients and update tests

Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>

* Update src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com>
Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>
2025-08-20 18:26:38 +08:00
Phantom
11b130736c fix(Translate): update settings into db (#9305)
* fix(翻译): 修复设置没有储存到db的错误

* fix(translate): 修复自动检测方法设置更新失败的问题

添加错误处理逻辑,当更新自动检测方法设置失败时显示错误信息
2025-08-20 17:42:33 +08:00
Phantom
25531ecd76 fix: timeout memory leak (#9312)
* fix(MinappPopupContainer): 修复内存泄漏问题,清理未使用的定时器

在组件卸载时清理setTimeout定时器,避免潜在的内存泄漏

* fix(SelectModelButton): 修复模型选择后更新导致的卡顿问题

使用useRef存储定时器并在组件卸载时清理,避免内存泄漏

* fix(QuickPanel): 修复定时器未清理导致的内存泄漏问题

添加 clearSearchTimerRef 和 focusTimerRef 来管理定时器
在组件清理和状态变化时清理所有定时器

* fix(useInPlaceEdit): 修复编辑模式下定时器未清理的问题

添加清理定时器的逻辑,避免组件卸载时内存泄漏

* refactor(useKnowledge): 使用ref管理定时器并统一检查知识库逻辑

将分散的setTimeout调用统一为checkAllBases方法
使用useRef管理定时器并在组件卸载时清理

* fix(useScrollPosition): 修复滚动位置恢复时的内存泄漏问题

添加清理函数以清除未完成的定时器,防止组件卸载时内存泄漏

* fix(WebSearchProviderSetting): 清理定时器防止内存泄漏

在组件卸载时清理检查API有效性的定时器,避免潜在的内存泄漏问题

* fix(selection-toolbar): 修复选中文本时定时器未清理的问题

* fix(translate): 修复复制文本时定时器未清理的问题

添加 copyTimerRef 来管理复制操作的定时器,并在组件卸载时清理定时器

* fix(WebSearchSettings): 使用useRef管理订阅验证定时器以避免内存泄漏

* fix(MCPSettings): 修复定时器未清理导致的内存泄漏问题

添加 useRef 来存储定时器引用,并在组件卸载时清理定时器

* refactor(ThinkingBlock): 使用 useTemporaryValue 替换手动 setTimeout

移除手动设置的 setTimeout 来重置 copied 状态,改用 useTemporaryValue hook 自动处理

* refactor(ChatNavigation): 使用 useRef 替代 useState 管理定时器

简化定时器管理逻辑,避免不必要的状态更新

* fix(AddAssistantPopup): 清理创建助手时的定时器以避免内存泄漏

添加useEffect清理定时器,防止组件卸载时内存泄漏

* feat(hooks): 添加useTimer钩子管理定时器

实现一个自定义hook来集中管理setTimeout和setInterval定时器
自动在组件卸载时清理所有定时器防止内存泄漏

* refactor(Inputbar): 使用 useTimer 替换 setTimeout 实现延迟更新

将 setTimeout 替换为 useTimer 的自定义 setTimeoutTimer 方法,提高代码可维护性并统一计时器管理

* refactor(WindowFooter): 使用 useTimer 替换 setTimeout 以管理定时器

* docs(useTimer): 更新定时器hook的注释格式和描述

* feat(hooks): 为useTimer添加返回清理函数的功能

允许从setTimeoutTimer和setIntervalTimer返回清理函数,便于手动清除定时器

* refactor(ImportAgentPopup): 使用useTimer替换setTimeout以管理定时器

* refactor: 使用useTimer替代setTimeout以优化定时器管理

* refactor(SearchResults): 使用useTimer替换setTimeout以管理定时器

* refactor(消息组件): 使用useTimer替换setTimeout以管理定时器

* refactor: 使用useTimer替换setTimeout以优化定时器管理

* refactor(AssistantsDrawer): 使用useTimer替换setTimeout以优化定时器管理

* refactor(Inputbar): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MCPToolsButton): 使用useTimer优化定时器管理

* refactor(QuickPhrasesButton): 使用useTimer替换setTimeout以优化定时器管理

* refactor(ChatFlowHistory): 使用useTimer替换setTimeout以管理定时器

* refactor(Message): 使用useTimer替换setTimeout以管理定时器

* refactor(MessageAnchorLine): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MessageGroup): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MessageMenubar): 使用 useTemporaryValue 替换手动 setTimeout 逻辑

* refactor(Messages): 使用 useTimer 优化定时器管理

* refactor(MessageTools): 使用useTimer替换setTimeout以管理定时器

* fix(SelectionBox): 修复鼠标移动时未清除定时器导致的内存泄漏

在鼠标移动事件处理中增加定时器清理逻辑,避免组件卸载时未清除定时器导致的内存泄漏问题

* refactor(ErrorBlock): 使用自定义hook替换setTimeout

使用useTimer中的setTimeoutTimer替代原生setTimeout,便于统一管理定时器

* refactor(GeneralSettings): 使用useTimer替换setTimeout以实现更好的定时器管理

* refactor(ShortcutSettings): 使用useTimer替换setTimeout以优化定时器管理

* refactor(AssistantModelSettings): 使用useTimer替换setTimeout以优化定时器管理

* refactor(DataSettings): 使用useTimer替换setTimeout以增强定时器管理

统一使用useTimer hook管理所有定时器操作,提高代码可维护性

* refactor(NutstoreSettings): 使用useTimer优化setTimeout管理

替换直接使用setTimeout为useTimer hook的setTimeoutTimer方法,提升定时器管理的可维护性

* refactor(MCPSettings): 使用useTimer替换setTimeout以提升代码可维护性

* refactor(ProviderSetting): 使用useTimer优化setTimeout管理

* refactor(ProviderSettings): 使用 useTimer 替换 setTimeout 以优化定时器管理

* refactor(InputBar): 使用useTimer替换setTimeout以实现更好的定时器管理

* refactor(MessageEditor): 使用useTimer替换setTimeout以管理定时器

使用自定义hook useTimer来替代原生setTimeout,便于统一管理和清理定时器

* docs(useTimer): 添加 useTimer hook 的使用示例和详细说明

* refactor(MinappPopupContainer): 使用useTimer替换setTimeout实现

替换直接使用setTimeout为自定义hook useTimer,简化组件清理逻辑

* refactor(AddAssistantPopup): 使用useTimer替换手动定时器管理

用useTimer钩子替代手动管理定时器,简化代码并提高可维护性

* refactor(WebSearchSettings): 使用 useTimer 替换手动定时器管理

移除手动管理的定时器逻辑,改用 useTimer hook 统一处理

* refactor(WebSearchProviderSetting): 使用自定义hook替代原生定时器

用useTimer hook替换原有的setTimeout和clearTimeout逻辑,提高代码可维护性

* refactor(translate): 使用 useTemporaryValue 替换手动实现的复制状态定时器

* refactor(SelectionToolbar): 使用 useTimer 钩子替换 setTimeout 和 clearTimeout

重构 SelectionToolbar 组件,使用自定义的 useTimer 钩子来管理定时器,提升代码可维护性
清理隐藏时的定时器逻辑,避免内存泄漏
2025-08-20 16:38:13 +08:00
beyondkmp
332ba5d678 feat: support openai codex (#9332)
* support openai codex

* lint

* refactor: remove unused codeTools enum from constant.ts

* fix build

* fix lin

* fix: add support for qwenCode CLI tool and improve error handling in CodeToolsService
2025-08-20 15:46:44 +08:00
Phantom
1da1721ec2 feat(openai): handle special tokens for zhipu api (#9323)
* feat(openai): 添加对智谱特殊token的过滤处理

在OpenAIAPIClient中添加对智谱AI特殊token的过滤逻辑,避免不需要的token被输出

* docs(OpenAIApiClient): 添加注释

* refactor(zhipu): 重命名并更新智谱特殊token处理逻辑

将 ZHIPU_SPECIAL_TOKENS_TO_FILTER 重命名为 ZHIPU_RESULT_TOKENS 以更准确描述用途
修改智谱API特殊token处理逻辑,不再过滤而是用**标记结果token
2025-08-20 15:11:07 +08:00
one
f8120c2ebb fix: web search references missing caused by early reset (#9328) 2025-08-20 13:25:22 +08:00
fullex
cdca8c0ed7 fix(SelectionHook): improve validation for selected text range to handle empty strings and ensure valid extraction (#9329)
chore: update selection-hook dependency to version 1.0.10 in package.json and yarn.lock
2025-08-20 13:00:56 +08:00
alickreborn0
4f2b1e23a9 feat: 同步百炼服务器功能 (#9205)
* 同步百炼服务器功能

* cr修改

---------

Co-authored-by: yunze <yunze.wyz@alibaba-inc.com>
2025-08-20 11:26:38 +08:00
kangfenmao
47f49532c6 fix: KaTeX math engine render 2025-08-20 11:12:42 +08:00
kangfenmao
cffaf99b17 feat: add 'code_tools' to sidebar icons and update related components 2025-08-20 10:56:44 +08:00
Phantom
973ece9eb9 feat: use quick model to detect translate language (#9315)
* refactor(语言检测): 移除翻译模型依赖,改用快速或默认模型

* feat(i18n): 添加希腊语翻译支持

* fix(i18n): 更新i18n

统一将翻译模型提示改为快速模型提示,优化多语言文件中的描述

* Revert "feat(i18n): 添加希腊语翻译支持"

This reverts commit 42613cb2e2.
2025-08-20 10:07:10 +08:00
Phantom
a21fc91915 fix: unexpected quitting full screen mode (#9200)
* fix(Inputbar): 修正拼写错误,将expend改为expand

* fix: 修复Escape键事件冒泡问题并改进全屏处理

修复多个组件中Escape键事件未阻止冒泡的问题
添加全屏控制IPC通道
将全屏退出逻辑移至渲染进程处理
移除主进程中冗余的全屏退出处理代码

* fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect

将键盘事件监听从window移动到Modal容器,避免事件冒泡问题
移除无效的useEffect并更新键盘事件类型定义

* fix(QuickPanel): 拦截window上的keydown事件

* fix(QuickPanel): 修复事件监听器移除时未使用相同参数的问题

* fix(TopView): 修复左侧导航栏布局崩坏问题

* fix: 修正变量名拼写错误,将expended改为expanded

* Revert "fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect"

This reverts commit 4211780b95.
2025-08-19 21:32:53 +08:00
Phantom
80dfcf05a7 feat: quick model (#9290)
* refactor(i18n): 将话题命名模型相关文案更新为摘要模型

更新所有语言文件中关于话题命名模型的文案,统一改为摘要模型,以反映功能的扩展和更通用的用途

* refactor(设置页面): 优化主题命名弹窗组件性能

使用useCallback和useMemo优化回调函数和渲染性能
将重复的JSX代码提取为独立组件

* feat(设置): 在模型设置中添加话题命名折叠面板

将话题命名设置从直接显示改为折叠面板形式,提升界面整洁度

* refactor(i18n): 重构话题命名相关翻译字段结构

* docs(i18n): 添加生成图像的高度、宽度和安全容忍度翻译占位符

* fix(settings): 修正主题命名弹窗中的翻译键名

* style(ui): 调整主题命名弹窗的间距和文本区域高度

移除多余的上下边距,并使用自适应高度的文本区域

* refactor(llm): 将 topicNamingModel 重命名为 summaryModel

更新相关函数、状态和测试用例以反映命名变更
增加迁移逻辑处理旧状态数据
更新持久化版本号至133

* fix(ApiService): 优先使用摘要模型替代默认模型

当获取摘要时,优先使用getSummaryModel()返回的模型,其次才是助手指定的模型或默认模型,以确保摘要生成的一致性

* docs(i18n): 更新摘要模型描述中的搜索关键词提炼

将"搜索结果摘要"修改为"搜索关键字提炼"以更准确描述功能

* fix(i18n): 更新多语言翻译文件中的摘要模型相关文本

* feat(i18n): 为摘要模型设置添加工具提示说明

添加摘要模型设置的工具提示,建议用户选择轻量模型而非思考模型

* refactor(i18n): 将摘要模型相关文案更新为快速模型

更新国际化文案和组件引用,将"摘要模型"统一改为"快速模型"以更准确描述功能用途

* feat(i18n): 将摘要模型重命名为快速模型并更新相关描述

* refactor(llm): 将summaryModel重命名为quickModel以提升语义清晰度

* test(api): 在ApiService测试中添加LlmState类型和awsBedrock配置

添加LlmState类型以满足类型检查要求,并补充awsBedrock的mock配置以完善测试覆盖

* Revert "feat(设置): 在模型设置中添加话题命名折叠面板"

This reverts commit 4d58c053da.

* refactor(settings): 重命名并移动 TopicNamingModalPopup 组件文件

将 TopicNamingModalPopup.tsx 重命名为 QuickModelPopup.tsx 并移动到相应目录

* refactor(QuickModelPopup): 优化主题命名设置布局和样式

移除 TopicNamingSettings 组件内联实现,直接整合到 Modal 中
调整间距和样式,提升视觉一致性
修复文本区域 onChange 去除换行的逻辑

* feat(模型设置): 在快速模型弹窗中添加重置按钮图标并调整布局

将重置按钮改为图标形式并内联显示,同时调整输入区域的高度样式

* docs(i18n): 更新快速模型相关翻译文本

* fix: 将迁移错误日志从133更新为134

* style(settings): 替换模型设置中的图标为Rocket图标以提升视觉一致性
2025-08-19 20:39:52 +08:00
kangfenmao
0368583cfc refactor(Markdown): update disallowed elements to include 'script' for enhanced security 2025-08-19 18:11:20 +08:00
kangfenmao
c5554995dd refactor(CodeToolsService): adjust command construction for Windows compatibility; streamline installation command handling 2025-08-19 18:10:10 +08:00
kangfenmao
70cc1c4a32 chore: release v1.5.7-rc.1 2025-08-19 17:38:24 +08:00
kangfenmao
2ace9ba492 refactor(CodeToolsService): replace npm package version fetching with direct API call; simplify command construction for installation 2025-08-19 17:30:07 +08:00
one
cc8915842a refactor(SvgPreview): use transparent container for SVG (#9294)
* refactor(SvgPreview): use transparent container for SVG

* test: fix snapshot
2025-08-19 17:22:05 +08:00
kangfenmao
2e2cfc2409 refactor(Sidebar): remove unused imports and code related to documentation; streamline sidebar functionality 2025-08-19 16:42:20 +08:00
kangfenmao
2265ecab21 feat(CodeTools): add environment variable support for CLI tools; update UI to manage environment variables and enhance localization for related strings 2025-08-19 16:39:50 +08:00
kangfenmao
29d4e37f6b feat(Sidebar): add 'code_tools' icon and route; enhance CodeToolsPage layout with Navbar and improved provider filtering 2025-08-19 15:38:03 +08:00
kangfenmao
e0bc3bb2c5 refactor(MarkdownStyles): remove last-child margin override; adjust MessageFooter margin and clean up unused code in MessageAttachments 2025-08-19 13:53:46 +08:00
one
6d602d5d48 fix: MentionModelsButton re-rendering (#9296) 2025-08-19 13:46:29 +08:00
Phantom
1e7718162d feat(ProviderSetting): add tooltip for api options (#9292)
feat(ProviderSetting): 为API选项按钮添加工具提示说明

添加工具提示以解释按钮功能,提升用户体验
2025-08-19 12:59:29 +08:00
one
e3c52a6174 fix(ImagePreview): add relaxed sanitize rules for svg (#9293) 2025-08-19 12:58:45 +08:00
one
585e49ac65 fix: svg rendering (#9291)
* fix: svg max-width

* fix: disable sanitizer
2025-08-19 12:21:48 +08:00
kangfenmao
86545f4fff refactor(i18n): remove 'enable_delete_model' translations from multiple language files and related settings; streamline Inputbar and SettingsTab components by eliminating backspace delete model functionality. 2025-08-19 12:19:41 +08:00
Konv Suu
b57ec9fe70 fix: update invalid link (#9285)
* fix: update invalid link

* update
2025-08-19 12:15:53 +08:00
kangfenmao
b96af0fdef fix(useAssistant): ensure safe access to assistant settings and update reasoning effort handling logic 2025-08-19 11:55:22 +08:00
kangfenmao
b0ea7ad71c feat(i18n): integrate internationalization for minapp names across multiple languages; update minapp configuration to use localized names for better user experience. 2025-08-19 11:39:36 +08:00
kangfenmao
c8c0d22787 refactor(Inputbar): remove MentionModelsInput and KnowledgeBaseInput components; update InputbarTools and Inputbar to handle model mentions and knowledge base selections directly. Update icons to use Hammer instead of SquareTerminal in various components. Enhance i18n translations for clear actions across multiple languages. 2025-08-19 11:11:49 +08:00
one
263166c9d1 refactor: improve mcp server list (#9257)
* refactor: mcp server list

* feat: add a delete button, improve button style

* refactor(McpServerList): extract McpServerCard

* feat: show numbers in tab titles

* refactor(preload): 明确getServerVersion返回类型为Promise<string | null>

* refactor(hooks): 移除MCPServer数组的类型断言

* refactor(MCPSettings): 简化服务器标签的渲染逻辑

* Revert "refactor(MCPSettings): 简化服务器标签的渲染逻辑"

This reverts commit 1dd08909af.

* doc: add comments

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-08-19 10:26:06 +08:00
Gophlet
f3884af4b9 fix(Artboard): update dimensions to use CSS variables for better layout control (#9277) 2025-08-18 18:18:44 +08:00
Phantom
9a4200ac1a feat(translate): Automatic language detection based on LLM (#7798)
* docs(i18n): 添加LLM语言检测相关i18n文本

* feat(translate): 添加语言自动检测功能支持

新增语言自动检测方法选择,支持算法检测、LLM检测和智能选择模式
添加未知语言类型支持并更新多语言翻译配置
重构语言检测逻辑,移除旧版基于Unicode的检测方法

* fix: 从依赖数组中移除未使用的autoDetectMethod

* refactor(translate): 修复命名语法错误

* fix(translate): 移除历史记录点击时设置源语言的操作

* refactor(Inputbar): 使用useTranslate钩子替换直接导入的翻译函数

将直接导入的getLanguageByLangcode函数替换为useTranslate钩子中的实现,以保持代码一致性

* refactor(翻译): 将translateLanguageOptions移动到utils/translate中获取

* refactor(TextEditPopup): 使用useTranslate钩子替代直接导入翻译工具

将直接导入的getLanguageByLangcode函数替换为useTranslate钩子中的实现,保持代码一致性

* refactor(translate): 调整翻译设置界面的语言检测方法位置

将语言检测方法选项从中间位置移动到底部,并更新相关标签文本

* refactor(types): 将AutoDetectionMethod类型移至types文件并添加类型守卫

将AutoDetectionMethod类型定义从translate.ts移动到types/index.ts
添加AutoDetectionMethods常量和isAutoDetectionMethod验证方法

* style(translate): 调整翻译设置页面的样式和内联条件渲染

优化翻译设置页面的布局间距,使用条件渲染替代display属性控制元素显示

* refactor(translate): 使用useCallback优化setTranslating函数

* feat(i18n): 添加语言自动检测方法的翻译文本

* fix(翻译动作): 修复源语言与目标语言比较逻辑错误

* fix(翻译设置): 修复未保存设置的问题

* fix(QwenMT): 修复QwenMT模型语言检测问题并添加错误处理

当使用QwenMT模型进行语言检测时自动回退到默认模型,并添加相关错误提示
更新i18n翻译文本以支持新的错误消息

* feat(translate): 添加日志记录以跟踪语言检测过程

添加日志记录来跟踪语言检测方法的选择和检测结果

* feat(i18n): 添加模型不存在提示和语言检测相关翻译

* fix(翻译提示): 更新语言检测提示以避免输出多余内容

* fix(翻译): 改进未知语言处理和日志记录

修复未知语言检测时的处理逻辑,当检测到未知语言时直接使用目标语言
为ActionTranslate组件添加日志上下文
在日志中记录检测到的语言信息

* fix: 将语言检测的callType从lang-detect更新为translate-lang-detect

* refactor(translate): 使用token计数替代字符长度判断语言检测方式

将基于字符长度的语言检测阈值判断改为基于token计数,提高检测准确性
使用sliceByTokens方法替代简单的slice,确保截取的文本符合token限制

* fix(i18n): 更新未知语言检测的错误消息并移除废弃字段

统一将未知语言错误提示从'translate.error.detected_unknown'迁移至'translate.error.detect.unknown',并移除所有语言文件中废弃的'detected_unknown'字段

* docs(schemas): 添加测试调用和翻译语言检测的callType选项文档
2025-08-18 18:00:08 +08:00
one
32d5f7477a fix: markdown span wrap (#9264) 2025-08-18 13:40:59 +08:00
one
ecf1f816c3 refactor: bump antd to 5.27.0, update Input.Password suffix (#9263) 2025-08-18 13:39:56 +08:00
Phantom
f9056b0680 fix(providers): update poe url (#9262)
* fix(providers): 修正poe api的url结尾缺少斜杠的问题

* feat(types): 扩展 ReasoningEffortOptionalParams 中的 extra_body 类型

为 extra_body 添加具体的 google 配置类型,包括 thinking_config 及其属性

* feat(openai): 为Poe提供商添加推理参数传递支持

在Poe提供商的消息处理中,根据模型类型将reasoning_effort和thinking_budget参数附加到用户消息内容。支持GPT5、Claude和Gemini模型的特定参数传递。

* docs(openai): 添加关于poe reasoning_effort参数的注释
2025-08-18 11:55:10 +08:00
one
afae33d588 refactor: remove rc-virtual-list from DraggableList (#9258) 2025-08-18 09:36:15 +08:00
one
0b8c6ee536 perf(DraggableList): skip update if dnd to the same position (#9255) 2025-08-17 21:34:37 +08:00
Phantom
e652c1d783 fix: set developer role and service tier to default not supported (#9245)
* refactor(api): 将 isNotSupportDeveloperRole 替换为 isSupportDeveloperRole

重构开发者角色支持逻辑,使用正向命名的 isSupportDeveloperRole 替代反向命名的 isNotSupportDeveloperRole
更新相关迁移逻辑和配置检查逻辑

* refactor(api): 替换 isNotSupportServiceTier 为 isSupportServiceTier

将服务层级支持的否定式参数改为肯定式参数,修改默认行为为不支持
2025-08-17 19:48:41 +08:00
Phantom
aed9566409 fix: thinking button doesn't update assistant reasoning effort (#9247)
* feat(思考模式): 添加doubao_no_auto模型支持并重构选项回退逻辑

将思考模式选项回退逻辑从组件移至配置文件中统一管理
添加doubao_no_auto模型类型及其支持的选项配置

* refactor(assistant): 将模型兼容性检查逻辑移至useAssistant钩子

将ThinkingButton组件中的模型兼容性检查逻辑重构到useAssistant钩子中,使逻辑更集中
移除不再需要的useEffect导入

* refactor(useAssistant): 移除不必要的类型断言

* refactor: 移除THINKING_OPTION_FALLBACK并使用支持选项的第一个值作为回退

不再使用预定义的选项回退映射表,改为直接使用模型支持选项列表中的第一个值作为回退选项
2025-08-17 19:43:56 +08:00
one
33ec5c5c6b refactor: improve html artifact style (#9242)
* refactor: use code font family in HtmlArtifactsCard

* fix: pass onSave to HtmlArtifactsPopup

* feat: add a save button

* fix: avoid extra blank lines

* feat: make split view resizable

* refactor: improve streaming check, simplify Markdown component

* refactor: improve button style and icons

* test: update snapshots, add tests

* refactor: move font family to TerminalPreview

* test: update

* refactor: add explicit type for Node

* refactor: remove min-height

* fix: type

* refactor: improve scrollbar and splitter style
2025-08-17 19:42:40 +08:00
one
b53a5aa3af test: add tests for rehypeScalableSvg (#9248) 2025-08-17 18:46:33 +08:00
one
635bc084b7 refactor: rename some MCP list (#9253)
- BuiltinMCPServersSection -> BuiltinMCPServerList
- McpResourcesSection -> McpMarketList
2025-08-17 17:55:59 +08:00
Phantom
f0bd6c97fa fix(providers): update not support enable_thinking providers (#9251)
fix(providers): 更新不支持enable_thinking参数的系统提供商列表

将不支持enable_thinking参数的系统提供商明确设置为'ollama'和'lmstudio',移除之前的注释说明
2025-08-17 17:28:39 +08:00
George·Dong
13a834ceaa ci(PR-CI): grant only read permission for contents in CI (#9246) 2025-08-17 15:27:24 +08:00
one
ded941b7b9 refactor(Mermaid): render mermaid in shadow dom (#9187)
* refactor(Mermaid): render mermaid in shadow dom

* refactor: pass style overrides to renderSvgInShadowHost

* refactor(MermaidPreview): separate measurement from rendering

* refactor: rename hostCss to customCss

* refactor: use custom properties in shadow host

* test: update snapshots

* fix: remove svg max-width

* refactor: add viewBox to svg (experimental)

* Revert "refactor: add viewBox to svg (experimental)"

This reverts commit 8a265fa8a4.
2025-08-17 13:22:59 +08:00
Jason Young
535dcf4778 Fix/at symbol deletion issue (#9206)
* fix: prevent incorrect @ symbol deletion in QuickPanel

- Track trigger source (input vs button) and @ position
- Only delete @ when triggered by input with model selection
- Button-triggered panels never delete text content
- Validate @ still exists at recorded position before deletion

* feat: delete search text along with @ symbol

- Pass searchText from QuickPanel to onClose callback
- Delete both @ and search text (e.g., @cla) when model selected
- Validate text matches before deletion for safety
- Fallback to deleting only @ if text doesn't match

* refactor: clarify ESC vs Backspace behavior in QuickPanel

- ESC: Cancel operation, delete @ + searchText when models selected
- Backspace: Natural editing, @ already deleted by browser, no extra action
- Clear separation of intent improves predictability and UX
2025-08-17 11:43:44 +08:00
George·Dong
4dad2a593b fix(export): robustly export reasoning and handle errors (#9221)
* fix(export): robustly export reasoning and handle errors

* fix(export): normalize <br> to newline before notion parsing

* feat(i18n): add notion truncation and unify export warn keys

* refactor(export): add typing, state guards, and error logging

* fix(export): preserve existing <br> in reasoning when convert to html

* feat(export): add DOMPurify sanitization for reasoning content

* chore(deps): remove unused @types/dompurify dev dep

* chore(deps): remove dompurify dependency

Remove dompurify from package.json and yarn. The changes
delete dompurify entries and simplify the lockfile resolution so the
project no longer declares dompurify as a direct dependency.

This cleans up unused dependency declarations and prevents installing
dompurify when it is not required.
2025-08-17 00:41:48 +08:00
chenxue
8b5a3f734c feat(aihubmix): painting support flux model & update web search rules & update default models (#9220)
* feat: add painting flux model & update web search models

* feat: update flux api

---------

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>
2025-08-17 00:40:06 +08:00
Caelan
b3643944f3 feat(DMXAPI): new add painting seededit (#9226)
adjust api
2025-08-16 23:32:26 +08:00
one
e2e8ded2c0 refactor: improve style for ManageModelList and Tooltips (#9227)
* refactor(ManageModelsPopup): remove margin of Empty

* chore: use destroyOnHidden rather than deprecated destroyTooltipOnHide

* refactor: center Empty
2025-08-16 23:20:38 +08:00
one
72d0fea3a1 refactor(SvgPreview,Markdown): make svg size adaptive (#9232)
* refactor(Svg): make svg preview scalable

* feat: make svg in markdown scalable

* refactor: add measureElementSize

* refactor: improve rehypeScalableSvg, add MarkdownSvgRenderer

* fix: svg namespace

* perf: improve namespace correction

* refactor: rename makeSvgScalable to makeSvgSizeAdaptive

* test: fix tests for renderSvgInShadowHost

* refactor: improve MarkdownSvgRenderer re-render

* feat: sanitize svg before rendering

* feat: make MarkdownSvgRenderer clickable

* test: fix

* Revert "feat: make MarkdownSvgRenderer clickable"

This reverts commit 73af8fbb8c.

* refactor: use context menu in MarkdownSvgRenderer

* refactor: remove preserveAspectRatio from svg
2025-08-16 23:19:47 +08:00
beyondkmp
62a6a0a8be fix: Update KnowledgeBase form and service to handle preprocess provider correctly (#9229)
* fix: Update KnowledgeBase form and service to handle preprocess provider correctly

- Enhanced useKnowledgeBaseForm hook to set preprocessProvider with the correct providerId type.
- Modified getKnowledgeBaseParams function to retrieve preprocess provider from the store instead of the base, ensuring accurate provider assignment.

* fix: Remove unused providerId from preprocessProvider in useKnowledgeBaseForm hook

- Cleaned up the useKnowledgeBaseForm hook by removing the unused providerId property from the preprocessProvider object, ensuring a more accurate representation of the data structure.

* format code

* feat: Sync preprocess provider updates across knowledge bases

- Added a new action to synchronize updates to the preprocess provider across all knowledge bases that reference it.
- Updated the usePreprocessProvider hook to dispatch the new sync action after updating the provider.
- Modified getKnowledgeBaseParams to ensure the correct preprocess provider is assigned when retrieving knowledge base parameters.

* fix: Update sync logic for preprocess provider updates

- Modified the syncPreprocessProvider action to merge updates directly into the existing provider object instead of replacing it.
- Adjusted the usePreprocessProvider hook to only dispatch the sync action when specific fields (apiHost, apiKey, model) are updated, improving efficiency.
2025-08-16 21:14:47 +08:00
miro
04326eba21 feat: Use different window name for Quick Assistant (#9217)
Co-authored-by: Miro Klarin <miro.klarin@proton.me>
2025-08-16 11:21:29 +08:00
Pleasure1234
a02b4b3955 fix: websearch (#9222)
Update LocalSearchProvider.ts
2025-08-16 04:00:32 +08:00
SuYao
e0dbd2d2db fix/9165 (#9194)
* fix/9165

* fix: early return
2025-08-15 22:56:40 +08:00
beyondkmp
4a62bb6ad7 refactor: replace axios and node fetch with electron's net module (#9212)
* refactor: replace axios and node fetch with electron's net module for network requests in preprocess providers

- Updated Doc2xPreprocessProvider and MineruPreprocessProvider to use net.fetch instead of axios for making HTTP requests.
- Improved error handling for network responses across various methods.
- Removed unnecessary AxiosRequestConfig and related code to streamline the implementation.

* lint

* refactor(Doc2xPreprocessProvider): enhance file validation and upload process

- Added file size validation to prevent loading files larger than 300MB into memory.
- Implemented file size check before reading the PDF to ensure efficient memory usage.
- Updated the file upload method to use a stream, setting the 'Content-Length' header for better handling of large files.

* refactor(brave-search): update net.fetch calls to use url.toString()

- Modified all instances of net.fetch to use url.toString() for better URL handling.
- Ensured consistency in how URLs are passed to the fetch method across various functions.

* refactor(MCPService): improve URL handling in net.fetch calls

- Updated net.fetch to use url.toString() for better type handling of URLs.
- Ensured consistent URL processing across the MCPService class.

* feat(ProxyManager): integrate axios with fetch proxy support

- Added axios as a dependency to enable fetch proxy usage.
- Implemented logic to set axios's adapter to 'fetch' for proxy handling.
- Preserved original axios adapter for restoration when disabling the proxy.
2025-08-15 22:48:22 +08:00
陈天寒
748ac600fa fix(aws-bedrock): support thinking mode (#9172)
* fix(aws-bedrock): support thinking mode

* fix(aws-bedrock): fix code review suggestions

* fix(aws-bedrock): Add thinking processing for other models
2025-08-15 15:13:48 +08:00
Phantom
c2561726e0 style(Inputbar): use primary color for buttons (#9174)
style(Inputbar): 统一按钮激活状态颜色为主题色

将输入栏中多个按钮的激活状态颜色从链接色(--color-link)统一为主题色(--color-primary),保持UI一致性
2025-08-15 14:31:49 +08:00
beyondkmp
f2b7b07e51 refactor(AppUpdater): streamline release version fetching and improve update logic (#9167)
- Renamed method from _getPreReleaseVersionFromGithub to _getReleaseVersionFromGithub for clarity.
- Enhanced logic to check for the latest release version using semver.
- Removed unnecessary checks related to test plans when updates are not available.
- Improved logging for better traceability of release version fetching.
2025-08-15 10:45:11 +08:00
SuYao
d1e19aad51 fix: unexpected loading (#9193)
fix
2025-08-15 09:28:43 +08:00
George·Dong
5d34e49c57 refactor(bakcup): 单例化S3/WebDAV (#9181)
* feat(backup): 单例化S3/WebDAV并动态更新配置

* feat(backup): reuse storage instances by comparing core configs

* feat(backup): cache only connection fields for storages
2025-08-15 01:55:19 +08:00
Phantom
bef0180e4c feat: web search icons (#9147)
* feat(类型): 添加WebSearchProviderIds常量并更新WebSearchProvider类型

* refactor(web-search): 重构网络搜索提供商配置和logo获取逻辑

将webSearchProviders.ts中的提供商logo获取函数移动到使用组件中
并优化提供商配置的类型定义

* feat(WebSearchButton): 添加不同搜索引擎的图标支持

为WebSearchButton组件添加多个搜索引擎的图标支持,包括Baidu、Google、Bing等

* feat(types): 添加预处理和网页搜索提供者的类型校验函数

添加 PreprocessProviderId 和 WebSearchProviderId 的类型校验函数 isPreprocessProviderId 和 isWebSearchProviderId,用于验证字符串是否为有效的提供者 ID

* refactor(types): 重命名ApiProviderUnion并添加更新函数类型

添加用于更新不同类型API提供者的函数类型,提高类型安全性

* refactor(websearch): 将搜索提供商配置提取到单独文件

将websearch store中的搜索提供商配置提取到单独的配置文件,提高代码可维护性

* refactor(PreprocessSettings): 移除未使用的 system 选项禁用逻辑

由于 system 字段实际未使用,移除相关代码以简化逻辑

* refactor(api-key-popup): 移除providerKind参数,改用providerId判断类型

* refactor(preprocessProviders): 使用类型定义优化预处理提供者配置

将 providerId 参数类型从 string 改为 PreprocessProviderId
为 PREPROCESS_PROVIDER_CONFIG 添加类型定义

* refactor(hooks): 使用PreprocessProviderId类型替换字符串类型参数

* refactor(hooks): 使用 WebSearchProviderId 类型替换字符串类型参数

将 useWebSearchProvider 钩子的 id 参数类型从 string 改为 WebSearchProviderId,提高类型安全性

* refactor(knowledge): 将providerId类型改为PreprocessProviderId

* refactor(PreprocessSettings): 移除未使用的options相关代码

清理PreprocessSettings组件中已被注释掉的options状态和相关逻辑,简化代码结构

* refactor(WebSearchProviderSetting): 将providerId类型从string改为WebSearchProviderId

* refactor(websearch): 移除WebSearchProvider类型中不必要的id字段约束

* style(WebSearchButton): 调整图标大小和样式以保持视觉一致性

* fix(ApiKeyListPopup): 修正LLM提供者判断逻辑

使用'models'属性检查替代原有逻辑,更准确地判断是否为LLM provider

* fix(ApiKeyListPopup): 修复预处理provider判断逻辑

处理mistral同时提供预处理和llm服务的情况,避免误判
2025-08-14 23:19:17 +08:00
fullex
31e59ab395 fix: update selection-hook to v1.0.9 (#9180)
chore: update selection-hook to v1.0.9
2025-08-14 20:08:46 +08:00
SuYao
37dccd93e9 fix: modelname (#9183) 2025-08-14 20:06:57 +08:00
299 changed files with 12910 additions and 3982 deletions

View File

@@ -1,5 +1,8 @@
name: Pull Request CI
permissions:
contents: read
on:
workflow_dispatch:
pull_request:
@@ -42,8 +45,14 @@ jobs:
- name: Install Dependencies
run: yarn install
- name: Build Check
run: yarn build:check
- name: Lint Check
run: yarn test:lint
- name: Type Check
run: yarn typecheck
- name: i18n Check
run: yarn check:i18n
- name: Test
run: yarn test

47
.vscode/launch.json vendored
View File

@@ -1,39 +1,40 @@
{
"version": "0.2.0",
"compounds": [
{
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"name": "Debug All",
"presentation": {
"order": 1
}
}
],
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--inspect", "--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
},
"envFile": "${workspaceFolder}/.env",
"name": "Debug Main Process",
"request": "launch",
"runtimeArgs": ["--inspect", "--sourcemap"],
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"type": "node",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 3000000,
"presentation": {
"hidden": true
}
},
"request": "attach",
"timeout": 3000000,
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer"
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
"version": "0.2.0"
}

View File

@@ -0,0 +1,348 @@
diff --git a/src/constants/languages.d.ts b/src/constants/languages.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6a2ba5086187622b8ca8887bcc7406018fba8a89
--- /dev/null
+++ b/src/constants/languages.d.ts
@@ -0,0 +1,43 @@
+/**
+ * Languages with existing tesseract traineddata
+ * https://tesseract-ocr.github.io/tessdoc/Data-Files#data-files-for-version-400-november-29-2016
+ */
+
+// Define the language codes as string literals
+type LanguageCode =
+ | 'afr' | 'amh' | 'ara' | 'asm' | 'aze' | 'aze_cyrl' | 'bel' | 'ben' | 'bod' | 'bos'
+ | 'bul' | 'cat' | 'ceb' | 'ces' | 'chi_sim' | 'chi_tra' | 'chr' | 'cym' | 'dan' | 'deu'
+ | 'dzo' | 'ell' | 'eng' | 'enm' | 'epo' | 'est' | 'eus' | 'fas' | 'fin' | 'fra'
+ | 'frk' | 'frm' | 'gle' | 'glg' | 'grc' | 'guj' | 'hat' | 'heb' | 'hin' | 'hrv'
+ | 'hun' | 'iku' | 'ind' | 'isl' | 'ita' | 'ita_old' | 'jav' | 'jpn' | 'kan' | 'kat'
+ | 'kat_old' | 'kaz' | 'khm' | 'kir' | 'kor' | 'kur' | 'lao' | 'lat' | 'lav' | 'lit'
+ | 'mal' | 'mar' | 'mkd' | 'mlt' | 'msa' | 'mya' | 'nep' | 'nld' | 'nor' | 'ori'
+ | 'pan' | 'pol' | 'por' | 'pus' | 'ron' | 'rus' | 'san' | 'sin' | 'slk' | 'slv'
+ | 'spa' | 'spa_old' | 'sqi' | 'srp' | 'srp_latn' | 'swa' | 'swe' | 'syr' | 'tam' | 'tel'
+ | 'tgk' | 'tgl' | 'tha' | 'tir' | 'tur' | 'uig' | 'ukr' | 'urd' | 'uzb' | 'uzb_cyrl'
+ | 'vie' | 'yid';
+
+// Define the language keys as string literals
+type LanguageKey =
+ | 'AFR' | 'AMH' | 'ARA' | 'ASM' | 'AZE' | 'AZE_CYRL' | 'BEL' | 'BEN' | 'BOD' | 'BOS'
+ | 'BUL' | 'CAT' | 'CEB' | 'CES' | 'CHI_SIM' | 'CHI_TRA' | 'CHR' | 'CYM' | 'DAN' | 'DEU'
+ | 'DZO' | 'ELL' | 'ENG' | 'ENM' | 'EPO' | 'EST' | 'EUS' | 'FAS' | 'FIN' | 'FRA'
+ | 'FRK' | 'FRM' | 'GLE' | 'GLG' | 'GRC' | 'GUJ' | 'HAT' | 'HEB' | 'HIN' | 'HRV'
+ | 'HUN' | 'IKU' | 'IND' | 'ISL' | 'ITA' | 'ITA_OLD' | 'JAV' | 'JPN' | 'KAN' | 'KAT'
+ | 'KAT_OLD' | 'KAZ' | 'KHM' | 'KIR' | 'KOR' | 'KUR' | 'LAO' | 'LAT' | 'LAV' | 'LIT'
+ | 'MAL' | 'MAR' | 'MKD' | 'MLT' | 'MSA' | 'MYA' | 'NEP' | 'NLD' | 'NOR' | 'ORI'
+ | 'PAN' | 'POL' | 'POR' | 'PUS' | 'RON' | 'RUS' | 'SAN' | 'SIN' | 'SLK' | 'SLV'
+ | 'SPA' | 'SPA_OLD' | 'SQI' | 'SRP' | 'SRP_LATN' | 'SWA' | 'SWE' | 'SYR' | 'TAM' | 'TEL'
+ | 'TGK' | 'TGL' | 'THA' | 'TIR' | 'TUR' | 'UIG' | 'UKR' | 'URD' | 'UZB' | 'UZB_CYRL'
+ | 'VIE' | 'YID';
+
+// Create a mapped type to ensure each key maps to its specific value
+type LanguagesMap = {
+ [K in LanguageKey]: LanguageCode;
+};
+
+// Declare the exported constant with the specific type
+export const LANGUAGES: LanguagesMap;
+
+// Export the individual types for use in other modules
+export type { LanguageCode, LanguageKey, LanguagesMap };
\ No newline at end of file
diff --git a/src/index.d.ts b/src/index.d.ts
index 1f5a9c8094fe4de7983467f9efb43bdb4de535f2..16dc95cf68663673e37e189b719cb74897b7735f 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -1,31 +1,74 @@
+// Import the languages types
+import { LanguagesMap } from "./constants/languages";
+
+/// <reference types="node" />
+
declare namespace Tesseract {
- function createScheduler(): Scheduler
- function createWorker(langs?: string | string[] | Lang[], oem?: OEM, options?: Partial<WorkerOptions>, config?: string | Partial<InitOptions>): Promise<Worker>
- function setLogging(logging: boolean): void
- function recognize(image: ImageLike, langs?: string, options?: Partial<WorkerOptions>): Promise<RecognizeResult>
- function detect(image: ImageLike, options?: Partial<WorkerOptions>): any
+ function createScheduler(): Scheduler;
+ function createWorker(
+ langs?: LanguageCode | LanguageCode[] | Lang[],
+ oem?: OEM,
+ options?: Partial<WorkerOptions>,
+ config?: string | Partial<InitOptions>
+ ): Promise<Worker>;
+ function setLogging(logging: boolean): void;
+ function recognize(
+ image: ImageLike,
+ langs?: LanguageCode,
+ options?: Partial<WorkerOptions>
+ ): Promise<RecognizeResult>;
+ function detect(image: ImageLike, options?: Partial<WorkerOptions>): any;
+
+ // Export languages constant
+ const languages: LanguagesMap;
+
+ type LanguageCode = import("./constants/languages").LanguageCode;
+ type LanguageKey = import("./constants/languages").LanguageKey;
interface Scheduler {
- addWorker(worker: Worker): string
- addJob(action: 'recognize', ...args: Parameters<Worker['recognize']>): Promise<RecognizeResult>
- addJob(action: 'detect', ...args: Parameters<Worker['detect']>): Promise<DetectResult>
- terminate(): Promise<any>
- getQueueLen(): number
- getNumWorkers(): number
+ addWorker(worker: Worker): string;
+ addJob(
+ action: "recognize",
+ ...args: Parameters<Worker["recognize"]>
+ ): Promise<RecognizeResult>;
+ addJob(
+ action: "detect",
+ ...args: Parameters<Worker["detect"]>
+ ): Promise<DetectResult>;
+ terminate(): Promise<any>;
+ getQueueLen(): number;
+ getNumWorkers(): number;
}
interface Worker {
- load(jobId?: string): Promise<ConfigResult>
- writeText(path: string, text: string, jobId?: string): Promise<ConfigResult>
- readText(path: string, jobId?: string): Promise<ConfigResult>
- removeText(path: string, jobId?: string): Promise<ConfigResult>
- FS(method: string, args: any[], jobId?: string): Promise<ConfigResult>
- reinitialize(langs?: string | Lang[], oem?: OEM, config?: string | Partial<InitOptions>, jobId?: string): Promise<ConfigResult>
- setParameters(params: Partial<WorkerParams>, jobId?: string): Promise<ConfigResult>
- getImage(type: imageType): string
- recognize(image: ImageLike, options?: Partial<RecognizeOptions>, output?: Partial<OutputFormats>, jobId?: string): Promise<RecognizeResult>
- detect(image: ImageLike, jobId?: string): Promise<DetectResult>
- terminate(jobId?: string): Promise<ConfigResult>
+ load(jobId?: string): Promise<ConfigResult>;
+ writeText(
+ path: string,
+ text: string,
+ jobId?: string
+ ): Promise<ConfigResult>;
+ readText(path: string, jobId?: string): Promise<ConfigResult>;
+ removeText(path: string, jobId?: string): Promise<ConfigResult>;
+ FS(method: string, args: any[], jobId?: string): Promise<ConfigResult>;
+ reinitialize(
+ langs?: string | Lang[],
+ oem?: OEM,
+ config?: string | Partial<InitOptions>,
+ jobId?: string
+ ): Promise<ConfigResult>;
+ setParameters(
+ params: Partial<WorkerParams>,
+ jobId?: string
+ ): Promise<ConfigResult>;
+ getImage(type: imageType): string;
+ recognize(
+ image: ImageLike,
+ options?: Partial<RecognizeOptions>,
+ output?: Partial<OutputFormats>,
+ jobId?: string
+ ): Promise<RecognizeResult>;
+ detect(image: ImageLike, jobId?: string): Promise<DetectResult>;
+ terminate(jobId?: string): Promise<ConfigResult>;
}
interface Lang {
@@ -34,43 +77,43 @@ declare namespace Tesseract {
}
interface InitOptions {
- load_system_dawg: string
- load_freq_dawg: string
- load_unambig_dawg: string
- load_punc_dawg: string
- load_number_dawg: string
- load_bigram_dawg: string
- }
-
- type LoggerMessage = {
- jobId: string
- progress: number
- status: string
- userJobId: string
- workerId: string
+ load_system_dawg: string;
+ load_freq_dawg: string;
+ load_unambig_dawg: string;
+ load_punc_dawg: string;
+ load_number_dawg: string;
+ load_bigram_dawg: string;
}
-
+
+ type LoggerMessage = {
+ jobId: string;
+ progress: number;
+ status: string;
+ userJobId: string;
+ workerId: string;
+ };
+
interface WorkerOptions {
- corePath: string
- langPath: string
- cachePath: string
- dataPath: string
- workerPath: string
- cacheMethod: string
- workerBlobURL: boolean
- gzip: boolean
- legacyLang: boolean
- legacyCore: boolean
- logger: (arg: LoggerMessage) => void,
- errorHandler: (arg: any) => void
+ corePath: string;
+ langPath: string;
+ cachePath: string;
+ dataPath: string;
+ workerPath: string;
+ cacheMethod: string;
+ workerBlobURL: boolean;
+ gzip: boolean;
+ legacyLang: boolean;
+ legacyCore: boolean;
+ logger: (arg: LoggerMessage) => void;
+ errorHandler: (arg: any) => void;
}
interface WorkerParams {
- tessedit_pageseg_mode: PSM
- tessedit_char_whitelist: string
- tessedit_char_blacklist: string
- preserve_interword_spaces: string
- user_defined_dpi: string
- [propName: string]: any
+ tessedit_pageseg_mode: PSM;
+ tessedit_char_whitelist: string;
+ tessedit_char_blacklist: string;
+ preserve_interword_spaces: string;
+ user_defined_dpi: string;
+ [propName: string]: any;
}
interface OutputFormats {
text: boolean;
@@ -88,36 +131,36 @@ declare namespace Tesseract {
debug: boolean;
}
interface RecognizeOptions {
- rectangle: Rectangle
- pdfTitle: string
- pdfTextOnly: boolean
- rotateAuto: boolean
- rotateRadians: number
+ rectangle: Rectangle;
+ pdfTitle: string;
+ pdfTextOnly: boolean;
+ rotateAuto: boolean;
+ rotateRadians: number;
}
interface ConfigResult {
- jobId: string
- data: any
+ jobId: string;
+ data: any;
}
interface RecognizeResult {
- jobId: string
- data: Page
+ jobId: string;
+ data: Page;
}
interface DetectResult {
- jobId: string
- data: DetectData
+ jobId: string;
+ data: DetectData;
}
interface DetectData {
- tesseract_script_id: number | null
- script: string | null
- script_confidence: number | null
- orientation_degrees: number | null
- orientation_confidence: number | null
+ tesseract_script_id: number | null;
+ script: string | null;
+ script_confidence: number | null;
+ orientation_degrees: number | null;
+ orientation_confidence: number | null;
}
interface Rectangle {
- left: number
- top: number
- width: number
- height: number
+ left: number;
+ top: number;
+ width: number;
+ height: number;
}
enum OEM {
TESSERACT_ONLY,
@@ -126,28 +169,36 @@ declare namespace Tesseract {
DEFAULT,
}
enum PSM {
- OSD_ONLY = '0',
- AUTO_OSD = '1',
- AUTO_ONLY = '2',
- AUTO = '3',
- SINGLE_COLUMN = '4',
- SINGLE_BLOCK_VERT_TEXT = '5',
- SINGLE_BLOCK = '6',
- SINGLE_LINE = '7',
- SINGLE_WORD = '8',
- CIRCLE_WORD = '9',
- SINGLE_CHAR = '10',
- SPARSE_TEXT = '11',
- SPARSE_TEXT_OSD = '12',
- RAW_LINE = '13'
+ OSD_ONLY = "0",
+ AUTO_OSD = "1",
+ AUTO_ONLY = "2",
+ AUTO = "3",
+ SINGLE_COLUMN = "4",
+ SINGLE_BLOCK_VERT_TEXT = "5",
+ SINGLE_BLOCK = "6",
+ SINGLE_LINE = "7",
+ SINGLE_WORD = "8",
+ CIRCLE_WORD = "9",
+ SINGLE_CHAR = "10",
+ SPARSE_TEXT = "11",
+ SPARSE_TEXT_OSD = "12",
+ RAW_LINE = "13",
}
const enum imageType {
COLOR = 0,
GREY = 1,
- BINARY = 2
+ BINARY = 2,
}
- type ImageLike = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement
- | CanvasRenderingContext2D | File | Blob | Buffer | OffscreenCanvas;
+ type ImageLike =
+ | string
+ | HTMLImageElement
+ | HTMLCanvasElement
+ | HTMLVideoElement
+ | CanvasRenderingContext2D
+ | File
+ | Blob
+ | (typeof Buffer extends undefined ? never : Buffer)
+ | OffscreenCanvas;
interface Block {
paragraphs: Paragraph[];
text: string;
@@ -179,7 +230,7 @@ declare namespace Tesseract {
text: string;
confidence: number;
baseline: Baseline;
- rowAttributes: RowAttributes
+ rowAttributes: RowAttributes;
bbox: Bbox;
}
interface Paragraph {

View File

@@ -116,9 +116,11 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
支持 GPT-5 模型
新增代码工具,支持快速启动 Qwen Code, Gemini Cli, Claude Code
翻译页面改版,支持更多设置
支持保存整个话题到知识库
坚果云备份支持设置最大备份数量
输入框快捷菜单增加清除按钮
侧边栏增加代码工具入口,代码工具增加环境变量设置
小程序增加多语言显示
优化 MCP 服务器列表
新增 Web 搜索图标
优化 SVG 预览,优化 HTML 内容样式
修复知识库文档预处理失败问题
稳定性改进和错误修复

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.5.6",
"version": "1.5.7-rc.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -78,7 +78,9 @@
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"selection-hook": "^1.0.8",
"selection-hook": "^1.0.11",
"sharp": "^0.34.3",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0"
},
"devDependencies": {
@@ -103,7 +105,10 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@codemirror/view": "^6.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
@@ -145,7 +150,7 @@
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
"@types/node": "^18.19.9",
"@types/node": "^22.17.1",
"@types/pako": "^1.0.2",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
@@ -153,9 +158,9 @@
"@types/react-transition-group": "^4.4.12",
"@types/tinycolor2": "^1",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.14",
"@uiw/codemirror-themes-all": "^4.23.14",
"@uiw/react-codemirror": "^4.23.14",
"@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1",
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
@@ -164,7 +169,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"antd": "patch:antd@npm%3A5.26.7#~/.yarn/patches/antd-npm-5.26.7-029c5c381a.patch",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
@@ -180,7 +185,7 @@
"diff": "^7.0.0",
"docx": "^9.0.2",
"dotenv-cli": "^7.4.2",
"electron": "37.2.3",
"electron": "37.3.1",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0",
@@ -204,6 +209,7 @@
"husky": "^9.1.7",
"i18next": "^23.11.5",
"iconv-lite": "^0.6.3",
"isbinaryfile": "5.0.4",
"jaison": "^2.0.2",
"jest-styled-components": "^7.2.0",
"linguist-languages": "^8.0.0",
@@ -225,9 +231,9 @@
"prettier": "^3.5.3",
"prettier-plugin-sort-json": "^4.1.1",
"proxy-agent": "^6.5.0",
"rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
@@ -243,7 +249,9 @@
"reflect-metadata": "0.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-github-blockquote-alert": "^2.0.0",
@@ -270,24 +278,28 @@
"winston-daily-rotate-file": "^5.0.0",
"word-extractor": "^1.0.4",
"zipread": "^1.3.3",
"zod": "^4.0.0"
"zod": "^3.25.74"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch",
"node-abi": "4.12.0",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch"
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -35,6 +35,7 @@ export enum IpcChannel {
App_InstallBunBinary = 'app:install-bun-binary',
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
App_SetFullScreen = 'app:set-full-screen',
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
@@ -155,7 +156,9 @@ export enum IpcChannel {
File_Base64File = 'file:base64File',
File_GetPdfInfo = 'file:getPdfInfo',
Fs_Read = 'fs:read',
Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath',
File_IsTextFile = 'file:isTextFile',
// file service
FileService_Upload = 'file-service:upload',
@@ -279,5 +282,8 @@ export enum IpcChannel {
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
// CodeTools
CodeTools_Run = 'code-tools:run'
CodeTools_Run = 'code-tools:run',
// OCR
OCR_ocr = 'ocr:ocr'
}

View File

@@ -211,3 +211,10 @@ export const MIN_WINDOW_WIDTH = 1080
export const SECOND_MIN_WINDOW_WIDTH = 520
export const MIN_WINDOW_HEIGHT = 600
export const defaultByPassRules = 'localhost,127.0.0.1,::1'
export enum codeTools {
qwenCode = 'qwen-code',
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex'
}

View File

@@ -1,7 +1,7 @@
import { isDev, isWin } from '@main/constant'
import { app } from 'electron'
import { getDataPath } from './utils'
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
app.setPath('userData', app.getPath('userData') + 'Dev')
@@ -11,7 +11,7 @@ export const DATA_PATH = getDataPath()
export const titleBarOverlayDark = {
height: 42,
color: 'rgba(255,255,255,0)',
color: isWin ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0)',
symbolColor: '#fff'
}

View File

@@ -30,6 +30,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic
import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -71,7 +72,7 @@ const dxtService = new DxtService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
const notificationService = new NotificationService(mainWindow)
const notificationService = new NotificationService()
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
@@ -191,6 +192,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
}
ipcMain.handle(IpcChannel.App_SetFullScreen, (_, value: boolean): void => {
mainWindow.setFullScreen(value)
})
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
@@ -440,6 +445,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -464,6 +470,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService))
ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService))
// export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
@@ -704,4 +711,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CodeTools
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
}

View File

@@ -91,7 +91,7 @@ export default abstract class BasePreprocessProvider {
}
public async readPdf(buffer: Buffer) {
const pdfDoc = await PDFDocument.load(buffer)
const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true })
return {
numPages: pdfDoc.getPageCount()
}

View File

@@ -5,7 +5,7 @@ import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios, { AxiosRequestConfig } from 'axios'
import { net } from 'electron'
import BasePreprocessProvider from './BasePreprocessProvider'
@@ -38,19 +38,24 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
private async validateFile(filePath: string): Promise<void> {
const pdfBuffer = await fs.promises.readFile(filePath)
// 首先检查文件大小,避免读取大文件到内存
const stats = await fs.promises.stat(filePath)
const fileSizeBytes = stats.size
// 文件大小小于300MB
if (fileSizeBytes >= 300 * 1024 * 1024) {
const fileSizeMB = Math.round(fileSizeBytes / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
}
// 只有在文件大小合理的情况下才读取文件内容检查页数
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(pdfBuffer)
// 文件页数小于1000页
if (doc.numPages >= 1000) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`)
}
// 文件大小小于300MB
if (pdfBuffer.length >= 300 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
}
}
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
@@ -160,11 +165,23 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @returns 预上传响应的url和uid
*/
private async preupload(): Promise<PreuploadResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload`
try {
const { data } = await axios.post<ApiResponse<PreuploadResponse>>(endpoint, null, config)
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`
},
body: null
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<PreuploadResponse>
if (data.code === 'success' && data.data) {
return data.data
@@ -178,17 +195,29 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
/**
* 上传文件
* 上传文件(使用流式上传)
* @param filePath 文件路径
* @param url 预上传响应的url
*/
private async putFile(filePath: string, url: string): Promise<void> {
try {
const fileStream = fs.createReadStream(filePath)
const response = await axios.put(url, fileStream)
// 获取文件大小用于设置 Content-Length
const stats = await fs.promises.stat(filePath)
const fileSize = stats.size
if (response.status !== 200) {
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
// 创建可读流
const fileStream = fs.createReadStream(filePath)
const response = await net.fetch(url, {
method: 'PUT',
body: fileStream as any, // TypeScript 类型转换net.fetch 支持 ReadableStream
headers: {
'Content-Length': fileSize.toString()
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error) {
logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
@@ -197,16 +226,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
private async getStatus(uid: string): Promise<StatusResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}`
try {
const response = await axios.get<ApiResponse<StatusResponse>>(endpoint, config)
const response = await net.fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
})
if (response.data.code === 'success' && response.data.data) {
return response.data.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<StatusResponse>
if (data.code === 'success' && data.data) {
return data.data
} else {
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
}
} catch (error) {
logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`)
@@ -221,13 +259,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
*/
private async convertFile(uid: string, filePath: string): Promise<void> {
const fileName = path.parse(filePath).name
const config = {
...this.createAuthConfig(),
headers: {
...this.createAuthConfig().headers,
'Content-Type': 'application/json'
}
}
const payload = {
uid,
@@ -239,10 +270,22 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse`
try {
const response = await axios.post<ApiResponse<any>>(endpoint, payload, config)
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`
},
body: JSON.stringify(payload)
})
if (response.data.code !== 'success') {
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<any>
if (data.code !== 'success') {
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
}
} catch (error) {
logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
@@ -256,16 +299,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @returns 解析后的文件信息
*/
private async getParsedFile(uid: string): Promise<ParsedFileResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}`
try {
const response = await axios.get<ApiResponse<ParsedFileResponse>>(endpoint, config)
const response = await net.fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
})
if (response.status === 200 && response.data.data) {
return response.data.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<ParsedFileResponse>
if (data.data) {
return data.data
} else {
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
throw new Error(`No data in response`)
}
} catch (error) {
logger.error(
@@ -295,8 +347,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
try {
// 下载文件
const response = await axios.get(url, { responseType: 'arraybuffer' })
fs.writeFileSync(zipPath, response.data)
const response = await net.fetch(url, { method: 'GET' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
// 确保提取目录存在
if (!fs.existsSync(extractPath)) {
@@ -318,14 +374,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
}
private createAuthConfig(): AxiosRequestConfig {
return {
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
}
}
public checkQuota(): Promise<number> {
throw new Error('Method not implemented.')
}

View File

@@ -5,7 +5,7 @@ import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios from 'axios'
import { net } from 'electron'
import BasePreprocessProvider from './BasePreprocessProvider'
@@ -95,7 +95,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
public async checkQuota() {
try {
const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, {
const quota = await net.fetch(`${this.provider.apiHost}/api/v4/quota`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -179,8 +179,12 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
try {
// 下载ZIP文件
const response = await axios.get(zipUrl, { responseType: 'arraybuffer' })
fs.writeFileSync(zipPath, Buffer.from(response.data))
const response = await net.fetch(zipUrl, { method: 'GET' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
logger.info(`Downloaded ZIP file: ${zipPath}`)
// 确保提取目录存在
@@ -236,7 +240,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
}
try {
const response = await fetch(endpoint, {
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -271,7 +275,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
try {
const fileBuffer = await fs.promises.readFile(filePath)
const response = await fetch(uploadUrl, {
const response = await net.fetch(uploadUrl, {
method: 'PUT',
body: fileBuffer,
headers: {
@@ -316,7 +320,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}`
try {
const response = await fetch(endpoint, {
const response = await net.fetch(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import { net } from 'electron'
import BaseReranker from './BaseReranker'
@@ -15,7 +15,17 @@ export default class GeneralReranker extends BaseReranker {
const requestBody = this.getRerankRequestBody(query, searchResults)
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const response = await net.fetch(url, {
method: 'POST',
headers: this.defaultHeaders(),
body: JSON.stringify(requestBody)
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const rerankResults = this.extractRerankResult(data)
return this.getRerankResult(searchResults, rerankResults)

View File

@@ -3,6 +3,7 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
const WEB_SEARCH_TOOL: Tool = {
name: 'brave_web_search',
@@ -159,7 +160,7 @@ async function performWebSearch(apiKey: string, query: string, count: number = 1
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
url.searchParams.set('offset', offset.toString())
const response = await fetch(url, {
const response = await net.fetch(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
@@ -192,7 +193,7 @@ async function performLocalSearch(apiKey: string, query: string, count: number =
webUrl.searchParams.set('result_filter', 'locations')
webUrl.searchParams.set('count', Math.min(count, 20).toString())
const webResponse = await fetch(webUrl, {
const webResponse = await net.fetch(webUrl.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
@@ -225,7 +226,7 @@ async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiRespo
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
const response = await net.fetch(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
@@ -244,7 +245,7 @@ async function getDescriptionsData(apiKey: string, ids: string[]): Promise<Brave
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
const response = await net.fetch(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',

View File

@@ -2,6 +2,7 @@
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import * as z from 'zod/v4'
const logger = loggerService.withContext('DifyKnowledgeServer')
@@ -134,7 +135,7 @@ class DifyKnowledgeServer {
private async performListKnowledges(difyKey: string, apiHost: string): Promise<McpResponse> {
try {
const url = `${apiHost.replace(/\/$/, '')}/datasets`
const response = await fetch(url, {
const response = await net.fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${difyKey}`
@@ -186,7 +187,7 @@ class DifyKnowledgeServer {
try {
const url = `${apiHost.replace(/\/$/, '')}/datasets/${id}/retrieve`
const response = await fetch(url, {
const response = await net.fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${difyKey}`,

View File

@@ -2,9 +2,10 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod/v3'
import { z } from 'zod'
export const RequestPayloadSchema = z.object({
url: z.string().url(),
@@ -16,7 +17,7 @@ export type RequestPayload = z.infer<typeof RequestPayloadSchema>
export class Fetcher {
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
try {
const response = await fetch(url, {
const response = await net.fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',

View File

@@ -6,9 +6,10 @@ import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
@@ -44,12 +45,6 @@ export default class AppUpdater {
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
if (configManager.getTestPlan() && this.autoUpdater.channel !== UpgradeChannel.LATEST) {
logger.info('test plan is enabled, but update is not available, do not send update not available event')
// will not send update not available event, because will check for updates with latest channel
return
}
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateNotAvailable)
})
@@ -72,18 +67,24 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
private async _getReleaseVersionFromGithub(channel: UpgradeChannel) {
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
try {
logger.info(`get pre release version from github: ${channel}`)
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
logger.info(`get release version from github: ${channel}`)
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers
})
const data = (await responses.json()) as GithubReleaseInfo[]
let mightHaveLatest = false
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
if (!item.draft && !item.prerelease) {
mightHaveLatest = true
}
return item.prerelease && item.tag_name.includes(`-${channel}.`)
})
@@ -91,8 +92,29 @@ export default class AppUpdater {
return null
}
logger.info(`prerelease url is ${release.tag_name}, set channel to ${channel}`)
// if the release version is the same as the current version, return null
if (release.tag_name === app.getVersion()) {
return null
}
if (mightHaveLatest) {
logger.info(`might have latest release, get latest release`)
const latestReleaseResponse = await net.fetch(
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
{
headers
}
)
const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo
if (semver.gt(latestRelease.tag_name, release.tag_name)) {
logger.info(
`latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null`
)
return null
}
}
logger.info(`release url is ${release.tag_name}, set channel to ${channel}`)
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
} catch (error) {
logger.error('Failed to get latest not draft version from github:', error as Error)
@@ -151,14 +173,14 @@ export default class AppUpdater {
return
}
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
if (preReleaseUrl) {
logger.info(`prerelease url is ${preReleaseUrl}, set channel to ${channel}`)
this._setChannel(channel, preReleaseUrl)
const releaseUrl = await this._getReleaseVersionFromGithub(channel)
if (releaseUrl) {
logger.info(`release url is ${releaseUrl}, set channel to ${channel}`)
this._setChannel(channel, releaseUrl)
return
}
// if no prerelease url, use github latest to avoid error
// if no prerelease url, use github latest to get release
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
@@ -195,17 +217,6 @@ export default class AppUpdater {
`update check result: ${this.updateCheckResult?.isUpdateAvailable}, channel: ${this.autoUpdater.channel}, currentVersion: ${this.autoUpdater.currentVersion}`
)
// if the update is not available, and the test plan is enabled, set the feed url to the github latest
if (
!this.updateCheckResult?.isUpdateAvailable &&
configManager.getTestPlan() &&
this.autoUpdater.channel !== UpgradeChannel.LATEST
) {
logger.info('test plan is enabled, but update is not available, set channel to latest')
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
}
if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
// 如果 autoDownload 为 false则需要再调用下面的函数触发下
// do not use await, because it will block the return of this function

View File

@@ -21,6 +21,27 @@ class BackupManager {
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
// 缓存实例,避免重复创建
private s3Storage: S3Storage | null = null
private webdavInstance: WebDav | null = null
// 缓存核心连接配置,用于检测连接配置是否变更
private cachedS3ConnectionConfig: {
endpoint: string
region: string
bucket: string
accessKeyId: string
secretAccessKey: string
root?: string
} | null = null
private cachedWebdavConnectionConfig: {
webdavHost: string
webdavUser?: string
webdavPass?: string
webdavPath?: string
} | null = null
constructor() {
this.checkConnection = this.checkConnection.bind(this)
this.backup = this.backup.bind(this)
@@ -87,6 +108,88 @@ class BackupManager {
}
}
/**
* 比较两个配置对象是否相等,只比较影响客户端连接的核心字段,忽略 fileName 等易变字段
*/
private isS3ConfigEqual(cachedConfig: typeof this.cachedS3ConnectionConfig, config: S3Config): boolean {
if (!cachedConfig) return false
return (
cachedConfig.endpoint === config.endpoint &&
cachedConfig.region === config.region &&
cachedConfig.bucket === config.bucket &&
cachedConfig.accessKeyId === config.accessKeyId &&
cachedConfig.secretAccessKey === config.secretAccessKey &&
cachedConfig.root === config.root
)
}
/**
* 深度比较两个 WebDAV 配置对象是否相等,只比较影响客户端连接的核心字段,忽略 fileName 等易变字段
*/
private isWebDavConfigEqual(cachedConfig: typeof this.cachedWebdavConnectionConfig, config: WebDavConfig): boolean {
if (!cachedConfig) return false
return (
cachedConfig.webdavHost === config.webdavHost &&
cachedConfig.webdavUser === config.webdavUser &&
cachedConfig.webdavPass === config.webdavPass &&
cachedConfig.webdavPath === config.webdavPath
)
}
/**
* 获取 S3Storage 实例,如果连接配置未变且实例已存在则复用,否则创建新实例
* 注意:只有连接相关的配置变更才会重新创建实例,其他配置变更不影响实例复用
*/
private getS3Storage(config: S3Config): S3Storage {
// 检查核心连接配置是否变更
const configChanged = !this.isS3ConfigEqual(this.cachedS3ConnectionConfig, config)
if (configChanged || !this.s3Storage) {
this.s3Storage = new S3Storage(config)
// 只缓存连接相关的配置字段
this.cachedS3ConnectionConfig = {
endpoint: config.endpoint,
region: config.region,
bucket: config.bucket,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
root: config.root
}
logger.debug('[BackupManager] Created new S3Storage instance')
} else {
logger.debug('[BackupManager] Reusing existing S3Storage instance')
}
return this.s3Storage
}
/**
* 获取 WebDav 实例,如果连接配置未变且实例已存在则复用,否则创建新实例
* 注意:只有连接相关的配置变更才会重新创建实例,其他配置变更不影响实例复用
*/
private getWebDavInstance(config: WebDavConfig): WebDav {
// 检查核心连接配置是否变更
const configChanged = !this.isWebDavConfigEqual(this.cachedWebdavConnectionConfig, config)
if (configChanged || !this.webdavInstance) {
this.webdavInstance = new WebDav(config)
// 只缓存连接相关的配置字段
this.cachedWebdavConnectionConfig = {
webdavHost: config.webdavHost,
webdavUser: config.webdavUser,
webdavPass: config.webdavPass,
webdavPath: config.webdavPath
}
logger.debug('[BackupManager] Created new WebDav instance')
} else {
logger.debug('[BackupManager] Reusing existing WebDav instance')
}
return this.webdavInstance
}
async backup(
_: Electron.IpcMainInvokeEvent,
fileName: string,
@@ -322,7 +425,7 @@ class BackupManager {
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
const webdavClient = new WebDav(webdavConfig)
const webdavClient = this.getWebDavInstance(webdavConfig)
try {
let result
if (webdavConfig.disableStream) {
@@ -349,7 +452,7 @@ class BackupManager {
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const webdavClient = new WebDav(webdavConfig)
const webdavClient = this.getWebDavInstance(webdavConfig)
try {
const retrievedFile = await webdavClient.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
@@ -377,7 +480,7 @@ class BackupManager {
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
const client = new WebDav(config)
const client = this.getWebDavInstance(config)
const response = await client.getDirectoryContents()
const files = Array.isArray(response) ? response : response.data
@@ -467,7 +570,7 @@ class BackupManager {
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const webdavClient = new WebDav(webdavConfig)
const webdavClient = this.getWebDavInstance(webdavConfig)
return await webdavClient.checkConnection()
}
@@ -477,13 +580,13 @@ class BackupManager {
path: string,
options?: CreateDirectoryOptions
) {
const webdavClient = new WebDav(webdavConfig)
const webdavClient = this.getWebDavInstance(webdavConfig)
return await webdavClient.createDirectory(path, options)
}
async deleteWebdavFile(_: Electron.IpcMainInvokeEvent, fileName: string, webdavConfig: WebDavConfig) {
try {
const webdavClient = new WebDav(webdavConfig)
const webdavClient = this.getWebDavInstance(webdavConfig)
return await webdavClient.deleteFile(fileName)
} catch (error: any) {
logger.error('Failed to delete WebDAV file:', error)
@@ -525,7 +628,7 @@ class BackupManager {
logger.debug(`Starting S3 backup to ${filename}`)
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
const s3Client = new S3Storage(s3Config)
const s3Client = this.getS3Storage(s3Config)
try {
const fileBuffer = await fs.promises.readFile(backupedFilePath)
const result = await s3Client.putFileContents(filename, fileBuffer)
@@ -603,7 +706,7 @@ class BackupManager {
logger.debug(`Starting restore from S3: ${filename}`)
const s3Client = new S3Storage(s3Config)
const s3Client = this.getS3Storage(s3Config)
try {
const retrievedFile = await s3Client.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
@@ -628,7 +731,7 @@ class BackupManager {
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
try {
const s3Client = new S3Storage(s3Config)
const s3Client = this.getS3Storage(s3Config)
const objects = await s3Client.listFiles()
const files = objects
@@ -652,7 +755,7 @@ class BackupManager {
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
try {
const s3Client = new S3Storage(s3Config)
const s3Client = this.getS3Storage(s3Config)
return await s3Client.deleteFile(fileName)
} catch (error: any) {
logger.error('Failed to delete S3 file:', error)
@@ -661,7 +764,7 @@ class BackupManager {
}
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const s3Client = new S3Storage(s3Config)
const s3Client = this.getS3Storage(s3Config)
return await s3Client.checkConnection()
}
}

View File

@@ -3,9 +3,11 @@ import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { removeEnvProxy } from '@main/utils'
import { isUserInChina } from '@main/utils/ipService'
import { getBinaryName } from '@main/utils/process'
import { codeTools } from '@shared/config/constant'
import { spawn } from 'child_process'
import { promisify } from 'util'
@@ -40,23 +42,33 @@ class CodeToolsService {
}
public async getPackageName(cliTool: string) {
if (cliTool === 'claude-code') {
return '@anthropic-ai/claude-code'
switch (cliTool) {
case codeTools.claudeCode:
return '@anthropic-ai/claude-code'
case codeTools.geminiCli:
return '@google/gemini-cli'
case codeTools.openaiCodex:
return '@openai/codex'
case codeTools.qwenCode:
return '@qwen-code/qwen-code'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
if (cliTool === 'gemini-cli') {
return '@google/gemini-cli'
}
return '@qwen-code/qwen-code'
}
public async getCliExecutableName(cliTool: string) {
if (cliTool === 'claude-code') {
return 'claude'
switch (cliTool) {
case codeTools.claudeCode:
return 'claude'
case codeTools.geminiCli:
return 'gemini'
case codeTools.openaiCodex:
return 'codex'
case codeTools.qwenCode:
return 'qwen'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
if (cliTool === 'gemini-cli') {
return 'gemini'
}
return 'qwen'
}
private async isPackageInstalled(cliTool: string): Promise<boolean> {
@@ -114,9 +126,21 @@ class CodeToolsService {
} else {
logger.info(`Fetching latest version for ${packageName} from npm`)
try {
const bunPath = await this.getBunPath()
const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 })
latestVersion = stdout.trim().replace(/["']/g, '')
// Get registry URL
const registryUrl = await this.getNpmRegistryUrl()
// Fetch package info directly from npm registry API
const packageUrl = `${registryUrl}/${packageName}/latest`
const response = await fetch(packageUrl, {
signal: AbortSignal.timeout(15000)
})
if (!response.ok) {
throw new Error(`Failed to fetch package info: ${response.statusText}`)
}
const packageInfo = await response.json()
latestVersion = packageInfo.version
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
@@ -283,12 +307,11 @@ class CodeToolsService {
}
// Build command to execute
let baseCommand: string
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) {
// If already installed, run executable directly (with optional update message)
baseCommand = `"${executablePath}"`
if (updateMessage) {
baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
}
@@ -300,8 +323,8 @@ class CodeToolsService {
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"`
const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
}
switch (platform) {

View File

@@ -1,10 +1,10 @@
import { loggerService } from '@logger'
import { AxiosRequestConfig } from 'axios'
import axios from 'axios'
import { app, safeStorage } from 'electron'
import fs from 'fs/promises'
import { app, net, safeStorage } from 'electron'
import fs from 'fs'
import path from 'path'
import { getConfigDir } from '../utils/file'
const logger = loggerService.withContext('CopilotService')
// 配置常量,集中管理
@@ -29,7 +29,8 @@ const CONFIG = {
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token',
COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/token'
}
},
TOKEN_FILE_NAME: '.copilot_token'
}
// 接口定义移到顶部,便于查阅
@@ -68,8 +69,20 @@ class CopilotService {
private headers: Record<string, string>
constructor() {
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token')
this.headers = { ...CONFIG.DEFAULT_HEADERS }
this.tokenFilePath = this.getTokenFilePath()
this.headers = {
...CONFIG.DEFAULT_HEADERS,
accept: 'application/json',
'user-agent': 'Visual Studio Code (desktop)'
}
}
private getTokenFilePath = (): string => {
const oldTokenFilePath = path.join(app.getPath('userData'), CONFIG.TOKEN_FILE_NAME)
if (fs.existsSync(oldTokenFilePath)) {
return oldTokenFilePath
}
return path.join(getConfigDir(), CONFIG.TOKEN_FILE_NAME)
}
/**
@@ -86,21 +99,27 @@ class CopilotService {
*/
public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<UserResponse> => {
try {
const config: AxiosRequestConfig = {
const response = await net.fetch(CONFIG.API_URLS.GITHUB_USER, {
method: 'GET',
headers: {
Connection: 'keep-alive',
'user-agent': 'Visual Studio Code (desktop)',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'empty',
accept: 'application/json',
authorization: `token ${token}`
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
const data = await response.json()
return {
login: response.data.login,
avatar: response.data.avatar_url
login: data.login,
avatar: data.avatar_url
}
} catch (error) {
logger.error('Failed to get user information:', error as Error)
@@ -118,16 +137,23 @@ class CopilotService {
try {
this.updateHeaders(headers)
const response = await axios.post<AuthResponse>(
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
{
const response = await net.fetch(CONFIG.API_URLS.GITHUB_DEVICE_CODE, {
method: 'POST',
headers: {
...this.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: CONFIG.GITHUB_CLIENT_ID,
scope: 'read:user'
},
{ headers: this.headers }
)
})
})
return response.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return (await response.json()) as AuthResponse
} catch (error) {
logger.error('Failed to get auth message:', error as Error)
throw new CopilotServiceError('无法获取GitHub授权信息', error)
@@ -150,17 +176,25 @@ class CopilotService {
await this.delay(currentDelay)
try {
const response = await axios.post<TokenResponse>(
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
{
const response = await net.fetch(CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, {
method: 'POST',
headers: {
...this.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: CONFIG.GITHUB_CLIENT_ID,
device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
},
{ headers: this.headers }
)
})
})
const { access_token } = response.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as TokenResponse
const { access_token } = data
if (access_token) {
return { access_token }
}
@@ -185,7 +219,13 @@ class CopilotService {
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
try {
const encryptedToken = safeStorage.encryptString(token)
await fs.writeFile(this.tokenFilePath, encryptedToken)
// 确保目录存在
const dir = path.dirname(this.tokenFilePath)
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, { recursive: true })
}
await fs.promises.writeFile(this.tokenFilePath, encryptedToken)
} catch (error) {
logger.error('Failed to save token:', error as Error)
throw new CopilotServiceError('无法保存访问令牌', error)
@@ -202,19 +242,22 @@ class CopilotService {
try {
this.updateHeaders(headers)
const encryptedToken = await fs.readFile(this.tokenFilePath)
const encryptedToken = await fs.promises.readFile(this.tokenFilePath)
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
const config: AxiosRequestConfig = {
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
method: 'GET',
headers: {
...this.headers,
authorization: `token ${access_token}`
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
return response.data
return (await response.json()) as CopilotTokenResponse
} catch (error) {
logger.error('Failed to get Copilot token:', error as Error)
throw new CopilotServiceError('无法获取Copilot令牌请重新授权', error)
@@ -227,8 +270,8 @@ class CopilotService {
public logout = async (): Promise<void> => {
try {
try {
await fs.access(this.tokenFilePath)
await fs.unlink(this.tokenFilePath)
await fs.promises.access(this.tokenFilePath)
await fs.promises.unlink(this.tokenFilePath)
logger.debug('Successfully logged out from Copilot')
} catch (error) {
// 文件不存在不是错误,只是记录一下

View File

@@ -1,10 +1,12 @@
import { loggerService } from '@logger'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import chardet from 'chardet'
import * as crypto from 'crypto'
import {
dialog,
net,
OpenDialogOptions,
OpenDialogReturnValue,
SaveDialogOptions,
@@ -14,6 +16,7 @@ import {
import * as fs from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
import { isBinaryFile } from 'isbinaryfile'
import officeParser from 'officeparser'
import * as path from 'path'
import { PDFDocument } from 'pdf-lib'
@@ -509,7 +512,7 @@ class FileStorage {
isUseContentType?: boolean
): Promise<FileMetadata> => {
try {
const response = await fetch(url)
const response = await net.fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
@@ -629,6 +632,34 @@ class FileStorage {
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}
public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> => {
try {
const isBinary = await isBinaryFile(filePath)
if (isBinary) {
return false
}
const length = 8 * KB
const fileHandle = await fs.promises.open(filePath, 'r')
const buffer = Buffer.alloc(length)
const { bytesRead } = await fileHandle.read(buffer, 0, length, 0)
await fileHandle.close()
const sampleBuffer = buffer.subarray(0, bytesRead)
const matches = chardet.analyse(sampleBuffer)
// 如果检测到的编码置信度较高,认为是文本文件
if (matches.length > 0 && matches[0].confidence > 0.8) {
return true
}
return false
} catch (error) {
logger.error('Failed to check if file is text:', error as Error)
return false
}
}
}
export const fileStorage = new FileStorage()

View File

@@ -1,3 +1,4 @@
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import fs from 'fs/promises'
@@ -8,4 +9,15 @@ export default class FileService {
if (encoding) return fs.readFile(path, { encoding })
return fs.readFile(path)
}
/**
* 自动识别编码,读取文本文件
* @param _ event
* @param pathOrUrl
* @throws 路径不存在时抛出错误
*/
@TraceMethod({ spanName: 'readTextFileWithAutoEncoding', tag: 'FileService' })
public static async readTextFileWithAutoEncoding(_: Electron.IpcMainInvokeEvent, path: string): Promise<string> {
return readTextFileWithAutoEncoding(path)
}
}

View File

@@ -21,7 +21,6 @@ import {
CancelledNotificationSchema,
type GetPromptResult,
LoggingMessageNotificationSchema,
ProgressNotificationSchema,
PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
ResourceUpdatedNotificationSchema,
@@ -29,7 +28,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js'
import { nanoid } from '@reduxjs/toolkit'
import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types'
import { app } from 'electron'
import { app, net } from 'electron'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
import { v4 as uuidv4 } from 'uuid'
@@ -205,7 +204,7 @@ class McpService {
}
}
return fetch(url, { ...init, headers })
return net.fetch(typeof url === 'string' ? url : url.toString(), { ...init, headers })
}
},
requestInit: {
@@ -432,15 +431,6 @@ class McpService {
this.clearResourceCaches(serverKey)
})
// Set up progress notification handler
client.setNotificationHandler(ProgressNotificationSchema, async (notification) => {
logger.debug(`Progress notification received for server: ${server.name}`, notification.params)
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp-progress', notification.params.progress / (notification.params.total || 1))
}
})
// Set up cancelled notification handler
client.setNotificationHandler(CancelledNotificationSchema, async (notification) => {
logger.debug(`Operation cancelled for server: ${server.name}`, notification.params)
@@ -629,6 +619,11 @@ class McpService {
const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
logger.debug(`Progress notification received for server: ${server.name}`, process)
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
}
},
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute,
// 需要服务端支持: https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#timeouts

View File

@@ -1,14 +1,9 @@
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
import { Notification as ElectronNotification } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { windowService } from './WindowService'
class NotificationService {
private window: BrowserWindow
constructor(window: BrowserWindow) {
// Initialize the service
this.window = window
}
public async sendNotification(notification: Notification) {
// 使用 Electron Notification API
const electronNotification = new ElectronNotification({
@@ -17,8 +12,8 @@ class NotificationService {
})
electronNotification.on('click', () => {
this.window.show()
this.window.webContents.send('notification-click', notification)
windowService.getMainWindow()?.show()
windowService.getMainWindow()?.webContents.send('notification-click', notification)
})
electronNotification.show()

View File

@@ -2,6 +2,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { net } from 'electron'
import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
@@ -62,7 +63,7 @@ export async function getDirectoryContents(token: string, target: string): Promi
let currentUrl = `${NUTSTORE_HOST}${target}`
while (true) {
const response = await fetch(currentUrl, {
const response = await net.fetch(currentUrl, {
method: 'PROPFIND',
headers: {
Authorization: `Basic ${token}`,

View File

@@ -11,14 +11,42 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
const logger = loggerService.withContext('ProxyManager')
let byPassRules: string[] = []
const isByPass = (hostname: string) => {
const isByPass = (url: string) => {
if (byPassRules.length === 0) {
return false
}
return byPassRules.includes(hostname)
}
try {
const subjectUrlTokens = new URL(url)
for (const rule of byPassRules) {
const ruleMatch = rule.replace(/^(?<leadingDot>\.)/, '*').match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/)
if (!ruleMatch || !ruleMatch.groups) {
logger.warn('Failed to parse bypass rule:', { rule })
continue
}
if (!ruleMatch.groups.hostname) {
continue
}
const hostnameIsMatch = subjectUrlTokens.hostname === ruleMatch.groups.hostname
if (
hostnameIsMatch &&
(!ruleMatch.groups ||
!ruleMatch.groups.port ||
(subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port))
) {
return true
}
}
return false
} catch (error) {
logger.error('Failed to check bypass:', error as Error)
return false
}
}
class SelectiveDispatcher extends Dispatcher {
private proxyDispatcher: Dispatcher
private directDispatcher: Dispatcher
@@ -31,9 +59,7 @@ class SelectiveDispatcher extends Dispatcher {
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
if (opts.origin) {
const url = new URL(opts.origin)
// 检查是否为 localhost 或本地地址
if (isByPass(url.hostname)) {
if (isByPass(opts.origin.toString())) {
return this.directDispatcher.dispatch(opts, handler)
}
}
@@ -93,15 +119,20 @@ export class ProxyManager {
// Set new interval
this.systemProxyInterval = setInterval(async () => {
const currentProxy = await getSystemProxy()
if (currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules) {
if (
currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules &&
currentProxy?.noProxy.join(',').toLowerCase() === this.config?.proxyBypassRules?.toLowerCase()
) {
return
}
logger.info(`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}`)
logger.info(
`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}, this.config.proxyBypassRules: ${this.config.proxyBypassRules}`
)
await this.configureProxy({
mode: 'system',
proxyRules: currentProxy?.proxyUrl.toLowerCase(),
proxyBypassRules: undefined
proxyBypassRules: currentProxy?.noProxy.join(',')
})
}, 1000 * 60)
}
@@ -151,6 +182,7 @@ export class ProxyManager {
delete process.env.grpc_proxy
delete process.env.http_proxy
delete process.env.https_proxy
delete process.env.no_proxy
delete process.env.SOCKS_PROXY
delete process.env.ALL_PROXY
@@ -162,6 +194,7 @@ export class ProxyManager {
process.env.HTTPS_PROXY = url
process.env.http_proxy = url
process.env.https_proxy = url
process.env.no_proxy = byPassRules.join(',')
if (url.startsWith('socks')) {
process.env.SOCKS_PROXY = url
@@ -229,8 +262,7 @@ export class ProxyManager {
// filter localhost
if (url) {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname
if (isByPass(hostname)) {
if (isByPass(url.toString())) {
return originalMethod(url, options, callback)
}
}

View File

@@ -204,7 +204,7 @@ export function registerShortcuts(window: BrowserWindow) {
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
break
//the following ZOOMs will register shortcuts seperately, so will return
//the following ZOOMs will register shortcuts separately, so will return
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window))
globalShortcut.register('CommandOrControl+numadd', () => handler(window))

View File

@@ -223,26 +223,26 @@ export class WindowService {
})
// 添加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()) {
// 获取 shortcuts 配置
const shortcuts = configManager.getShortcuts()
const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
if (exitFullscreenShortcut == undefined) {
mainWindow.setFullScreen(false)
return
}
if (exitFullscreenShortcut?.enabled) {
event.preventDefault()
mainWindow.setFullScreen(false)
return
}
}
}
return
})
// mainWindow.webContents.on('before-input-event', (event, input) => {
// // 当按下Escape键且窗口处于全屏状态时退出全屏
// if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
// if (mainWindow.isFullScreen()) {
// // 获取 shortcuts 配置
// const shortcuts = configManager.getShortcuts()
// const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
// if (exitFullscreenShortcut == undefined) {
// mainWindow.setFullScreen(false)
// return
// }
// if (exitFullscreenShortcut?.enabled) {
// event.preventDefault()
// mainWindow.setFullScreen(false)
// return
// }
// }
// }
// return
// })
}
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
@@ -555,9 +555,9 @@ export class WindowService {
// [Windows] hacky fix
// the window is minimized only when in Windows platform
// because it's a workround for Windows, see `hideMiniWindow()`
// because it's a workaround for Windows, see `hideMiniWindow()`
if (this.miniWindow?.isMinimized()) {
// don't let the window being seen before we finish adusting the position across screens
// don't let the window being seen before we finish adjusting the position across screens
this.miniWindow?.setOpacity(0)
// DO NOT use `restore()` here, Electron has the bug with screens of different scale factor
// We have to use `show()` here, then set the position and bounds

View File

@@ -0,0 +1,34 @@
import { loggerService } from '@logger'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { tesseractService } from './tesseract/TesseractService'
const logger = loggerService.withContext('OcrService')
export class OcrService {
private registry: Map<string, OcrHandler> = new Map()
register(providerId: string, handler: OcrHandler): void {
if (this.registry.has(providerId)) {
logger.warn(`Provider ${providerId} has existing handler. Overwrited.`)
}
this.registry.set(providerId, handler)
}
unregister(providerId: string): void {
this.registry.delete(providerId)
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
throw new Error(`Provider ${provider.id} is not registered`)
}
return handler(file)
}
}
export const ocrService = new OcrService()
// Register built-in providers
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))

View File

@@ -0,0 +1,82 @@
import { loggerService } from '@logger'
import { getIpCountry } from '@main/utils/ipService'
import { loadOcrImage } from '@main/utils/ocr'
import { MB } from '@shared/config/constant'
import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types'
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
const logger = loggerService.withContext('TesseractService')
// config
const MB_SIZE_THRESHOLD = 50
const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
enum TesseractLangsDownloadUrl {
CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/',
GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/'
}
export class TesseractService {
private worker: Tesseract.Worker | null = null
async getWorker(): Promise<Tesseract.Worker> {
if (!this.worker) {
// for now, only support limited languages
this.worker = await createWorker(tesseractLangs, undefined, {
langPath: await this._getLangPath(),
cachePath: await this._getCacheDir(),
gzip: false,
logger: (m) => logger.debug('From worker', m)
})
}
return this.worker
}
async imageOcr(file: ImageFileMetadata): Promise<OcrResult> {
const worker = await this.getWorker()
const stat = await fs.promises.stat(file.path)
if (stat.size > MB_SIZE_THRESHOLD * MB) {
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
}
const buffer = await loadOcrImage(file)
const result = await worker.recognize(buffer)
return { text: result.data.text }
}
async ocr(file: SupportedOcrFile): Promise<OcrResult> {
if (!isImageFile(file)) {
throw new Error('Only image files are supported currently')
}
return this.imageOcr(file)
}
private async _getLangPath(): Promise<string> {
const country = await getIpCountry()
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
}
private async _getCacheDir(): Promise<string> {
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
// use access to check if the directory exists
if (
!(await fs.promises
.access(cacheDir, fs.constants.F_OK)
.then(() => true)
.catch(() => false))
) {
await fs.promises.mkdir(cacheDir, { recursive: true })
}
return cacheDir
}
async dispose(): Promise<void> {
if (this.worker) {
await this.worker.terminate()
this.worker = null
}
}
}
export const tesseractService = new TesseractService()

View File

@@ -168,6 +168,7 @@ export function getMcpDir() {
* 读取文件内容并自动检测编码格式进行解码
* @param filePath - 文件路径
* @returns 解码后的文件内容
* @throws 如果路径不存在抛出错误
*/
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8'

View File

@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import { net } from 'electron'
const logger = loggerService.withContext('IpService')
@@ -12,7 +13,7 @@ export async function getIpCountry(): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await fetch('https://ipinfo.io/json', {
const ipinfo = await net.fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':

27
src/main/utils/ocr.ts Normal file
View File

@@ -0,0 +1,27 @@
import { ImageFileMetadata } from '@types'
import { readFile } from 'fs/promises'
import sharp from 'sharp'
const preprocessImage = async (buffer: Buffer) => {
return await sharp(buffer)
.grayscale() // 转为灰度
.normalize()
.sharpen()
.toBuffer()
}
/**
* 加载并预处理OCR图像
* @param file - 图像文件元数据
* @returns 预处理后的图像Buffer
* @throws {Error} 当文件不存在或无法读取时抛出错误;当图像预处理失败时抛出错误
*
* 预处理步骤:
* 1. 读取图像文件
* 2. 转换为灰度图
* 3. 后续可扩展其他预处理步骤
*/
export const loadOcrImage = async (file: ImageFileMetadata): Promise<Buffer> => {
const buffer = await readFile(file.path)
return await preprocessImage(buffer)
}

View File

@@ -17,9 +17,12 @@ import {
MemoryConfig,
MemoryListOptions,
MemorySearchOptions,
OcrProvider,
OcrResult,
Provider,
S3Config,
Shortcut,
SupportedOcrFile,
ThemeMode,
WebDavConfig
} from '@types'
@@ -76,6 +79,7 @@ const api = {
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) =>
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
mac: {
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
@@ -132,14 +136,15 @@ const api = {
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
select: (options?: OpenDialogOptions): Promise<FileMetadata[] | null> =>
ipcRenderer.invoke(IpcChannel.File_Select, options),
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
get: (filePath: string): Promise<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
* 创建一个空的临时文件
* @param fileName 文件名
@@ -169,10 +174,12 @@ const api = {
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@@ -295,7 +302,8 @@ const api = {
return ipcRenderer.invoke(IpcChannel.Mcp_UploadDxt, buffer, file.name)
},
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
getServerVersion: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
getServerVersion: (server: MCPServer): Promise<string | null> =>
ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
@@ -403,6 +411,10 @@ const api = {
env: Record<string, string>,
options?: { autoUpdateToLatest?: boolean }
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
}
}

View File

@@ -6,7 +6,7 @@
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<title>Cherry Studio Quick Assistant</title>
<style>
html,

View File

@@ -4,6 +4,7 @@ import { FC, useMemo } from 'react'
import { HashRouter, Route, Routes } from 'react-router-dom'
import Sidebar from './components/app/Sidebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings'
@@ -23,18 +24,20 @@ const Router: FC = () => {
const routes = useMemo(() => {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
</ErrorBoundary>
)
}, [])

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../AihubmixAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { ApiClientFactory } from '../ApiClientFactory'
import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { VertexAPIClient } from '../gemini/VertexAPIClient'
import { NewAPIClient } from '../NewAPIClient'
@@ -54,6 +55,19 @@ vi.mock('../openai/OpenAIResponseAPIClient', () => ({
vi.mock('../ppio/PPIOAPIClient', () => ({
PPIOAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../aws/AwsBedrockAPIClient', () => ({
AwsBedrockAPIClient: vi.fn().mockImplementation(() => ({}))
}))
// Mock the models config to prevent circular dependency issues
vi.mock('@renderer/config/models', () => ({
findTokenLimit: vi.fn(),
isReasoningModel: vi.fn(),
SYSTEM_MODELS: {
silicon: [],
defaultModel: []
}
}))
describe('ApiClientFactory', () => {
beforeEach(() => {
@@ -144,6 +158,15 @@ describe('ApiClientFactory', () => {
expect(client).toBeDefined()
})
it('should create AwsBedrockAPIClient for aws-bedrock type', () => {
const provider = createTestProvider('aws-bedrock', 'aws-bedrock')
const client = ApiClientFactory.create(provider)
expect(AwsBedrockAPIClient).toHaveBeenCalledWith(provider)
expect(client).toBeDefined()
})
// 测试默认情况
it('should create OpenAIAPIClient as default for unknown type', () => {
const provider = createTestProvider('unknown', 'unknown-type')

View File

@@ -2,19 +2,24 @@ import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesComman
import {
BedrockRuntimeClient,
ConverseCommand,
ConverseStreamCommand,
InvokeModelCommand
InvokeModelCommand,
InvokeModelWithResponseStreamCommand
} from '@aws-sdk/client-bedrock-runtime'
import { loggerService } from '@logger'
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { findTokenLimit, isReasoningModel } from '@renderer/config/models'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
import { getAssistantSettings } from '@renderer/services/AssistantService'
import { estimateTextTokens } from '@renderer/services/TokenService'
import {
Assistant,
EFFORT_RATIO,
FileTypes,
GenerateImageParams,
MCPCallToolResponse,
MCPTool,
@@ -23,7 +28,13 @@ import {
Provider,
ToolCallResponse
} from '@renderer/types'
import { ChunkType, MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk'
import {
ChunkType,
MCPToolCreatedChunk,
TextDeltaChunk,
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
AwsBedrockSdkInstance,
@@ -33,6 +44,7 @@ import {
AwsBedrockSdkRawOutput,
AwsBedrockSdkTool,
AwsBedrockSdkToolCall,
AwsBedrockStreamChunk,
SdkModel
} from '@renderer/types/sdk'
import { convertBase64ImageToAwsBedrockFormat } from '@renderer/utils/aws-bedrock-utils'
@@ -42,7 +54,7 @@ import {
mcpToolCallResponseToAwsBedrockMessage,
mcpToolsToAwsBedrockTools
} from '@renderer/utils/mcp-tools'
import { findImageBlocks } from '@renderer/utils/messageUtils/find'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { t } from 'i18next'
import { BaseApiClient } from '../BaseApiClient'
@@ -103,46 +115,65 @@ export class AwsBedrockAPIClient extends BaseApiClient<
override async createCompletions(payload: AwsBedrockSdkParams): Promise<AwsBedrockSdkRawOutput> {
const sdk = await this.getSdkInstance()
// 转换消息格式到AWS SDK原生格式
// 转换消息格式(用于 InvokeModelWithResponseStreamCommand
const awsMessages = payload.messages.map((msg) => ({
role: msg.role,
content: msg.content.map((content) => {
if (content.text) {
return { text: content.text }
return { type: 'text', text: content.text }
}
if (content.image) {
// 处理图片数据,将 Uint8Array 或数字数组转换为 base64 字符串
let base64Data = ''
if (content.image.source.bytes) {
if (typeof content.image.source.bytes === 'string') {
// 如果已经是字符串,直接使用
base64Data = content.image.source.bytes
} else {
// 如果是数组或 Uint8Array转换为 base64
const uint8Array = new Uint8Array(Object.values(content.image.source.bytes))
const binaryString = Array.from(uint8Array)
.map((byte) => String.fromCharCode(byte))
.join('')
base64Data = btoa(binaryString)
}
}
return {
image: {
format: content.image.format,
source: content.image.source
type: 'image',
source: {
type: 'base64',
media_type: `image/${content.image.format}`,
data: base64Data
}
}
}
if (content.toolResult) {
return {
toolResult: {
toolUseId: content.toolResult.toolUseId,
content: content.toolResult.content,
status: content.toolResult.status
}
type: 'tool_result',
tool_use_id: content.toolResult.toolUseId,
content: content.toolResult.content
}
}
if (content.toolUse) {
return {
toolUse: {
toolUseId: content.toolUse.toolUseId,
name: content.toolUse.name,
input: content.toolUse.input
}
type: 'tool_use',
id: content.toolUse.toolUseId,
name: content.toolUse.name,
input: content.toolUse.input
}
}
// 返回符合AWS SDK ContentBlock类型的对象
return { text: 'Unknown content type' }
return { type: 'text', text: 'Unknown content type' }
})
}))
logger.info('Creating completions with model ID:', { modelId: payload.modelId })
const excludeKeys = ['modelId', 'messages', 'system', 'maxTokens', 'temperature', 'topP', 'stream', 'tools']
const additionalParams = Object.keys(payload)
.filter((key) => !excludeKeys.includes(key))
.reduce((acc, key) => ({ ...acc, [key]: payload[key] }), {})
const commonParams = {
modelId: payload.modelId,
messages: awsMessages as any,
@@ -162,10 +193,18 @@ export class AwsBedrockAPIClient extends BaseApiClient<
try {
if (payload.stream) {
const command = new ConverseStreamCommand(commonParams)
// 根据模型类型选择正确的 API 格式
const requestBody = this.createRequestBodyForModel(commonParams, additionalParams)
const command = new InvokeModelWithResponseStreamCommand({
modelId: commonParams.modelId,
body: JSON.stringify(requestBody),
contentType: 'application/json',
accept: 'application/json'
})
const response = await sdk.client.send(command)
// 直接返回AWS Bedrock流式响应的异步迭代器
return this.createStreamIterator(response)
return this.createInvokeModelStreamIterator(response)
} else {
const command = new ConverseCommand(commonParams)
const response = await sdk.client.send(command)
@@ -177,32 +216,236 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
}
private async *createStreamIterator(response: any): AsyncIterable<AwsBedrockSdkRawChunk> {
try {
if (response.stream) {
for await (const chunk of response.stream) {
logger.debug('AWS Bedrock chunk received:', chunk)
/**
* 根据模型类型创建请求体
*/
private createRequestBodyForModel(commonParams: any, additionalParams: any): any {
const modelId = commonParams.modelId.toLowerCase()
// AWS Bedrock的流式响应格式转换为标准格式
if (chunk.contentBlockDelta?.delta?.text) {
yield {
contentBlockDelta: {
delta: { text: chunk.contentBlockDelta.delta.text }
// Claude 系列模型使用 Anthropic API 格式
if (modelId.includes('claude')) {
return {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP,
messages: commonParams.messages,
...(commonParams.system && commonParams.system[0]?.text ? { system: commonParams.system[0].text } : {}),
...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {}),
...additionalParams
}
}
// OpenAI 系列模型
if (modelId.includes('gpt') || modelId.includes('openai')) {
const messages: any[] = []
// 添加系统消息
if (commonParams.system && commonParams.system[0]?.text) {
messages.push({
role: 'system',
content: commonParams.system[0].text
})
}
// 转换消息格式
for (const message of commonParams.messages) {
const content: any[] = []
for (const part of message.content) {
if (part.text) {
content.push({ type: 'text', text: part.text })
} else if (part.image) {
content.push({
type: 'image_url',
image_url: {
url: `data:image/${part.image.format};base64,${part.image.source.bytes}`
}
})
}
}
messages.push({
role: message.role,
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
})
}
const baseBody: any = {
model: commonParams.modelId,
messages: messages,
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP,
stream: true,
...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {})
}
// OpenAI 模型的 thinking 参数格式
if (additionalParams.reasoning_effort) {
baseBody.reasoning_effort = additionalParams.reasoning_effort
delete additionalParams.reasoning_effort
}
return {
...baseBody,
...additionalParams
}
}
// Llama 系列模型
if (modelId.includes('llama')) {
const baseBody: any = {
prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
max_gen_len: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP
}
// Llama 模型的 thinking 参数格式
if (additionalParams.thinking_mode) {
baseBody.thinking_mode = additionalParams.thinking_mode
delete additionalParams.thinking_mode
}
return {
...baseBody,
...additionalParams
}
}
// Amazon Titan 系列模型
if (modelId.includes('titan')) {
const textGenerationConfig: any = {
maxTokenCount: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
topP: commonParams.inferenceConfig.topP
}
// 将 thinking 相关参数添加到 textGenerationConfig 中
if (additionalParams.thinking) {
textGenerationConfig.thinking = additionalParams.thinking
delete additionalParams.thinking
}
return {
inputText: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
textGenerationConfig: {
...textGenerationConfig,
...Object.keys(additionalParams).reduce((acc, key) => {
if (['thinking_tokens', 'reasoning_mode'].includes(key)) {
acc[key] = additionalParams[key]
delete additionalParams[key]
}
return acc
}, {} as any)
},
...additionalParams
}
}
// Cohere Command 系列模型
if (modelId.includes('cohere') || modelId.includes('command')) {
const baseBody: any = {
message: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
p: commonParams.inferenceConfig.topP
}
// Cohere 模型的 thinking 参数格式
if (additionalParams.thinking) {
baseBody.thinking = additionalParams.thinking
delete additionalParams.thinking
}
if (additionalParams.reasoning_tokens) {
baseBody.reasoning_tokens = additionalParams.reasoning_tokens
delete additionalParams.reasoning_tokens
}
return {
...baseBody,
...additionalParams
}
}
// 默认使用通用格式
const baseBody: any = {
prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP
}
return {
...baseBody,
...additionalParams
}
}
/**
* 将消息转换为简单的 prompt 格式
*/
private convertMessagesToPrompt(messages: any[], system?: any[]): string {
let prompt = ''
// 添加系统消息
if (system && system[0]?.text) {
prompt += `System: ${system[0].text}\n\n`
}
// 添加对话消息
for (const message of messages) {
const role = message.role === 'assistant' ? 'Assistant' : 'Human'
let content = ''
for (const part of message.content) {
if (part.text) {
content += part.text
} else if (part.image) {
content += '[Image]'
}
}
prompt += `${role}: ${content}\n\n`
}
prompt += 'Assistant:'
return prompt
}
private async *createInvokeModelStreamIterator(response: any): AsyncIterable<AwsBedrockSdkRawChunk> {
try {
if (response.body) {
for await (const event of response.body) {
if (event.chunk) {
const chunk: AwsBedrockStreamChunk = JSON.parse(new TextDecoder().decode(event.chunk.bytes))
// 转换为标准格式
if (chunk.type === 'content_block_delta') {
yield {
contentBlockDelta: {
delta: chunk.delta,
contentBlockIndex: chunk.index
}
}
} else if (chunk.type === 'message_start') {
yield { messageStart: chunk }
} else if (chunk.type === 'message_stop') {
yield { messageStop: chunk }
} else if (chunk.type === 'content_block_start') {
yield {
contentBlockStart: {
start: chunk.content_block,
contentBlockIndex: chunk.index
}
}
} else if (chunk.type === 'content_block_stop') {
yield {
contentBlockStop: {
contentBlockIndex: chunk.index
}
}
}
}
if (chunk.messageStart) {
yield { messageStart: chunk.messageStart }
}
if (chunk.messageStop) {
yield { messageStop: chunk.messageStop }
}
if (chunk.metadata) {
yield { metadata: chunk.metadata }
}
}
}
} catch (error) {
@@ -441,6 +684,30 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
}
// 处理文件内容
const fileBlocks = findFileBlocks(message)
for (const fileBlock of fileBlocks) {
const file = fileBlock.file
if (!file) {
logger.warn(`No file in the file block. Passed.`, { fileBlock })
continue
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
try {
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
if (fileContent) {
parts.push({
text: `${file.origin_name}\n${fileContent}`
})
}
} catch (error) {
logger.error('Error reading file content:', error as Error)
parts.push({ text: `[File: ${file.origin_name} - Failed to read content]` })
}
}
}
// 如果没有任何内容,添加默认文本而不是空文本
if (parts.length === 0) {
parts.push({ text: 'No content provided' })
@@ -485,6 +752,38 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
}
// 获取推理预算token对所有支持推理的模型
const budgetTokens = this.getBudgetToken(assistant, model)
// 构建基础自定义参数
const customParams: Record<string, any> =
coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {}
// 根据模型类型添加 thinking 参数
if (budgetTokens) {
const modelId = model.id.toLowerCase()
if (modelId.includes('claude')) {
// Claude 模型使用 Anthropic 格式
customParams.thinking = { type: 'enabled', budget_tokens: budgetTokens }
} else if (modelId.includes('gpt') || modelId.includes('openai')) {
// OpenAI 模型格式
customParams.reasoning_effort = assistant?.settings?.reasoning_effort
} else if (modelId.includes('llama')) {
// Llama 模型格式
customParams.thinking_mode = true
customParams.thinking_tokens = budgetTokens
} else if (modelId.includes('titan')) {
// Titan 模型格式
customParams.thinking = { enabled: true }
customParams.thinking_tokens = budgetTokens
} else if (modelId.includes('cohere') || modelId.includes('command')) {
// Cohere 模型格式
customParams.thinking = { enabled: true }
customParams.reasoning_tokens = budgetTokens
}
}
const payload: AwsBedrockSdkParams = {
modelId: model.id,
messages:
@@ -497,9 +796,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
topP: this.getTopP(assistant, model),
stream: streamOutput !== false,
tools: tools.length > 0 ? tools : undefined,
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
// 注意:用户自定义参数总是应该覆盖其他参数
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
...customParams
}
const timeout = this.getTimeout(model)
@@ -511,6 +808,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
getResponseChunkTransformer(): ResponseChunkTransformer<AwsBedrockSdkRawChunk> {
return () => {
let hasStartedText = false
let hasStartedThinking = false
let accumulatedJson = ''
const toolCalls: Record<number, AwsBedrockSdkToolCall> = {}
@@ -570,6 +868,24 @@ export class AwsBedrockAPIClient extends BaseApiClient<
} as TextDeltaChunk)
}
// 处理thinking增量
if (
rawChunk.contentBlockDelta?.delta?.type === 'thinking_delta' &&
rawChunk.contentBlockDelta?.delta?.thinking
) {
if (!hasStartedThinking) {
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
hasStartedThinking = true
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: rawChunk.contentBlockDelta.delta.thinking
} as ThinkingDeltaChunk)
}
// 处理内容块停止事件 - 参考 Anthropic 的 content_block_stop 处理
if (rawChunk.contentBlockStop) {
const blockIndex = rawChunk.contentBlockStop.contentBlockIndex || 0
@@ -708,4 +1024,49 @@ export class AwsBedrockAPIClient extends BaseApiClient<
extractMessagesFromSdkPayload(sdkPayload: AwsBedrockSdkParams): AwsBedrockSdkMessageParam[] {
return sdkPayload.messages || []
}
/**
* 获取 AWS Bedrock 的推理工作量预算token
* @param assistant - The assistant
* @param model - The model
* @returns The budget tokens for reasoning effort
*/
private getBudgetToken(assistant: Assistant, model: Model): number | undefined {
try {
if (!isReasoningModel(model)) {
return undefined
}
const { maxTokens } = getAssistantSettings(assistant)
const reasoningEffort = assistant?.settings?.reasoning_effort
if (reasoningEffort === undefined) {
return undefined
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
const tokenLimits = findTokenLimit(model.id)
if (tokenLimits) {
// 使用模型特定的 token 限制
const budgetTokens = Math.max(
1024,
Math.floor(
Math.min(
(tokenLimits.max - tokenLimits.min) * effortRatio + tokenLimits.min,
(maxTokens || DEFAULT_MAX_TOKENS) * effortRatio
)
)
)
return budgetTokens
} else {
// 对于没有特定限制的模型,使用简化计算
const budgetTokens = Math.max(1024, Math.floor((maxTokens || DEFAULT_MAX_TOKENS) * effortRatio))
return budgetTokens
}
} catch (error) {
logger.warn('Failed to calculate budget tokens for reasoning effort:', error as Error)
return undefined
}
}
}

View File

@@ -5,10 +5,15 @@ import {
GEMINI_FLASH_MODEL_REGEX,
getOpenAIWebSearchParams,
getThinkModelType,
isClaudeReasoningModel,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGeminiReasoningModel,
isGPT5SeriesModel,
isGrokReasoningModel,
isNotSupportSystemMessageModel,
isOpenAIOpenWeightModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
isQwenMTModel,
isQwenReasoningModel,
@@ -23,7 +28,8 @@ import {
isSupportedThinkingTokenQwenModel,
isSupportedThinkingTokenZhipuModel,
isVisionModel,
MODEL_SUPPORTED_REASONING_EFFORT
MODEL_SUPPORTED_REASONING_EFFORT,
ZHIPU_RESULT_TOKENS
} from '@renderer/config/models'
import {
isSupportArrayContentProvider,
@@ -39,12 +45,14 @@ import {
Assistant,
EFFORT_RATIO,
FileTypes,
isSystemProvider,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
Model,
OpenAIServiceTier,
Provider,
SystemProviderIds,
ToolCallResponse,
TranslateAssistant,
WebSearchSource
@@ -52,7 +60,6 @@ import {
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
MistralDeltaSchema,
OpenAISdkMessageParam,
OpenAISdkParams,
OpenAISdkRawChunk,
@@ -108,7 +115,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
*/
// Method for reasoning effort, moved from OpenAIProvider
override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams {
if (this.provider.id === 'groq') {
if (this.provider.id === SystemProviderIds.groq) {
return {}
}
@@ -117,22 +124,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
const reasoningEffort = assistant?.settings?.reasoning_effort
// Doubao 思考模式支持
if (isSupportedThinkingTokenDoubaoModel(model)) {
// reasoningEffort 为空,默认开启 enabled
if (!reasoningEffort) {
return { thinking: { type: 'disabled' } }
}
if (reasoningEffort === 'high') {
return { thinking: { type: 'enabled' } }
}
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
return { thinking: { type: 'auto' } }
}
// 其他情况不带 thinking 字段
return {}
}
if (isSupportedThinkingTokenZhipuModel(model)) {
if (!reasoningEffort) {
return { thinking: { type: 'disabled' } }
@@ -141,29 +132,41 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
if (!reasoningEffort) {
if (model.provider === 'openrouter') {
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
// if (isDeepSeekHybridInferenceModel(model)) {
// // do nothing for now. default to non-think.
// }
// openrouter: use reasoning
if (model.provider === SystemProviderIds.openrouter) {
// Don't disable reasoning for Gemini models that support thinking tokens
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {}
}
// Don't disable reasoning for models that require it
if (isGrokReasoningModel(model)) {
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
}
// providers that use enable_thinking
if (
isSupportEnableThinkingProvider(this.provider) &&
(isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model))
(isSupportedThinkingTokenQwenModel(model) ||
isSupportedThinkingTokenHunyuanModel(model) ||
(this.provider.id === SystemProviderIds.dashscope && isDeepSeekHybridInferenceModel(model)))
) {
return { enable_thinking: false }
}
// claude
if (isSupportedThinkingTokenClaudeModel(model)) {
return {}
}
// gemini
if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {
@@ -192,8 +195,48 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min!
)
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(this.provider)) {
switch (this.provider.id) {
case SystemProviderIds.dashscope:
return {
enable_thinking: true,
incremental_output: true
}
case SystemProviderIds.silicon:
return {
enable_thinking: true
}
case SystemProviderIds.doubao:
return {
thinking: {
type: 'enabled' // auto is invalid
}
}
case SystemProviderIds.openrouter:
return {
reasoning: {
enabled: true
}
}
case 'nvidia':
return {
chat_template_kwargs: {
thinking: true
}
}
default:
logger.warn(
`Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown`
)
}
}
}
// OpenRouter models
if (model.provider === 'openrouter') {
if (model.provider === SystemProviderIds.openrouter) {
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -203,6 +246,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
}
// Doubao 思考模式支持
if (isSupportedThinkingTokenDoubaoModel(model)) {
if (reasoningEffort === 'high') {
return { thinking: { type: 'enabled' } }
}
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
return { thinking: { type: 'auto' } }
}
// 其他情况不带 thinking 字段
return {}
}
// Qwen models
if (isQwenReasoningModel(model)) {
const thinkConfig = {
@@ -210,7 +265,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true,
thinking_budget: budgetTokens
}
if (this.provider.id === 'dashscope') {
if (this.provider.id === SystemProviderIds.dashscope) {
return {
...thinkConfig,
incremental_output: true
@@ -525,13 +580,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 1. 处理系统消息
let systemMessage = { role: 'system', content: assistant.prompt || '' }
const systemMessage = { role: 'system', content: assistant.prompt || '' }
if (isSupportedReasoningEffortOpenAIModel(model)) {
systemMessage = {
role: isSupportDeveloperRoleProvider(this.provider) ? 'developer' : 'system',
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
}
if (
isSupportedReasoningEffortOpenAIModel(model) &&
isSupportDeveloperRoleProvider(this.provider) &&
!isOpenAIOpenWeightModel(model)
) {
systemMessage.role = 'developer'
}
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) {
@@ -555,24 +611,46 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
userMessages.push(await this.convertMessageToSdkParam(message, model))
}
}
if (userMessages.length === 0) {
logger.warn('No user message. Some providers may not support.')
}
// poe 需要通过用户消息传递 reasoningEffort
const reasoningEffort = this.getReasoningEffort(assistant, model)
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
if (
lastUserMsg &&
isSupportedThinkingTokenQwenModel(model) &&
!isSupportEnableThinkingProvider(this.provider)
) {
const postsuffix = '/no_think'
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
const currentContent = lastUserMsg.content
if (lastUserMsg) {
if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
const currentContent = lastUserMsg.content
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled)
}
if (this.provider.id === SystemProviderIds.poe) {
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
}
}
}
// 4. 最终请求消息
let reqMessages: OpenAISdkMessageParam[]
if (!systemMessage.content || isNotSupportSystemMessageModel(model)) {
if (!systemMessage.content) {
reqMessages = [...userMessages]
} else if (isNotSupportSystemMessageModel(model)) {
// transform into user message
const firstUserMsg = userMessages.shift()
if (firstUserMsg) {
firstUserMsg.content = `System Instruction: \n${systemMessage.content}\n\nUser Message(s):\n${firstUserMsg.content}`
reqMessages = [firstUserMsg, ...userMessages]
} else {
reqMessages = []
}
} else {
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as OpenAISdkMessageParam[]
}
@@ -584,8 +662,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// Note: Some providers like Mistral don't support stream_options
const shouldIncludeStreamOptions = streamOutput && isSupportStreamOptionsProvider(this.provider)
const reasoningEffort = this.getReasoningEffort(assistant, model)
// minimal cannot be used with web_search tool
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort === 'minimal' && enableWebSearch) {
reasoningEffort.reasoning_effort = 'low'
@@ -805,8 +881,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
(typeof choice.delta.content === 'string' && choice.delta.content !== '') ||
(typeof (choice.delta as any).reasoning_content === 'string' &&
(choice.delta as any).reasoning_content !== '') ||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') ||
Array.isArray(choice.delta.content))
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== ''))
) {
contentSource = choice.delta
} else if ('message' in choice) {
@@ -817,15 +892,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (!contentSource?.content) {
accumulatingText = false
}
const mistralDelta = MistralDeltaSchema.safeParse(contentSource?.content)
if (
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
!contentSource?.reasoning_content &&
// @ts-ignore - reasoning is not in standard OpenAI types but some providers use it
!contentSource?.reasoning &&
(mistralDelta.data?.[0]?.type !== 'thinking' || !mistralDelta.success)
) {
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
if (!contentSource?.reasoning_content && !contentSource?.reasoning) {
isThinking = false
}
@@ -859,14 +927,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
const reasoningText =
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
contentSource.reasoning_content ||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
contentSource.reasoning ||
(mistralDelta.data?.[0]?.type === 'thinking' ? mistralDelta.data?.[0]?.thinking[0]?.text : undefined)
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
if (reasoningText) {
// logger.silly('since reasoningText is trusy, try to enqueue THINKING_START AND THINKING_DELTA')
if (!isThinking) {
// logger.silly('since isThinking is falsy, try to enqueue THINKING_START')
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
@@ -883,35 +949,34 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 处理文本内容
if (mistralDelta.success && mistralDelta.data?.[0]?.type === 'text') {
if (contentSource.content) {
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
if (!accumulatingText) {
// logger.silly('enqueue TEXT_START')
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
accumulatingText = true
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: mistralDelta.data?.[0]?.text
})
} else if (!mistralDelta.success) {
if (contentSource.content) {
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
if (!accumulatingText) {
// logger.silly('enqueue TEXT_START')
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
accumulatingText = true
}
// logger.silly('enqueue TEXT_DELTA')
// logger.silly('enqueue TEXT_DELTA')
// 处理特殊token
// 智谱api的一个chunk中只会输出一个token因而使用 ===,避免正常内容被误判
if (
context.provider.id === SystemProviderIds.zhipu &&
ZHIPU_RESULT_TOKENS.some((pattern) => contentSource.content === pattern)
) {
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: '**' // strong
})
} else {
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: contentSource.content
})
} else {
accumulatingText = false
}
} else {
accumulatingText = false
}
// 处理工具调用
@@ -920,13 +985,19 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if ('index' in toolCall) {
const { id, index, function: fun } = toolCall
if (fun?.name) {
toolCalls[index] = {
const toolCallObject = {
id: id || '',
function: {
name: fun.name,
arguments: fun.arguments || ''
},
type: 'function'
type: 'function' as const
}
if (index === -1) {
toolCalls.push(toolCallObject)
} else {
toolCalls[index] = toolCallObject
}
} else if (fun?.arguments) {
if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) {

View File

@@ -5,6 +5,7 @@ import {
isGPT5SeriesModel,
isOpenAIChatCompletionOnlyModel,
isOpenAILLMModel,
isOpenAIOpenWeightModel,
isSupportedReasoningEffortOpenAIModel,
isSupportVerbosityModel,
isVisionModel
@@ -374,12 +375,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
text: assistant.prompt || '',
type: 'input_text'
}
if (isSupportedReasoningEffortOpenAIModel(model)) {
if (isSupportDeveloperRoleProvider(this.provider)) {
systemMessage.role = 'developer'
} else {
systemMessage.role = 'system'
}
if (
isSupportedReasoningEffortOpenAIModel(model) &&
isSupportDeveloperRoleProvider(this.provider) &&
isOpenAIOpenWeightModel(model)
) {
systemMessage.role = 'developer'
}
// 2. 设置工具

View File

@@ -1,5 +1,4 @@
import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient'
import { isAnthropicModel } from '@renderer/config/models'
import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk'
import { AnthropicStreamListener } from '../../clients/types'
@@ -16,9 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
// 在这里可以监听到从SDK返回的最原始流
if (result.rawOutput) {
const model = params.assistant.model
// TODO: 后面下放到AnthropicAPIClient
if (isAnthropicModel(model)) {
if (ctx.apiClientInstance instanceof AnthropicAPIClient) {
const anthropicListener: AnthropicStreamListener<AnthropicSdkRawChunk> = {
onMessage: (message) => {
if (ctx._internal?.toolProcessingState) {

View File

@@ -7,6 +7,7 @@ import {
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { getLowerBaseModelName } from '@renderer/utils'
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
@@ -22,13 +23,16 @@ const reasoningTags: TagConfig[] = [
{ openingTag: '<thought>', closingTag: '</thought>', separator: '\n' },
{ openingTag: '###Thinking', closingTag: '###Response', separator: '\n' },
{ openingTag: '◁think▷', closingTag: '◁/think▷', separator: '\n' },
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' }
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' },
{ openingTag: '<seed:think>', closingTag: '</seed:think>', separator: '\n' }
]
const getAppropriateTag = (model?: Model): TagConfig => {
if (model?.id?.includes('qwen3')) return reasoningTags[0]
if (model?.id?.includes('gemini-2.5')) return reasoningTags[1]
if (model?.id?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3]
const modelId = model?.id ? getLowerBaseModelName(model.id) : undefined
if (modelId?.includes('qwen3')) return reasoningTags[0]
if (modelId?.includes('gemini-2.5')) return reasoningTags[1]
if (modelId?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3]
if (modelId?.includes('seed-oss-36b')) return reasoningTags[5]
// 可以在这里添加更多模型特定的标签配置
return reasoningTags[0] // 默认使用 <think> 标签
}

View File

@@ -22,8 +22,10 @@ export interface CompletionsParams {
* 'search': 搜索摘要
* 'generate': 生成
* 'check': API检查
* 'test': 测试调用
* 'translate-lang-detect': 翻译语言检测
*/
callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test'
callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test' | 'translate-lang-detect'
// 基础对话数据
messages: Message[] | string // 联合类型方便判断是否为空

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Poe</title><path d="M20.708 6.876a1.412 1.412 0 00-1.029-.415h-.006a2.019 2.019 0 01-2.02-2.023A1.415 1.415 0 0016.254 3H4.871A1.412 1.412 0 003.47 4.434a2.026 2.026 0 01-2.025 2.025v.002A1.414 1.414 0 000 7.883v3.642a1.414 1.414 0 001.444 1.42 2.025 2.025 0 012.025 2.02v3.693a.5.5 0 00.89.313l2.051-2.567h9.843a1.412 1.412 0 001.4-1.434v-.002c0-1.12.904-2.025 2.026-2.025a1.412 1.412 0 001.446-1.42V7.88c0-.363-.14-.727-.417-1.005zm-2.42 4.687a2.025 2.025 0 01-2.025 2.005H4.861a2.025 2.025 0 01-2.025-2.005v-3.72A2.026 2.026 0 014.86 5.838h11.4a2.026 2.026 0 012.026 2.005v3.72h.002z"></path><path d="M7.413 7.57A1.422 1.422 0 005.99 8.99v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422zm6.297 0a1.422 1.422 0 00-1.422 1.421v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422z"></path><path d="M7.292 22.643l1.993-2.492h9.844a1.413 1.413 0 001.4-1.434 2.025 2.025 0 012.017-2.027h.01A1.409 1.409 0 0024 15.27v-3.594c0-.344-.113-.68-.324-.951l-.397-.519v4.127a1.415 1.415 0 01-1.444 1.42h-.007a2.026 2.026 0 00-2.018 2.025 1.415 1.415 0 01-1.402 1.436H8.565l-2.169 2.712a.574.574 0 00.896.715v.002z" fill="url(#lobe-icons-poe-fill-0)"></path><path d="M5.004 19.992l2.12-2.65h9.844a1.414 1.414 0 001.402-1.437c0-1.116.9-2.021 2.014-2.025h.012a1.413 1.413 0 001.443-1.422v-4.13l.52.68c.21.273.324.607.324.95v3.594a1.416 1.416 0 01-1.443 1.42h-.01a2.026 2.026 0 00-2.016 2.026 1.414 1.414 0 01-1.402 1.435H7.97l-1.916 2.4a.671.671 0 01-1.049-.839v-.002z" fill="url(#lobe-icons-poe-fill-1)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-poe-fill-0" x1="34.01" x2="1.086" y1="7.303" y2="27.715"><stop stop-color="#46A6F7"></stop><stop offset="1" stop-color="#8364FF"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-poe-fill-1" x1="4.915" x2="24.34" y1="23.511" y2="9.464"><stop stop-color="#FF44D3"></stop><stop offset="1" stop-color="#CF4BFF"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -68,3 +68,23 @@
transform-origin: center;
animation: animation-rotate 0.75s linear infinite;
}
// 定位高亮动画
@keyframes animation-locate-highlight {
0% {
background-color: transparent;
}
10% {
background-color: var(--color-primary-mute);
}
70% {
background-color: var(--color-primary-mute);
}
100% {
background-color: transparent;
}
}
.animation-locate-highlight {
animation: animation-locate-highlight 2.5s ease-in-out;
}

View File

@@ -1,5 +1,26 @@
@use './container.scss';
/* Modal 关闭按钮不应该可拖拽,以确保点击正常 */
.ant-modal-close {
-webkit-app-region: no-drag;
}
/* 普通 Drawer 内容不应该可拖拽 */
.ant-drawer-content {
-webkit-app-region: no-drag;
}
/* minapp-drawer 有自己的拖拽规则 */
/* 下拉菜单和弹出框内容不应该可拖拽 */
.ant-dropdown,
.ant-dropdown-menu,
.ant-popover-content,
.ant-tooltip-content,
.ant-popconfirm {
-webkit-app-region: no-drag;
}
#inputbar {
resize: none;
}
@@ -66,6 +87,7 @@
}
.ant-drawer-header {
/* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
-webkit-app-region: no-drag;
}
@@ -76,7 +98,7 @@
}
.ant-dropdown-menu .ant-dropdown-menu-sub {
max-height: 50vh;
max-height: 80vh;
width: max-content;
overflow-y: auto;
overflow-x: hidden;
@@ -88,7 +110,7 @@
border-radius: var(--ant-border-radius-lg);
user-select: none;
.ant-dropdown-menu {
max-height: 50vh;
max-height: 80vh;
overflow-y: auto;
border: 0.5px solid var(--color-border);
@@ -148,6 +170,7 @@
border-radius: 10px;
}
.ant-modal-body {
/* 保持 body 在视口内,使用标准的最大高度 */
max-height: 80vh;
overflow-y: auto;
padding: 0 16px 0 16px;
@@ -184,3 +207,28 @@
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
}
}
.ant-splitter-bar {
.ant-splitter-bar-dragger {
&::before {
background-color: var(--color-border) !important;
transition:
background-color 0.15s ease,
width 0.15s ease;
}
&:hover {
&::before {
width: 4px !important;
background-color: var(--color-primary) !important;
transition-delay: 0.15s;
}
}
}
.ant-splitter-bar-dragger-active {
&::before {
width: 4px !important;
background-color: var(--color-primary) !important;
}
}
}

View File

@@ -103,6 +103,10 @@
}
span {
white-space: pre-wrap;
}
.katex span {
white-space: pre;
}
@@ -260,10 +264,6 @@
text-decoration: underline;
}
}
> *:last-child {
margin-bottom: 0 !important;
}
}
.footnotes {

View File

@@ -6,10 +6,8 @@ html {
:root {
// Basic Colors
--color-primary: #00b96b;
--color-error: #f44336;
--selection-toolbar-color-primary: var(--color-primary);
--selection-toolbar-color-error: var(--color-error);
// Toolbar
@@ -54,8 +52,6 @@ html {
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor: transparent; // default: transparent
--selection-toolbar-button-bgcolor-hover: #333333;
}
@@ -72,7 +68,5 @@ html {
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
}

View File

@@ -1,11 +1,11 @@
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
import { CodeOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { Code, Download, Globe, Sparkles } from 'lucide-react'
import { FC, useMemo, useState } from 'react'
import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipLoader } from 'react-spinners'
import styled, { keyframes } from 'styled-components'
@@ -14,92 +14,10 @@ import HtmlArtifactsPopup from './HtmlArtifactsPopup'
const logger = loggerService.withContext('HtmlArtifactsCard')
const HTML_VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
])
const HTML_COMPLETION_PATTERNS = [
/<\/html\s*>/i,
/<!DOCTYPE\s+html/i,
/<\/body\s*>/i,
/<\/div\s*>/i,
/<\/script\s*>/i,
/<\/style\s*>/i
]
interface Props {
html: string
}
function hasUnmatchedTags(html: string): boolean {
const stack: string[] = []
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
let match
while ((match = tagRegex.exec(html)) !== null) {
const [fullTag, tagName] = match
const isClosing = fullTag.startsWith('</')
const isSelfClosing = fullTag.endsWith('/>') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
if (isSelfClosing) continue
if (isClosing) {
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
return true
}
} else {
stack.push(tagName.toLowerCase())
}
}
return stack.length > 0
}
function checkIsStreaming(html: string): boolean {
if (!html?.trim()) return false
const trimmed = html.trim()
// 快速检查如果有明显的完成标志直接返回false
for (const pattern of HTML_COMPLETION_PATTERNS) {
if (pattern.test(trimmed)) {
// 特殊情况同时有DOCTYPE和</body>
if (trimmed.includes('<!DOCTYPE') && /<\/body\s*>/i.test(trimmed)) {
return false
}
// 如果只是以</html>结尾,也认为是完成的
if (/<\/html\s*>$/i.test(trimmed)) {
return false
}
}
}
// 检查未完成的标志
const hasIncompleteTag = /<[^>]*$/.test(trimmed)
const hasUnmatched = hasUnmatchedTags(trimmed)
if (hasIncompleteTag || hasUnmatched) return true
// 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成
const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed)
if (!hasStructureTags && trimmed.length < 500) {
return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed))
}
return false
onSave?: (html: string) => void
isStreaming?: boolean
}
const getTerminalStyles = (theme: ThemeMode) => ({
@@ -108,7 +26,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({
promptColor: theme === 'dark' ? '#00ff00' : '#007700'
})
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'HTML Artifacts'
const [isPopupOpen, setIsPopupOpen] = useState(false)
@@ -116,7 +34,6 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const htmlContent = html || ''
const hasContent = htmlContent.trim().length > 0
const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent])
const handleOpenExternal = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
@@ -181,10 +98,10 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
<Button icon={<LinkIcon size={14} />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<Download size={16} />} onClick={handleDownload} type="text" disabled={!hasContent}>
<Button icon={<DownloadIcon size={14} />} onClick={handleDownload} type="text" disabled={!hasContent}>
{t('code_block.download.label')}
</Button>
</ButtonContainer>
@@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
</Content>
</Container>
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
<HtmlArtifactsPopup
open={isPopupOpen}
title={title}
html={htmlContent}
onSave={onSave}
onClose={() => setIsPopupOpen(false)}
/>
</>
)
}
@@ -234,6 +157,7 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 44px;
height: 44px;
background: ${(props) =>
@@ -254,13 +178,16 @@ const TitleSection = styled.div`
gap: 6px;
`
const Title = styled.h3`
margin: 0 !important;
font-size: 14px !important;
font-weight: 600;
color: var(--color-text);
const Title = styled.span`
font-size: 14px;
font-weight: bold;
color: var(--color-text-1);
line-height: 1.4;
font-family: 'Ubuntu';
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
`
const TypeBadge = styled.div`
@@ -286,7 +213,6 @@ const ButtonContainer = styled.div`
margin: 10px 16px !important;
display: flex;
flex-direction: row;
gap: 8px;
`
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
@@ -294,7 +220,7 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
background: ${(props) => getTerminalStyles(props.$theme).background};
border-radius: 8px;
overflow: hidden;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-family: var(--code-font-family);
`
const TerminalContent = styled.div<{ $theme: ThemeMode }>`

View File

@@ -1,8 +1,8 @@
import CodeEditor from '@renderer/components/CodeEditor'
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { classNames } from '@renderer/utils'
import { Button, Modal } from 'antd'
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
import { Button, Modal, Splitter, Tooltip, Typography } from 'antd'
import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -11,60 +11,17 @@ interface HtmlArtifactsPopupProps {
open: boolean
title: string
html: string
onSave?: (html: string) => void
onClose: () => void
}
type ViewMode = 'split' | 'code' | 'preview'
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
const { t } = useTranslation()
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [currentHtml, setCurrentHtml] = useState(html)
const [isFullscreen, setIsFullscreen] = useState(false)
// Preview refresh related state
const [previewHtml, setPreviewHtml] = useState(html)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const latestHtmlRef = useRef(html)
const currentPreviewHtmlRef = useRef(html)
// Sync internal state when external html updates
useEffect(() => {
setCurrentHtml(html)
latestHtmlRef.current = html
}, [html])
// Update reference when internally edited html changes
useEffect(() => {
latestHtmlRef.current = currentHtml
}, [currentHtml])
// Update reference when preview content changes
useEffect(() => {
currentPreviewHtmlRef.current = previewHtml
}, [previewHtml])
// Check and refresh preview every 2 seconds (only when content changes)
useEffect(() => {
if (!open) return
// Set initial preview content immediately
setPreviewHtml(latestHtmlRef.current)
// Set timer to check for content changes every 2 seconds
intervalRef.current = setInterval(() => {
if (latestHtmlRef.current !== currentPreviewHtmlRef.current) {
setPreviewHtml(latestHtmlRef.current)
}
}, 2000)
// Cleanup function
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [open])
const codeEditorRef = useRef<CodeEditorHandles>(null)
// Prevent body scroll when fullscreen
useEffect(() => {
@@ -79,13 +36,14 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
}
}, [isFullscreen, open])
const showCode = viewMode === 'split' || viewMode === 'code'
const showPreview = viewMode === 'split' || viewMode === 'preview'
const handleSave = () => {
codeEditorRef.current?.save?.()
}
const renderHeader = () => (
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
<TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
</HeaderLeft>
<HeaderCenter>
@@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<ViewButton
size="small"
type={viewMode === 'split' ? 'primary' : 'default'}
icon={<MonitorSpeaker size={14} />}
icon={<SquareSplitHorizontal size={14} />}
onClick={() => setViewMode('split')}>
{t('html_artifacts.split')}
</ViewButton>
@@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<ViewButton
size="small"
type={viewMode === 'preview' ? 'primary' : 'default'}
icon={<Monitor size={14} />}
icon={<Eye size={14} />}
onClick={() => setViewMode('preview')}>
{t('html_artifacts.preview')}
</ViewButton>
@@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
</ModalHeader>
)
const renderContent = () => {
const codePanel = (
<CodeSection>
<CodeEditor
ref={codeEditorRef}
value={html}
language="html"
editable={true}
onSave={onSave}
style={{ height: '100%' }}
expanded
unwrapped={false}
options={{
stream: true, // FIXME: 避免多余空行
lineNumbers: true,
keymap: true
}}
/>
<ToolbarWrapper>
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
<Button
shape="circle"
size="large"
icon={<SaveIcon size={16} className="custom-lucide" />}
onClick={handleSave}
/>
</Tooltip>
</ToolbarWrapper>
</CodeSection>
)
const previewPanel = (
<PreviewSection>
{html.trim() ? (
<PreviewFrame
key={html} // Force recreate iframe when preview content changes
srcDoc={html}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
) : (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
)}
</PreviewSection>
)
switch (viewMode) {
case 'split':
return (
<Splitter>
<Splitter.Panel defaultSize="50%" min="25%">
{codePanel}
</Splitter.Panel>
<Splitter.Panel defaultSize="50%" min="25%">
{previewPanel}
</Splitter.Panel>
</Splitter>
)
case 'code':
return codePanel
case 'preview':
return previewPanel
default:
return null
}
}
return (
<StyledModal
$isFullscreen={isFullscreen}
@@ -144,41 +171,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
zIndex={isFullscreen ? 10000 : 1000}
footer={null}
closable={false}>
<Container>
{showCode && (
<CodeSection>
<CodeEditor
value={currentHtml}
language="html"
editable={true}
onSave={setCurrentHtml}
style={{ height: '100%' }}
expanded
unwrapped={false}
options={{
stream: false
}}
/>
</CodeSection>
)}
{showPreview && (
<PreviewSection>
{previewHtml.trim() ? (
<PreviewFrame
key={previewHtml} // Force recreate iframe when preview content changes
srcDoc={previewHtml}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
) : (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
)}
</PreviewSection>
)}
</Container>
<Container>{renderContent()}</Container>
</StyledModal>
)
}
@@ -213,7 +206,6 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
: `
.ant-modal-body {
height: 80vh !important;
min-height: 600px !important;
}
`}
@@ -238,6 +230,10 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
margin-bottom: 0 !important;
border-radius: 0 !important;
}
::-webkit-scrollbar {
width: 8px;
}
`
const ModalHeader = styled.div`
@@ -270,13 +266,13 @@ const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
`
const TitleText = styled.span`
const TitleText = styled(Typography.Text)`
font-size: 16px;
font-weight: 600;
font-weight: bold;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 50%;
`
const ViewControls = styled.div`
@@ -315,13 +311,24 @@ const Container = styled.div`
width: 100%;
flex: 1;
background: var(--color-background);
overflow: hidden;
.ant-splitter {
width: 100%;
height: 100%;
border: none;
.ant-splitter-pane {
overflow: hidden;
}
}
`
const CodeSection = styled.div`
flex: 1;
min-width: 300px;
border-right: 1px solid var(--color-border);
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
.monaco-editor,
.cm-editor,
@@ -331,8 +338,8 @@ const CodeSection = styled.div`
`
const PreviewSection = styled.div`
flex: 1;
min-width: 300px;
height: 100%;
width: 100%;
background: white;
overflow: hidden;
`
@@ -355,4 +362,15 @@ const EmptyPreview = styled.div`
font-size: 14px;
`
const ToolbarWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
gap: 4px;
right: 1rem;
bottom: 1rem;
z-index: 1;
`
export default HtmlArtifactsPopup

View File

@@ -1,2 +1,3 @@
export { default as HtmlArtifactsCard } from './HtmlArtifactsCard'
export * from './types'
export * from './view'

View File

@@ -18,15 +18,14 @@ import { BasicPreviewHandles } from '@renderer/components/Preview'
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { getExtensionByLanguage } from '@renderer/utils/code-language'
import { extractTitle } from '@renderer/utils/formats'
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import StatusBar from './StatusBar'
import { ViewMode } from './types'
@@ -301,11 +300,6 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
)
}, [specialView, sourceView, viewMode])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html' && isHtmlCode(children)) {
return <HtmlArtifactsCard html={children} />
}
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}

View File

@@ -1,10 +1,11 @@
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
const logger = loggerService.withContext('CodeEditorHooks')
// 语言对应的 linter 加载器
@@ -17,32 +18,33 @@ const linterLoaders: Record<string, () => Promise<any>> = {
/**
* 特殊语言加载器
* key: 语言文件扩展名(不包含 `.`
*/
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => {
const mod = await import('@viz-js/lang-dot')
return mod.dot()
},
// @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来
mmd: async () => {
const mod = await import('codemirror-lang-mermaid')
return mod.mermaid()
}
}
/**
* 加载语言扩展
*/
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
async function loadLanguageExtension(language: string): Promise<Extension | null> {
const fileExt = await getNormalizedExtension(language)
// 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[normalizedLang]
const specialLoader = specialLanguageLoaders[fileExt]
if (specialLoader) {
try {
return await specialLoader()
} catch (error) {
logger.debug(`Failed to load language ${normalizedLang}`, error as Error)
logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
return null
}
}
@@ -50,10 +52,10 @@ async function loadLanguageExtension(language: string, languageMap: Record<strin
// 回退到 uiw/codemirror 包含的语言
try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(normalizedLang as any)
const extension = loadLanguage(fileExt as any)
return extension || null
} catch (error) {
logger.debug(`Failed to load language ${normalizedLang}`, error as Error)
logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
return null
}
}
@@ -77,7 +79,6 @@ async function loadLinterExtension(language: string): Promise<Extension | null>
* 加载语言相关扩展
*/
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
useEffect(() => {
@@ -87,7 +88,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
try {
// 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language, languageMap),
loadLanguageExtension(language),
lint ? loadLinterExtension(language) : Promise.resolve(null)
])
@@ -119,7 +120,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
return () => {
cancelled = true
}
}, [language, lint, languageMap])
}, [language, lint])
return extensions
}

View File

@@ -0,0 +1,34 @@
import { getExtensionByLanguage } from '@renderer/utils/code-language'
// 自定义语言文件扩展名映射
// key: 语言名小写
// value: 扩展名
const _customLanguageExtensions: Record<string, string> = {
svg: 'xml',
vab: 'vb',
graphviz: 'dot'
}
/**
* 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs
* - 先搜索自定义扩展名
* - 再搜索 github linguist 扩展名
* @param language 语言名称
* @returns 扩展名(不包含 `.`
*/
export async function getNormalizedExtension(language: string) {
const lowerLanguage = language.toLowerCase()
const customExt = _customLanguageExtensions[lowerLanguage]
if (customExt) {
return customExt
}
const linguistExt = getExtensionByLanguage(language)
if (linguistExt) {
return linguistExt.slice(1)
}
// 回退到语言名称
return language
}

View File

@@ -1,21 +1,30 @@
import i18n from '@renderer/i18n'
import { Input, InputRef, Tooltip } from 'antd'
import { Search } from 'lucide-react'
import { motion } from 'motion/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface CollapsibleSearchBarProps {
onSearch: (text: string) => void
placeholder?: string
tooltip?: string
icon?: React.ReactNode
maxWidth?: string | number
style?: React.CSSProperties
}
/**
* A collapsible search bar for list headers
* Renders as an icon initially, expands to full search input when clicked
*/
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => {
const { t } = useTranslation()
const CollapsibleSearchBar = ({
onSearch,
placeholder = i18n.t('common.search'),
tooltip = i18n.t('common.search'),
icon = <Search size={14} color="var(--color-icon)" />,
maxWidth = '100%',
style
}: CollapsibleSearchBarProps) => {
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
@@ -46,22 +55,23 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
initial="collapsed"
animate={searchVisible ? 'expanded' : 'collapsed'}
variants={{
expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
}}
style={{ overflow: 'hidden', flex: 1 }}>
<Input
ref={inputRef}
type="text"
placeholder={t('models.search')}
placeholder={placeholder}
size="small"
suffix={icon || <Search size={14} color="var(--color-icon)" />}
suffix={icon}
value={searchText}
autoFocus
allowClear
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
handleTextChange('')
if (!searchText) setSearchVisible(false)
}
@@ -70,7 +80,7 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
if (!searchText) setSearchVisible(false)
}}
onClear={handleClear}
style={{ width: '100%' }}
style={{ width: '100%', ...style }}
/>
</motion.div>
<motion.div
@@ -82,8 +92,8 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
}}
style={{ cursor: 'pointer', display: 'flex' }}
onClick={() => setSearchVisible(true)}>
<Tooltip title={t('models.search')} mouseLeaveDelay={0}>
{icon || <Search size={14} color="var(--color-icon)" />}
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
{icon}
</Tooltip>
</motion.div>
</div>

View File

@@ -282,6 +282,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
implementation.searchNext()
}
} else if (event.key === 'Escape') {
event.stopPropagation()
implementation.disable()
}
},

View File

@@ -29,14 +29,14 @@ vi.mock('@hello-pangea/dnd', () => {
}
})
// mock VirtualList 只做简单渲染
vi.mock('rc-virtual-list', () => ({
// mock antd list 只做简单渲染
vi.mock('antd', () => ({
__esModule: true,
default: ({ data, itemKey, children }: any) => (
List: ({ dataSource, renderItem }: any) => (
<div data-testid="virtual-list">
{data.map((item: any, idx: number) => (
<div key={item[itemKey] || item} data-testid="virtual-list-item">
{children(item, idx)}
{dataSource.map((item: any, idx: number) => (
<div key={item.id || item} data-testid="virtual-list-item">
{renderItem(item, idx)}
</div>
))}
</div>
@@ -157,8 +157,7 @@ describe('DraggableList', () => {
// 模拟拖拽到自身
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
expect(onUpdate).toHaveBeenCalledTimes(0)
})
})
@@ -175,8 +174,7 @@ describe('DraggableList', () => {
// 拖拽自身
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
expect(onUpdate).toHaveBeenCalledTimes(0)
})
it('should not crash if callbacks are undefined', () => {

View File

@@ -1,5 +1,3 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
@@ -47,11 +45,6 @@ vi.mock('@tanstack/react-virtual', () => ({
})
}))
vi.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: ({ children }) => <div data-testid="auto-sizer">{children({ height: 500, width: 300 })}</div>
}))
vi.mock('@renderer/components/Scrollbar', () => ({
__esModule: true,
default: ({ ref, children, ...props }) => (

View File

@@ -9,13 +9,14 @@ import {
ResponderProvided
} from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils'
import VirtualList from 'rc-virtual-list'
import { List, ListProps } from 'antd'
import { FC } from 'react'
interface Props<T> {
list: T[]
style?: React.CSSProperties
listStyle?: React.CSSProperties
listProps?: ListProps<T>
children: (item: T, index: number) => React.ReactNode
onUpdate: (list: T[]) => void
onDragStart?: OnDragStartResponder
@@ -28,6 +29,7 @@ const DraggableList: FC<Props<any>> = ({
list,
style,
listStyle,
listProps,
droppableProps,
onDragStart,
onUpdate,
@@ -38,8 +40,10 @@ const DraggableList: FC<Props<any>> = ({
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
if (sourceIndex !== destIndex) {
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
}
}
}
@@ -48,8 +52,10 @@ const DraggableList: FC<Props<any>> = ({
<Droppable droppableId="droppable" {...droppableProps}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
<VirtualList data={list} itemKey="id">
{(item, index) => {
<List
{...listProps}
dataSource={list}
renderItem={(item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
@@ -69,7 +75,7 @@ const DraggableList: FC<Props<any>> = ({
</Draggable>
)
}}
</VirtualList>
/>
{provided.placeholder}
</div>
)}

View File

@@ -82,8 +82,10 @@ function DraggableVirtualList<T>({
if (onUpdate && result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
if (sourceIndex !== destIndex) {
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
}
}
}

View File

@@ -63,6 +63,7 @@ const EditableNumber: FC<EditableNumberProps> = ({
if (e.key === 'Enter') {
handleBlur()
} else if (e.key === 'Escape') {
e.stopPropagation()
setInputValue(value)
setIsEditing(false)
}

View File

@@ -0,0 +1,57 @@
import { formatErrorMessage } from '@renderer/utils/error'
import { Alert, Button, Space } from 'antd'
import { ComponentType, ReactNode } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const DefaultFallback: ComponentType<FallbackProps> = (props: FallbackProps): ReactNode => {
const { t } = useTranslation()
const { error } = props
const debug = async () => {
await window.api.devTools.toggle()
}
const reload = async () => {
await window.api.reload()
}
return (
<ErrorContainer>
<Alert
message={t('error.boundary.default.message')}
showIcon
description={formatErrorMessage(error)}
type="error"
action={
<Space>
<Button size="small" danger onClick={debug}>
{t('error.boundary.default.devtools')}
</Button>
<Button size="small" danger onClick={reload}>
{t('error.boundary.default.reload')}
</Button>
</Space>
}
/>
</ErrorContainer>
)
}
const ErrorBoundaryCustomized = ({
children,
fallbackComponent
}: {
children: ReactNode
fallbackComponent?: ComponentType<FallbackProps>
}) => {
return <ErrorBoundary FallbackComponent={fallbackComponent ?? DefaultFallback}>{children}</ErrorBoundary>
}
const ErrorContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 8px;
`
export { ErrorBoundaryCustomized as ErrorBoundary }

View File

@@ -112,3 +112,174 @@ export function MdiLightbulbOn(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function BingLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fillRule="evenodd"
width="1em"
height="1em"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path d="M4.842.005a.966.966 0 01.604.142l2.62 1.813c.369.256.492.352.637.496.471.47.752 1.09.797 1.765l.008.847.003 1.441.004 13.002.144-.094 7.015-4.353.015.003.029.01c-.398-.17-.893-.339-1.655-.566l-.484-.146c-.584-.18-.71-.238-.921-.38a2.009 2.009 0 01-.37-.312 2.172 2.172 0 01-.41-.592L11.32 9.063c-.166-.444-.166-.49-.156-.63a.92.92 0 01.806-.864l.094-.01c.044-.005.22.023.29.044l.052.021c.06.026.16.075.313.154l3.63 1.908a6.626 6.626 0 013.292 4.531c.194.99.159 2.037-.102 3.012-.216.805-.639 1.694-1.054 2.213l-.08.099-.047.05c-.01.01-.013.01-.01.002l.043-.074-.072.114c-.011.031-.233.28-.38.425l-.17.161c-.22.202-.431.36-.832.62L13.544 23c-.941.6-1.86.912-2.913.992-.23.018-.854.008-1.074-.017a6.31 6.31 0 01-1.658-.412c-1.854-.738-3.223-2.288-3.705-4.195a8.077 8.077 0 01-.121-.57l-.046-.325a1.123 1.123 0 01-.014-.168l-.006-.029L4 11.617 4.01.866a.981.981 0 01.007-.111.943.943 0 01.825-.75z"></path>
</svg>
)
}
export function SearXNGLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265 265" style={{ display: 'block' }} {...props}>
<g transform="translate(-40.921 -17.417)">
<circle
cx="142.2"
cy="122.9"
r="85"
fill="none"
stroke="currentColor"
strokeWidth="28.3465"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="11.3386"
/>
<path
d="M118.4 77.6c19.8-10.2 44-6.4 59.7 9.4s19.3 40 8.9 59.7"
fill="none"
stroke="currentColor"
strokeWidth="14.1732"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="11.3386"
/>
<path d="m184.2 202 37-38.6 81.8 78.3-37 38.6z" fill="currentColor" />
</g>
</svg>
)
}
export function TavilyLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="m16.44.964 4.921 7.79c.79 1.252-.108 2.883-1.588 2.883H17.76V23.3h-2.91V.088c.61 0 1.22.292 1.59.876z"
fill="currentColor"
/>
<path
d="M8.342 8.755 13.263.964a1.864 1.864 0 0 1 1.59-.876V23.3a4.87 4.87 0 0 0-.252-.006c-.99 0-1.907.311-2.658.842V11.637H9.93c-1.48 0-2.38-1.631-1.589-2.882z"
fill="currentColor"
/>
<path
d="M30.278 31H18.031a4.596 4.596 0 0 0 1.219-2.91h22.577c0 .61-.292 1.22-.875 1.59L33.16 34.6c-1.251.791-2.883-.108-2.883-1.588V31z"
fill="currentColor"
/>
<path
d="m33.16 21.581 7.79 4.921c.585.369.876.979.876 1.589H19.25a4.619 4.619 0 0 0-.858-2.91h11.887V23.17c0-1.48 1.631-2.38 2.882-1.589z"
fill="currentColor"
/>
<path
d="m8.24 34.25-7.107 7.108a1.864 1.864 0 0 0 1.742.504l8.989-2.03c1.443-.325 1.961-2.114.915-3.16l-1.423-1.423 5.356-5.356a2.805 2.805 0 0 0 0-3.966l-.074-.075L8.24 34.25z"
fill="currentColor"
/>
<path
d="m7.243 31.135 5.355-5.356a2.805 2.805 0 0 1 3.967 0l.074.074-8.397 8.397-7.108 7.108a1.864 1.864 0 0 1-.504-1.742l2.029-8.989c.325-1.444 2.115-1.961 3.161-.915l1.423 1.423z"
fill="currentColor"
/>
</svg>
)
}
export function ExaLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fillRule="evenodd"
width="1em"
height="1em"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<title>Exa</title>
<path
clip-rule="evenodd"
d="M3 0h19v1.791L13.892 12 22 22.209V24H3V0zm9.62 10.348l6.589-8.557H6.03l6.59 8.557zM5.138 3.935v7.17h5.52l-5.52-7.17zm5.52 8.96h-5.52v7.17l5.52-7.17zM6.03 22.21l6.59-8.557 6.589 8.557H6.03z"></path>
</svg>
)
}
export function BochaLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg width="1em" height="1em" viewBox="0 0 135 116" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.5754 13.8123C24.6109 7.94459 39.1223 12.9435 44.9955 24.9805L57.5355 50.6805C60.4695 56.6936 57.9756 63.9478 51.9652 66.8832C51.9627 66.8844 51.9602 66.8856 51.9577 66.8868C45.94 69.8206 38.6843 67.3212 35.7477 61.3027L12.5754 13.8123Z"
fill="currentColor"
/>
<path
opacity="0.64774"
fillRule="evenodd"
clipRule="evenodd"
d="M0 38.3013C9.46916 28.836 24.813 28.836 34.2822 38.3013L55.2526 59.2631C59.9819 63.9904 59.9852 71.6582 55.2601 76.3896C55.2576 76.3921 55.2551 76.3946 55.2526 76.397C50.5181 81.1297 42.8461 81.1297 38.1116 76.397L0 38.3013Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M86.8777 18.0444C113.939 18.0444 135.876 39.9725 135.876 67.0222C135.876 80.2286 129.086 93.6477 120.585 102.457L117.065 98.2367C111.026 90.9998 108.882 81.2777 111.314 72.1702C111.755 70.5198 111.976 69.0033 111.976 67.6209C111.976 53.6689 100.661 42.3586 86.7029 42.3586C72.7452 42.3586 61.4303 53.6689 61.4303 67.6209C61.4303 81.5728 72.7452 92.8831 86.7029 92.8831C89.3159 92.8831 91.8363 92.4867 94.2071 91.7508C101.312 89.5455 109.054 91.3768 114.419 96.5322L120.585 102.457C111.83 110.626 99.7992 116 86.8777 116C59.8168 116 37.8796 94.0719 37.8796 67.0222C37.8796 39.9725 59.8168 18.0444 86.8777 18.0444Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M37.8796 0C51.2677 0 62.1208 10.8581 62.1208 24.2522V41.7389C62.1208 55.133 51.2677 65.9911 37.8796 65.9911V0Z"
fill="currentColor"
/>
</svg>
)
}
export function PoeLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fillRule="evenodd"
height="1em"
width="1em"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<title>Poe</title>
<path d="M20.708 6.876a1.412 1.412 0 00-1.029-.415h-.006a2.019 2.019 0 01-2.02-2.023A1.415 1.415 0 0016.254 3H4.871A1.412 1.412 0 003.47 4.434a2.026 2.026 0 01-2.025 2.025v.002A1.414 1.414 0 000 7.883v3.642a1.414 1.414 0 001.444 1.42 2.025 2.025 0 012.025 2.02v3.693a.5.5 0 00.89.313l2.051-2.567h9.843a1.412 1.412 0 001.4-1.434v-.002c0-1.12.904-2.025 2.026-2.025a1.412 1.412 0 001.446-1.42V7.88c0-.363-.14-.727-.417-1.005zm-2.42 4.687a2.025 2.025 0 01-2.025 2.005H4.861a2.025 2.025 0 01-2.025-2.005v-3.72A2.026 2.026 0 014.86 5.838h11.4a2.026 2.026 0 012.026 2.005v3.72h.002z"></path>
<path d="M7.413 7.57A1.422 1.422 0 005.99 8.99v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422zm6.297 0a1.422 1.422 0 00-1.422 1.421v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422z"></path>
<path
d="M7.292 22.643l1.993-2.492h9.844a1.413 1.413 0 001.4-1.434 2.025 2.025 0 012.017-2.027h.01A1.409 1.409 0 0024 15.27v-3.594c0-.344-.113-.68-.324-.951l-.397-.519v4.127a1.415 1.415 0 01-1.444 1.42h-.007a2.026 2.026 0 00-2.018 2.025 1.415 1.415 0 01-1.402 1.436H8.565l-2.169 2.712a.574.574 0 00.896.715v.002z"
fill="url(#lobe-icons-poe-fill-0)"></path>
<path
d="M5.004 19.992l2.12-2.65h9.844a1.414 1.414 0 001.402-1.437c0-1.116.9-2.021 2.014-2.025h.012a1.413 1.413 0 001.443-1.422v-4.13l.52.68c.21.273.324.607.324.95v3.594a1.416 1.416 0 01-1.443 1.42h-.01a2.026 2.026 0 00-2.016 2.026 1.414 1.414 0 01-1.402 1.435H7.97l-1.916 2.4a.671.671 0 01-1.049-.839v-.002z"
fill="url(#lobe-icons-poe-fill-1)"></path>
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-poe-fill-0"
x1="34.01"
x2="1.086"
y1="7.303"
y2="27.715">
<stop stopColor="#46A6F7"></stop>
<stop offset="1" stop-color="#8364FF"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-poe-fill-1"
x1="4.915"
x2="24.34"
y1="23.511"
y2="9.464">
<stop stopColor="#FF44D3"></stop>
<stop offset="1" stop-color="#CF4BFF"></stop>
</linearGradient>
</defs>
</svg>
)
}

View File

@@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
}
return (
<div ref={hostRef}>
<div ref={hostRef} style={{ display: 'none' }}>
{createPortal(
<StyleSheetManager target={shadowRoot}>
<StyleProvider container={shadowRoot} layer>

View File

@@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
@@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => {
const isInDevelopment = process.env.NODE_ENV === 'development'
const { setTimeoutTimer } = useTimer()
useBridge()
/** set the popup display status */
@@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
if (appid == currentMinappId) {
setTimeout(() => setIsReady(true), 200)
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
}
}

View File

@@ -1,6 +1,7 @@
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useSystemAgents } from '@renderer/pages/agents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@@ -33,6 +34,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const { setTimeoutTimer } = useTimer()
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@@ -80,11 +82,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
assistant = await createAssistantFromAgent(agent)
}
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
},
[resolve, addAssistant, setOpen]
[setTimeoutTimer, resolve, addAssistant]
) // 添加函数内使用的依赖项
// 键盘导航处理
useEffect(() => {

View File

@@ -3,7 +3,14 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup'
import { checkApi } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
import {
isPreprocessProviderId,
isWebSearchProviderId,
Model,
PreprocessProvider,
Provider,
WebSearchProvider
} from '@renderer/types'
import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
import { formatErrorMessage } from '@renderer/utils/error'
@@ -12,12 +19,11 @@ import { isEmpty } from 'lodash'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiKeyValidity, ApiProviderKind, ApiProviderUnion } from './types'
import { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types'
interface UseApiKeysProps {
provider: ApiProviderUnion
updateProvider: (provider: Partial<ApiProviderUnion>) => void
providerKind: ApiProviderKind
provider: ApiProvider
updateProvider: UpdateApiProviderFunc
}
const logger = loggerService.withContext('ApiKeyListPopup')
@@ -25,7 +31,7 @@ const logger = loggerService.withContext('ApiKeyListPopup')
/**
* API Keys 管理 hook
*/
export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKeysProps) {
export function useApiKeys({ provider, updateProvider }: UseApiKeysProps) {
const { t } = useTranslation()
// 连通性检查的 UI 状态管理
@@ -199,11 +205,13 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
try {
const startTime = Date.now()
if (isLlmProvider(provider, providerKind) && model) {
if (isLlmProvider(provider) && model) {
await checkApi({ ...provider, apiKey: keyToCheck }, model)
} else {
} else if (isWebSearchProvider(provider)) {
const result = await WebSearchService.checkSearch({ ...provider, apiKey: keyToCheck })
if (!result.valid) throw new Error(result.error)
} else {
// 不处理预处理供应商
}
const latency = Date.now() - startTime
@@ -228,7 +236,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
logger.error('failed to validate the connectivity of the api key', error)
}
},
[keys, connectivityStates, updateConnectivityState, provider, providerKind]
[keys, connectivityStates, updateConnectivityState, provider]
)
// 检查单个 key 的连通性
@@ -240,23 +248,23 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
const currentState = connectivityStates.get(keyToCheck)
if (currentState?.checking) return
const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined
const model = isLlmProvider(provider) ? await getModelForCheck(provider, t) : undefined
if (model === null) return
await runConnectivityCheck(index, model)
},
[provider, keys, connectivityStates, providerKind, t, runConnectivityCheck]
[provider, keys, connectivityStates, t, runConnectivityCheck]
)
// 检查所有 keys 的连通性
const checkAllKeysConnectivity = useCallback(async () => {
if (!provider || keys.length === 0) return
const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined
const model = isLlmProvider(provider) ? await getModelForCheck(provider, t) : undefined
if (model === null) return
await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, model)))
}, [provider, keys, providerKind, t, runConnectivityCheck])
}, [provider, keys, t, runConnectivityCheck])
// 计算是否有 key 正在检查
const isChecking = useMemo(() => {
@@ -275,16 +283,18 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
}
}
export function isLlmProvider(obj: any, kind: ApiProviderKind): obj is Provider {
return kind === 'llm' && 'type' in obj && 'models' in obj
export function isLlmProvider(provider: ApiProvider): provider is Provider {
return 'models' in provider
}
export function isWebSearchProvider(obj: any, kind: ApiProviderKind): obj is WebSearchProvider {
return kind === 'websearch' && ('url' in obj || 'engines' in obj)
export function isWebSearchProvider(provider: ApiProvider): provider is WebSearchProvider {
return isWebSearchProviderId(provider.id)
}
export function isPreprocessProvider(obj: any, kind: ApiProviderKind): obj is PreprocessProvider {
return kind === 'doc-preprocess' && ('quota' in obj || 'options' in obj)
export function isPreprocessProvider(provider: ApiProvider): provider is PreprocessProvider {
// NOTE: mistral 同时提供预处理和llm服务所以其llm provier可能被误判为预处理provider
// 后面需要使用更严格的判断方式
return isPreprocessProviderId(provider.id) && !isLlmProvider(provider)
}
// 获取模型用于检查

View File

@@ -130,7 +130,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
mouseEnterDelay={0.5}
placement="top"
// 确保不留下明文
destroyTooltipOnHide>
destroyOnHidden>
<span style={{ cursor: 'help' }}>{maskApiKey(keyStatus.key)}</span>
</Tooltip>

View File

@@ -6,6 +6,7 @@ import { useProvider } from '@renderer/hooks/useProvider'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { SettingHelpText } from '@renderer/pages/settings'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { PreprocessProviderId, WebSearchProviderId } from '@renderer/types'
import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd'
import { Plus } from 'lucide-react'
@@ -15,19 +16,18 @@ import styled from 'styled-components'
import { isLlmProvider, useApiKeys } from './hook'
import ApiKeyItem from './item'
import { ApiProviderKind, ApiProviderUnion } from './types'
import { ApiProvider, UpdateApiProviderFunc } from './types'
interface ApiKeyListProps {
provider: ApiProviderUnion
updateProvider: (provider: Partial<ApiProviderUnion>) => void
providerKind: ApiProviderKind
provider: ApiProvider
updateProvider: UpdateApiProviderFunc
showHealthCheck?: boolean
}
/**
* Api key 列表,管理 CRUD 操作、连接检查
*/
export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, providerKind, showHealthCheck = true }) => {
export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, showHealthCheck = true }) => {
const { t } = useTranslation()
// 临时新项状态
@@ -42,7 +42,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
checkKeyConnectivity,
checkAllKeysConnectivity,
isChecking
} = useApiKeys({ provider, updateProvider, providerKind: providerKind })
} = useApiKeys({ provider, updateProvider })
// 创建一个临时新项
const handleAddNew = () => {
@@ -73,7 +73,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
const shouldAutoFocus = () => {
if (provider.apiKey) return false
return isLlmProvider(provider, providerKind) && provider.enabled && !isProviderSupportAuth(provider)
return isLlmProvider(provider) && provider.enabled && !isProviderSupportAuth(provider)
}
// 合并真实 keys 和临时新项
@@ -179,55 +179,33 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
interface SpecificApiKeyListProps {
providerId: string
providerKind: ApiProviderKind
showHealthCheck?: boolean
}
export const LlmApiKeyList: FC<SpecificApiKeyListProps> = ({ providerId, providerKind, showHealthCheck = true }) => {
type WebSearchApiKeyList = SpecificApiKeyListProps & {
providerId: WebSearchProviderId
}
type DocPreprocessApiKeyListProps = SpecificApiKeyListProps & {
providerId: PreprocessProviderId
}
export const LlmApiKeyList: FC<SpecificApiKeyListProps> = ({ providerId, showHealthCheck = true }) => {
const { provider, updateProvider } = useProvider(providerId)
return (
<ApiKeyList
provider={provider}
updateProvider={updateProvider}
providerKind={providerKind}
showHealthCheck={showHealthCheck}
/>
)
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
}
export const WebSearchApiKeyList: FC<SpecificApiKeyListProps> = ({
providerId,
providerKind,
showHealthCheck = true
}) => {
export const WebSearchApiKeyList: FC<WebSearchApiKeyList> = ({ providerId, showHealthCheck = true }) => {
const { provider, updateProvider } = useWebSearchProvider(providerId)
return (
<ApiKeyList
provider={provider}
updateProvider={updateProvider}
providerKind={providerKind}
showHealthCheck={showHealthCheck}
/>
)
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
}
export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
providerId,
providerKind,
showHealthCheck = true
}) => {
export const DocPreprocessApiKeyList: FC<DocPreprocessApiKeyListProps> = ({ providerId, showHealthCheck = true }) => {
const { provider, updateProvider } = usePreprocessProvider(providerId)
return (
<ApiKeyList
provider={provider}
updateProvider={updateProvider}
providerKind={providerKind}
showHealthCheck={showHealthCheck}
/>
)
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
}
const ListContainer = styled.div`

View File

@@ -1,14 +1,13 @@
import { TopView } from '@renderer/components/TopView'
import { isPreprocessProviderId, isWebSearchProviderId } from '@renderer/types'
import { Modal } from 'antd'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DocPreprocessApiKeyList, LlmApiKeyList, WebSearchApiKeyList } from './list'
import { ApiProviderKind } from './types'
interface ShowParams {
providerId: string
providerKind: ApiProviderKind
title?: string
showHealthCheck?: boolean
}
@@ -20,7 +19,7 @@ interface Props extends ShowParams {
/**
* API Key 列表弹窗容器组件
*/
const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, resolve, showHealthCheck = true }) => {
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
@@ -33,17 +32,14 @@ const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, reso
}
const ListComponent = useMemo(() => {
switch (providerKind) {
case 'llm':
return LlmApiKeyList
case 'websearch':
return WebSearchApiKeyList
case 'doc-preprocess':
return DocPreprocessApiKeyList
default:
return null
if (isWebSearchProviderId(providerId)) {
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
}, [providerKind])
if (isPreprocessProviderId(providerId)) {
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}, [providerId, showHealthCheck])
return (
<Modal
@@ -55,9 +51,7 @@ const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, reso
centered
width={600}
footer={null}>
{ListComponent && (
<ListComponent providerId={providerId} providerKind={providerKind} showHealthCheck={showHealthCheck} />
)}
{ListComponent}
</Modal>
)
}

View File

@@ -8,6 +8,12 @@ export type ApiKeyValidity = {
error?: string
}
export type ApiProviderUnion = Provider | WebSearchProvider | PreprocessProvider
export type ApiProvider = Provider | WebSearchProvider | PreprocessProvider
export type ApiProviderKind = 'llm' | 'websearch' | 'doc-preprocess'
export type UpdateProviderFunc = (p: Partial<Provider>) => void
export type UpdateWebSearchProviderFunc = (p: Partial<WebSearchProvider>) => void
export type UpdatePreprocessProviderFunc = (p: Partial<PreprocessProvider>) => void
export type UpdateApiProviderFunc = UpdateProviderFunc | UpdateWebSearchProviderFunc | UpdatePreprocessProviderFunc

View File

@@ -0,0 +1,238 @@
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'
import { getFancyProviderName } from '@renderer/utils'
import { ConflictInfo, ConflictResolution } from '@renderer/utils/provider'
import { Button, Card, Modal, Radio, Space, Tag, Typography } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Text, Title } = Typography
interface Props {
conflicts: ConflictInfo[]
onResolve: (resolutions: ConflictResolution[]) => void
onCancel: () => void
visible: boolean
}
const ConflictResolutionPopup: FC<Props> = ({ conflicts, onResolve, onCancel, visible }) => {
const { t } = useTranslation()
const [resolutions, setResolutions] = useState<Record<string, string>>({})
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({})
const handleProviderSelect = (conflictId: string, providerId: string) => {
setResolutions((prev) => ({
...prev,
[conflictId]: providerId
}))
}
const toggleApiKeyVisibility = (providerKey: string) => {
setShowApiKeys((prev) => ({
...prev,
[providerKey]: !prev[providerKey]
}))
}
const handleResolve = () => {
const conflictResolutions: ConflictResolution[] = Object.entries(resolutions).map(
([conflictId, selectedProviderId]) => ({
conflictId,
selectedProviderId
})
)
onResolve(conflictResolutions)
}
const isAllResolved = conflicts.every((conflict) => resolutions[conflict.id])
const renderProviderCard = (provider: ConflictInfo['providers'][0], conflictId: string, isSelected: boolean) => {
const providerName = getFancyProviderName(provider)
const providerKey = `${conflictId}-${provider._tempIndex}`
const isApiKeyVisible = showApiKeys[providerKey]
const renderApiKeyValue = () => {
if (!provider.apiKey) {
return <DetailValue></DetailValue>
}
return (
<ApiKeyContainer>
<DetailValue>{isApiKeyVisible ? provider.apiKey : '●●●●●●●●'}</DetailValue>
<ApiKeyToggle
onClick={(e) => {
e.stopPropagation() // 防止触发卡片选择
toggleApiKeyVisibility(providerKey)
}}>
{isApiKeyVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
</ApiKeyToggle>
</ApiKeyContainer>
)
}
return (
<ProviderCard
key={provider._tempIndex}
size="small"
$selected={isSelected}
onClick={() => handleProviderSelect(conflictId, provider._tempIndex!.toString())}>
<ProviderHeader>
<Radio checked={isSelected} />
<ProviderName>{providerName}</ProviderName>
{provider.enabled && <Tag color="green">ON</Tag>}
</ProviderHeader>
<ProviderDetails>
<DetailRow>
<DetailLabel>API Key:</DetailLabel>
{renderApiKeyValue()}
</DetailRow>
<DetailRow>
<DetailLabel>API Host:</DetailLabel>
<DetailValue>{provider.apiHost || '默认'}</DetailValue>
</DetailRow>
</ProviderDetails>
</ProviderCard>
)
}
return (
<Modal
title={t('settings.provider.cleanup.conflict.resolution_title')}
open={visible}
onCancel={onCancel}
width={600}
footer={
<Space>
<Button onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="primary" onClick={handleResolve} disabled={!isAllResolved}>
{t('settings.provider.cleanup.conflict.apply_resolution')}
</Button>
</Space>
}>
<ConflictContainer>
<Text type="secondary">{t('settings.provider.cleanup.conflict.resolution_desc')}</Text>
{conflicts.map((conflict, index) => (
<ConflictSection key={conflict.id}>
<Title level={5}>
{t('settings.provider.cleanup.conflict.provider_conflict', {
provider: getFancyProviderName({ name: conflict.id, id: conflict.id } as any)
})}
</Title>
<ProvidersGrid>
{conflict.providers.map((provider) =>
renderProviderCard(provider, conflict.id, resolutions[conflict.id] === provider._tempIndex!.toString())
)}
</ProvidersGrid>
{index < conflicts.length - 1 && <ConflictDivider />}
</ConflictSection>
))}
</ConflictContainer>
</Modal>
)
}
const ConflictContainer = styled.div`
max-height: 500px;
overflow-y: auto;
`
const ConflictSection = styled.div`
margin-bottom: 24px;
`
const ProvidersGrid = styled.div`
display: grid;
grid-template-columns: 1fr;
gap: 12px;
`
const ProviderCard = styled(Card)<{ $selected: boolean }>`
cursor: pointer;
border: 2px solid ${(props) => (props.$selected ? 'var(--color-primary)' : 'var(--color-border)')};
background: ${(props) => (props.$selected ? 'var(--color-primary-bg)' : 'var(--color-background)')};
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary);
}
.ant-card-body {
padding: 12px 16px;
}
`
const ProviderHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
`
const ProviderName = styled.span`
font-weight: 500;
flex: 1;
`
const ProviderDetails = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`
const DetailRow = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const DetailLabel = styled(Text)`
min-width: 80px;
color: var(--color-text-3);
font-size: 12px;
`
const DetailValue = styled(Text)`
font-size: 12px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
`
const ApiKeyContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex: 1;
`
const ApiKeyToggle = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
color: var(--color-text-3);
transition: all 0.2s ease;
&:hover {
background: var(--color-fill-tertiary);
color: var(--color-text-1);
}
&:active {
transform: scale(0.95);
}
`
const ConflictDivider = styled.div`
height: 1px;
background: var(--color-border);
margin: 24px 0;
`
export default ConflictResolutionPopup

View File

@@ -1,6 +1,6 @@
import { Input, Modal } from 'antd'
import { TextAreaProps } from 'antd/es/input'
import { useRef, useState } from 'react'
import { ReactNode, useRef, useState } from 'react'
import { Box } from '../Layout'
import { TopView } from '../TopView'
@@ -11,6 +11,7 @@ interface PromptPopupShowParams {
defaultValue?: string
inputPlaceholder?: string
inputProps?: TextAreaProps
extraNode?: ReactNode
}
interface Props extends PromptPopupShowParams {
@@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC<Props> = ({
defaultValue = '',
inputPlaceholder = '',
inputProps = {},
extraNode = null,
resolve
}) => {
const [value, setValue] = useState(defaultValue)
@@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC<Props> = ({
rows={1}
{...inputProps}
/>
{extraNode}
</Modal>
)
}

View File

@@ -256,6 +256,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
resolve(undefined)
break

View File

@@ -41,7 +41,7 @@ const SelectModelSearchBar: React.FC<SelectModelSearchBarProps> = ({ onSearch })
</SearchIcon>
}
ref={inputRef}
placeholder={t('models.search')}
placeholder={t('models.search.placeholder')}
value={searchText}
onChange={(e) => handleTextChange(e.target.value)}
onClear={handleClear}

View File

@@ -1,8 +1,8 @@
import { LoadingOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import { translateText } from '@renderer/services/TranslateService'
import { getLanguageByLangcode } from '@renderer/utils/translate'
import { Modal, ModalProps } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { TextAreaProps } from 'antd/lib/input'
@@ -38,6 +38,7 @@ const PopupContainer: React.FC<Props> = ({
}) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { getLanguageByLangcode } = useTranslate()
const [textValue, setTextValue] = useState(text)
const [isTranslating, setIsTranslating] = useState(false)
const textareaRef = useRef<TextAreaRef>(null)

View File

@@ -1,9 +1,9 @@
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
import React, { memo, useCallback } from 'react'
import styled from 'styled-components'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
@@ -13,8 +13,10 @@ const vizInitializer = new AsyncInitializer(async () => {
return await module.instance()
})
/** 预览 Graphviz 图表
* 使用 usePreviewRenderer hook 大幅简化组件逻辑
/**
* 预览 Graphviz 图表
* - 使用 useDebouncedRender 改善体验
* - 使用 shadow dom 渲染 SVG
*/
const GraphvizPreview = ({
children,
@@ -41,16 +43,9 @@ const GraphvizPreview = ({
ref={ref}
imageRef={containerRef}
source="graphviz">
<StyledGraphviz ref={containerRef} className="graphviz special-preview" />
<ShadowWhiteContainer ref={containerRef} className="graphviz special-preview" />
</ImagePreviewLayout>
)
}
const StyledGraphviz = styled.div`
overflow: auto;
position: relative;
width: 100%;
height: 100%;
`
export default memo(GraphvizPreview)

View File

@@ -1,15 +1,17 @@
import { nanoid } from '@reduxjs/toolkit'
import { useMermaid } from '@renderer/hooks/useMermaid'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowTransparentContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
/** 预览 Mermaid 图表
* 使用 usePreviewRenderer hook 重构,同时保留必要的可见性检测逻辑
* FIXME: 等将来 mermaid-js 修复可见性问题后可以进一步简化
/**
* 预览 Mermaid 图表
* - 使用 useDebouncedRender 改善体验
* - 使用 shadow dom 渲染 SVG
*/
const MermaidPreview = ({
children,
@@ -20,17 +22,39 @@ const MermaidPreview = ({
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const [isVisible, setIsVisible] = useState(true)
// 定义渲染函数
/**
* 定义渲染函数,在临时容器中测量,在 shadow dom 中渲染。
* 如果这个方案有问题,可以回退到 innerHTML。
*/
const renderMermaid = useCallback(
async (content: string, container: HTMLDivElement) => {
// 验证语法,提前抛出异常
await mermaid.parse(content)
const { svg } = await mermaid.render(diagramId, content, container)
// 获取容器宽度
const { width } = container.getBoundingClientRect()
if (width === 0) return
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
container.innerHTML = fixedSvg
// 创建临时的 div 用于 mermaid 测量
const measureEl = document.createElement('div')
measureEl.style.position = 'absolute'
measureEl.style.left = '-9999px'
measureEl.style.top = '-9999px'
measureEl.style.width = `${width}px`
document.body.appendChild(measureEl)
try {
const { svg } = await mermaid.render(diagramId, content, measureEl)
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
// 有问题可以回退到 innerHTML
renderSvgInShadowHost(fixedSvg, container)
// container.innerHTML = fixedSvg
} finally {
document.body.removeChild(measureEl)
}
},
[diagramId, mermaid]
)
@@ -63,7 +87,7 @@ const MermaidPreview = ({
const element = containerRef.current
if (!element) return
const currentlyVisible = element.offsetParent !== null
const currentlyVisible = element.offsetParent !== null && element.offsetWidth > 0 && element.offsetHeight > 0
setIsVisible(currentlyVisible)
}
@@ -105,16 +129,9 @@ const MermaidPreview = ({
ref={ref}
imageRef={containerRef}
source="mermaid">
<StyledMermaid ref={containerRef} className="mermaid special-preview" />
<ShadowTransparentContainer ref={containerRef} className="mermaid special-preview" />
</ImagePreviewLayout>
)
}
const StyledMermaid = styled.div`
overflow: auto;
position: relative;
width: 100%;
height: 100%;
`
export default memo(MermaidPreview)

View File

@@ -4,6 +4,7 @@ import React, { memo, useCallback, useEffect } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
@@ -128,7 +129,7 @@ const PlantUmlPreview = ({
ref={ref}
imageRef={containerRef}
source="plantuml">
<div ref={containerRef} className="plantuml-preview special-preview" />
<ShadowWhiteContainer ref={containerRef} className="plantuml-preview special-preview" />
</ImagePreviewLayout>
)
}

View File

@@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowTransparentContainer } from './styles'
import { BasicPreviewHandles } from './types'
import { renderSvgInShadowHost } from './utils'
@@ -34,7 +35,8 @@ const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPrev
ref={ref}
imageRef={containerRef}
source="svg">
<div ref={containerRef} className={className ?? 'svg-preview special-preview'}></div>
{/* 使用透明容器,把背景色完全交给 SVG 自己控制 */}
<ShadowTransparentContainer ref={containerRef} className={className ?? 'svg-preview special-preview'} />
</ImagePreviewLayout>
)
}

View File

@@ -2,10 +2,9 @@
exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = `
.c0 {
overflow: auto;
position: relative;
width: 100%;
height: 100%;
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div>

View File

@@ -2,10 +2,9 @@
exports[`MermaidPreview > basic rendering > should match snapshot 1`] = `
.c0 {
overflow: auto;
position: relative;
width: 100%;
height: 100%;
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
}
<div>

View File

@@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div>
<div
data-source="plantuml"
@@ -15,7 +21,7 @@ exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content"
>
<div
class="plantuml-preview special-preview"
class="c0 plantuml-preview special-preview"
/>
</div>
</div>

View File

@@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
}
<div>
<div
data-source="svg"
@@ -15,7 +21,7 @@ exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content"
>
<div
class="svg-preview special-preview"
class="c0 svg-preview special-preview"
/>
</div>
</div>

View File

@@ -10,29 +10,39 @@ describe('renderSvgInShadowHost', () => {
// Mock attachShadow
Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
const shadowRoot = document.createElement('div')
// Check if a shadow root already exists to prevent re-creating it.
if (this.shadowRoot) {
return this.shadowRoot
}
// Create a container that acts as the shadow root.
const shadowRootContainer = document.createElement('div')
shadowRootContainer.dataset.testid = 'shadow-root'
Object.defineProperty(this, 'shadowRoot', {
value: shadowRoot,
value: shadowRootContainer,
writable: true,
configurable: true
})
// Simple innerHTML copy for test verification
Object.defineProperty(shadowRoot, 'innerHTML', {
set(value) {
shadowRoot.textContent = value // A simplified mock
// Mock essential methods like appendChild and innerHTML.
// JSDOM doesn't fully implement shadow DOM, so we simulate its behavior.
const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')
Object.defineProperty(shadowRootContainer, 'innerHTML', {
set(value: string) {
// Clear existing content and parse the new HTML.
originalInnerHTMLDescriptor?.set?.call(this, '')
const template = document.createElement('template')
template.innerHTML = value
shadowRootContainer.append(...Array.from(template.content.childNodes))
},
get() {
return shadowRoot.textContent || ''
return originalInnerHTMLDescriptor?.get?.call(this) ?? ''
},
configurable: true
})
shadowRoot.appendChild = vi.fn(<T extends Node>(node: T): T => {
shadowRoot.append(node)
return node
})
return shadowRoot as unknown as ShadowRoot
return shadowRootContainer as unknown as ShadowRoot
})
})
@@ -57,7 +67,7 @@ describe('renderSvgInShadowHost', () => {
expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
// Verify it works with the existing shadow root
expect(existingShadowRoot.appendChild).toHaveBeenCalled()
expect(existingShadowRoot.innerHTML).toContain('<svg')
})
it('should inject styles and valid SVG content into the shadow DOM', () => {
@@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => {
expect(shadowRoot?.querySelector('rect')).not.toBeNull()
})
it('should add the xmlns attribute if it is missing', () => {
const svgWithoutXmlns = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
renderSvgInShadowHost(svgWithoutXmlns, hostElement)
const svgElement = hostElement.shadowRoot?.querySelector('svg')
expect(svgElement).not.toBeNull()
expect(svgElement?.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
})
it('should throw an error if the host element is not available', () => {
expect(() => renderSvgInShadowHost('<svg></svg>', null as any)).toThrow(
'Host element for SVG rendering is not available.'
)
})
it('should throw an error for invalid SVG content', () => {
const invalidSvg = '<svg><rect></svg>' // Malformed
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
it('should not throw an error for malformed SVG content due to HTML parser fallback', () => {
const invalidSvg = '<svg><rect></svg>' // Malformed, but fixable by the browser's HTML parser
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).not.toThrow()
// Also, assert that it successfully rendered something.
expect(hostElement.shadowRoot?.querySelector('svg')).not.toBeNull()
})
it('should throw an error for non-SVG content', () => {
const nonSvg = '<div>this is not svg</div>'
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow()
})
it('should not throw an error for empty or whitespace content', () => {

View File

@@ -33,3 +33,15 @@ export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })`
}
}
`
export const ShadowWhiteContainer = styled.div`
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
`
export const ShadowTransparentContainer = styled.div`
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
`

View File

@@ -1,3 +1,6 @@
import { makeSvgSizeAdaptive } from '@renderer/utils'
import DOMPurify from 'dompurify'
/**
* Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
* This function handles creating the shadow root, injecting base styles for the host,
@@ -12,50 +15,76 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
throw new Error('Host element for SVG rendering is not available.')
}
// Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, {
ADD_TAGS: ['foreignObject']
})
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
// Base styles for the host element
// Base styles for the host element and the inner SVG
const style = document.createElement('style')
style.textContent = `
:host {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
background-color: var(--shadow-host-background-color);
border: var(--shadow-host-border);
border-radius: var(--shadow-host-border-radius);
padding: 1em;
background-color: white;
overflow: auto;
border: 0.5px solid var(--color-code-background);
border-radius: 8px;
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
display: block;
position: relative;
width: 100%;
height: 100%;
}
svg {
max-width: 100%;
height: auto;
}
`
// Clear previous content and append new style and SVG
// Clear previous content and append new style
shadowRoot.innerHTML = ''
shadowRoot.appendChild(style)
// Parse and append the SVG using DOMParser to prevent script execution and check for errors
if (svgContent.trim() === '') {
if (sanitizedContent.trim() === '') {
return
}
const parser = new DOMParser()
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
const parser = new DOMParser()
const doc = parser.parseFromString(sanitizedContent, 'image/svg+xml')
const parserError = doc.querySelector('parsererror')
if (parserError) {
// Throw a specific error that can be caught by the calling component
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
let svgElement: Element = doc.documentElement
// If parsing fails or the namespace is incorrect, fall back to the more lenient HTML parser.
if (parserError || svgElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = sanitizedContent
const svgFromHtml = tempDiv.querySelector('svg')
if (svgFromHtml) {
// Directly use the DOM node created by the HTML parser.
svgElement = svgFromHtml
// Ensure the xmlns attribute is present.
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
} else {
// If both parsing methods fail, the SVG content is genuinely invalid.
if (parserError) {
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
}
throw new Error('Invalid SVG content: The provided string does not contain a valid SVG element.')
}
}
const svgElement = doc.documentElement
if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
shadowRoot.appendChild(svgElement.cloneNode(true))
} else if (svgContent.trim() !== '') {
// Do not throw error for empty content
// Type guard
if (svgElement instanceof SVGSVGElement) {
// Standardize the SVG element for proper scaling
makeSvgSizeAdaptive(svgElement)
// Append the SVG element to the shadow root
shadowRoot.appendChild(svgElement)
} else {
// This path is taken if the content is valid XML but not a valid SVG document
// (e.g., root element is not <svg>), or if the fallback parser fails.
throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
}
}

View File

@@ -0,0 +1,113 @@
import { SearchOutlined } from '@ant-design/icons'
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
import { getProviderLabel } from '@renderer/i18n/label'
import { Input, Tooltip } from 'antd'
import { FC, useMemo, useState } from 'react'
import styled from 'styled-components'
interface Props {
onProviderClick: (providerId: string) => void
}
// 用于选择内置头像的提供商Logo选择器组件
const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
const [searchText, setSearchText] = useState('')
const filteredProviders = useMemo(() => {
const providers = Object.entries(PROVIDER_LOGO_MAP).map(([id, logo]) => ({
id,
logo,
name: getProviderLabel(id)
}))
if (!searchText) return providers
const searchLower = searchText.toLowerCase()
return providers.filter((p) => p.name.toLowerCase().includes(searchLower))
}, [searchText])
const handleProviderClick = (event: React.MouseEvent, providerId: string) => {
event.stopPropagation()
onProviderClick(providerId)
}
return (
<Container>
<SearchContainer>
<Input
placeholder="search"
prefix={<SearchOutlined style={{ color: 'var(--color-text-3)' }} />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
size="small"
allowClear
style={{
borderRadius: 'var(--list-item-border-radius)',
background: 'var(--color-background-soft)'
}}
/>
</SearchContainer>
<LogoGrid>
{filteredProviders.map(({ id, logo, name }) => (
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
<img src={logo} alt={name} draggable={false} />
</LogoItem>
</Tooltip>
))}
</LogoGrid>
</Container>
)
}
const Container = styled.div`
width: 350px;
max-height: 300px;
display: flex;
flex-direction: column;
padding: 12px;
background: var(--color-background);
border-radius: 8px;
`
const SearchContainer = styled.div`
margin-bottom: 12px;
`
const LogoGrid = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
overflow-y: auto;
flex: 1;
padding: 4px;
`
const LogoItem = styled.div`
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
&:hover {
background: var(--color-background-mute);
transform: scale(1.05);
border-color: var(--color-primary);
}
img {
width: 32px;
height: 32px;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
`
export default ProviderLogoPicker

View File

@@ -5,7 +5,8 @@ import {
QuickPanelCloseAction,
QuickPanelContextType,
QuickPanelListItem,
QuickPanelOpenOptions
QuickPanelOpenOptions,
QuickPanelTriggerInfo
} from './types'
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
@@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const [defaultIndex, setDefaultIndex] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(7)
const [multiple, setMultiple] = useState<boolean>(false)
const [onClose, setOnClose] = useState<
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
>()
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
@@ -44,6 +44,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setPageSize(options.pageSize ?? 7)
setMultiple(options.multiple ?? false)
setSymbol(options.symbol)
setTriggerInfo(options.triggerInfo)
setOnClose(() => options.onClose)
setBeforeAction(() => options.beforeAction)
@@ -53,9 +54,9 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
}, [])
const close = useCallback(
(action?: QuickPanelCloseAction) => {
(action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false)
onClose?.({ symbol, action })
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
clearTimer.current = setTimeout(() => {
setList([])
@@ -64,9 +65,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setAfterAction(undefined)
setTitle(undefined)
setSymbol('')
setTriggerInfo(undefined)
}, 200)
},
[onClose, symbol]
[onClose, symbol, triggerInfo]
)
useEffect(() => {
@@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
triggerInfo,
onClose,
beforeAction,
afterAction
@@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
triggerInfo,
onClose,
beforeAction,
afterAction

View File

@@ -1,6 +1,12 @@
import React from 'react'
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
export type QuickPanelTriggerInfo = {
type: 'input' | 'button'
position?: number
originalText?: string
}
export type QuickPanelCallBackOptions = {
symbol: string
action: QuickPanelCloseAction
@@ -8,6 +14,7 @@ export type QuickPanelCallBackOptions = {
searchText?: string
/** 是否处于多选状态 */
multiple?: boolean
triggerInfo?: QuickPanelTriggerInfo
}
export type QuickPanelOpenOptions = {
@@ -26,6 +33,8 @@ export type QuickPanelOpenOptions = {
* 可以是/@#符号,也可以是其他字符串
*/
symbol: string
/** 触发信息,记录面板是如何被打开的 */
triggerInfo?: QuickPanelTriggerInfo
beforeAction?: (options: QuickPanelCallBackOptions) => void
afterAction?: (options: QuickPanelCallBackOptions) => void
onClose?: (options: QuickPanelCallBackOptions) => void
@@ -45,13 +54,19 @@ export type QuickPanelListItem = {
isSelected?: boolean
isMenu?: boolean
disabled?: boolean
/**
* 固定显示项:不参与过滤,始终出现在列表顶部。
* 例如“清除”按钮可设置为 alwaysVisible从而在有匹配项时始终可见
* 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。
*/
alwaysVisible?: boolean
action?: (options: QuickPanelCallBackOptions) => void
}
// 定义上下文类型
export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly isVisible: boolean
readonly symbol: string
@@ -60,6 +75,7 @@ export interface QuickPanelContextType {
readonly defaultIndex: number
readonly pageSize: number
readonly multiple: boolean
readonly triggerInfo?: QuickPanelTriggerInfo
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void

View File

@@ -1,10 +1,12 @@
import { RightOutlined } from '@ant-design/icons'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { isMac } from '@renderer/config/constant'
import { useTimer } from '@renderer/hooks/useTimer'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { t } from 'i18next'
import { debounce } from 'lodash'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
@@ -62,18 +64,32 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 缓存:按 item 缓存拼音文本,避免重复转换
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
// 轻量防抖:减少高频输入时的过滤调用
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 无匹配项自动关闭的定时器
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 处理搜索,过滤列表
const { setTimeoutTimer } = useTimer()
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
const newList = ctx.list?.filter((item) => {
const _searchText = searchText.replace(/^[/@]/, '')
const _searchText = searchText.replace(/^[/@]/, '')
const lowerSearchText = _searchText.toLowerCase()
const fuzzyPattern = lowerSearchText
.split('')
.map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*')
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
// 拆分:固定显示项(不参与过滤)与普通项
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
const filteredNormalItems = normalItems.filter((item) => {
if (!_searchText) return true
let filterText = item.filterText || ''
@@ -85,29 +101,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = _searchText.toLowerCase()
if (lowerFilterText.includes(lowerSearchText)) {
return true
}
const pattern = lowerSearchText
.split('')
.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
.join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
const regex = new RegExp(pattern, 'ig')
return regex.test(pinyinText)
let pinyinText = pinyinCacheRef.current.get(item)
if (!pinyinText) {
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
pinyinCacheRef.current.set(item, pinyinText)
}
return fuzzyRegex.test(pinyinText)
} catch (error) {
return true
}
} else {
const regex = new RegExp(pattern, 'ig')
return regex.test(filterText.toLowerCase())
return fuzzyRegex.test(filterText.toLowerCase())
}
})
@@ -120,8 +131,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} else {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
if (prevIndex >= newList.length) {
return newList.length > 0 ? newList.length - 1 : -1
const combinedLength = pinnedItems.length + filteredNormalItems.length
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
return prevIndex
})
@@ -130,81 +142,59 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
return newList
// 固定项置顶 + 过滤后的普通项
return [...pinnedItems, ...filteredNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
}, [list, historyPanel])
// 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板
useEffect(() => {
const _searchText = searchText.replace(/^[/@]/, '')
// 清除之前的定时器(无论面板是否可见都要清理)
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
// 面板不可见时不设置新定时器
if (!ctx.isVisible) {
return
}
// 只有在有搜索文本但无匹配项时才设置延迟关闭
if (_searchText && _searchText.length > 0 && list.length === 0) {
noMatchTimeoutRef.current = setTimeout(() => {
ctx.close('no-matches')
}, 300)
}
// 清理函数
return () => {
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定使用具体属性避免过度重渲染
}, [ctx.isVisible, searchText, list.length, ctx.close])
const clearSearchText = useCallback(
(includeSymbol = false) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return
const cursorPosition = textArea.selectionStart ?? 0
const prevChar = textArea.value[cursorPosition - 1]
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
searchTextRef.current = prevChar
}
const textBeforeCursor = textArea.value.slice(0, cursorPosition)
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
if (!_searchText) return
// 查找最后一个 @ 或 / 符号的位置
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex)
const inputText = textArea.value
let newText = inputText
const searchPattern = new RegExp(`${_searchText}$`)
if (lastSymbolIndex === -1) return
const match = inputText.slice(0, cursorPosition).match(searchPattern)
if (match) {
const start = match.index || 0
const end = start + match[0].length
newText = inputText.slice(0, start) + inputText.slice(end)
setInputText(newText)
// 根据 includeSymbol 决定是否删除符号
const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
const deleteEnd = cursorPosition
setTimeout(() => {
if (deleteStart >= deleteEnd) return
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus()
textArea.setSelectionRange(start, start)
}, 0)
}
textArea.setSelectionRange(deleteStart, deleteStart)
},
0
)
setSearchText('')
},
[setInputText]
[setInputText, setTimeoutTimer]
)
const handleClose = useCallback(
(action?: QuickPanelCloseAction) => {
ctx.close(action)
// 传递 searchText 给 close 函数,去掉第一个字符(@ 或 /
const cleanSearchText = searchText.length > 1 ? searchText.slice(1) : ''
ctx.close(action, cleanSearchText)
setHistoryPanel([])
scrollTriggerRef.current = 'initial'
@@ -217,7 +207,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
clearSearchText(true)
}
},
[ctx, clearSearchText, setInputText]
[ctx, clearSearchText, setInputText, searchText]
)
const handleItemAction = useCallback(
@@ -308,9 +298,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchText(newSearchText)
setSearchTextDebounced(newSearchText)
} else {
ctx.close('delete-symbol')
// 使用本地 handleClose确保在删除触发符时同步受控输入值
handleClose('delete-symbol')
}
}
@@ -331,9 +322,14 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd)
setTimeout(() => {
setSearchText('')
}, 200) // 等待面板关闭动画结束后,再清空搜索词
setSearchTextDebounced.cancel()
setTimeoutTimer(
'quickpanel_clear_search',
() => {
setSearchText('')
},
200
) // 等待面板关闭动画结束后,再清空搜索词
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
@@ -347,9 +343,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'none'
}, [index])
// 处理键盘事件
// 处理键盘事件(折叠时不拦截全局键盘)
useEffect(() => {
if (!ctx.isVisible) return
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
const isCollapsed = hasSearchTextFlag && list.length === 0
if (!ctx.isVisible || isCollapsed) return
const handleKeyDown = (e: KeyboardEvent) => {
if (isMac ? e.metaKey : e.ctrlKey) {
@@ -456,6 +454,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
break
case 'Escape':
e.stopPropagation()
handleClose('esc')
break
}
@@ -475,16 +474,26 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('click', handleClickOutside)
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('keyup', handleKeyUp, true)
window.addEventListener('click', handleClickOutside, true)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('keyup', handleKeyUp, true)
window.removeEventListener('click', handleClickOutside, true)
}
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
}, [
index,
isAssistiveKeyPressed,
historyPanel,
ctx,
list,
handleItemAction,
handleClose,
clearSearchText,
searchText
])
const [footerWidth, setFooterWidth] = useState(0)
@@ -504,6 +513,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const listHeight = useMemo(() => {
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
}, [ctx.pageSize, list.length])
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
const collapsed = hasSearchText && visibleNonPinnedCount === 0
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@@ -551,6 +564,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize}
$selectedColor={selectedColor}
$selectedColorHover={selectedColorHover}
$collapsed={collapsed}
className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel">
<QuickPanelBody
@@ -561,17 +575,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
<DynamicVirtualList
ref={listRef}
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{rowRenderer}
</DynamicVirtualList>
{!collapsed && (
<DynamicVirtualList
ref={listRef}
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{rowRenderer}
</DynamicVirtualList>
)}
<QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}>
@@ -615,6 +631,7 @@ const QuickPanelContainer = styled.div<{
$pageSize: number
$selectedColor: string
$selectedColorHover: string
$collapsed?: boolean
}>`
--focused-color: rgba(0, 0, 0, 0.06);
--selected-color: ${(props) => props.$selectedColor};
@@ -633,8 +650,8 @@ const QuickPanelContainer = styled.div<{
pointer-events: none;
&.visible {
pointer-events: auto;
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')};
max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px;
}
body[theme-mode='dark'] & {
--focused-color: rgba(255, 255, 255, 0.1);

View File

@@ -14,6 +14,7 @@ import { Tooltip } from 'antd'
import {
FileSearch,
Folder,
Hammer,
Home,
Languages,
LayoutGrid,
@@ -22,7 +23,6 @@ import {
Palette,
Settings,
Sparkle,
SquareTerminal,
Sun,
Terminal,
X
@@ -53,7 +53,7 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
case 'knowledge':
return <FileSearch size={14} />
case 'mcp':
return <SquareTerminal size={14} />
return <Hammer size={14} />
case 'files':
return <Folder size={14} />
case 'settings':
@@ -206,8 +206,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
height: var(--navbar-height);
position: relative;
-webkit-app-region: drag;
/* 确保交互元素在拖拽区域之上 */
> * {
position: relative;
z-index: 1;
-webkit-app-region: no-drag;
}
`
const Tab = styled.div<{ active?: boolean }>`
@@ -220,7 +228,6 @@ const Tab = styled.div<{ active?: boolean }>`
border-radius: var(--list-item-border-radius);
cursor: pointer;
user-select: none;
-webkit-app-region: none;
height: 30px;
min-width: 90px;
transition: background 0.2s;
@@ -273,7 +280,6 @@ const AddTabButton = styled.div`
height: 30px;
cursor: pointer;
color: var(--color-text-2);
-webkit-app-region: none;
border-radius: var(--list-item-border-radius);
&.active {
background: var(--color-list-item);
@@ -298,7 +304,6 @@ const ThemeButton = styled.div`
height: 30px;
cursor: pointer;
color: var(--color-text);
-webkit-app-region: none;
&:hover {
background: var(--color-list-item);
@@ -314,7 +319,6 @@ const SettingsButton = styled.div<{ $active: boolean }>`
height: 30px;
cursor: pointer;
color: var(--color-text);
-webkit-app-region: none;
border-radius: 8px;
background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')};
&:hover {

View File

@@ -1,5 +1,7 @@
// import { loggerService } from '@logger'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { message, Modal } from 'antd'
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
@@ -24,6 +26,8 @@ type ElementItem = {
element: React.FC | React.ReactNode
}
// const logger = loggerService.withContext('TopView')
const TopViewContainer: React.FC<Props> = ({ children }) => {
const [elements, setElements] = useState<ElementItem[]>([])
const elementsRef = useRef<ElementItem[]>([])
@@ -31,6 +35,8 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
const [messageApi, messageContextHolder] = message.useMessage()
const [modal, modalContextHolder] = Modal.useModal()
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
useAppInit()
@@ -72,6 +78,21 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
)
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// logger.debug('keydown', e)
if (!enableQuitFullScreen) return
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
window.api.setFullScreen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
})
return (
<>
{children}

View File

@@ -1,6 +1,6 @@
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
import { isMac } from '@renderer/config/constant'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import { UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
@@ -9,13 +9,12 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { getSidebarIconLabel, getThemeModeLabel } from '@renderer/i18n/label'
import { ThemeMode } from '@renderer/types'
import { isEmoji } from '@renderer/utils'
import { Avatar, Tooltip } from 'antd'
import {
CircleHelp,
Code,
FileSearch,
Folder,
Languages,
@@ -37,8 +36,8 @@ import UserPopup from '../Popups/UserPopup'
import { SidebarOpenedMinappTabs, SidebarPinnedApps } from './PinnedMinapps'
const Sidebar: FC = () => {
const { hideMinappPopup, openMinapp } = useMinappPopup()
const { minappShow, currentMinappId } = useRuntime()
const { hideMinappPopup } = useMinappPopup()
const { minappShow } = useRuntime()
const { sidebarIcons } = useSettings()
const { pinned } = useMinapps()
@@ -60,17 +59,6 @@ const Sidebar: FC = () => {
navigate(path)
}
const docsId = 'cherrystudio-docs'
const onOpenDocs = () => {
const isChinese = i18n.language.startsWith('zh')
openMinapp({
id: docsId,
name: t('docs.title'),
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
logo: AppLogo
})
}
const isFullscreen = useFullscreen()
return (
@@ -100,11 +88,6 @@ const Sidebar: FC = () => {
)}
</MainMenusContainer>
<Menus>
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
<CircleHelp size={20} className="icon" />
</Icon>
</Tooltip>
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
mouseEnterDelay={0.8}
@@ -153,7 +136,8 @@ const MainMenus: FC = () => {
translate: <Languages size={18} className="icon" />,
minapp: <LayoutGrid size={18} className="icon" />,
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={17} className="icon" />
files: <Folder size={17} className="icon" />,
code_tools: <Code size={18} className="icon" />
}
const pathMap = {
@@ -163,7 +147,8 @@ const MainMenus: FC = () => {
translate: '/translate',
minapp: '/apps',
knowledge: '/knowledge',
files: '/files'
files: '/files',
code_tools: '/code'
}
return sidebarIcons.visible.map((icon) => {

View File

@@ -0,0 +1,110 @@
import { DraggableSyntheticListeners } from '@dnd-kit/core'
import { Transform } from '@dnd-kit/utilities'
import { classNames } from '@renderer/utils'
import React, { useEffect } from 'react'
import styled from 'styled-components'
interface ItemRendererProps<T> {
ref?: React.Ref<HTMLDivElement>
item: T
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
dragging?: boolean
dragOverlay?: boolean
ghost?: boolean
transform?: Transform | null
transition?: string | null
listeners?: DraggableSyntheticListeners
}
export function ItemRenderer<T>({
ref,
item,
renderItem,
dragging,
dragOverlay,
ghost,
transform,
transition,
listeners,
...props
}: ItemRendererProps<T>) {
useEffect(() => {
if (!dragOverlay) {
return
}
document.body.style.cursor = 'grabbing'
return () => {
document.body.style.cursor = ''
}
}, [dragOverlay])
const wrapperStyle = {
transition,
'--translate-x': transform ? `${Math.round(transform.x)}px` : undefined,
'--translate-y': transform ? `${Math.round(transform.y)}px` : undefined,
'--scale-x': transform?.scaleX ? `${transform.scaleX}` : undefined,
'--scale-y': transform?.scaleY ? `${transform.scaleY}` : undefined
} as React.CSSProperties
return (
<ItemWrapper ref={ref} className={classNames({ dragOverlay: dragOverlay })} style={{ ...wrapperStyle }}>
<DraggableItem
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
{...listeners}
{...props}>
{renderItem(item, { dragging: !!dragging })}
</DraggableItem>
</ItemWrapper>
)
}
const ItemWrapper = styled.div`
box-sizing: border-box;
transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1))
scaleY(var(--scale-y, 1));
transform-origin: 0 0;
touch-action: manipulation;
&.dragOverlay {
--scale: 1.02;
z-index: 999;
position: relative;
}
`
const DraggableItem = styled.div`
position: relative;
box-sizing: border-box;
cursor: pointer; /* default cursor for items */
touch-action: manipulation;
transform-origin: 50% 50%;
transform: scale(var(--scale, 1));
&.dragging:not(.dragOverlay) {
z-index: 0;
opacity: 0.25;
&:not(.ghost) {
opacity: 0;
}
}
&.dragOverlay {
cursor: inherit;
animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
transform: scale(var(--scale));
opacity: 1;
pointer-events: none; /* prevent pointer events on drag overlay */
}
@keyframes pop {
0% {
transform: scale(1);
}
100% {
transform: scale(var(--scale));
}
}
`

View File

@@ -0,0 +1,192 @@
import {
Active,
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
DropAnimation,
KeyboardSensor,
Over,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors
} from '@dnd-kit/core'
import { restrictToHorizontalAxis, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
horizontalListSortingStrategy,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import React, { useCallback, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import styled from 'styled-components'
import { ItemRenderer } from './ItemRenderer'
import { SortableItem } from './SortableItem'
import { PortalSafePointerSensor } from './utils'
interface SortableProps<T> {
/** Array of sortable items */
items: T[]
/** Function or key to get unique identifier for each item */
itemKey: keyof T | ((item: T) => string | number)
/** Callback when sorting is complete, receives old and new indices */
onSortEnd: (event: { oldIndex: number; newIndex: number }) => void
/** Callback when drag starts, will be passed to dnd-kit's onDragStart */
onDragStart?: (event: { active: Active }) => void
/** Callback when drag ends, will be passed to dnd-kit's onDragEnd */
onDragEnd?: (event: { over: Over }) => void
/** Function to render individual item, receives item data and drag state */
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
/** Layout type - 'list' for vertical/horizontal list, 'grid' for grid layout */
layout?: 'list' | 'grid'
/** Whether sorting is horizontal */
horizontal?: boolean
/** Whether to use drag overlay
* If you want to hide ghost item, set showGhost to false rather than useDragOverlay.
*/
useDragOverlay?: boolean
/** Whether to show ghost item, only works when useDragOverlay is true */
showGhost?: boolean
/** Item list class name */
className?: string
/** Item list style */
listStyle?: React.CSSProperties
/** Ghost item style */
ghostItemStyle?: React.CSSProperties
}
function Sortable<T>({
items,
itemKey,
onSortEnd,
onDragStart: customOnDragStart,
onDragEnd: customOnDragEnd,
renderItem,
layout = 'list',
horizontal = false,
useDragOverlay = true,
showGhost = false,
className,
listStyle
}: SortableProps<T>) {
const sensors = useSensors(
useSensor(PortalSafePointerSensor, {
activationConstraint: {
distance: 8
}
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 5
}
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
const getId = useCallback(
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as string | number)),
[itemKey]
)
const itemIds = useMemo(() => items.map(getId), [items, getId])
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const activeItem = activeId ? items.find((item) => getId(item) === activeId) : null
const getIndex = (id: UniqueIdentifier) => itemIds.indexOf(id)
const activeIndex = activeId ? getIndex(activeId) : -1
const handleDragStart = ({ active }) => {
customOnDragStart?.({ active })
if (active) {
setActiveId(active.id)
}
}
const handleDragEnd = ({ over }) => {
setActiveId(null)
customOnDragEnd?.({ over })
if (over) {
const overIndex = getIndex(over.id)
if (activeIndex !== overIndex) {
onSortEnd({ oldIndex: activeIndex, newIndex: overIndex })
}
}
}
const handleDragCancel = () => {
setActiveId(null)
}
const strategy =
layout === 'list' ? (horizontal ? horizontalListSortingStrategy : verticalListSortingStrategy) : rectSortingStrategy
const modifiers = layout === 'list' ? (horizontal ? [restrictToHorizontalAxis] : [restrictToVerticalAxis]) : []
const dropAnimation: DropAnimation = useMemo(
() => ({
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: { opacity: showGhost ? '0.25' : '0' }
}
})
}),
[showGhost]
)
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={modifiers}>
<SortableContext items={itemIds} strategy={strategy}>
<ListWrapper className={className} data-layout={layout} style={listStyle}>
{items.map((item, index) => (
<SortableItem
key={itemIds[index]}
item={item}
getId={getId}
renderItem={renderItem}
useDragOverlay={useDragOverlay}
showGhost={showGhost}
/>
))}
</ListWrapper>
</SortableContext>
{useDragOverlay
? createPortal(
<DragOverlay adjustScale dropAnimation={dropAnimation}>
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
</DragOverlay>,
document.body
)
: null}
</DndContext>
)
}
const ListWrapper = styled.div`
&[data-layout='grid'] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
width: 100%;
gap: 12px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
`
export default Sortable

View File

@@ -0,0 +1,41 @@
import { useSortable } from '@dnd-kit/sortable'
import React from 'react'
import { ItemRenderer } from './ItemRenderer'
interface SortableItemProps<T> {
item: T
getId: (item: T) => string | number
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
useDragOverlay?: boolean
showGhost?: boolean
}
export function SortableItem<T>({
item,
getId,
renderItem,
useDragOverlay = true,
showGhost = true
}: SortableItemProps<T>) {
const id = getId(item)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id
})
return (
<ItemRenderer
ref={setNodeRef}
item={item}
renderItem={renderItem}
dragging={isDragging}
dragOverlay={!useDragOverlay && isDragging}
ghost={showGhost && useDragOverlay && isDragging}
transform={transform}
transition={transition}
listeners={listeners}
{...attributes}
/>
)
}

View File

@@ -0,0 +1,3 @@
export { default as Sortable } from './Sortable'
export * from './useDndReorder'
export * from './useDndState'

Some files were not shown because too many files have changed in this diff Show More