Compare commits

...

66 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
153 changed files with 6269 additions and 1039 deletions

View File

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

@@ -79,6 +79,8 @@
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"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,6 +105,10 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@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",
@@ -144,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",
@@ -179,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",
@@ -203,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",
@@ -226,6 +233,7 @@
"proxy-agent": "^6.5.0",
"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",
@@ -290,7 +298,8 @@
"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"
"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

@@ -156,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',
@@ -280,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

@@ -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)
@@ -444,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) => {
@@ -468,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))
@@ -708,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

@@ -203,7 +203,7 @@ class CodeToolsService {
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const updateCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
logger.info(`Executing update command: ${updateCommand}`)
await execAsync(updateCommand, { timeout: 60000 })
@@ -307,7 +307,7 @@ class CodeToolsService {
}
// Build command to execute
let baseCommand = isWin ? `${executablePath}` : `${bunPath} ${executablePath}`
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) {

View File

@@ -1,9 +1,10 @@
import { loggerService } from '@logger'
import { net } from 'electron'
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')
// 配置常量,集中管理
@@ -28,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'
}
// 接口定义移到顶部,便于查阅
@@ -67,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)
}
/**
@@ -93,6 +107,7 @@ class CopilotService {
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'empty',
accept: 'application/json',
authorization: `token ${token}`
}
})
@@ -204,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)
@@ -221,7 +242,7 @@ 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 response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
@@ -249,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,7 +1,8 @@
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,
@@ -15,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'
@@ -630,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,
@@ -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

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

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

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'
@@ -133,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 文件名
@@ -170,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)
@@ -405,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

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

@@ -6,11 +6,13 @@ import {
getOpenAIWebSearchParams,
getThinkModelType,
isClaudeReasoningModel,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGeminiReasoningModel,
isGPT5SeriesModel,
isGrokReasoningModel,
isNotSupportSystemMessageModel,
isOpenAIOpenWeightModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
isQwenMTModel,
@@ -43,6 +45,7 @@ import {
Assistant,
EFFORT_RATIO,
FileTypes,
isSystemProvider,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
@@ -112,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 {}
}
@@ -121,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' } }
@@ -145,7 +132,14 @@ 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 {}
@@ -157,17 +151,22 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
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 {
@@ -196,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: {
@@ -207,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 = {
@@ -214,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
@@ -531,12 +582,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 1. 处理系统消息
const systemMessage = { role: 'system', content: assistant.prompt || '' }
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'
}
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) {
@@ -560,6 +611,9 @@ 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)
@@ -567,11 +621,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
if (lastUserMsg) {
if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
const postsuffix = '/no_think'
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 参数了,可以删掉这部分
@@ -587,8 +640,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 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[]
}
@@ -923,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> 标签
}

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

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

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

@@ -157,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) =>
@@ -177,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`

View File

@@ -1,7 +1,7 @@
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { classNames } from '@renderer/utils'
import { Button, Modal, Splitter, Tooltip } from 'antd'
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'
@@ -43,7 +43,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
const renderHeader = () => (
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
<TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
</HeaderLeft>
<HeaderCenter>
@@ -266,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`

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,16 +55,16 @@ 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
@@ -71,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
@@ -83,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

@@ -9,13 +9,14 @@ import {
ResponderProvided
} from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils'
import { List } from 'antd'
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,
@@ -51,6 +53,7 @@ const DraggableList: FC<Props<any>> = ({
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
<List
{...listProps}
dataSource={list}
renderItem={(item, index) => {
const id = item.id || item

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

@@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fill-rule="evenodd"
fillRule="evenodd"
width="1em"
height="1em"
viewBox="0 0 24 24"
@@ -193,7 +193,7 @@ export function ExaLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fill-rule="evenodd"
fillRule="evenodd"
width="1em"
height="1em"
viewBox="0 0 24 24"
@@ -211,30 +211,75 @@ 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
fill-rule="evenodd"
clip-rule="evenodd"
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"
fill-rule="evenodd"
clip-rule="evenodd"
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
fill-rule="evenodd"
clip-rule="evenodd"
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
fill-rule="evenodd"
clip-rule="evenodd"
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

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

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

@@ -17,8 +17,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
// Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['style', 'defs', 'foreignObject']
ADD_TAGS: ['foreignObject']
})
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })

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

@@ -54,6 +54,12 @@ export type QuickPanelListItem = {
isSelected?: boolean
isMenu?: boolean
disabled?: boolean
/**
* 固定显示项:不参与过滤,始终出现在列表顶部。
* 例如“清除”按钮可设置为 alwaysVisible从而在有匹配项时始终可见
* 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。
*/
alwaysVisible?: boolean
action?: (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,20 +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 clearSearchTimerRef = useRef<NodeJS.Timeout>(undefined)
const focusTimerRef = useRef<NodeJS.Timeout>(undefined)
// 处理搜索,过滤列表
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 || ''
@@ -87,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())
}
})
@@ -122,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
})
@@ -132,81 +142,52 @@ 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
clearTimeout(clearSearchTimerRef.current)
clearTimeout(focusTimerRef.current)
}
// 面板不可见时不设置新定时器
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
}
clearTimeout(clearSearchTimerRef.current)
clearTimeout(focusTimerRef.current)
}
// 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
clearTimeout(focusTimerRef.current)
focusTimerRef.current = 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(
@@ -317,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')
}
}
@@ -340,10 +322,14 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd)
clearTimeout(clearSearchTimerRef.current)
clearSearchTimerRef.current = setTimeout(() => {
setSearchText('')
}, 200) // 等待面板关闭动画结束后,再清空搜索词
setSearchTextDebounced.cancel()
setTimeoutTimer(
'quickpanel_clear_search',
() => {
setSearchText('')
},
200
) // 等待面板关闭动画结束后,再清空搜索词
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
@@ -357,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) {
@@ -495,7 +483,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
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)
@@ -515,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, [])
@@ -562,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
@@ -572,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}>
@@ -626,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};
@@ -644,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

@@ -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,4 +1,4 @@
import { loggerService } from '@logger'
// import { loggerService } from '@logger'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
@@ -26,7 +26,7 @@ type ElementItem = {
element: React.FC | React.ReactNode
}
const logger = loggerService.withContext('TopView')
// const logger = loggerService.withContext('TopView')
const TopViewContainer: React.FC<Props> = ({ children }) => {
const [elements, setElements] = useState<ElementItem[]>([])
@@ -80,7 +80,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
logger.debug('keydown', e)
// logger.debug('keydown', e)
if (!enableQuitFullScreen) return
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {

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'

View File

@@ -0,0 +1,74 @@
import { Key, useCallback, useMemo } from 'react'
interface UseDndReorderParams<T> {
/** 原始的、完整的数据列表 */
originalList: T[]
/** 当前在界面上渲染的、可能被过滤的列表 */
filteredList: T[]
/** 用于更新原始列表状态的函数 */
onUpdate: (newList: T[]) => void
/** 用于从列表项中获取唯一ID的属性名或函数 */
idKey: keyof T | ((item: T) => Key)
}
/**
* 增强拖拽排序能力,处理“过滤后列表”与“原始列表”的索引映射问题。
*
* @template T 列表项的类型
* @param params - { originalList, filteredList, onUpdate, idKey }
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
*/
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
// 创建从 item ID 到其在 *原始列表* 中索引的映射
const itemIndexMap = useMemo(() => {
const map = new Map<Key, number>()
originalList.forEach((item, index) => {
map.set(getId(item), index)
})
return map
}, [originalList, getId])
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
const getItemKey = useCallback(
(index: number): Key => {
const item = filteredList[index]
// 如果找不到item返回视图索引兜底
if (!item) return index
const originalIndex = itemIndexMap.get(getId(item))
return originalIndex ?? index
},
[filteredList, itemIndexMap, getId]
)
// 创建 onSortEnd 回调,封装了所有重排逻辑
const onSortEnd = useCallback(
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
// 使用 getItemKey 将视图索引转换为数据索引
const sourceOriginalIndex = getItemKey(oldIndex) as number
const destOriginalIndex = getItemKey(newIndex) as number
// 如果索引转换失败,不进行任何操作
if (sourceOriginalIndex === undefined || destOriginalIndex === undefined) {
return
}
if (sourceOriginalIndex === destOriginalIndex) {
return
}
// 操作原始列表的副本
const newList = [...originalList]
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
newList.splice(destOriginalIndex, 0, movedItem)
// 调用外部更新函数
onUpdate(newList)
},
[getItemKey, originalList, onUpdate]
)
return { onSortEnd, itemKey: getItemKey }
}

View File

@@ -0,0 +1,28 @@
import { useDndContext } from '@dnd-kit/core'
interface DndState {
/** 是否有元素正在拖拽 */
isDragging: boolean
/** 当前拖拽元素的ID */
draggedId: string | number | null
/** 当前悬停位置的ID */
overId: string | number | null
/** 是否正在悬停在某个可放置区域 */
isOver: boolean
}
/**
* 提供 dnd-kit 的全局拖拽状态管理
*
* @returns 当前拖拽状态信息
*/
export function useDndState(): DndState {
const { active, over } = useDndContext()
return {
isDragging: active !== null,
draggedId: active?.id ?? null,
overId: over?.id ?? null,
isOver: over !== null
}
}

View File

@@ -0,0 +1,45 @@
import { defaultDropAnimationSideEffects, type DropAnimation, PointerSensor } from '@dnd-kit/core'
export const PORTAL_NO_DND_SELECTORS = [
'.ant-dropdown',
'.ant-select-dropdown',
'.ant-popover',
'.ant-tooltip',
'.ant-modal'
].join(',')
/**
* Default drop animation config.
* The opacity is set so to match the drag overlay case.
*/
export const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.25'
}
}
})
}
/**
* Prevent drag on elements with specific classes or data-no-dnd attribute
*/
export class PortalSafePointerSensor extends PointerSensor {
static activators = [
{
eventName: 'onPointerDown',
handler: ({ nativeEvent: event }) => {
let target = event.target as HTMLElement
while (target) {
if (target.closest(PORTAL_NO_DND_SELECTORS) || target.dataset?.noDnd) {
return false
}
target = target.parentElement as HTMLElement
}
return true
}
}
] as (typeof PointerSensor)['activators']
}

View File

@@ -150,6 +150,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
import { getProviderByModel } from '@renderer/services/AssistantService'
import {
isSystemProviderId,
Model,
ReasoningEffortConfig,
SystemProviderId,
@@ -290,6 +291,7 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
)
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
default: ['low', 'medium', 'high'] as const,
o: ['low', 'medium', 'high'] as const,
@@ -303,7 +305,8 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
doubao_no_auto: ['high'] as const,
hunyuan: ['auto'] as const,
zhipu: ['auto'] as const,
perplexity: ['low', 'medium', 'high'] as const
perplexity: ['low', 'medium', 'high'] as const,
deepseek_hybrid: ['auto'] as const
} as const
// 模型类型到支持选项的映射表
@@ -320,7 +323,8 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
} as const
export const getThinkModelType = (model: Model): ThinkingModelType => {
@@ -350,6 +354,7 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
return thinkingModelType
}
@@ -372,11 +377,21 @@ export function isFunctionCallingModel(model?: Model): boolean {
return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name)
}
if (['deepseek', 'anthropic'].includes(model.provider)) {
if (['deepseek', 'anthropic', 'kimi', 'moonshot'].includes(model.provider)) {
return true
}
if (['kimi', 'moonshot'].includes(model.provider)) {
// 2025/08/26 百炼与火山引擎均不支持 v3.1 函数调用
// 先默认支持
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProviderId(model.provider)) {
switch (model.provider) {
case 'dashscope':
case 'doubao':
// case 'nvidia': // nvidia api 太烂了 测不了能不能用 先假设能用
return false
}
}
return true
}
@@ -1401,7 +1416,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
dashscope: [
{ id: 'qwen-vl-plus', name: 'qwen-vl-plus', provider: 'dashscope', group: 'qwen-vl', owned_by: 'system' },
{ id: 'qwen-coder-plus', name: 'qwen-coder-plus', provider: 'dashscope', group: 'qwen-coder', owned_by: 'system' },
{ id: 'qwen-turbo', name: 'qwen-turbo', provider: 'dashscope', group: 'qwen-turbo', owned_by: 'system' },
{ id: 'qwen-flash', name: 'qwen-flash', provider: 'dashscope', group: 'qwen-flash', owned_by: 'system' },
{ id: 'qwen-plus', name: 'qwen-plus', provider: 'dashscope', group: 'qwen-plus', owned_by: 'system' },
{ id: 'qwen-max', name: 'qwen-max', provider: 'dashscope', group: 'qwen-max', owned_by: 'system' }
],
@@ -2627,6 +2642,13 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
return false
}
// Specifically for DeepSeek V3.1. White list for now
if (isDeepSeekHybridInferenceModel(model)) {
return (['openrouter', 'dashscope', 'doubao', 'silicon', 'nvidia'] satisfies SystemProviderId[]).some(
(id) => id === model.provider
)
}
return (
isSupportedThinkingTokenGeminiModel(model) ||
isSupportedThinkingTokenQwenModel(model) ||
@@ -2764,7 +2786,9 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
'qwen-turbo-0428',
'qwen-turbo-2025-04-28',
'qwen-turbo-0715',
'qwen-turbo-2025-07-15'
'qwen-turbo-2025-07-15',
'qwen-flash',
'qwen-flash-2025-07-28'
].includes(modelId)
}
@@ -2838,6 +2862,15 @@ export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
return modelId.includes('glm-4.5')
}
export const isDeepSeekHybridInferenceModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
// deepseek官方使用chat和reasoner做推理控制其他provider需要单独判断id可能会有所差别
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型这里有风险
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId === 'deepseek-chat-v3.1'
}
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
export const isZhipuReasoningModel = (model?: Model): boolean => {
if (!model) {
return false
@@ -2870,6 +2903,8 @@ export function isReasoningModel(model?: Model): boolean {
REASONING_REGEX.test(modelId) ||
REASONING_REGEX.test(model.name) ||
isSupportedThinkingTokenDoubaoModel(model) ||
isDeepSeekHybridInferenceModel(model) ||
isDeepSeekHybridInferenceModel({ ...model, id: model.name }) ||
false
)
}
@@ -2884,6 +2919,7 @@ export function isReasoningModel(model?: Model): boolean {
isPerplexityReasoningModel(model) ||
isZhipuReasoningModel(model) ||
isStepReasoningModel(model) ||
isDeepSeekHybridInferenceModel(model) ||
modelId.includes('magistral') ||
modelId.includes('minimax-m1') ||
modelId.includes('pangu-pro-moe')
@@ -2909,7 +2945,11 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean {
return true
}
if (isOpenAIReasoningModel(model) || isOpenAIChatCompletionOnlyModel(model) || isQwenMTModel(model)) {
if (
(isOpenAIReasoningModel(model) && !isOpenAIOpenWeightModel(model)) ||
isOpenAIChatCompletionOnlyModel(model) ||
isQwenMTModel(model)
) {
return true
}
@@ -2988,7 +3028,7 @@ export function isWebSearchModel(model: Model): boolean {
}
if (provider.id === 'dashscope') {
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq']
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq', 'qwen-flash']
// matches id like qwen-max-0919, qwen-max-latest
return models.some((i) => modelId.startsWith(i))
}
@@ -3004,6 +3044,26 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
export function isMandatoryWebSearchModel(model: Model): boolean {
if (!model) {
return false
}
const provider = getProviderByModel(model)
if (!provider) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (provider.id === 'perplexity' || provider.id === 'openrouter') {
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
}
return false
}
export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
if (!model) {
return false
@@ -3172,6 +3232,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen3-0\\.6b$': { min: 0, max: 30_720 },
'qwen-plus.*$': { min: 0, max: 38_912 },
'qwen-turbo.*$': { min: 0, max: 38_912 },
'qwen-flash.*$': { min: 0, max: 81_920 },
'qwen3-.*$': { min: 1024, max: 38_912 },
// Claude models
@@ -3239,5 +3300,10 @@ export const isGPT5SeriesModel = (model: Model) => {
return modelId.includes('gpt-5')
}
export const isOpenAIOpenWeightModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gpt-oss')
}
// zhipu 视觉推理模型用这组 special token 标记推理结果
export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const

View File

@@ -0,0 +1,32 @@
import {
BuiltinOcrProvider,
BuiltinOcrProviderId,
ImageOcrProvider,
OcrProviderCapability,
OcrTesseractProvider
} from '@renderer/types'
const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = {
id: 'tesseract',
name: 'Tesseract',
capabilities: {
image: true
},
config: {
langs: {
chi_sim: true,
chi_tra: true,
eng: true
}
}
} as const satisfies OcrTesseractProvider
export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
export const DEFAULT_OCR_PROVIDER = {
image: tesseract
} as const satisfies Record<OcrProviderCapability, BuiltinOcrProvider>

View File

@@ -1,12 +0,0 @@
import MacOSLogo from '@renderer/assets/images/providers/macos.svg'
export function getOcrProviderLogo(providerId: string) {
switch (providerId) {
case 'system':
return MacOSLogo
default:
return undefined
}
}
export const OCR_PROVIDER_CONFIG = {}

View File

@@ -166,7 +166,7 @@ export const SEARCH_SUMMARY_PROMPT = `
</knowledge>
\`
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
Rephrased question: \`
<websearch>
<question>
@@ -279,7 +279,7 @@ export const SEARCH_SUMMARY_PROMPT_WEB_ONLY = `
</websearch>
\`
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
Rephrased question: \`
<websearch>
<question>
@@ -374,7 +374,7 @@ export const SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY = `
</knowledge>
\`
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
Rephrased question: \`
<knowledge>
<rewrite>

View File

@@ -38,7 +38,6 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.png'
import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg'
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
@@ -594,7 +593,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
export const SYSTEM_PROVIDERS: SystemProvider[] = Object.values(SYSTEM_PROVIDERS_CONFIG)
const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
ph8: Ph8ProviderLogo,
'302ai': Ai302ProviderLogo,
openai: OpenAiProviderLogo,
@@ -649,7 +648,7 @@ const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
vertexai: VertexAIProviderLogo,
'new-api': NewAPIProviderLogo,
'aws-bedrock': AwsProviderLogo,
poe: PoeProviderLogo
poe: 'svg' // use svg icon component
} as const
export function getProviderLogo(providerId: string) {
@@ -1277,7 +1276,11 @@ export const isSupportStreamOptionsProvider = (provider: Provider) => {
)
}
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = ['ollama', 'lmstudio'] as const satisfies SystemProviderId[]
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [
'ollama',
'lmstudio',
'nvidia'
] as const satisfies SystemProviderId[]
/**
* 判断提供商是否支持使用 enable_thinking 参数来控制 Qwen3 等模型的思考。 Only for OpenAI Chat Completions API.

View File

@@ -66,7 +66,7 @@ db.version(6).stores({
// --- NEW VERSION 7 ---
db.version(7)
.stores({
// Re-declare all tables for the new version
// Redeclare all tables for the new version
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id', // Correct index for topics
settings: '&id, value',
@@ -79,7 +79,7 @@ db.version(7)
db.version(8)
.stores({
// Re-declare all tables for the new version
// Redeclare all tables for the new version
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id', // Correct index for topics
settings: '&id, value',
@@ -91,7 +91,7 @@ db.version(8)
.upgrade((tx) => upgradeToV8(tx))
db.version(9).stores({
// Re-declare all tables for the new version
// Redeclare all tables for the new version
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id', // Correct index for topics
settings: '&id, value',

View File

@@ -3,7 +3,8 @@ import {
getThinkModelType,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
MODEL_SUPPORTED_OPTIONS
MODEL_SUPPORTED_OPTIONS,
MODEL_SUPPORTED_REASONING_EFFORT
} from '@renderer/config/models'
import { db } from '@renderer/databases'
import { getDefaultTopic } from '@renderer/services/AssistantService'
@@ -24,9 +25,9 @@ import {
updateTopics
} from '@renderer/store/assistants'
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { TopicManager } from './useTopic'
@@ -84,6 +85,12 @@ export function useAssistant(id: string) {
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
const settingsRef = useRef(assistant?.settings)
useEffect(() => {
settingsRef.current = assistant.settings
}, [assistant?.settings])
const updateAssistantSettings = useCallback(
(settings: Partial<AssistantSettings>) => {
assistant?.id && dispatch(_updateAssistantSettings({ assistantId: assistant.id, settings }))
@@ -93,28 +100,46 @@ export function useAssistant(id: string) {
// 当model变化时同步reasoning effort为模型支持的合法值
useEffect(() => {
if (assistant?.settings) {
const settings = settingsRef.current
if (settings) {
const currentReasoningEffort = settings.reasoning_effort
if (isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) {
const currentReasoningEffort = assistant?.settings?.reasoning_effort
const supportedOptions = MODEL_SUPPORTED_OPTIONS[getThinkModelType(model)]
if (currentReasoningEffort && !supportedOptions.includes(currentReasoningEffort)) {
// 选项不支持时,回退到第一个支持的值
// 注意这里假设可用的options不会为空
const fallbackOption = supportedOptions[0]
const modelType = getThinkModelType(model)
const supportedOptions = MODEL_SUPPORTED_OPTIONS[modelType]
if (supportedOptions.every((option) => option !== currentReasoningEffort)) {
const cache = settings.reasoning_effort_cache
let fallbackOption: ThinkingOption
// 选项不支持时,首先尝试恢复到上次使用的值
if (cache && supportedOptions.includes(cache)) {
fallbackOption = cache
} else {
// 灵活回退到支持的值
// 注意这里假设可用的options不会为空
const enableThinking = currentReasoningEffort !== undefined
fallbackOption = enableThinking
? MODEL_SUPPORTED_REASONING_EFFORT[modelType][0]
: MODEL_SUPPORTED_OPTIONS[modelType][0]
}
updateAssistantSettings({
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
qwenThinkMode: fallbackOption === 'off'
reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
qwenThinkMode: fallbackOption === 'off' ? undefined : true
})
} else {
// 对于支持的选项, 不再更新 cache.
}
} else {
// 切换到非思考模型时保留cache
updateAssistantSettings({
reasoning_effort: undefined,
reasoning_effort_cache: currentReasoningEffort,
qwenThinkMode: undefined
})
}
}
}, [assistant?.settings, model, updateAssistantSettings])
}, [model, updateAssistantSettings])
return {
assistant: assistantWithModel,

View File

@@ -0,0 +1,43 @@
// import { loggerService } from '@logger'
import { useCallback, useState } from 'react'
// const logger = loggerService.withContext('useDrag')
export const useDrag = <T extends HTMLElement>(onDrop?: (e: React.DragEvent<T>) => Promise<void> | void) => {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
// 确保是离开当前元素,而不是进入子元素
// logger.debug('drag leave', { currentTarget: e.currentTarget, relatedTarget: e.relatedTarget })
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return
}
setIsDragging(false)
}, [])
const handleDrop = useCallback(
async (e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
await onDrop?.(e)
},
[onDrop]
)
return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
}

View File

@@ -0,0 +1,97 @@
import { FileMetadata } from '@renderer/types'
import { filterSupportedFiles } from '@renderer/utils'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
/** 支持选择的扩展名 */
extensions?: string[]
}
export const useFiles = (props?: Props) => {
const { t } = useTranslation()
const [files, setFiles] = useState<FileMetadata[]>([])
const [selecting, setSelecting] = useState<boolean>(false)
const extensions = useMemo(() => {
if (props?.extensions) {
return props.extensions
} else {
return ['*']
}
}, [props?.extensions])
/**
* 选择文件的回调函数
* @param multipleSelections - 是否允许多选文件,默认为 true
* @returns 返回选中的文件元数据数组
* @description
* 1. 打开系统文件选择对话框
* 2. 根据扩展名过滤文件
* 3. 更新内部文件状态
* 4. 当选择了不支持的文件类型时,会显示提示信息
*/
const onSelectFile = useCallback(
async ({ multipleSelections = true }: { multipleSelections?: boolean }): Promise<FileMetadata[]> => {
if (selecting) {
return []
}
const selectProps: Electron.OpenDialogOptions['properties'] = multipleSelections
? ['openFile', 'multiSelections']
: ['openFile']
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
const useAllFiles = extensions.length > 20
setSelecting(true)
const _files = await window.api.file.select({
properties: selectProps,
filters: [
{
name: 'Files',
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
}
]
})
setSelecting(false)
if (_files) {
if (!useAllFiles) {
setFiles([...files, ..._files])
return _files
}
const supportedFiles = await filterSupportedFiles(_files, extensions)
if (supportedFiles.length > 0) {
setFiles([...files, ...supportedFiles])
}
if (supportedFiles.length !== _files.length) {
window.message.info({
key: 'file_not_supported',
content: t('chat.input.file_not_supported_count', {
count: _files.length - supportedFiles.length
})
})
}
return supportedFiles
} else {
return []
}
},
[extensions, files, selecting, t]
)
const clearFiles = useCallback(() => {
setFiles([])
}, [])
return {
files,
selecting,
setFiles,
onSelectFile,
clearFiles
}
}

View File

@@ -0,0 +1,54 @@
import { loggerService } from '@logger'
import * as OcrService from '@renderer/services/ocr/OcrService'
import { useAppSelector } from '@renderer/store'
import { ImageFileMetadata, isImageFile, SupportedOcrFile } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useOcr')
export const useOcr = () => {
const { t } = useTranslation()
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
/**
* 对图片文件进行OCR识别
* @param image 图片文件元数据
* @returns OCR识别结果的Promise
* @throws OCR失败时抛出错误
*/
const ocrImage = async (image: ImageFileMetadata) => {
return OcrService.ocr(image, imageProvider)
}
/**
* 对支持的文件进行OCR识别.
* @param file 支持OCR的文件
* @returns OCR识别结果的Promise
* @throws 当文件类型不支持或OCR失败时抛出错误
*/
const ocr = async (file: SupportedOcrFile) => {
const key = uuid()
window.message.loading({ content: t('ocr.processing'), key, duration: 0 })
// await to keep show loading message
try {
if (isImageFile(file)) {
return await ocrImage(file)
} else {
// @ts-expect-error all types should be covered
throw new Error(t('ocr.file.not_supported', { type: file.type }))
}
} catch (e) {
logger.error('Failed to ocr.', e as Error)
window.message.error(t('ocr.error.unknown') + ': ' + formatErrorMessage(e))
throw e
} finally {
window.message.destroy(key)
}
}
return {
ocr
}
}

View File

@@ -0,0 +1,84 @@
import { loggerService } from '@logger'
import { BUILTIN_OCR_PROVIDERS_MAP } from '@renderer/config/ocr'
import { useAppSelector } from '@renderer/store'
import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr'
import { isBuiltinOcrProviderId, OcrProvider, OcrProviderConfig } from '@renderer/types'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
const logger = loggerService.withContext('useOcrProvider')
export const useOcrProviders = () => {
const providers = useAppSelector((state) => state.ocr.providers)
const dispatch = useDispatch()
const { t } = useTranslation()
/**
* 添加一个新的OCR服务提供者
* @param provider - OCR提供者对象包含id和其他配置信息
* @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误
*/
const addProvider = (provider: OcrProvider) => {
if (providers.some((p) => p.id === provider.id)) {
const msg = `Provider with id ${provider.id} already exists`
logger.error(msg)
window.message.error(t('ocr.error.provider.existing'))
throw new Error(msg)
}
dispatch(addOcrProvider(provider))
}
/**
* 移除一个OCR服务提供者
* @param id - 要移除的OCR提供者ID
* @throws {Error} 当尝试移除一个内置提供商时抛出错误
*/
const removeProvider = (id: string) => {
if (isBuiltinOcrProviderId(id)) {
const msg = `Cannot remove builtin provider ${id}`
logger.error(msg)
window.message.error(t('ocr.error.provider.cannot_remove_builtin'))
throw new Error(msg)
}
dispatch(removeOcrProvider(id))
}
return { providers, addProvider, removeProvider }
}
export const useOcrProvider = (id: string) => {
const { t } = useTranslation()
const dispatch = useDispatch()
const { providers, addProvider } = useOcrProviders()
let provider = providers.find((p) => p.id === id)
// safely fallback
if (!provider) {
logger.error(`Ocr Provider ${id} not found`)
window.message.error(t('ocr.error.provider.not_found'))
if (isBuiltinOcrProviderId(id)) {
try {
addProvider(BUILTIN_OCR_PROVIDERS_MAP[id])
} catch (e) {
logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`)
window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name }))
} finally {
provider = BUILTIN_OCR_PROVIDERS_MAP[id]
}
} else {
logger.warn(`Fallback to tesseract`)
window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' }))
provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract
}
}
const updateConfig = (update: Partial<OcrProviderConfig>) => {
dispatch(updateOcrProviderConfig({ id: provider.id, update }))
}
return {
provider,
updateConfig
}
}

View File

@@ -322,6 +322,7 @@
"expand": "Expand",
"file_error": "Error processing file",
"file_not_supported": "Model does not support this file type",
"file_not_supported_count": "{{count}} files are not supported",
"generate_image": "Generate image",
"generate_image_not_supported": "The model does not support generating images.",
"knowledge_base": "Knowledge Base",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "Enter new name",
"title": "Edit Name"
"title": "Edit Name",
"title_tip": "Tips: Double-click the topic name to rename it directly in place"
},
"export": {
"image": "Export as image",
@@ -745,6 +747,9 @@
"enabled": "Enabled",
"error": "error",
"expand": "Expand",
"file": {
"not_supported": "Unsupported file type {{type}}"
},
"footnote": "Reference content",
"footnotes": "References",
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
@@ -761,6 +766,7 @@
"open": "Open",
"paste": "Paste",
"preview": "Preview",
"proceed": "Proceed",
"prompt": "Prompt",
"provider": "Provider",
"reasoning_content": "Deep reasoning",
@@ -785,6 +791,7 @@
"success": "Success",
"swap": "Swap",
"topics": "Topics",
"upload_files": "Upload file",
"warning": "Warning",
"you": "You"
},
@@ -803,6 +810,13 @@
"backup": {
"file_format": "Backup file format error"
},
"boundary": {
"default": {
"devtools": "Open debug panel",
"message": "It seems that something went wrong...",
"reload": "Reload"
}
},
"chat": {
"chunk": {
"non_json": "Returned an invalid data format"
@@ -883,6 +897,9 @@
},
"history": {
"continue_chat": "Continue Chatting",
"error": {
"topic_not_found": "Topic not found"
},
"locate": {
"message": "Locate the message"
},
@@ -1529,7 +1546,10 @@
"rerank_model_not_support_provider": "Currently, the reranker model does not support this provider ({{provider}})",
"rerank_model_support_provider": "Currently, the reranker model only supports some providers ({{provider}})",
"rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add.",
"search": "Search models...",
"search": {
"placeholder": "Search models...",
"tooltip": "Search models"
},
"stream_output": "Stream output",
"type": {
"embedding": "Embedding",
@@ -1556,6 +1576,26 @@
},
"tip": "If the response is successful, then only messages exceeding 30 seconds will trigger a reminder"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "Cannot delete built-in provider",
"existing": "The provider already exists",
"not_found": "OCR provider does not exist",
"update_failed": "Failed to update configuration"
},
"unknown": "An error occurred during the OCR process"
},
"file": {
"not_supported": "Unsupported file type {{type}}"
},
"processing": "OCR processing...",
"warning": {
"provider": {
"fallback": "Reverted to {{name}}, which may cause issues"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "The time in minutes to keep the connection alive, default is 5 minutes.",
@@ -2677,7 +2717,8 @@
"title": "Auto Update"
},
"avatar": {
"reset": "Reset Avatar"
"builtin": "Builtin avatar",
"reset": "Reset avatar"
},
"backup": {
"button": "Backup",
@@ -2896,6 +2937,10 @@
"text": "Text",
"uri": "URI"
},
"search": {
"placeholder": "Search MCP servers...",
"tooltip": "Search MCP servers"
},
"searchNpx": "Search MCP",
"serverPlural": "servers",
"serverSingular": "server",
@@ -3298,6 +3343,36 @@
"check": "Check",
"check_all_keys": "Check All Keys",
"check_multiple_keys": "Check Multiple API Keys",
"cleanup": {
"button": {
"tooltip": "Clean up duplicate and missing providers"
},
"confirm": {
"content": "This will clean up duplicate providers and add missing system providers. Do you want to continue?",
"title": "Confirm Provider Cleanup"
},
"conflict": {
"apply_resolution": "Apply Selection",
"both_have_apikey": "{{provider}} has multiple providers with API keys configured",
"both_have_apikey_desc": "Multiple providers with API keys detected, please select which configuration to keep",
"description": "The following conflicts were detected and need manual handling:",
"different_apihost": "{{provider}} has providers with different API hosts",
"different_apihost_desc": "Providers with different API hosts detected, please select which configuration to keep",
"multiple_enabled": "{{provider}} has multiple enabled providers",
"multiple_enabled_desc": "Multiple enabled providers detected, please select which configuration to keep",
"proceed_question": "One configuration has been automatically selected to keep. Do you want to continue?",
"provider_conflict": "{{provider}} Configuration Conflict",
"resolution_desc": "Please select which configuration to keep for each conflicting provider:",
"resolution_title": "Resolve Provider Configuration Conflicts",
"title": "Provider Configuration Conflicts",
"unknown": "{{provider}} has unknown configuration conflicts",
"unknown_desc": "Unknown configuration conflicts detected"
},
"no_changes": "Provider configuration does not need cleanup",
"success": "Provider cleanup completed",
"success_with_conflicts": "Provider cleanup completed (conflicts automatically handled)",
"success_with_user_resolution": "Provider cleanup completed (conflicts resolved)"
},
"copilot": {
"auth_failed": "Github Copilot authentication failed.",
"auth_success": "GitHub Copilot authentication successful.",
@@ -3475,6 +3550,20 @@
},
"title": "Settings",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "The provider does not exist"
},
"tesseract": {
"langs": "Supported languages",
"temp_tooltip": "Currently only Chinese and English are supported"
},
"title": "Image"
},
"image_provider": "OCR service provider",
"title": "OCR service"
},
"preprocess": {
"provider": "Document Processing Provider",
"provider_placeholder": "Choose a document processing provider",
@@ -3715,14 +3804,27 @@
"exchange": {
"label": "Swap the source and target languages"
},
"files": {
"drag_text": "Drop here",
"error": {
"multiple": "Multiple file uploads are not allowed",
"too_large": "File too large",
"unknown": "Failed to read file content"
},
"reading": "Reading file content..."
},
"history": {
"clear": "Clear History",
"clear_description": "Clear history will delete all translation history, continue?",
"delete": "Delete",
"delete": "Delete translation history",
"empty": "No translation history",
"error": {
"delete": "Deletion failed",
"save": "Failed to save translation history"
},
"search": {
"placeholder": "Search translation history"
},
"title": "Translation History"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "展開",
"file_error": "ファイル処理エラー",
"file_not_supported": "モデルはこのファイルタイプをサポートしません",
"file_not_supported_count": "{{count}} 個のファイルはサポートされていません",
"generate_image": "画像を生成する",
"generate_image_not_supported": "モデルは画像の生成をサポートしていません。",
"knowledge_base": "ナレッジベース",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "新しい名前を入力",
"title": "名前を編集"
"title": "名前を編集",
"title_tip": "ヒント: トピック名をダブルクリックすると、直接その場で名前を変更できます"
},
"export": {
"image": "画像としてエクスポート",
@@ -745,6 +747,9 @@
"enabled": "有効",
"error": "エラー",
"expand": "展開",
"file": {
"not_supported": "サポートされていないファイルタイプ {{type}}"
},
"footnote": "引用内容",
"footnotes": "脚注",
"fullscreen": "全画面モードに入りました。F11キーで終了します",
@@ -785,6 +790,7 @@
"success": "成功",
"swap": "交換",
"topics": "トピック",
"upload_files": "ファイルをアップロードする",
"warning": "警告",
"you": "あなた"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "バックアップファイルの形式エラー"
},
"boundary": {
"default": {
"devtools": "デバッグパネルを開く",
"message": "何か問題が発生したようです...",
"reload": "再読み込み"
}
},
"chat": {
"chunk": {
"non_json": "無効なデータ形式が返されました"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "チャットを続ける",
"error": {
"topic_not_found": "トピックが見つかりません"
},
"locate": {
"message": "メッセージを探す"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。",
"rerank_model_support_provider": "現在の再順序付けモデルは、{{provider}} のみサポートしています",
"rerank_model_tooltip": "設定->モデルサービスに移動し、管理ボタンをクリックして追加します。",
"search": "モデルを検索...",
"search": {
"placeholder": "モデルを検索...",
"tooltip": "モデルを検索"
},
"stream_output": "ストリーム出力",
"type": {
"embedding": "埋め込み",
@@ -1556,6 +1575,26 @@
},
"tip": "応答が成功した場合、30秒を超えるメッセージのみに通知を行います"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
"existing": "プロバイダーはすでに存在します",
"not_found": "OCRプロバイダーが存在しません",
"update_failed": "更新構成に失敗しました"
},
"unknown": "OCR処理中にエラーが発生しました"
},
"file": {
"not_supported": "サポートされていないファイルタイプ {{type}}"
},
"processing": "OCR処理中...",
"warning": {
"provider": {
"fallback": "{{name}} に戻されました。これにより問題が発生する可能性があります。"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "モデルがメモリに保持される時間デフォルト5分",
@@ -2677,6 +2716,7 @@
"title": "自動更新"
},
"avatar": {
"builtin": "内蔵アバター",
"reset": "アバターをリセット"
},
"backup": {
@@ -2896,6 +2936,10 @@
"text": "テキスト",
"uri": "URI"
},
"search": {
"placeholder": "MCP サーバーを検索...",
"tooltip": "MCP サーバーを検索"
},
"searchNpx": "MCP を検索",
"serverPlural": "サーバー",
"serverSingular": "サーバー",
@@ -3475,6 +3519,20 @@
},
"title": "設定",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "該提供者は存在しません"
},
"tesseract": {
"langs": "サポートされている言語",
"temp_tooltip": "現在のところ、中国語と英語のみをサポートしています"
},
"title": "画像"
},
"image_provider": "OCRサービスプロバイダー",
"title": "OCRサービス"
},
"preprocess": {
"provider": "プレプロセスプロバイダー",
"provider_placeholder": "前処理プロバイダーを選択してください",
@@ -3715,14 +3773,27 @@
"exchange": {
"label": "入力言語と出力言語を入れ替える"
},
"files": {
"drag_text": "ここにドラッグ&ドロップしてください",
"error": {
"multiple": "複数のファイルのアップロードは許可されていません",
"too_large": "ファイルが大きすぎます",
"unknown": "ファイルの内容を読み取るのに失敗しました"
},
"reading": "ファイルの内容を読み込んでいます..."
},
"history": {
"clear": "履歴をクリア",
"clear_description": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?",
"delete": "削除",
"delete": "翻訳履歴を削除する",
"empty": "翻訳履歴がありません",
"error": {
"delete": "削除に失敗しました",
"save": "保存翻訳履歴に失敗しました"
},
"search": {
"placeholder": "翻訳履歴を検索する"
},
"title": "翻訳履歴"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "Развернуть",
"file_error": "Ошибка обработки файла",
"file_not_supported": "Модель не поддерживает этот тип файла",
"file_not_supported_count": "{{count}} файлов не поддерживаются",
"generate_image": "Сгенерировать изображение",
"generate_image_not_supported": "Модель не поддерживает генерацию изображений.",
"knowledge_base": "База знаний",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "Введите новый заголовок",
"title": "Редактировать заголовок"
"title": "Редактировать заголовок",
"title_tip": "Совет: двойной щелчок по названию темы позволяет переименовать её на месте"
},
"export": {
"image": "Экспорт как изображение",
@@ -745,6 +747,9 @@
"enabled": "Включено",
"error": "ошибка",
"expand": "Развернуть",
"file": {
"not_supported": "Неподдерживаемый тип файла {{type}}"
},
"footnote": "Цитируемый контент",
"footnotes": "Сноски",
"fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода",
@@ -785,6 +790,7 @@
"success": "Успешно",
"swap": "Поменять местами",
"topics": "Топики",
"upload_files": "Загрузить файл",
"warning": "Предупреждение",
"you": "Вы"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "Ошибка формата файла резервной копии"
},
"boundary": {
"default": {
"devtools": "Открыть панель отладки",
"message": "Похоже, возникла какая-то проблема...",
"reload": "Перезагрузить"
}
},
"chat": {
"chunk": {
"non_json": "Вернулся недопустимый формат данных"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "Продолжить чат",
"error": {
"topic_not_found": "Топик не найден"
},
"locate": {
"message": "Найти сообщение"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})",
"rerank_model_support_provider": "Текущая модель переупорядочивания поддерживается только некоторыми поставщиками ({{provider}})",
"rerank_model_tooltip": "В настройках -> Служба модели нажмите кнопку \"Управление\", чтобы добавить.",
"search": "Поиск моделей...",
"search": {
"placeholder": "Поиск моделей...",
"tooltip": "Поиск моделей"
},
"stream_output": "Потоковый вывод",
"type": {
"embedding": "Встраиваемые",
@@ -1556,6 +1575,26 @@
},
"tip": "Если ответ успешен, уведомление выдается только по сообщениям, превышающим 30 секунд"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
"existing": "Поставщик уже существует",
"not_found": "Поставщик OCR отсутствует",
"update_failed": "Обновление конфигурации не удалось"
},
"unknown": "Произошла ошибка в процессе распознавания текста"
},
"file": {
"not_supported": "Неподдерживаемый тип файла {{type}}"
},
"processing": "Обработка OCR...",
"warning": {
"provider": {
"fallback": "Возвращено к {{name}}, это может вызвать проблемы"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
@@ -2677,6 +2716,7 @@
"title": "Автоматическое обновление"
},
"avatar": {
"builtin": "Встроенный аватар",
"reset": "Сброс аватара"
},
"backup": {
@@ -2896,6 +2936,10 @@
"text": "Текст",
"uri": "URI"
},
"search": {
"placeholder": "Найти MCP серверы...",
"tooltip": "Найти MCP серверы"
},
"searchNpx": "Найти MCP",
"serverPlural": "серверы",
"serverSingular": "сервер",
@@ -3475,6 +3519,20 @@
},
"title": "Настройки",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "Поставщик не существует"
},
"tesseract": {
"langs": "Поддерживаемые языки",
"temp_tooltip": "На данный момент поддерживаются только китайский и английский языки"
},
"title": "Изображение"
},
"image_provider": "Поставщик услуг OCR",
"title": "OCR-сервис"
},
"preprocess": {
"provider": "Поставщик обработки документов",
"provider_placeholder": "Выберите поставщика услуг обработки документов",
@@ -3715,14 +3773,27 @@
"exchange": {
"label": "Поменяйте исходный и целевой языки местами"
},
"files": {
"drag_text": "Перетащите сюда",
"error": {
"multiple": "Не разрешается загружать несколько файлов",
"too_large": "Файл слишком большой",
"unknown": "Ошибка при чтении содержимого файла"
},
"reading": "Чтение содержимого файла..."
},
"history": {
"clear": "Очистить историю",
"clear_description": "Очистка истории удалит все записи переводов. Продолжить?",
"delete": "Удалить",
"delete": "Удалить историю переводов",
"empty": "История переводов отсутствует",
"error": {
"delete": "Удаление не удалось",
"save": "Не удалось сохранить историю переводов"
},
"search": {
"placeholder": "Поиск истории переводов"
},
"title": "История переводов"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "展开",
"file_error": "文件处理出错",
"file_not_supported": "模型不支持此文件类型",
"file_not_supported_count": "{{count}} 个文件不被支持",
"generate_image": "生成图片",
"generate_image_not_supported": "模型不支持生成图片",
"knowledge_base": "知识库",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "输入新名称",
"title": "编辑话题名"
"title": "编辑话题名",
"title_tip": "提示: 双击话题名可以直接就地重命名"
},
"export": {
"image": "导出为图片",
@@ -745,6 +747,9 @@
"enabled": "已启用",
"error": "错误",
"expand": "展开",
"file": {
"not_supported": "不支持的文件类型 {{type}}"
},
"footnote": "引用内容",
"footnotes": "引用内容",
"fullscreen": "已进入全屏模式,按 F11 退出",
@@ -761,6 +766,7 @@
"open": "打开",
"paste": "粘贴",
"preview": "预览",
"proceed": "继续",
"prompt": "提示词",
"provider": "提供商",
"reasoning_content": "已深度思考",
@@ -785,6 +791,7 @@
"success": "成功",
"swap": "交换",
"topics": "话题",
"upload_files": "上传文件",
"warning": "警告",
"you": "用户"
},
@@ -803,6 +810,13 @@
"backup": {
"file_format": "备份文件格式错误"
},
"boundary": {
"default": {
"devtools": "打开调试面板",
"message": "似乎出现了一些问题...",
"reload": "重新加载"
}
},
"chat": {
"chunk": {
"non_json": "返回了无效的数据格式"
@@ -883,6 +897,9 @@
},
"history": {
"continue_chat": "继续聊天",
"error": {
"topic_not_found": "话题不存在"
},
"locate": {
"message": "定位到消息"
},
@@ -1529,7 +1546,10 @@
"rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})",
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
"rerank_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加",
"search": "搜索模型...",
"search": {
"placeholder": "搜索模型...",
"tooltip": "搜索模型"
},
"stream_output": "流式输出",
"type": {
"embedding": "嵌入",
@@ -1556,6 +1576,26 @@
},
"tip": "如果响应成功则只针对超过30秒的消息进行提醒"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "不能删除内置提供商",
"existing": "提供商已存在",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失败"
},
"unknown": "OCR 过程发生错误"
},
"file": {
"not_supported": "不支持的文件类型 {{type}}"
},
"processing": "OCR 处理中...",
"warning": {
"provider": {
"fallback": "已回退到 {{name}},这可能导致问题"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "对话后模型在内存中保持的时间默认5 分钟)",
@@ -2677,6 +2717,7 @@
"title": "自动更新"
},
"avatar": {
"builtin": "内置头像",
"reset": "重置头像"
},
"backup": {
@@ -2896,6 +2937,10 @@
"text": "文本",
"uri": "URI"
},
"search": {
"placeholder": "搜索 MCP 服务器...",
"tooltip": "搜索 MCP 服务器"
},
"searchNpx": "搜索 MCP",
"serverPlural": "服务器",
"serverSingular": "服务器",
@@ -3298,6 +3343,36 @@
"check": "检测",
"check_all_keys": "检测所有密钥",
"check_multiple_keys": "检测多个 API 密钥",
"cleanup": {
"button": {
"tooltip": "清理重复和缺失的提供商"
},
"confirm": {
"content": "这将清理重复的提供商并添加缺失的系统提供商,是否继续?",
"title": "确认清理提供商"
},
"conflict": {
"apply_resolution": "应用选择",
"both_have_apikey": "{{provider}} 存在多个配置了 API 密钥的提供商",
"both_have_apikey_desc": "检测到多个配置了 API 密钥的提供商,请选择要保留的配置",
"description": "检测到以下冲突需要手动处理:",
"different_apihost": "{{provider}} 存在不同 API 地址的提供商配置",
"different_apihost_desc": "检测到不同 API 地址的提供商配置,请选择要保留的配置",
"multiple_enabled": "{{provider}} 存在多个已启用的提供商",
"multiple_enabled_desc": "检测到多个已启用的提供商,请选择要保留的配置",
"proceed_question": "已自动选择一个配置保留,是否继续?",
"provider_conflict": "{{provider}} 配置冲突",
"resolution_desc": "请为每个冲突的提供商选择要保留的配置:",
"resolution_title": "解决提供商配置冲突",
"title": "提供商配置冲突",
"unknown": "{{provider}} 存在未知配置冲突",
"unknown_desc": "检测到未知配置冲突"
},
"no_changes": "提供商配置无需清理",
"success": "提供商清理完成",
"success_with_conflicts": "提供商清理完成(存在冲突已自动处理)",
"success_with_user_resolution": "提供商清理完成(冲突已解决)"
},
"copilot": {
"auth_failed": "Github Copilot 认证失败",
"auth_success": "Github Copilot 认证成功",
@@ -3475,6 +3550,20 @@
},
"title": "设置",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "该提供商不存在"
},
"tesseract": {
"langs": "支持的语言",
"temp_tooltip": "目前暂时只支持中文和英文"
},
"title": "图片"
},
"image_provider": "OCR 服务提供商",
"title": "OCR 服务"
},
"preprocess": {
"provider": "文档处理服务商",
"provider_placeholder": "选择一个文档处理服务商",
@@ -3715,14 +3804,27 @@
"exchange": {
"label": "交换源语言与目标语言"
},
"files": {
"drag_text": "拖放到此处",
"error": {
"multiple": "不允许上传多个文件",
"too_large": "文件过大",
"unknown": "读取文件内容失败"
},
"reading": "读取文件内容中..."
},
"history": {
"clear": "清空历史",
"clear_description": "清空历史将删除所有翻译历史记录,是否继续?",
"delete": "删除",
"delete": "删除翻译历史",
"empty": "暂无翻译历史",
"error": {
"delete": "删除失败",
"save": "保存翻译历史失败"
},
"search": {
"placeholder": "搜索翻译历史"
},
"title": "翻译历史"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "展開",
"file_error": "檔案處理錯誤",
"file_not_supported": "模型不支援此檔案類型",
"file_not_supported_count": "{{count}} 個檔案不被支援",
"generate_image": "生成圖片",
"generate_image_not_supported": "模型不支援生成圖片",
"knowledge_base": "知識庫",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "輸入新名稱",
"title": "編輯名稱"
"title": "編輯名稱",
"title_tip": "提示:雙擊話題名可以直接就地重新命名"
},
"export": {
"image": "匯出為圖片",
@@ -745,6 +747,9 @@
"enabled": "已啟用",
"error": "錯誤",
"expand": "展開",
"file": {
"not_supported": "不支持的文件類型 {{type}}"
},
"footnote": "引用內容",
"footnotes": "引用",
"fullscreen": "已進入全螢幕模式,按 F11 結束",
@@ -785,6 +790,7 @@
"success": "成功",
"swap": "交換",
"topics": "話題",
"upload_files": "上傳檔案",
"warning": "警告",
"you": "您"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "備份檔案格式錯誤"
},
"boundary": {
"default": {
"devtools": "打開除錯面板",
"message": "似乎出現了一些問題...",
"reload": "重新載入"
}
},
"chat": {
"chunk": {
"non_json": "返回了無效的資料格式"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "繼續聊天",
"error": {
"topic_not_found": "話題不存在"
},
"locate": {
"message": "定位到訊息"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}}",
"rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})",
"rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加",
"search": "搜尋模型...",
"search": {
"placeholder": "搜尋模型...",
"tooltip": "搜尋模型"
},
"stream_output": "串流輸出",
"type": {
"embedding": "嵌入",
@@ -1556,6 +1575,26 @@
},
"tip": "如果回應成功則只針對超過30秒的訊息發出提醒"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "不能刪除內建提供者",
"existing": "提供商已存在",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失敗"
},
"unknown": "OCR過程發生錯誤"
},
"file": {
"not_supported": "不支持的文件類型 {{type}}"
},
"processing": "OCR 處理中...",
"warning": {
"provider": {
"fallback": "已回退到 {{name}},這可能導致問題"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
@@ -2677,6 +2716,7 @@
"title": "自動更新"
},
"avatar": {
"builtin": "內置頭像",
"reset": "重設頭像"
},
"backup": {
@@ -2896,6 +2936,10 @@
"text": "文字",
"uri": "URI"
},
"search": {
"placeholder": "搜索 MCP 伺服器...",
"tooltip": "搜索 MCP 伺服器"
},
"searchNpx": "搜索 MCP",
"serverPlural": "伺服器",
"serverSingular": "伺服器",
@@ -3475,6 +3519,20 @@
},
"title": "設定",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "該提供商不存在"
},
"tesseract": {
"langs": "支援的語言",
"temp_tooltip": "目前暫時只支援中文和英文"
},
"title": "圖片"
},
"image_provider": "OCR 服務提供商",
"title": "OCR 服務"
},
"preprocess": {
"provider": "文件處理供應商",
"provider_placeholder": "選擇一個文件處理供應商",
@@ -3715,14 +3773,27 @@
"exchange": {
"label": "交換源語言與目標語言"
},
"files": {
"drag_text": "拖放到此处",
"error": {
"multiple": "不允许上传多个文件",
"too_large": "文件過大",
"unknown": "读取文件内容失败"
},
"reading": "讀取檔案內容中..."
},
"history": {
"clear": "清空歷史",
"clear_description": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?",
"delete": "刪除",
"delete": "刪除翻譯歷史",
"empty": "翻譯歷史為空",
"error": {
"delete": "删除失败",
"save": "保存翻譯歷史失敗"
},
"search": {
"placeholder": "搜索翻譯歷史"
},
"title": "翻譯歷史"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "Επεκτάση",
"file_error": "Σφάλμα κατά την επεξεργασία του αρχείου",
"file_not_supported": "Το μοντέλο δεν υποστηρίζει αυτό το είδος αρχείων",
"file_not_supported_count": "{{count}} αρχεία δεν υποστηρίζονται",
"generate_image": "Δημιουργία εικόνας",
"generate_image_not_supported": "Το μοντέλο δεν υποστηρίζει τη δημιουργία εικόνων",
"knowledge_base": "Βάση γνώσεων",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "Εισαγάγετε το νέο όνομα",
"title": "Επεξεργασία ονόματος θέματος"
"title": "Επεξεργασία ονόματος θέματος",
"title_tip": "Συμβουλές: Διπλό κλικ στο όνομα του θέματος για να το μετονομάσετε απευθείας"
},
"export": {
"image": "Εξαγωγή ως εικόνα",
@@ -745,6 +747,9 @@
"enabled": "Ενεργοποιημένο",
"error": "σφάλμα",
"expand": "Επεκτάση",
"file": {
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
},
"footnote": "Παραπομπή",
"footnotes": "Παραπομπές",
"fullscreen": "Εισήχθη σε πλήρη οθόνη, πατήστε F11 για να έξω",
@@ -785,6 +790,7 @@
"success": "Επιτυχία",
"swap": "Εναλλαγή",
"topics": "Θέματα",
"upload_files": "Ανέβασμα αρχείου",
"warning": "Προσοχή",
"you": "Εσείς"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "Λάθος μορφή αρχείου που επιστρέφεται"
},
"boundary": {
"default": {
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
"reload": "Επαναφόρτωση"
}
},
"chat": {
"chunk": {
"non_json": "Επέστρεψε μη έγκυρη μορφή δεδομένων"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "Συνεχίστε το συνομιλημένο",
"error": {
"topic_not_found": "Το θέμα δεν υπάρχει"
},
"locate": {
"message": "Εφαρμογή στο μήνυμα"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "Ο επαναξιολογητικός μοντέλος δεν υποστηρίζει αυτόν τον πάροχο ({{provider}})",
"rerank_model_support_provider": "Σημειώστε ότι το μοντέλο αναδιάταξης υποστηρίζεται από μερικούς παρόχους ({{provider}})",
"rerank_model_tooltip": "Κάντε κλικ στο κουμπί Διαχείριση στο παράθυρο Ρυθμίσεις -> Υπηρεσία Μοντέλων",
"search": "Αναζήτηση μοντέλου...",
"search": {
"placeholder": "Αναζήτηση μοντέλου...",
"tooltip": "Αναζήτηση μοντέλου"
},
"stream_output": "Διαρκής Εξόδος",
"type": {
"embedding": "ενσωμάτωση",
@@ -1556,6 +1575,26 @@
},
"tip": "Εάν η απάντηση είναι επιτυχής, η ειδοποίηση εμφανίζεται μόνο για μηνύματα που υπερβαίνουν τα 30 δευτερόλεπτα"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
"not_found": "Ο πάροχος OCR δεν υπάρχει",
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
},
"unknown": "Η διαδικασία OCR εμφάνισε σφάλμα"
},
"file": {
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
},
"processing": "Η επεξεργασία OCR βρίσκεται σε εξέλιξη...",
"warning": {
"provider": {
"fallback": "Επαναφέρθηκε στο {{name}}, το οποίο μπορεί να προκαλέσει προβλήματα"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "Χρόνος που ο μοντέλος διατηρείται στη μνήμη μετά τη συζήτηση (προεπιλογή: 5 λεπτά)",
@@ -2896,6 +2935,10 @@
"text": "Κείμενο",
"uri": "URI"
},
"search": {
"placeholder": "Αναζήτηση MCP διακομιστών...",
"tooltip": "Αναζήτηση MCP διακομιστών"
},
"searchNpx": "Αναζήτηση MCP",
"serverPlural": "Διακομιστές",
"serverSingular": "Διακομιστής",
@@ -3475,6 +3518,20 @@
},
"title": "Ρυθμίσεις",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "Ο πάροχος δεν υπάρχει"
},
"tesseract": {
"langs": "Υποστηριζόμενες γλώσσες",
"temp_tooltip": "Προς το παρόν υποστηρίζονται μόνο η κινεζική και η αγγλική γλώσσα"
},
"title": "Εικόνα"
},
"image_provider": "Πάροχοι υπηρεσιών OCR",
"title": "Υπηρεσία OCR"
},
"preprocess": {
"provider": "πάροχος υπηρεσιών προεπεξεργασίας εγγράφων",
"provider_placeholder": "Επιλέξτε έναν πάροχο υπηρεσιών προεπεξεργασίας εγγράφων",
@@ -3715,14 +3772,27 @@
"exchange": {
"label": "Ανταλλαγή γλώσσας πηγής και γλώσσας προορισμού"
},
"files": {
"drag_text": "Σύρετε και αφήστε εδώ",
"error": {
"multiple": "Δεν επιτρέπεται η μεταφόρτωση πολλαπλών αρχείων",
"too_large": "Το αρχείο είναι πολύ μεγάλο",
"unknown": "Αποτυχία ανάγνωσης του περιεχομένου του αρχείου"
},
"reading": "Διαβάζοντας το περιεχόμενο του αρχείου..."
},
"history": {
"clear": "Καθαρισμός ιστορικού",
"clear_description": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;",
"delete": "Διαγραφή",
"delete": "Διαγραφή του ιστορικού μετάφρασης",
"empty": "δεν υπάρχουν απομνημονεύματα μετάφρασης",
"error": {
"delete": "Αποτυχία διαγραφής",
"save": "Αποτυχία αποθήκευσης του ιστορικού μεταφράσεων"
},
"search": {
"placeholder": "Αναζήτηση ιστορικού μεταφράσεων"
},
"title": "Ιστορικό μετάφρασης"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "Expandir",
"file_error": "Error al procesar el archivo",
"file_not_supported": "El modelo no admite este tipo de archivo",
"file_not_supported_count": "{{count}} archivos no soportados",
"generate_image": "Generar imagen",
"generate_image_not_supported": "El modelo no soporta la generación de imágenes",
"knowledge_base": "Base de conocimientos",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "Introduce nuevo nombre",
"title": "Editar nombre del tema"
"title": "Editar nombre del tema",
"title_tip": "Consejos: hacer doble clic en el nombre del tema permite cambiar el nombre directamente en el lugar"
},
"export": {
"image": "Exportar como imagen",
@@ -745,6 +747,9 @@
"enabled": "Activado",
"error": "error",
"expand": "Expandir",
"file": {
"not_supported": "Tipo de archivo no compatible {{type}}"
},
"footnote": "Nota al pie",
"footnotes": "Notas al pie",
"fullscreen": "En modo pantalla completa, presione F11 para salir",
@@ -785,6 +790,7 @@
"success": "Éxito",
"swap": "Intercambiar",
"topics": "Temas",
"upload_files": "Subir archivo",
"warning": "Advertencia",
"you": "Usuario"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "Formato de archivo de copia de seguridad incorrecto"
},
"boundary": {
"default": {
"devtools": "Abrir el panel de depuración",
"message": "Parece que ha surgido un problema...",
"reload": "Recargar"
}
},
"chat": {
"chunk": {
"non_json": "Devuelve un formato de datos no válido"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "Continuar chat",
"error": {
"topic_not_found": "El tema no existe"
},
"locate": {
"message": "Localizar mensaje"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "Actualmente, el modelo de reordenamiento no admite este proveedor ({{provider}})",
"rerank_model_support_provider": "Actualmente, el modelo de reordenamiento solo es compatible con algunos proveedores ({{provider}})",
"rerank_model_tooltip": "Haga clic en el botón Administrar en Configuración->Servicio de modelos para agregar",
"search": "Buscar modelo...",
"search": {
"placeholder": "Buscar modelo...",
"tooltip": "Buscar modelo"
},
"stream_output": "Salida en flujo",
"type": {
"embedding": "Incrustación",
@@ -1556,6 +1575,26 @@
},
"tip": "Si la respuesta es exitosa, solo se enviará un recordatorio para mensajes que excedan los 30 segundos"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
"existing": "El proveedor ya existe",
"not_found": "El proveedor de OCR no existe",
"update_failed": "Actualización de la configuración fallida"
},
"unknown": "El proceso OCR ha fallado"
},
"file": {
"not_supported": "Tipo de archivo no compatible {{type}}"
},
"processing": "Procesando OCR...",
"warning": {
"provider": {
"fallback": "Se ha revertido a {{name}}, lo que podría causar problemas"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "Tiempo que el modelo permanece en memoria después de la conversación (por defecto: 5 minutos)",
@@ -2896,6 +2935,10 @@
"text": "Texto",
"uri": "URI"
},
"search": {
"placeholder": "Buscar servidores MCP...",
"tooltip": "Buscar servidores MCP"
},
"searchNpx": "Buscar MCP",
"serverPlural": "Servidores",
"serverSingular": "Servidor",
@@ -3475,6 +3518,20 @@
},
"title": "Configuración",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "El proveedor no existe"
},
"tesseract": {
"langs": "Idiomas compatibles",
"temp_tooltip": "Actualmente solo se admiten chino e inglés."
},
"title": "Imagen"
},
"image_provider": "Proveedor de servicios OCR",
"title": "Servicio OCR"
},
"preprocess": {
"provider": "Proveedor de servicios de preprocesamiento de documentos",
"provider_placeholder": "Seleccionar un proveedor de servicios de preprocesamiento de documentos",
@@ -3715,14 +3772,27 @@
"exchange": {
"label": "Intercambiar el idioma de origen y el idioma de destino"
},
"files": {
"drag_text": "Arrastrar y soltar aquí",
"error": {
"multiple": "No se permite cargar varios archivos",
"too_large": "El archivo es demasiado grande",
"unknown": "Error al leer el contenido del archivo"
},
"reading": "Leyendo el contenido del archivo..."
},
"history": {
"clear": "Borrar historial",
"clear_description": "Borrar el historial eliminará todos los registros de traducciones, ¿desea continuar?",
"delete": "Eliminar",
"delete": "Eliminar historial de traducción",
"empty": "Sin historial de traducciones por el momento",
"error": {
"delete": "Eliminación fallida",
"save": "Error al guardar el historial de traducciones"
},
"search": {
"placeholder": "Historial de búsqueda de traducción"
},
"title": "Historial de traducciones"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "Développer",
"file_error": "Erreur lors du traitement du fichier",
"file_not_supported": "Le modèle ne prend pas en charge ce type de fichier",
"file_not_supported_count": "{{count}} fichiers non pris en charge",
"generate_image": "Générer une image",
"generate_image_not_supported": "Le modèle ne supporte pas la génération d'images",
"knowledge_base": "Base de connaissances",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "Entrez un nouveau nom",
"title": "Modifier le nom du sujet"
"title": "Modifier le nom du sujet",
"title_tip": "Conseil : double-cliquez sur le nom du sujet pour le renommer directement sur place"
},
"export": {
"image": "Exporter sous forme d'image",
@@ -745,6 +747,9 @@
"enabled": "Activé",
"error": "erreur",
"expand": "Développer",
"file": {
"not_supported": "Type de fichier non pris en charge {{type}}"
},
"footnote": "Note de bas de page",
"footnotes": "Notes de bas de page",
"fullscreen": "Mode plein écran, appuyez sur F11 pour quitter",
@@ -785,6 +790,7 @@
"success": "Succès",
"swap": "Échanger",
"topics": "Sujets",
"upload_files": "Uploader des fichiers",
"warning": "Avertissement",
"you": "Vous"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "Le format du fichier de sauvegarde est incorrect"
},
"boundary": {
"default": {
"devtools": "Ouvrir le panneau de débogage",
"message": "Il semble que quelques problèmes soient survenus...",
"reload": "Recharger"
}
},
"chat": {
"chunk": {
"non_json": "a renvoyé un format de données invalide"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "Continuer la conversation",
"error": {
"topic_not_found": "Le sujet n'existe pas"
},
"locate": {
"message": "Localiser le message"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "Le modèle de réordonnancement ne prend pas en charge ce fournisseur ({{provider}}) pour le moment",
"rerank_model_support_provider": "Le modèle de réordonnancement ne prend actuellement en charge que certains fournisseurs ({{provider}})",
"rerank_model_tooltip": "Cliquez sur le bouton Gérer dans Paramètres -> Services de modèles pour ajouter",
"search": "Rechercher un modèle...",
"search": {
"placeholder": "Rechercher un modèle...",
"tooltip": "Rechercher un modèle"
},
"stream_output": "Sortie en flux",
"type": {
"embedding": "Incorporation",
@@ -1556,6 +1575,26 @@
},
"tip": "Si la réponse est réussie, un rappel est envoyé uniquement pour les messages dépassant 30 secondes"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
"existing": "Le fournisseur existe déjà",
"not_found": "Le fournisseur OCR n'existe pas",
"update_failed": "Échec de la mise à jour de la configuration"
},
"unknown": "Une erreur s'est produite lors du processus OCR"
},
"file": {
"not_supported": "Type de fichier non pris en charge {{type}}"
},
"processing": "Traitement OCR en cours...",
"warning": {
"provider": {
"fallback": "Revenu à {{name}}, ce qui pourrait entraîner des problèmes"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "Le temps pendant lequel le modèle reste en mémoire après la conversation (par défaut : 5 minutes)",
@@ -2896,6 +2935,10 @@
"text": "Текст",
"uri": "URI"
},
"search": {
"placeholder": "Rechercher des serveurs MCP...",
"tooltip": "Rechercher des serveurs MCP"
},
"searchNpx": "Поиск MCP",
"serverPlural": "Serveurs",
"serverSingular": "Serveur",
@@ -3475,6 +3518,20 @@
},
"title": "Paramètres",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "Ce fournisseur n'existe pas"
},
"tesseract": {
"langs": "Langues prises en charge",
"temp_tooltip": "Pour le moment, seuls le chinois et l'anglais sont pris en charge."
},
"title": "Image"
},
"image_provider": "Fournisseur de service OCR",
"title": "Service OCR"
},
"preprocess": {
"provider": "fournisseur de services de prétraitement de documents",
"provider_placeholder": "Choisissez un prestataire de traitement de documents",
@@ -3715,14 +3772,27 @@
"exchange": {
"label": "Échanger la langue source et la langue cible"
},
"files": {
"drag_text": "Glisser-déposer ici",
"error": {
"multiple": "Impossible de téléverser plusieurs fichiers",
"too_large": "Fichier trop volumineux",
"unknown": "Échec de la lecture du contenu du fichier"
},
"reading": "Lecture du contenu du fichier en cours..."
},
"history": {
"clear": "Effacer l'historique",
"clear_description": "L'effacement de l'historique supprimera toutes les entrées d'historique de traduction, voulez-vous continuer ?",
"delete": "Supprimer",
"delete": "Supprimer l'historique des traductions",
"empty": "Aucun historique de traduction pour le moment",
"error": {
"delete": "Échec de la suppression",
"save": "Échec de la sauvegarde de l'historique des traductions"
},
"search": {
"placeholder": "Rechercher l'historique des traductions"
},
"title": "Historique des traductions"
},
"input": {

View File

@@ -322,6 +322,7 @@
"expand": "Expandir",
"file_error": "Erro ao processar o arquivo",
"file_not_supported": "O modelo não suporta este tipo de arquivo",
"file_not_supported_count": "{{count}} arquivos não suportados",
"generate_image": "Gerar imagem",
"generate_image_not_supported": "Modelo não suporta geração de imagem",
"knowledge_base": "Base de conhecimento",
@@ -583,7 +584,8 @@
},
"edit": {
"placeholder": "Digite novo nome",
"title": "Editar nome do tópico"
"title": "Editar nome do tópico",
"title_tip": "Dicas: Clique duas vezes no nome do tópico para renomeá-lo diretamente no local"
},
"export": {
"image": "Exportar como imagem",
@@ -745,6 +747,9 @@
"enabled": "Ativado",
"error": "错误",
"expand": "Expandir",
"file": {
"not_supported": "Tipo de arquivo não suportado {{type}}"
},
"footnote": "Nota de rodapé",
"footnotes": "Notas de rodapé",
"fullscreen": "Entrou no modo de tela cheia, pressione F11 para sair",
@@ -785,6 +790,7 @@
"success": "Sucesso",
"swap": "Trocar",
"topics": "Tópicos",
"upload_files": "Carregar arquivo",
"warning": "Aviso",
"you": "Você"
},
@@ -803,6 +809,13 @@
"backup": {
"file_format": "Formato do arquivo de backup está incorreto"
},
"boundary": {
"default": {
"devtools": "Abrir o painel de depuração",
"message": "Parece que ocorreu um problema...",
"reload": "Recarregar"
}
},
"chat": {
"chunk": {
"non_json": "Devolveu um formato de dados inválido"
@@ -883,6 +896,9 @@
},
"history": {
"continue_chat": "Continuar conversando",
"error": {
"topic_not_found": "Tópico inexistente"
},
"locate": {
"message": "Localizar mensagem"
},
@@ -1529,7 +1545,10 @@
"rerank_model_not_support_provider": "Atualmente o modelo de reclassificação não suporta este provedor ({{provider}})",
"rerank_model_support_provider": "O modelo de reclassificação atualmente suporta apenas alguns provedores ({{provider}})",
"rerank_model_tooltip": "Clique no botão Gerenciar em Configurações -> Serviço de modelos para adicionar",
"search": "Procurar modelo...",
"search": {
"placeholder": "Procurar modelo...",
"tooltip": "Procurar modelo"
},
"stream_output": "Saída em fluxo",
"type": {
"embedding": "inserção",
@@ -1556,6 +1575,26 @@
},
"tip": "Se a resposta for bem-sucedida, lembrete apenas para mensagens que excedam 30 segundos"
},
"ocr": {
"error": {
"provider": {
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
"existing": "O provedor já existe",
"not_found": "O provedor OCR não existe",
"update_failed": "Falha ao atualizar a configuração"
},
"unknown": "O processo OCR apresentou um erro"
},
"file": {
"not_supported": "Tipo de arquivo não suportado {{type}}"
},
"processing": "Processamento OCR em andamento...",
"warning": {
"provider": {
"fallback": "Revertido para {{name}}, o que pode causar problemas"
}
}
},
"ollama": {
"keep_alive_time": {
"description": "Tempo que o modelo permanece na memória após a conversa (padrão: 5 minutos)",
@@ -2896,6 +2935,10 @@
"text": "Texto",
"uri": "URI"
},
"search": {
"placeholder": "Buscar servidores MCP...",
"tooltip": "Buscar servidores MCP"
},
"searchNpx": "Buscar MCP",
"serverPlural": "Servidores",
"serverSingular": "Servidor",
@@ -3475,6 +3518,20 @@
},
"title": "Configurações",
"tool": {
"ocr": {
"image": {
"error": {
"provider_not_found": "O provedor não existe"
},
"tesseract": {
"langs": "Idiomas suportados",
"temp_tooltip": "No momento, apenas chinês e inglês são suportados."
},
"title": "Imagem"
},
"image_provider": "Provedor de serviços OCR",
"title": "Serviço OCR"
},
"preprocess": {
"provider": "prestador de serviços de pré-processamento de documentos",
"provider_placeholder": "Escolha um fornecedor de pré-processamento de documentos",
@@ -3715,14 +3772,27 @@
"exchange": {
"label": "Trocar idioma de origem e idioma de destino"
},
"files": {
"drag_text": "Arraste e solte aqui",
"error": {
"multiple": "Não é permitido fazer upload de vários arquivos",
"too_large": "Arquivo muito grande",
"unknown": "Falha ao ler o conteúdo do arquivo"
},
"reading": "Lendo o conteúdo do arquivo..."
},
"history": {
"clear": "Limpar Histórico",
"clear_description": "Limpar histórico irá deletar todos os registros de tradução. Deseja continuar?",
"delete": "Excluir",
"delete": "Apagar histórico de traduções",
"empty": "Nenhum histórico de tradução disponível",
"error": {
"delete": "Falha ao excluir",
"save": "Falha ao guardar o histórico de traduções"
},
"search": {
"placeholder": "Pesquisar histórico de tradução"
},
"title": "Histórico de Tradução"
},
"input": {

View File

@@ -11,22 +11,19 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled } from '@renderer/store/mcp'
import { Model } from '@renderer/types'
import { codeTools } from '@shared/config/constant'
import { Alert, Button, Checkbox, Input, Select, Space } from 'antd'
import { Download, Terminal, X } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// CLI 工具选项
const CLI_TOOLS = [
{ value: codeTools.qwenCode, label: 'Qwen Code' },
{ value: codeTools.claudeCode, label: 'Claude Code' },
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' }
]
const SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
import {
CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS,
CLI_TOOL_PROVIDER_MAP,
CLI_TOOLS,
generateToolEnvironment,
parseEnvironmentVariables
} from '.'
const logger = loggerService.withContext('CodeToolsPage')
@@ -51,25 +48,17 @@ const CodeToolsPage: FC = () => {
} = useCodeTools()
const { setTimeoutTimer } = useTimer()
// 状态管理
const [isLaunching, setIsLaunching] = useState(false)
const [isInstallingBun, setIsInstallingBun] = useState(false)
const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false)
const handleCliToolChange = (value: codeTools) => setCliTool(value)
const openAiCompatibleProviders = providers.filter((p) => p.type.includes('openai'))
const openAiProviders = providers.filter((p) => p.id === 'openai')
const geminiProviders = providers.filter((p) => p.type === 'gemini' || SUPPORTED_PROVIDERS.includes(p.id))
const claudeProviders = providers.filter((p) => p.type === 'anthropic' || SUPPORTED_PROVIDERS.includes(p.id))
const modelPredicate = useCallback(
(m: Model) => {
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
return false
}
if (selectedCliTool === 'claude-code') {
return m.id.includes('claude')
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === 'gemini-cli') {
return m.id.includes('gemini')
@@ -80,20 +69,9 @@ const CodeToolsPage: FC = () => {
)
const availableProviders = useMemo(() => {
if (selectedCliTool === codeTools.claudeCode) {
return claudeProviders
}
if (selectedCliTool === codeTools.geminiCli) {
return geminiProviders
}
if (selectedCliTool === codeTools.qwenCode) {
return openAiCompatibleProviders
}
if (selectedCliTool === codeTools.openaiCodex) {
return openAiProviders
}
return []
}, [claudeProviders, geminiProviders, openAiCompatibleProviders, openAiProviders, selectedCliTool])
const filterFn = CLI_TOOL_PROVIDER_MAP[selectedCliTool]
return filterFn ? filterFn(providers) : []
}, [providers, selectedCliTool])
const handleModelChange = (value: string) => {
if (!value) {
@@ -111,25 +89,6 @@ const CodeToolsPage: FC = () => {
}
}
// 处理环境变量更改
const handleEnvVarsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEnvVars(e.target.value)
}
// 处理文件夹选择
const handleFolderSelect = async () => {
try {
await selectFolder()
} catch (error) {
logger.error('选择文件夹失败:', error as Error)
}
}
// 处理目录选择
const handleDirectoryChange = (value: string) => {
setCurrentDir(value)
}
// 处理删除目录
const handleRemoveDirectory = (directory: string, e: React.MouseEvent) => {
e.stopPropagation()
@@ -170,104 +129,74 @@ const CodeToolsPage: FC = () => {
}
}
// 处理启动
const handleLaunch = async () => {
// 验证启动条件
const validateLaunch = (): { isValid: boolean; message?: string } => {
if (!canLaunch || !isBunInstalled) {
if (!isBunInstalled) {
window.message.warning({
content: t('code.launch.bun_required'),
key: 'code-launch-message'
})
} else {
window.message.warning({
content: t('code.launch.validation_error'),
key: 'code-launch-message'
})
return {
isValid: false,
message: !isBunInstalled ? t('code.launch.bun_required') : t('code.launch.validation_error')
}
return
}
setIsLaunching(true)
if (!selectedModel) {
window.message.error({
content: t('code.model_required'),
key: 'code-launch-message'
})
return
return { isValid: false, message: t('code.model_required') }
}
return { isValid: true }
}
// 准备启动环境
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
if (!selectedModel) return null
const modelProvider = getProviderByModel(selectedModel)
const aiProvider = new AiProvider(modelProvider)
const baseUrl = await aiProvider.getBaseURL()
const apiKey = await aiProvider.getApiKey()
let env: Record<string, string> = {}
if (selectedCliTool === codeTools.claudeCode) {
env = {
ANTHROPIC_API_KEY: apiKey,
ANTHROPIC_BASE_URL: modelProvider.apiHost,
ANTHROPIC_MODEL: selectedModel.id
}
// 生成工具特定的环境变量
const toolEnv = generateToolEnvironment({
tool: selectedCliTool,
model: selectedModel,
modelProvider,
apiKey,
baseUrl
})
// 合并用户自定义的环境变量
const userEnv = parseEnvironmentVariables(environmentVariables)
return { ...toolEnv, ...userEnv }
}
// 执行启动操作
const executeLaunch = async (env: Record<string, string>) => {
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { autoUpdateToLatest })
window.message.success({ content: t('code.launch.success'), key: 'code-launch-message' })
}
// 处理启动
const handleLaunch = async () => {
const validation = validateLaunch()
if (!validation.isValid) {
window.message.warning({ content: validation.message, key: 'code-launch-message' })
return
}
if (selectedCliTool === codeTools.geminiCli) {
const apiSuffix = modelProvider.id === 'aihubmix' ? '/gemini' : ''
const apiBaseUrl = modelProvider.apiHost + apiSuffix
env = {
GEMINI_API_KEY: apiKey,
GEMINI_BASE_URL: apiBaseUrl,
GOOGLE_GEMINI_BASE_URL: apiBaseUrl,
GEMINI_MODEL: selectedModel.id
}
}
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.openaiCodex) {
env = {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: selectedModel.id
}
}
// 解析用户自定义的环境变量
if (environmentVariables) {
const lines = environmentVariables.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine && trimmedLine.includes('=')) {
const [key, ...valueParts] = trimmedLine.split('=')
const trimmedKey = key.trim()
const value = valueParts.join('=').trim()
if (trimmedKey) {
env[trimmedKey] = value
}
}
}
}
setIsLaunching(true)
try {
// 这里可以添加实际的启动逻辑
logger.info('启动配置:', {
cliTool: selectedCliTool,
model: selectedModel,
folder: currentDirectory
})
const env = await prepareLaunchEnvironment()
if (!env) {
window.message.error({ content: t('code.model_required'), key: 'code-launch-message' })
return
}
window.api.codeTools.run(selectedCliTool, selectedModel?.id, currentDirectory, env, {
autoUpdateToLatest
})
window.message.success({
content: t('code.launch.success'),
key: 'code-launch-message'
})
await executeLaunch(env)
} catch (error) {
logger.error('启动失败:', error as Error)
window.message.error({
content: t('code.launch.error'),
key: 'code-launch-message'
})
window.message.error({ content: t('code.launch.error'), key: 'code-launch-message' })
} finally {
setIsLaunching(false)
}
@@ -320,7 +249,7 @@ const CodeToolsPage: FC = () => {
style={{ width: '100%' }}
placeholder={t('code.cli_tool_placeholder')}
value={selectedCliTool}
onChange={handleCliToolChange}
onChange={setCliTool}
options={CLI_TOOLS}
/>
</SettingsItem>
@@ -345,7 +274,7 @@ const CodeToolsPage: FC = () => {
style={{ flex: 1, width: 480 }}
placeholder={t('code.folder_placeholder')}
value={currentDirectory || undefined}
onChange={handleDirectoryChange}
onChange={setCurrentDir}
allowClear
showSearch
filterOption={(input, option) => {
@@ -366,7 +295,7 @@ const CodeToolsPage: FC = () => {
)
}))}
/>
<Button onClick={handleFolderSelect} style={{ width: 120 }}>
<Button onClick={selectFolder} style={{ width: 120 }}>
{t('code.select_folder')}
</Button>
</Space.Compact>
@@ -377,7 +306,7 @@ const CodeToolsPage: FC = () => {
<Input.TextArea
placeholder={`KEY1=value1\nKEY2=value2`}
value={environmentVariables}
onChange={handleEnvVarsChange}
onChange={(e) => setEnvVars(e.target.value)}
rows={2}
style={{ fontFamily: 'monospace' }}
/>
@@ -417,11 +346,14 @@ const Container = styled.div`
const ContentContainer = styled.div`
display: flex;
flex: 1;
overflow-y: auto;
padding: 20px 0;
`
const MainContent = styled.div`
width: 600px;
margin: auto;
min-height: fit-content;
`
const Title = styled.h1`

View File

@@ -1 +1,135 @@
import { EndpointType, Model, Provider } from '@renderer/types'
import { codeTools } from '@shared/config/constant'
export interface LaunchValidationResult {
isValid: boolean
message?: string
}
export interface ToolEnvironmentConfig {
tool: codeTools
model: Model
modelProvider: Provider
apiKey: string
baseUrl: string
}
// CLI 工具选项
export const CLI_TOOLS = [
{ value: codeTools.qwenCode, label: 'Qwen Code' },
{ value: codeTools.claudeCode, label: 'Claude Code' },
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu']
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
// Provider 过滤映射
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
[codeTools.claudeCode]: (providers) =>
providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id)),
[codeTools.geminiCli]: (providers) =>
providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.openaiCodex]: (providers) => providers.filter((p) => p.id === 'openai')
}
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
const CODE_TOOLS_API_ENDPOINTS = {
aihubmix: {
gemini: {
api_base_url: 'https://api.aihubmix.com/gemini'
}
},
deepseek: {
anthropic: {
api_base_url: 'https://api.deepseek.com/anthropic'
}
},
moonshot: {
anthropic: {
api_base_url: 'https://api.moonshot.cn/anthropic'
}
},
zhipu: {
anthropic: {
api_base_url: 'https://open.bigmodel.cn/api/anthropic'
}
}
}
const provider = model.provider
return CODE_TOOLS_API_ENDPOINTS[provider]?.[type]?.api_base_url
}
// 解析环境变量字符串为对象
export const parseEnvironmentVariables = (envVars: string): Record<string, string> => {
const env: Record<string, string> = {}
if (!envVars) return env
const lines = envVars.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine && trimmedLine.includes('=')) {
const [key, ...valueParts] = trimmedLine.split('=')
const trimmedKey = key.trim()
const value = valueParts.join('=').trim()
if (trimmedKey) {
env[trimmedKey] = value
}
}
}
return env
}
// 为不同 CLI 工具生成环境变量配置
export const generateToolEnvironment = ({
tool,
model,
modelProvider,
apiKey,
baseUrl
}: {
tool: codeTools
model: Model
modelProvider: Provider
apiKey: string
baseUrl: string
}): Record<string, string> => {
const env: Record<string, string> = {}
switch (tool) {
case codeTools.claudeCode:
env.ANTHROPIC_BASE_URL = getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.apiHost
env.ANTHROPIC_MODEL = model.id
if (modelProvider.type === 'anthropic') {
env.ANTHROPIC_API_KEY = apiKey
} else {
env.ANTHROPIC_AUTH_TOKEN = apiKey
}
break
case codeTools.geminiCli: {
const apiBaseUrl = getCodeToolsApiBaseUrl(model, 'gemini') || modelProvider.apiHost
env.GEMINI_API_KEY = apiKey
env.GEMINI_BASE_URL = apiBaseUrl
env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl
env.GEMINI_MODEL = model.id
break
}
case codeTools.qwenCode:
case codeTools.openaiCodex:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = baseUrl
env.OPENAI_MODEL = model.id
break
}
return env
}
export { default } from './CodeToolsPage'

View File

@@ -22,7 +22,7 @@ let _stack: Route[] = ['topics']
let _topic: Topic | undefined
let _message: Message | undefined
const TopicsPage: FC = () => {
const HistoryPage: FC = () => {
const { t } = useTranslation()
const [search, setSearch] = useState(_search)
const [searchKeywords, setSearchKeywords] = useState(_search)
@@ -52,7 +52,12 @@ const TopicsPage: FC = () => {
setTopic(undefined)
}
const onTopicClick = (topic: Topic) => {
// topic 不包含 messages用到的时候才会获取
const onTopicClick = (topic: Topic | null | undefined) => {
if (!topic) {
window.message.error(t('history.error.topic_not_found'))
return
}
setStack((prev) => [...prev, 'topic'])
setTopic(topic)
}
@@ -86,7 +91,7 @@ const TopicsPage: FC = () => {
</SearchIcon>
)
}
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
suffix={search.length ? <CornerDownLeft size={16} /> : null}
ref={inputRef}
placeholder={t('history.search.placeholder')}
value={search}
@@ -146,4 +151,4 @@ const SearchIcon = styled.div`
}
`
export default TopicsPage
export default HistoryPage

View File

@@ -1,16 +1,23 @@
import { LoadingIcon } from '@renderer/components/Icons'
import db from '@renderer/databases'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useTimer } from '@renderer/hooks/useTimer'
import { getTopicById } from '@renderer/hooks/useTopic'
import { selectTopicsMap } from '@renderer/store/assistants'
import { Topic } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { List, Typography } from 'antd'
import { List, Spin, Typography } from 'antd'
import { useLiveQuery } from 'dexie-react-hooks'
import { FC, memo, useCallback, useEffect, useState } from 'react'
import { FC, memo, useCallback, useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
const { Text, Title } = Typography
type SearchResult = {
message: Message
topic: Topic
content: string
}
interface Props extends React.HTMLAttributes<HTMLDivElement> {
keywords: string
onMessageClick: (message: Message) => void
@@ -19,7 +26,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
const { handleScroll, containerRef } = useScrollPosition('SearchResults')
const { setTimeoutTimer } = useTimer()
const observerRef = useRef<MutationObserver | null>(null)
const [searchTerms, setSearchTerms] = useState<string[]>(
keywords
@@ -29,9 +36,12 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
)
const topics = useLiveQuery(() => db.topics.toArray(), [])
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
const storeTopicsMap = useSelector(selectTopicsMap)
const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([])
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [searchStats, setSearchStats] = useState({ count: 0, time: 0 })
const [isLoading, setIsLoading] = useState(false)
const removeMarkdown = (text: string) => {
return text
@@ -46,33 +56,40 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
const onSearch = useCallback(async () => {
setSearchResults([])
setIsLoading(true)
if (keywords.length === 0) {
setSearchStats({ count: 0, time: 0 })
setSearchTerms([])
setIsLoading(false)
return
}
const startTime = performance.now()
const results: { message: Message; topic: Topic; content: string }[] = []
const newSearchTerms = keywords
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0)
const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i'))
const blocksArray = await db.message_blocks.toArray()
const blocks = blocksArray
const blocks = (await db.message_blocks.toArray())
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term)))
.filter((block) => searchRegexes.some((regex) => regex.test(block.content)))
const messages = topics?.map((topic) => topic.messages).flat()
const messages = topics?.flatMap((topic) => topic.messages)
for (const block of blocks) {
const message = messages?.find((message) => message.id === block.messageId)
if (message) {
results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content })
}
}
const results = await Promise.all(
blocks.map(async (block) => {
const message = messages?.find((message) => message.id === block.messageId)
if (message) {
const topic = storeTopicsMap.get(message.topicId)
if (topic) {
return { message, topic, content: block.content }
}
}
return null
})
).then((results) => results.filter(Boolean) as SearchResult[])
const endTime = performance.now()
setSearchResults(results)
@@ -81,7 +98,8 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
time: (endTime - startTime) / 1000
})
setSearchTerms(newSearchTerms)
}, [keywords, topics])
setIsLoading(false)
}, [keywords, storeTopicsMap, topics])
const highlightText = (text: string) => {
let highlightedText = removeMarkdown(text)
@@ -100,9 +118,24 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
onSearch()
}, [onSearch])
useEffect(() => {
if (!containerRef.current) return
observerRef.current = new MutationObserver(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
})
observerRef.current.observe(containerRef.current, {
childList: true,
subtree: true
})
return () => observerRef.current?.disconnect()
}, [containerRef])
return (
<Container ref={containerRef} {...props} onScroll={handleScroll}>
<ContainerWrapper>
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
{searchResults.length > 0 && (
<SearchStats>
Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds
@@ -113,19 +146,15 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
dataSource={searchResults}
pagination={{
pageSize: 10,
onChange: () => {
setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0)
}
hideOnSinglePage: true
}}
style={{ opacity: isLoading ? 0 : 1 }}
renderItem={({ message, topic, content }) => (
<List.Item>
<Title
level={5}
style={{ color: 'var(--color-primary)', cursor: 'pointer' }}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onTopicClick(_topic)
}}>
onClick={() => onTopicClick(topic)}>
{topic.name}
</Title>
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
@@ -138,24 +167,17 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
)}
/>
<div style={{ minHeight: 30 }}></div>
</ContainerWrapper>
</Spin>
</Container>
)
}
const Container = styled.div`
width: 100%;
padding: 20px;
height: 100%;
padding: 20px 36px;
overflow-y: auto;
display: flex;
flex-direction: row;
justify-content: center;
`
const ContainerWrapper = styled.div`
width: 100%;
padding: 0 16px;
display: flex;
flex-direction: column;
`
@@ -166,6 +188,7 @@ const SearchStats = styled.div`
const SearchResultTime = styled.div`
margin-top: 10px;
text-align: right;
`
export default memo(SearchResults)

View File

@@ -5,18 +5,17 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { getTopicById } from '@renderer/hooks/useTopic'
import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
import NavigationService from '@renderer/services/NavigationService'
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { classNames, runAsyncFunction } from '@renderer/utils'
import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next'
import { Forward } from 'lucide-react'
import { FC, useEffect } from 'react'
import { FC, useEffect, useState } from 'react'
import styled from 'styled-components'
import { default as MessageItem } from '../../home/Messages/Message'
@@ -25,16 +24,22 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
topic?: Topic
}
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const TopicMessages: FC<Props> = ({ topic: _topic, ...props }) => {
const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const dispatch = useAppDispatch()
const { messageStyle } = useSettings()
const { setTimeoutTimer } = useTimer()
const [topic, setTopic] = useState<Topic | undefined>(_topic)
useEffect(() => {
topic && dispatch(loadTopicMessagesThunk(topic.id))
}, [dispatch, topic])
if (!_topic) return
runAsyncFunction(async () => {
const topic = await getTopicById(_topic.id)
setTopic(topic)
})
}, [_topic, topic])
const isEmpty = (topic?.messages || []).length === 0

View File

@@ -1,14 +1,14 @@
import { SearchOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout'
import { useAssistants } from '@renderer/hooks/useAssistant'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { getTopicById } from '@renderer/hooks/useTopic'
import { selectAllTopics } from '@renderer/store/assistants'
import { Topic } from '@renderer/types'
import { Button, Divider, Empty, Segmented } from 'antd'
import dayjs from 'dayjs'
import { groupBy, isEmpty, orderBy } from 'lodash'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
type SortType = 'createdAt' | 'updatedAt'
@@ -20,18 +20,18 @@ type Props = {
} & React.HTMLAttributes<HTMLDivElement>
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
const { assistants } = useAssistants()
const { t } = useTranslation()
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
const [sortType, setSortType] = useState<SortType>('createdAt')
const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc')
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
const topics = useSelector(selectAllTopics)
const filteredTopics = topics.filter((topic) => {
return topic.name.toLowerCase().includes(keywords.toLowerCase())
})
const groupedTopics = groupBy(filteredTopics, (topic) => {
const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => {
return dayjs(topic[sortType]).format('MM/DD')
})
@@ -66,19 +66,14 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
<Date>{date}</Date>
<Divider style={{ margin: '5px 0' }} />
{items.map((topic) => (
<TopicItem
key={topic.id}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onClick(_topic)
}}>
<TopicItem key={topic.id} onClick={() => onClick(topic)}>
<TopicName>{topic.name.substring(0, 50)}</TopicName>
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
</TopicItem>
))}
</ListItem>
))}
{keywords.length >= 2 && (
{keywords && (
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
{t('history.search.messages')}

View File

@@ -1,3 +1,4 @@
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useActiveTopic } from '@renderer/hooks/useTopic'
@@ -100,20 +101,24 @@ const HomePage: FC = () => {
)}
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>
{showAssistants && (
<HomeTabs
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}
setActiveTopic={setActiveTopic}
position="left"
/>
<ErrorBoundary>
<HomeTabs
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}
setActiveTopic={setActiveTopic}
position="left"
/>
</ErrorBoundary>
)}
<Chat
assistant={activeAssistant}
activeTopic={activeTopic}
setActiveTopic={setActiveTopic}
setActiveAssistant={setActiveAssistant}
/>
<ErrorBoundary>
<Chat
assistant={activeAssistant}
activeTopic={activeTopic}
setActiveTopic={setActiveTopic}
setActiveAssistant={setActiveAssistant}
/>
</ErrorBoundary>
</ContentContainer>
</Container>
)

View File

@@ -1,7 +1,8 @@
import { FileType } from '@renderer/types'
import { filterSupportedFiles } from '@renderer/utils/file'
import { Tooltip } from 'antd'
import { Paperclip } from 'lucide-react'
import { FC, useCallback, useImperativeHandle } from 'react'
import { FC, useCallback, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
export interface AttachmentButtonRef {
@@ -28,22 +29,47 @@ const AttachmentButton: FC<Props> = ({
disabled
}) => {
const { t } = useTranslation()
const [selecting, setSelecting] = useState<boolean>(false)
const onSelectFile = useCallback(async () => {
if (selecting) {
return
}
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
const useAllFiles = extensions.length > 20
setSelecting(true)
const _files = await window.api.file.select({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Files',
extensions: extensions.map((i) => i.replace('.', ''))
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
}
]
})
setSelecting(false)
if (_files) {
setFiles([...files, ..._files])
if (!useAllFiles) {
setFiles([...files, ..._files])
return
}
const supportedFiles = await filterSupportedFiles(_files, extensions)
if (supportedFiles.length > 0) {
setFiles([...files, ...supportedFiles])
}
if (supportedFiles.length !== _files.length) {
window.message.info({
key: 'file_not_supported',
content: t('chat.input.file_not_supported_count', {
count: _files.length - supportedFiles.length
})
})
}
}
}, [extensions, files, setFiles])
}, [extensions, files, selecting, setFiles, t])
const openQuickPanel = useCallback(() => {
onSelectFile()

View File

@@ -83,7 +83,7 @@ export const getFileIcon = (type?: string) => {
export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => {
const [visible, setVisible] = useState<boolean>(false)
const isImage = (ext: string) => {
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext.toLocaleLowerCase())
}
const fullName = FileManager.formatFileName(file)

View File

@@ -5,6 +5,7 @@ import TranslateButton from '@renderer/components/TranslateButton'
import {
isGenerateImageModel,
isGenerateImageModels,
isMandatoryWebSearchModel,
isSupportedDisableGenerationModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
@@ -37,7 +38,7 @@ import { setSearching } from '@renderer/store/runtime'
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
import { classNames, delay, formatFileSize } from '@renderer/utils'
import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils'
import { formatQuotedText } from '@renderer/utils/formats'
import {
getFilesFromDropEvent,
@@ -110,7 +111,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
const startHeight = useRef<number>(0)
const currentMessageId = useRef<string>('')
const { bases: knowledgeBases } = useKnowledgeBases()
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
@@ -241,17 +241,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
baseUserMessage.mentions = mentionedModels
}
const assistantWithTopicPrompt = topic.prompt
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
: assistant
baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage)
const { message, blocks } = getUserMessage(baseUserMessage)
message.traceId = parent?.spanContext().traceId
currentMessageId.current = message.id
dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id))
dispatch(_sendMessage(message, blocks, assistant, topic.id))
// Clear input
setText('')
@@ -511,30 +506,42 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const cursorPosition = textArea?.selectionStart ?? 0
const lastSymbol = newText[cursorPosition - 1]
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText,
openSelectFileMenu,
translate
}) || []
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '/') {
if (quickPanel.isVisible && quickPanel.symbol !== '/') {
quickPanel.close('switch-symbol')
}
if (!quickPanel.isVisible || quickPanel.symbol !== '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText,
openSelectFileMenu,
translate
}) || []
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
}
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
originalText: newText
})
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '@') {
if (quickPanel.isVisible && quickPanel.symbol !== '@') {
quickPanel.close('switch-symbol')
}
if (!quickPanel.isVisible || quickPanel.symbol !== '@') {
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
originalText: newText
})
}
}
},
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
@@ -585,26 +592,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setText(text + data)
const files = await getFilesFromDropEvent(e).catch((err) => {
const droppedFiles = await getFilesFromDropEvent(e).catch((err) => {
logger.error('handleDrop:', err)
return null
})
if (files) {
let supportedFiles = 0
files.forEach((file) => {
if (supportedExts.includes(file.ext)) {
setFiles((prevFiles) => [...prevFiles, file])
supportedFiles++
}
})
// 如果有文件,但都不支持
if (files.length > 0 && supportedFiles === 0) {
if (droppedFiles) {
const supportedFiles = await filterSupportedFiles(droppedFiles, supportedExts)
supportedFiles.length > 0 && setFiles((prevFiles) => [...prevFiles, ...supportedFiles])
if (droppedFiles.length > 0 && supportedFiles.length !== droppedFiles.length) {
window.message.info({
key: 'file_not_supported',
content: t('chat.input.file_not_supported')
content: t('chat.input.file_not_supported_count', {
count: droppedFiles.length - supportedFiles.length
})
})
}
}
@@ -773,7 +774,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
if (!isWebSearchModel(model) && assistant.enableWebSearch) {
updateAssistant({ ...assistant, enableWebSearch: false })
}
if (assistant.webSearchProviderId && !WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId)) {
if (
assistant.webSearchProviderId &&
(!WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId) || isMandatoryWebSearchModel(model))
) {
updateAssistant({ ...assistant, webSearchProviderId: undefined })
}
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {

View File

@@ -1,6 +1,6 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { isGenerateImageModel } from '@renderer/config/models'
import { isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
@@ -340,7 +340,8 @@ const InputbarTools = ({
{
key: 'web_search',
label: t('chat.input.web_search.label'),
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
condition: !isMandatoryWebSearchModel(model)
},
{
key: 'url_context',

View File

@@ -48,6 +48,66 @@ const MentionModelsButton: FC<Props> = ({
// 记录是否有模型被选择的动作发生
const hasModelActionRef = useRef<boolean>(false)
// 记录触发信息,用于清除操作
const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>(
undefined
)
// 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本
const removeAtSymbolAndText = useCallback(
(currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => {
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
// ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配
if (searchText !== undefined) {
const pattern = '@' + searchText
const fromIndex = Math.max(0, safeCaret - 1)
const start = currentText.lastIndexOf(pattern, fromIndex)
if (start !== -1) {
const end = start + pattern.length
return currentText.slice(0, start) + currentText.slice(end)
}
// 兜底:使用打开时的 position 做校验后再删
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
const expected = pattern
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
if (actual === expected) {
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
}
// 如果不完全匹配,安全起见仅删除单个 '@'
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
}
// 未找到匹配则不改动
return currentText
}
// 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾)
{
const fromIndex = Math.max(0, safeCaret - 1)
const start = currentText.lastIndexOf('@', fromIndex)
if (start === -1) {
// 兜底:使用打开时的 position若存在按空白边界删除
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
let endPos = fallbackPosition + 1
while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') {
endPos++
}
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
}
return currentText
}
let endPos = start + 1
while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') {
endPos++
}
return currentText.slice(0, start) + currentText.slice(endPos)
}
},
[]
)
const pinnedModels = useLiveQuery(
async () => {
@@ -140,9 +200,20 @@ const MentionModelsButton: FC<Props> = ({
label: t('settings.input.clear.all'),
description: t('settings.input.clear.models'),
icon: <CircleX />,
alwaysVisible: true,
isSelected: false,
action: () => {
onClearMentionModels()
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
if (triggerInfoRef.current?.type === 'input') {
setText((currentText) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position)
})
}
quickPanel.close()
}
})
@@ -157,13 +228,17 @@ const MentionModelsButton: FC<Props> = ({
onMentionModel,
navigate,
quickPanel,
onClearMentionModels
onClearMentionModels,
setText,
removeAtSymbolAndText
])
const openQuickPanel = useCallback(
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
// 重置模型动作标记
hasModelActionRef.current = false
// 保存触发信息
triggerInfoRef.current = triggerInfo
quickPanel.open({
title: t('agents.edit.model.select.title'),
@@ -183,28 +258,11 @@ const MentionModelsButton: FC<Props> = ({
closeTriggerInfo?.type === 'input' &&
closeTriggerInfo?.position !== undefined
) {
// 使用React的setText来更新状态
// 基于当前光标 + 搜索词精确定位并删除position 仅作兜底
setText((currentText) => {
const position = closeTriggerInfo.position!
// 验证位置的字符是否仍是 @
if (currentText[position] !== '@') {
return currentText
}
// 计算删除范围:@ + searchText
const deleteLength = 1 + (searchText?.length || 0)
// 验证要删除的内容是否匹配预期
const expectedText = '@' + (searchText || '')
const actualText = currentText.slice(position, position + deleteLength)
if (actualText !== expectedText) {
// 如果实际文本不匹配,只删除 @ 字符
return currentText.slice(0, position) + currentText.slice(position + 1)
}
// 删除 @ 和搜索文本
return currentText.slice(0, position) + currentText.slice(position + deleteLength)
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
return removeAtSymbolAndText(currentText, caret, searchText || '', closeTriggerInfo.position!)
})
}
}
@@ -213,7 +271,7 @@ const MentionModelsButton: FC<Props> = ({
}
})
},
[modelItems, quickPanel, t, setText]
[modelItems, quickPanel, t, setText, removeAtSymbolAndText]
)
const handleOpenQuickPanel = useCallback(() => {

View File

@@ -77,12 +77,14 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
if (!isEnabled) {
updateAssistantSettings({
reasoning_effort: undefined,
reasoning_effort_cache: undefined,
qwenThinkMode: false
})
return
}
updateAssistantSettings({
reasoning_effort: option,
reasoning_effort_cache: option,
qwenThinkMode: true
})
return

View File

@@ -28,48 +28,62 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const { providers } = useWebSearchProviders()
const { updateAssistant } = useAssistant(assistant.id)
// 注意assistant.enableWebSearch 有不同的语义
/** 表示是否启用网络搜索 */
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
const WebSearchIcon = useCallback(
({ pid, size = 18 }: { pid?: WebSearchProviderId; size?: number }) => {
const iconColor = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
({ pid, size = 18, color }: { pid?: WebSearchProviderId; size?: number; color?: string }) => {
switch (pid) {
case 'bocha':
return <BochaLogo width={size} height={size} color={iconColor} />
return <BochaLogo width={size} height={size} color={color} />
case 'exa':
// size微调视觉上和其他图标平衡一些
return <ExaLogo width={size - 2} height={size} color={iconColor} />
return <ExaLogo width={size - 2} height={size} color={color} />
case 'tavily':
return <TavilyLogo width={size} height={size} color={iconColor} />
return <TavilyLogo width={size} height={size} color={color} />
case 'searxng':
return <SearXNGLogo width={size} height={size} color={iconColor} />
return <SearXNGLogo width={size} height={size} color={color} />
case 'local-baidu':
return <BaiduOutlined size={size} style={{ color: iconColor, fontSize: size }} />
return <BaiduOutlined size={size} style={{ color, fontSize: size }} />
case 'local-bing':
return <BingLogo width={size} height={size} color={iconColor} />
return <BingLogo width={size} height={size} color={color} />
case 'local-google':
return <GoogleOutlined size={size} style={{ color: iconColor, fontSize: size }} />
return <GoogleOutlined size={size} style={{ color, fontSize: size }} />
default:
return <Globe size={size} style={{ color: iconColor, fontSize: size }} />
return <Globe size={size} style={{ color, fontSize: size }} />
}
},
[enableWebSearch]
)
const updateSelectedWebSearchProvider = useCallback(
const updateWebSearchProvider = useCallback(
async (providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
const currentWebSearchProviderId = assistant.webSearchProviderId
const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId
startTransition(() => {
updateAssistant({ ...assistant, webSearchProviderId: newWebSearchProviderId, enableWebSearch: false })
updateAssistant({
...assistant,
webSearchProviderId: providerId,
enableWebSearch: false
})
})
},
[assistant, updateAssistant]
)
const updateSelectedWebSearchBuiltin = useCallback(async () => {
const updateQuickPanelItem = useCallback(
async (providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
if (providerId === assistant.webSearchProviderId) {
updateWebSearchProvider(undefined)
} else {
updateWebSearchProvider(providerId)
}
},
[assistant.webSearchProviderId, updateWebSearchProvider]
)
const updateToModelBuiltinWebSearch = useCallback(async () => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
startTransition(() => {
updateAssistant({ ...assistant, webSearchProviderId: undefined, enableWebSearch: !assistant.enableWebSearch })
@@ -90,7 +104,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
icon: <WebSearchIcon size={13} pid={p.id} />,
isSelected: p.id === assistant?.webSearchProviderId,
disabled: !WebSearchService.isWebSearchEnabled(p.id),
action: () => updateSelectedWebSearchProvider(p.id)
action: () => updateQuickPanelItem(p.id)
}))
.filter((o) => !o.disabled)
@@ -103,7 +117,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
icon: <Globe />,
isSelected: assistant.enableWebSearch,
disabled: !isWebSearchModelEnabled,
action: () => updateSelectedWebSearchBuiltin()
action: () => updateToModelBuiltinWebSearch()
})
}
@@ -115,36 +129,18 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
assistant?.webSearchProviderId,
providers,
t,
updateSelectedWebSearchBuiltin,
updateSelectedWebSearchProvider
updateQuickPanelItem,
updateToModelBuiltinWebSearch
])
const openQuickPanel = useCallback(() => {
if (assistant.webSearchProviderId) {
updateSelectedWebSearchProvider(undefined)
return
}
if (assistant.enableWebSearch) {
updateSelectedWebSearchBuiltin()
return
}
quickPanel.open({
title: t('chat.input.web_search.label'),
list: providerItems,
symbol: '?',
pageSize: 9
})
}, [
assistant.webSearchProviderId,
assistant.enableWebSearch,
quickPanel,
t,
providerItems,
updateSelectedWebSearchProvider,
updateSelectedWebSearchBuiltin
])
}, [quickPanel, t, providerItems])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '?') {
@@ -154,18 +150,28 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
}
}, [openQuickPanel, quickPanel])
const onClick = useCallback(() => {
if (enableWebSearch) {
updateWebSearchProvider(undefined)
} else {
handleOpenQuickPanel()
}
}, [enableWebSearch, handleOpenQuickPanel, updateWebSearchProvider])
useImperativeHandle(ref, () => ({
openQuickPanel
}))
const color = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
return (
<Tooltip
placement="top"
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<WebSearchIcon pid={assistant.webSearchProviderId} />
<ToolbarButton type="text" onClick={onClick}>
<WebSearchIcon color={color} pid={assistant.webSearchProviderId} />
</ToolbarButton>
</Tooltip>
)

View File

@@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { MessageBlockStatus } from '@renderer/types/newMessage'
import { getCodeBlockId } from '@renderer/utils/markdown'
import { getCodeBlockId, isOpenFenceBlock } from '@renderer/utils/markdown'
import type { Node } from 'mdast'
import React, { memo, useCallback, useMemo } from 'react'
@@ -16,8 +16,9 @@ interface Props {
}
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
const language = match?.[1] ?? 'text'
const languageMatch = /language-([\w-+]+)/.exec(className || '')
const isMultiline = children?.includes('\n')
const language = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
// 代码块 id
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start])
@@ -39,11 +40,11 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
[blockId, id]
)
if (match) {
if (language !== null) {
// HTML 代码块特殊处理
// FIXME: 感觉没有必要用 isHtmlCode 判断
if (language === 'html') {
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming} />
const isOpenFence = isOpenFenceBlock(children?.length, languageMatch?.[1]?.length, node?.position)
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming && isOpenFence} />
}
return (

View File

@@ -1,4 +1,4 @@
import { omit } from 'lodash'
import { isEmpty, omit } from 'lodash'
import React from 'react'
import type { Node } from 'unist'
@@ -33,6 +33,7 @@ const Link: React.FC<LinkProps> = (props) => {
<CitationTooltip citation={props.citationData}>
<a
{...omit(props, ['node', 'citationData'])}
href={isEmpty(props.href) ? undefined : props.href}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}

View File

@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
emit: vi.fn()
},
getCodeBlockId: vi.fn(),
isOpenFenceBlock: vi.fn(),
selectById: vi.fn(),
CodeBlockView: vi.fn(({ onSave, children }) => (
<div>
@@ -36,7 +37,8 @@ vi.mock('@renderer/services/EventService', () => ({
}))
vi.mock('@renderer/utils/markdown', () => ({
getCodeBlockId: mocks.getCodeBlockId
getCodeBlockId: mocks.getCodeBlockId,
isOpenFenceBlock: mocks.isOpenFenceBlock
}))
vi.mock('@renderer/store', () => ({
@@ -74,6 +76,7 @@ describe('CodeBlock', () => {
vi.clearAllMocks()
// Default mock return values
mocks.getCodeBlockId.mockReturnValue('test-code-block-id')
mocks.isOpenFenceBlock.mockReturnValue(false)
mocks.selectById.mockReturnValue({
id: 'test-msg-block-id',
status: MessageBlockStatus.SUCCESS

View File

@@ -100,11 +100,8 @@ const MessageItem: FC<Props> = ({
const handleEditResend = useCallback(
async (blocks: MessageBlock[]) => {
const assistantWithTopicPrompt = topic.prompt
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
: assistant
try {
await resendUserMessageWithEdit(message, blocks, assistantWithTopicPrompt)
await resendUserMessageWithEdit(message, blocks, assistant)
stopEditing()
} catch (error) {
logger.error('Failed to resend message:', error as Error)

View File

@@ -150,10 +150,7 @@ const MessageMenubar: FC<Props> = (props) => {
const handleResendUserMessage = useCallback(
async (messageUpdate?: Message) => {
if (!loading) {
const assistantWithTopicPrompt = topic.prompt
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
: assistant
await resendMessage(messageUpdate ?? message, assistantWithTopicPrompt)
await resendMessage(messageUpdate ?? message, assistant)
}
},
[assistant, loading, message, resendMessage, topic.prompt]
@@ -379,12 +376,8 @@ const MessageMenubar: FC<Props> = (props) => {
// const _message = resetAssistantMessage(message, selectedModel)
// editMessage(message.id, { ..._message }) // REMOVED
const assistantWithTopicPrompt = topic.prompt
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
: assistant
// Call the function from the hook
regenerateAssistantMessage(message, assistantWithTopicPrompt)
regenerateAssistantMessage(message, assistant)
}
// 按条件筛选能够提及的模型该函数仅在isAssistantMessage时会用到

View File

@@ -72,7 +72,7 @@ const SettingsTab: FC<Props> = (props) => {
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
const { translateLanguages } = useTranslate()
const { t } = useTranslation()

View File

@@ -222,9 +222,19 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
key: 'rename',
icon: <EditIcon size={14} />,
disabled: isRenaming(topic.id),
onClick() {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
async onClick() {
const name = await PromptPopup.show({
title: t('chat.topics.edit.title'),
message: '',
defaultValue: topic?.name || '',
extraNode: (
<div style={{ color: 'var(--color-text-3)', marginTop: 8 }}>{t('chat.topics.edit.title_tip')}</div>
)
})
if (name && topic?.name !== name) {
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
updateTopic(updatedTopic)
}
}
},
{
@@ -448,7 +458,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
assistants,
assistant,
updateTopic,
topicEdit,
activeTopic.id,
setActiveTopic,
onPinTopic,
@@ -519,7 +528,13 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName className={getTopicNameClassName()} title={topicName}>
<TopicName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}>
{topicName}
</TopicName>
)}
@@ -527,11 +542,10 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
mouseLeaveDelay={0}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
@@ -546,9 +560,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
}
}}>
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" />
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (
<XIcon size={14} color="var(--color-text-3)" />
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
)}
</MenuButton>
</Tooltip>
@@ -622,6 +636,7 @@ const TopicNameContainer = styled.div`
flex-direction: row;
align-items: center;
gap: 4px;
height: 20px;
justify-content: space-between;
`
@@ -675,19 +690,14 @@ const TopicName = styled.div`
const TopicEditInput = styled.input`
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 4px;
border: none;
color: var(--color-text-1);
font-size: 13px;
font-family: inherit;
padding: 2px 6px;
width: 100%;
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
padding: 0;
`
const PendingIndicator = styled.div.attrs({

View File

@@ -137,7 +137,10 @@ const AssistantItem: FC<AssistantItemProps> = ({
)
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name" title={fullAssistantName}>
{assistantIconType === 'model' ? (
@@ -386,7 +389,6 @@ const Container = styled.div`
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);
}

View File

@@ -158,9 +158,13 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
<a href={`http://file/${item.file.name}`} target="_blank" rel="noreferrer">
{item.file.origin_name}
</a>
) : item.metadata.type !== 'LocalPathLoader' ? (
<a href={item.metadata.source} target="_blank" rel="noreferrer">
{item.metadata.source}
</a>
) : (
// item.metadata.source
<a href={`http://file/${item.metadata.source}`} target="_blank" rel="noreferrer">
// 处理预处理后的文件source
<a href={`file://${item.metadata.source}`} target="_blank" rel="noreferrer">
{item.metadata.source.split('/').pop() || item.metadata.source}
</a>
)}

View File

@@ -286,6 +286,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const formData = new FormData()
formData.append('prompt', prompt)
formData.append('model', painting.model)
if (painting.background && painting.background !== 'auto') {
formData.append('background', painting.background)
}

View File

@@ -28,7 +28,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt')
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)

View File

@@ -0,0 +1,62 @@
import { loggerService } from '@logger'
import { useAppSelector } from '@renderer/store'
import { setImageOcrProvider } from '@renderer/store/ocr'
import { isImageOcrProvider, OcrProvider } from '@renderer/types'
import { Select } from 'antd'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { SettingRow, SettingRowTitle } from '..'
const logger = loggerService.withContext('OcrImageSettings')
type Props = {
setProvider: (provider: OcrProvider) => void
}
const OcrImageSettings = ({ setProvider }: Props) => {
const { t } = useTranslation()
const providers = useAppSelector((state) => state.ocr.providers)
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
const imageProviders = providers.filter((p) => isImageOcrProvider(p))
const dispatch = useDispatch()
// 挂载时更新外部状态
useEffect(() => {
setProvider(imageProvider)
}, [imageProvider, setProvider])
const updateImageProvider = (id: string) => {
const provider = imageProviders.find((p) => p.id === id)
if (!provider) {
logger.error(`Failed to find image provider by id: ${id}`)
window.message.error(t('settings.tool.ocr.image.error.provider_not_found'))
return
}
setProvider(provider)
dispatch(setImageOcrProvider(provider))
}
return (
<>
<SettingRow>
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px' }}>
<Select
value={imageProvider.id}
style={{ width: '200px' }}
onChange={(id: string) => updateImageProvider(id)}
options={imageProviders.map((p) => ({
value: p.id,
label: p.name
}))}
/>
</div>
</SettingRow>
</>
)
}
export default OcrImageSettings

Some files were not shown because too many files have changed in this diff Show More