From 486c5c42f7de0db09e47fc388ab303fdccd4640a Mon Sep 17 00:00:00 2001 From: Xin Rui <71483384+Konjac-XZ@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:47:17 +0800 Subject: [PATCH 001/235] chore: format zh-cn and zh-tw i18n strings with pangu. (#7644) --- src/renderer/src/i18n/locales/zh-cn.json | 266 +++++++++++------------ src/renderer/src/i18n/locales/zh-tw.json | 204 ++++++++--------- 2 files changed, 235 insertions(+), 235 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0b4d7da254..475f766993 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "输入提示词", "add.prompt.variables.tip": { "title": "可用的变量", - "content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名" + "content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU 架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名" }, "add.title": "创建智能体", "import": { @@ -94,7 +94,7 @@ "titleLabel": "标题", "titlePlaceholder": "输入标题", "contentLabel": "内容", - "contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}" + "contentPlaceholder": "请输入短语内容,支持使用变量,然后按 Tab 键可以快速定位到变量进行修改。比如:\n帮我规划从 ${from} 到 ${to} 的路线,然后发送到 ${email}" }, "list": { "showByList": "列表展示", @@ -118,7 +118,7 @@ "get_key": "获取", "get_key_success": "自动获取密钥成功", "login": "登录", - "oauth_button": "使用{{provider}}登录" + "oauth_button": "使用 {{provider}} 登录" }, "backup": { "confirm": "确定要备份数据吗?", @@ -169,11 +169,11 @@ }, "input.auto_resize": "自动调整高度", "input.clear": "清空消息 {{Command}}", - "input.clear.content": "确定要清除当前会话所有消息吗?", + "input.clear.content": "确定要清除当前会话所有消息吗?", "input.clear.title": "清空消息", "input.collapse": "收起", "input.context_count.tip": "上下文数 / 最大上下文数", - "input.estimated_tokens.tip": "预估 token 数", + "input.estimated_tokens.tip": "预估 Token 数", "input.expand": "展开", "input.file_not_supported": "模型不支持此文件类型", "input.file_error": "文件处理出错", @@ -189,13 +189,13 @@ "input.settings": "设置", "input.thinking": "思考", "input.thinking.mode.default": "默认", - "input.thinking.mode.default.tip": "模型会自动确定思考的 token 数", + "input.thinking.mode.default.tip": "模型会自动确定思考的 Token 数", "input.thinking.mode.custom": "自定义", - "input.thinking.mode.custom.tip": "模型最多可以思考的 token 数。需要考虑模型的上下文限制,否则会报错", - "input.thinking.mode.tokens.tip": "设置思考的 token 数", - "input.thinking.budget_exceeds_max": "思考预算超过最大 token 数", - "input.topics": " 话题 ", - "input.translate": "翻译成{{target_language}}", + "input.thinking.mode.custom.tip": "模型最多可以思考的 Token 数。需要考虑模型的上下文限制,否则会报错", + "input.thinking.mode.tokens.tip": "设置思考的 Token 数", + "input.thinking.budget_exceeds_max": "思考预算超过最大 Token 数", + "input.topics": "话题", + "input.translate": "翻译成 {{target_language}}", "input.upload": "上传图片或文档", "input.upload.upload_from_local": "上传本地文件...", "input.upload.document": "上传文档(模型不支持图片)", @@ -258,12 +258,12 @@ "settings.code_cache_threshold": "缓存阈值", "settings.code_cache_threshold.tip": "允许缓存的最小代码长度(千字符),超过阈值的代码块才会被缓存", "settings.context_count": "上下文数", - "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", + "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 Token 越多。普通聊天建议 5-10", "settings.max": "不限", - "settings.max_tokens": "最大 TOKEN 数", + "settings.max_tokens": "最大 Token 数", "settings.max_tokens.confirm": "最大 Token 数", - "settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", - "settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", + "settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数,会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", + "settings.max_tokens.tip": "单次交互所用的最大 Token 数,会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", "settings.reset": "重置", "settings.set_as_default": "应用到默认助手", "settings.show_line_numbers": "代码显示行号", @@ -298,8 +298,8 @@ "topics.export.obsidian_btn": "确定", "topics.export.obsidian_created": "创建时间", "topics.export.obsidian_created_placeholder": "请选择创建时间", - "topics.export.obsidian_export_failed": "导出到Obsidian失败", - "topics.export.obsidian_export_success": "导出到Obsidian成功", + "topics.export.obsidian_export_failed": "导出到 Obsidian 失败", + "topics.export.obsidian_export_success": "导出到 Obsidian 成功", "topics.export.obsidian_operate": "处理方式", "topics.export.obsidian_operate_append": "追加", "topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆盖)", @@ -312,9 +312,9 @@ "topics.export.obsidian_title": "标题", "topics.export.obsidian_title_placeholder": "请输入标题", "topics.export.obsidian_title_required": "标题不能为空", - "topics.export.obsidian_no_vaults": "未找到Obsidian保管库", + "topics.export.obsidian_no_vaults": "未找到 Obsidian 保管库", "topics.export.obsidian_loading": "加载中...", - "topics.export.obsidian_fetch_error": "获取Obsidian保管库失败", + "topics.export.obsidian_fetch_error": "获取 Obsidian 保管库失败", "topics.export.obsidian_fetch_folders_error": "获取文件夹结构失败", "topics.export.obsidian_no_vault_selected": "请先选择一个保管库", "topics.export.obsidian_select_vault_first": "请先选择保管库", @@ -329,7 +329,7 @@ "topics.pinned": "固定话题", "topics.prompt": "话题提示词", "topics.prompt.edit.title": "编辑话题提示词", - "topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词", + "topics.prompt.tips": "话题提示词:针对当前话题提供额外的补充提示词", "topics.title": "话题", "topics.unpinned": "取消固定", "translate": "翻译", @@ -469,7 +469,7 @@ "count": "个文件", "created_at": "创建时间", "delete": "删除", - "delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?", + "delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?", "delete.paintings.warning": "绘图中包含该图片,暂时无法删除", "delete.title": "删除文件", "document": "文档", @@ -484,7 +484,7 @@ "type": "类型" }, "gpustack": { - "keep_alive_time.description": "模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.description": "模型在内存中保持的时间(默认:5 分钟)", "keep_alive_time.placeholder": "分钟", "keep_alive_time.title": "保持活跃时间", "title": "GPUStack" @@ -494,7 +494,7 @@ "locate.message": "定位到消息", "search.messages": "搜索所有消息", "search.placeholder": "搜索话题或消息...", - "search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息", + "search.topics.empty": "没有找到相关话题,点击回车键搜索所有消息", "title": "话题搜索" }, "knowledge": { @@ -517,12 +517,12 @@ "chunk_size_tooltip": "将文档切割分段,每段的大小,不能超过模型上下文限制", "clear_selection": "清除选择", "delete": "删除", - "delete_confirm": "确定要删除此知识库吗?", + "delete_confirm": "确定要删除此知识库吗?", "dimensions": "嵌入维度", "dimensions_size_tooltip": "嵌入维度大小,数值越大,嵌入维度越大,但消耗的 Token 也越多", "dimensions_set_right": "⚠️ 请确保模型支持所设置的嵌入维度大小", "dimensions_default": "模型将使用默认嵌入维度", - "dimensions_size_placeholder": " 嵌入维度大小,如 1024", + "dimensions_size_placeholder": "嵌入维度大小,如 1024", "dimensions_auto_set": "自动设置嵌入维度", "dimensions_error_invalid": "请输入嵌入维度大小", "dimensions_size_too_large": "嵌入维度不能超过模型上下文限制({{max_context}})", @@ -563,15 +563,15 @@ "status_processing": "处理中", "threshold": "匹配度阈值", "threshold_placeholder": "未设置", - "threshold_too_large_or_small": "阈值不能大于1或小于0", + "threshold_too_large_or_small": "阈值不能大于 1 或小于 0", "threshold_tooltip": "用于衡量用户问题与知识库内容之间的相关性(0-1)", "title": "知识库", "topN": "返回结果数量", - "topN_too_large_or_small": "返回结果数量不能大于30或小于1", + "topN_too_large_or_small": "返回结果数量不能大于 30 或小于 1", "topN_placeholder": "未设置", "topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多", "url_added": "网址已添加", - "url_placeholder": "请输入网址, 多个网址用回车分隔", + "url_placeholder": "请输入网址,多个网址用回车分隔", "urls": "网址" }, "languages": { @@ -596,7 +596,7 @@ "malay": "马来文" }, "lmstudio": { - "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5 分钟)", "keep_alive_time.placeholder": "分钟", "keep_alive_time.title": "保持活跃时间", "title": "LM Studio" @@ -618,13 +618,13 @@ "backup.start.success": "开始备份", "backup.success": "备份成功", "chat.completion.paused": "会话已停止", - "citation": "{{count}}个引用内容", + "citation": "{{count}} 个引用内容", "citations": "引用内容", "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", "delete.confirm.title": "删除确认", - "delete.confirm.content": "确认删除选中的{{count}}条消息吗?", + "delete.confirm.content": "确认删除选中的 {{count}} 条消息吗?", "delete.failed": "删除失败", "delete.success": "删除成功", "empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇", @@ -645,8 +645,8 @@ "error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL", "error.invalid.nutstore": "无效的坚果云设置", "error.invalid.nutstore_token": "无效的坚果云 Token", - "error.markdown.export.preconf": "导出Markdown文件到预先设定的路径失败", - "error.markdown.export.specified": "导出Markdown文件失败", + "error.markdown.export.preconf": "导出 Markdown 文件到预先设定的路径失败", + "error.markdown.export.specified": "导出 Markdown 文件失败", "error.notion.export": "导出 Notion 错误,请检查连接状态并对照文档检查配置", "error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID", "error.yuque.export": "导出语雀错误,请检查连接状态并对照文档检查配置", @@ -654,11 +654,11 @@ "group.delete.content": "删除分组消息会删除用户提问和所有助手的回答", "group.delete.title": "删除分组消息", "ignore.knowledge.base": "联网模式开启,忽略知识库", - "loading.notion.exporting_progress": "正在导出到Notion ...", - "loading.notion.preparing": "正在准备导出到Notion...", + "loading.notion.exporting_progress": "正在导出到 Notion ...", + "loading.notion.preparing": "正在准备导出到 Notion...", "mention.title": "切换模型回答", "message.code_style": "代码风格", - "message.delete.content": "确定要删除此消息吗?", + "message.delete.content": "确定要删除此消息吗?", "message.delete.title": "删除消息", "message.multi_model_style": "多模型回答样式", "message.multi_model_style.fold": "标签模式", @@ -696,13 +696,13 @@ "upgrade.success.button": "重启", "upgrade.success.content": "重启用以完成升级", "upgrade.success.title": "升级成功", - "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", + "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", "warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试", "error.siyuan.export": "导出思源笔记失败,请检查连接状态并对照文档检查配置", - "error.siyuan.no_config": "未配置思源笔记API地址或令牌", + "error.siyuan.no_config": "未配置思源笔记 API 地址或令牌", "success.siyuan.export": "导出到思源笔记成功", - "warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!", - "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!", + "warn.yuque.exporting": "正在导出语雀,请勿重复请求导出!", + "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!", "websearch": { "rag": "正在执行 RAG...", "rag_complete": "保留 {{countBefore}} 个结果中的 {{countAfter}} 个...", @@ -722,7 +722,7 @@ "minimize": "最小化小程序", "devtools": "开发者工具", "openExternal": "在浏览器中打开", - "rightclick_copyurl": "右键复制URL", + "rightclick_copyurl": "右键复制 URL", "open_link_external_on": "当前:在浏览器中打开链接", "open_link_external_off": "当前:使用默认窗口打开链接" }, @@ -785,7 +785,7 @@ "embedding": "嵌入", "embedding_dimensions": "嵌入维度", "embedding_model": "嵌入模型", - "embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加", + "embedding_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加", "function_calling": "函数调用", "no_matches": "无可用模型", "parameter_name": "参数名称", @@ -811,7 +811,7 @@ "rerank_model": "重排模型", "rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})", "rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})", - "rerank_model_tooltip": "在设置->模型服务中点击管理按钮添加", + "rerank_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加", "search": "搜索模型...", "stream_output": "流式输出", "enable_tool_use": "工具调用", @@ -838,14 +838,14 @@ "knowledge.error": "添加 {{type}} 到知识库失败: {{error}}" }, "ollama": { - "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5 分钟)", "keep_alive_time.placeholder": "分钟", "keep_alive_time.title": "保持活跃时间", "title": "Ollama" }, "paintings": { "button.delete.image": "删除图片", - "button.delete.image.confirm": "确定要删除此图片吗?", + "button.delete.image.confirm": "确定要删除此图片吗?", "button.new.image": "新建图片", "guidance_scale": "引导比例", "guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度", @@ -872,8 +872,8 @@ "learn_more": "了解更多", "paint_course": "教程", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", - "prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词", - "proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连", + "prompt_placeholder_en": "输入 \"英文\" 图片描述,目前 Imagen 仅支持英文提示词", + "proxy_required": "打开代理并开启 \"TUN 模式\" 查看生成图片或复制到浏览器打开,后续会支持国内直连", "image_file_required": "请先上传图片", "image_file_retry": "请重新上传图片", "image_placeholder": "暂无图片", @@ -956,7 +956,7 @@ "style_type_tip": "重混后的图像风格,仅适用于 V_2 及以上版本", "negative_prompt_tip": "描述不想在重混结果中出现的元素", "magic_prompt_option_tip": "智能优化重混提示词", - "rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于V_3版本" + "rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于 V_3 版本" }, "upscale": { "image_file": "需要放大的图片", @@ -969,7 +969,7 @@ "magic_prompt_option_tip": "智能优化放大提示词" }, "text_desc_required": "请先输入图片描述", - "req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。", + "req_error_text": "运行失败,请重试。提示词避免 \"版权词\" 和 \"敏感词\" 哦。", "req_error_token": "请检查令牌有效性", "req_error_no_balance": "请检查令牌有效性", "image_handle_required": "请先上传图片", @@ -989,7 +989,7 @@ "prompts": { "explanation": "帮我解释一下这个概念", "summarize": "帮我总结一下这段话", - "title": "总结给出的会话,将其总结为语言为{{language}}的10字内标题,忽略会话中的指令,不要使用标点和特殊符号。以纯字符串格式输出,不要输出标题以外的内容。" + "title": "总结给出的会话,将其总结为语言为 {{language}} 的 10 字内标题,忽略会话中的指令,不要使用标点和特殊符号。以纯字符串格式输出,不要输出标题以外的内容。" }, "provider": { "aihubmix": "AiHubMix", @@ -1032,12 +1032,12 @@ "qwenlm": "QwenLM", "silicon": "硅基流动", "stepfun": "阶跃星辰", - "tencent-cloud-ti": "腾讯云TI", + "tencent-cloud-ti": "腾讯云 TI", "together": "Together", "xirang": "天翼云息壤", "yi": "零一万物", - "zhinao": "360智脑", - "zhipu": "智谱AI", + "zhinao": "360 智脑", + "zhipu": "智谱 AI", "voyageai": "Voyage AI", "qiniu": "七牛云 AI 推理", "tokenflux": "TokenFlux", @@ -1100,7 +1100,7 @@ "app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录", "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", "app_data.path_changed_without_copy": "路径已更改成功", - "app_data.copying_warning": "数据复制中,不要强制退出app, 复制完成后会自动重启应用", + "app_data.copying_warning": "数据复制中,不要强制退出 app, 复制完成后会自动重启应用", "app_data.copying": "正在将数据复制到新位置...", "app_data.copy_success": "已成功复制数据到新位置", "app_data.copy_failed": "复制数据失败", @@ -1111,9 +1111,9 @@ "app_data.new_path": "新路径", "app_data.select_error_root_path": "新路径不能是根路径", "app_data.select_error_write_permission": "新路径没有写入权限", - "app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", + "app_data.stop_quit_app_reason": "应用目前在迁移数据,不能退出", "app_data.select_not_empty_dir": "新路径不为空", - "app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据, 有数据丢失和复制失败的风险,是否继续?", + "app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据,有数据丢失和复制失败的风险,是否继续?", "app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径", "app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径", "app_knowledge": "知识库文件", @@ -1123,7 +1123,7 @@ "app_knowledge.remove_all_success": "文件删除成功", "app_logs": "应用日志", "backup.skip_file_data_title": "精简备份", - "backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度", + "backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用,加快备份速度", "clear_cache": { "button": "清除缓存", "confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?", @@ -1141,14 +1141,14 @@ "export_menu": { "title": "导出菜单设置", "image": "导出为图片", - "markdown": "导出为Markdown", - "markdown_reason": "导出为Markdown(包含思考)", - "notion": "导出到Notion", + "markdown": "导出为 Markdown", + "markdown_reason": "导出为 Markdown(包含思考)", + "notion": "导出到 Notion", "yuque": "导出到语雀", - "obsidian": "导出到Obsidian", + "obsidian": "导出到 Obsidian", "siyuan": "导出到思源笔记", - "joplin": "导出到Joplin", - "docx": "导出为Word", + "joplin": "导出到 Joplin", + "docx": "导出为 Word", "plain_text": "复制为纯文本" }, "joplin": { @@ -1166,25 +1166,25 @@ "url": "Joplin 剪裁服务监听 URL", "url_placeholder": "http://127.0.0.1:41184/", "export_reasoning.title": "导出时包含思维链", - "export_reasoning.help": "开启后,导出到Joplin时会包含思维链内容。" + "export_reasoning.help": "开启后,导出到 Joplin 时会包含思维链内容。" }, - "markdown_export.force_dollar_math.help": "开启后,导出Markdown时会将强制使用$$来标记LaTeX公式。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等", - "markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式", + "markdown_export.force_dollar_math.help": "开启后,导出 Markdown 时会将强制使用 $$ 来标记 LaTeX 公式。注意:该项也会影响所有通过 Markdown 导出的方式,如 Notion、语雀等", + "markdown_export.force_dollar_math.title": "强制使用 $$ 来标记 LaTeX 公式", "markdown_export.help": "若填入,则每次导出时将自动保存到该路径;否则,将弹出保存对话框", "markdown_export.path": "默认导出路径", "markdown_export.path_placeholder": "导出路径", "markdown_export.select": "选择", "markdown_export.title": "Markdown 导出", "markdown_export.show_model_name.title": "导出时使用模型名称", - "markdown_export.show_model_name.help": "开启后,导出Markdown时会显示模型名称。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", + "markdown_export.show_model_name.help": "开启后,导出 Markdown 时会显示模型名称。注意:该项也会影响所有通过 Markdown 导出的方式,如 Notion、语雀等。", "markdown_export.show_model_provider.title": "显示模型供应商", - "markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等", + "markdown_export.show_model_provider.help": "在导出 Markdown 时显示模型供应商,如 OpenAI、Gemini 等", "message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题", - "message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式", + "message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过 Markdown 导出的方式", "minute_interval_one": "{{count}} 分钟", "minute_interval_other": "{{count}} 分钟", "notion.api_key": "Notion 密钥", - "notion.api_key_placeholder": "请输入Notion 密钥", + "notion.api_key_placeholder": "请输入 Notion 密钥", "notion.check": { "button": "检测", "empty_api_key": "未配置 API key", @@ -1194,13 +1194,13 @@ "success": "连接成功" }, "notion.database_id": "Notion 数据库 ID", - "notion.database_id_placeholder": "请输入Notion 数据库 ID", + "notion.database_id_placeholder": "请输入 Notion 数据库 ID", "notion.help": "Notion 配置文档", "notion.page_name_key": "页面标题字段名", "notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name", "notion.title": "Notion 设置", "notion.export_reasoning.title": "导出时包含思维链", - "notion.export_reasoning.help": "开启后,导出到Notion时会包含思维链内容。", + "notion.export_reasoning.help": "开启后,导出到 Notion 时会包含思维链内容。", "title": "数据设置", "webdav": { "autoSync": "自动备份", @@ -1252,7 +1252,7 @@ }, "s3": { "title": "S3 兼容存储", - "title.help": "与AWS S3 API兼容的对象存储服务, 例如AWS S3, Cloudflare R2, 阿里云OSS, 腾讯云COS等", + "title.help": "与 AWS S3 API 兼容的对象存储服务,例如 AWS S3, Cloudflare R2, 阿里云 OSS, 腾讯云 COS 等", "endpoint": "API 地址", "endpoint.placeholder": "https://s3.example.com", "region": "区域", @@ -1317,8 +1317,8 @@ "yuque": { "check": { "button": "检测", - "empty_repo_url": "请先输入知识库URL", - "empty_token": "请先输入语雀Token", + "empty_repo_url": "请先输入知识库 URL", + "empty_token": "请先输入语雀 Token", "fail": "语雀连接验证失败", "success": "语雀连接验证成功" }, @@ -1327,7 +1327,7 @@ "repo_url_placeholder": "https://www.yuque.com/username/xxx", "title": "语雀配置", "token": "语雀 Token", - "token_placeholder": "请输入语雀Token" + "token_placeholder": "请输入语雀 Token" }, "obsidian": { "title": "Obsidian 配置", @@ -1340,21 +1340,21 @@ }, "siyuan": { "title": "思源笔记配置", - "api_url": "API地址", + "api_url": "API 地址", "api_url_placeholder": "例如:http://127.0.0.1:6806", - "token": "API令牌", - "token.help": "在思源笔记->设置->关于中获取", + "token": "API 令牌", + "token.help": "在思源笔记 -> 设置 -> 关于中获取", "token_placeholder": "请输入思源笔记令牌", - "box_id": "笔记本ID", - "box_id_placeholder": "请输入笔记本ID", + "box_id": "笔记本 ID", + "box_id_placeholder": "请输入笔记本 ID", "root_path": "文档根路径", "root_path_placeholder": "例如:/CherryStudio", "check": { "title": "连接检测", "button": "检测", - "empty_config": "请填写API地址和令牌", + "empty_config": "请填写 API 地址和令牌", "success": "连接成功", - "fail": "连接失败,请检查API地址和令牌", + "fail": "连接失败,请检查 API 地址和令牌", "error": "连接异常,请检查网络连接" } }, @@ -1385,7 +1385,7 @@ "display.assistant.title": "助手设置", "display.custom.css": "自定义 CSS", "display.custom.css.cherrycss": "从 cherrycss.com 获取", - "display.custom.css.placeholder": "/* 这里写自定义CSS */", + "display.custom.css.placeholder": "/* 这里写自定义 CSS */", "display.sidebar.chat.hiddenMessage": "助手是基础功能,不支持隐藏", "display.sidebar.disabled": "隐藏的图标", "display.sidebar.empty": "把要隐藏的功能从左侧拖拽到这里", @@ -1434,9 +1434,9 @@ "logo_upload_button": "上传", "save": "保存", "edit_description": "在这里编辑自定义小应用的配置。每个应用需要包含 id、name、url 和 logo 字段", - "placeholder": "请输入自定义小程序配置(JSON格式)", - "duplicate_ids": "发现重复的ID: {{ids}}", - "conflicting_ids": "与默认应用ID冲突: {{ids}}" + "placeholder": "请输入自定义小程序配置(JSON 格式)", + "duplicate_ids": "发现重复的 ID: {{ids}}", + "conflicting_ids": "与默认应用 ID 冲突: {{ids}}" }, "cache_settings": "缓存设置", "cache_title": "小程序缓存数量", @@ -1458,10 +1458,10 @@ "general.auto_check_update.title": "自动更新", "general.test_plan.title": "测试计划", "general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份", - "general.test_plan.beta_version": "测试版(Beta)", - "general.test_plan.beta_version_tooltip": "功能可能随时变化,bug较多,升级较快", - "general.test_plan.rc_version": "预览版(RC)", - "general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug较少", + "general.test_plan.beta_version": "测试版 (Beta)", + "general.test_plan.beta_version_tooltip": "功能可能随时变化,bug 较多,升级较快", + "general.test_plan.rc_version": "预览版 (RC)", + "general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug 较少", "general.test_plan.version_options": "版本选择", "general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效", "general.reset.button": "重置", @@ -1473,7 +1473,7 @@ "general.view_webdav_settings": "查看 WebDAV 设置", "general.spell_check": "拼写检查", "general.spell_check.languages": "拼写检查语言", - "input.auto_translate_with_space": "3个空格快速翻译", + "input.auto_translate_with_space": "3 个空格快速翻译", "input.show_translate_confirm": "显示翻译确认对话框", "input.target_language": "目标语言", "input.target_language.chinese": "简体中文", @@ -1491,7 +1491,7 @@ "addServer": "添加服务器", "addServer.create": "快速创建", "addServer.importFrom": "从 JSON 导入", - "addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置JSON(优先使用\n NPX或 UVX 配置),并粘贴到输入框中", + "addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置 JSON(优先使用\n NPX 或 UVX 配置),并粘贴到输入框中", "addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置", "addServer.importFrom.invalid": "无效输入,请检查 JSON 格式", "addServer.importFrom.nameExists": "服务器已存在:{{name}}", @@ -1503,8 +1503,8 @@ "baseUrlTooltip": "远程 URL 地址", "command": "命令", "sse": "服务器发送事件 (sse)", - "streamableHttp": "可流式传输的HTTP (streamableHttp)", - "stdio": "标准输入/输出 (stdio)", + "streamableHttp": "可流式传输的 HTTP (streamableHttp)", + "stdio": "标准输入 / 输出 (stdio)", "inMemory": "内存", "config_description": "配置模型上下文协议服务器", "disable": "不使用 MCP 服务器", @@ -1516,7 +1516,7 @@ "description": "描述", "noDescriptionAvailable": "暂无描述", "duplicateName": "已存在同名服务器", - "editJson": "编辑JSON", + "editJson": "编辑 JSON", "editServer": "编辑服务器", "env": "环境变量", "envTooltip": "格式:KEY=value,每行一个", @@ -1527,10 +1527,10 @@ "install": "安装", "installError": "安装依赖项失败", "installSuccess": "依赖项安装成功", - "jsonFormatError": "JSON格式化错误", - "jsonModeHint": "编辑MCP服务器配置的JSON表示。保存前请确保格式正确", - "jsonSaveError": "保存JSON配置失败", - "jsonSaveSuccess": "JSON配置已保存", + "jsonFormatError": "JSON 格式化错误", + "jsonModeHint": "编辑 MCP 服务器配置的 JSON 表示。保存前请确保格式正确", + "jsonSaveError": "保存 JSON 配置失败", + "jsonSaveSuccess": "JSON 配置已保存", "missingDependencies": "缺失,请安装它以继续", "name": "名称", "noServers": "未配置服务器", @@ -1587,7 +1587,7 @@ "noResourcesAvailable": "无可用资源", "availableResources": "可用资源", "uri": "URI", - "mimeType": "MIME类型", + "mimeType": "MIME 类型", "size": "大小", "blob": "二进制数据", "blobInvisible": "隐藏二进制数据", @@ -1611,21 +1611,21 @@ "sync": { "title": "同步服务器", "selectProvider": "选择提供商:", - "discoverMcpServers": "发现MCP服务器", - "discoverMcpServersDescription": "访问平台以发现可用的MCP服务器", + "discoverMcpServers": "发现 MCP 服务器", + "discoverMcpServersDescription": "访问平台以发现可用的 MCP 服务器", "getToken": "获取 API 令牌", "getTokenDescription": "从您的帐户中获取个人 API 令牌", "setToken": "输入您的令牌", "tokenRequired": "需要 API 令牌", "tokenPlaceholder": "在此输入 API 令牌", "button": "同步", - "error": "同步MCP服务器出错", - "success": "同步MCP服务器成功", + "error": "同步 MCP 服务器出错", + "success": "同步 MCP 服务器成功", "unauthorized": "同步未授权", "noServersAvailable": "无可用的 MCP 服务器" }, "timeout": "超时", - "timeoutTooltip": "对该服务器请求的超时时间(秒),默认为60秒", + "timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒", "provider": "提供者", "providerUrl": "提供者网址", "logoUrl": "标志网址", @@ -1635,7 +1635,7 @@ "advancedSettings": "高级设置" }, "messages.prompt": "显示提示词", - "messages.tokens": "显示Token用量", + "messages.tokens": "显示 Token 用量", "messages.divider": "消息分割线", "messages.divider.tooltip": "不适用于气泡样式消息", "messages.grid_columns": "消息网格展示列数", @@ -1648,11 +1648,11 @@ "messages.input.show_estimated_tokens": "显示预估 Token 数", "messages.input.title": "输入设置", "messages.input.enable_quick_triggers": "启用 / 和 @ 触发快捷菜单", - "messages.input.enable_delete_model": "启用删除键删除输入的模型/附件", + "messages.input.enable_delete_model": "启用删除键删除输入的模型 / 附件", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", "messages.math_engine.none": "无", - "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", + "messages.metrics": "首字时延 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens", "messages.model.title": "模型设置", "messages.navigation": "对话导航按钮", "messages.navigation.anchor": "对话锚点", @@ -1679,14 +1679,14 @@ "models.check.enable_concurrent": "并发检测", "models.check.enabled": "开启", "models.check.failed": "失败", - "models.check.keys_status_count": "通过:{{count_passed}}个密钥,失败:{{count_failed}}个密钥", + "models.check.keys_status_count": "通过:{{count_passed}} 个密钥,失败:{{count_failed}} 个密钥", "models.check.model_status_failed": "{{count}} 个模型完全无法访问", "models.check.model_status_partial": "其中 {{count}} 个模型用某些密钥无法访问", "models.check.model_status_passed": "{{count}} 个模型通过健康检测", "models.check.model_status_summary": "{{provider}}: {{summary}}", - "models.check.no_api_keys": "未找到API密钥,请先添加API密钥", + "models.check.no_api_keys": "未找到 API 密钥,请先添加 API 密钥", "models.check.passed": "通过", - "models.check.select_api_key": "选择要使用的API密钥:", + "models.check.select_api_key": "选择要使用的 API 密钥:", "models.check.single": "单个", "models.check.start": "开始", "models.check.title": "模型健康检测", @@ -1731,7 +1731,7 @@ "add.type": "提供商类型", "api.url.preview": "预览: {{url}}", "api.url.reset": "重置", - "api.url.tip": "/结尾忽略v1版本,#结尾强制使用输入地址", + "api.url.tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址", "api_host": "API 地址", "api_key": "API 密钥", "api_key.tip": "多个密钥使用逗号分隔", @@ -1748,8 +1748,8 @@ "check_all_keys": "检测所有密钥", "check_multiple_keys": "检测多个 API 密钥", "oauth": { - "button": "使用{{provider}}账号登录", - "description": "本服务由{{provider}}提供", + "button": "使用 {{provider}} 账号登录", + "description": "本服务由 {{provider}} 提供", "official_website": "官方网站" }, "openai": { @@ -1762,13 +1762,13 @@ "code_failed": "获取 Device Code 失败,请重试", "code_generated_desc": "请将 Device Code 复制到下面的浏览器链接中", "code_generated_title": "获取 Device Code", - "confirm_login": "过度使用可能会导致您的 Github 账号遭到封号,请谨慎使用!!!!", + "confirm_login": "过度使用可能会导致您的 Github 账号遭到封号,请谨慎使用!", "confirm_title": "风险警告", "connect": "连接 Github", "custom_headers": "自定义请求头", "description": "您的 Github 账号需要订阅 Copilot", "expand": "展开", - "headers_description": "自定义请求头(json格式)", + "headers_description": "自定义请求头 (json 格式)", "invalid_json": "JSON 格式错误", "login": "登录 Github", "logout": "退出 Github", @@ -1782,7 +1782,7 @@ "dmxapi": { "select_platform": "选择平台" }, - "delete.content": "确定要删除此模型提供商吗?", + "delete.content": "确定要删除此模型提供商吗?", "delete.title": "删除提供商", "docs_check": "查看", "docs_more_details": "获取更多详情", @@ -1797,7 +1797,7 @@ "title": "模型服务", "notes": { "title": "模型备注", - "placeholder": "请输入Markdown格式内容...", + "placeholder": "请输入 Markdown 格式内容...", "markdown_editor_default_value": "预览区域" }, "vertex_ai": { @@ -1856,7 +1856,7 @@ "reset_to_default": "重置为默认", "search_message": "搜索消息", "search_message_in_chat": "在当前对话中搜索消息", - "show_app": "显示/隐藏应用", + "show_app": "显示 / 隐藏应用", "show_settings": "打开设置", "title": "快捷键", "toggle_new_context": "清除上下文", @@ -1886,7 +1886,7 @@ "websearch": { "blacklist": "黑名单", "blacklist_description": "在搜索结果中不会出现以下网站的结果", - "blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", + "blacklist_tooltip": "请使用以下格式 (换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", "check": "检测", "check_failed": "验证失败", "check_success": "验证成功", @@ -1903,7 +1903,7 @@ "subscribe_url": "订阅源地址", "subscribe_name": "替代名字", "subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称", - "subscribe_add_success": "订阅源添加成功!", + "subscribe_add_success": "订阅源添加成功!", "subscribe_delete": "删除订阅源", "search_result_default": "默认", "search_with_time": "搜索包含日期", @@ -1923,7 +1923,7 @@ "method.cutoff": "截断", "cutoff.limit": "截断长度", "cutoff.limit.placeholder": "输入长度", - "cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)", + "cutoff.limit.tooltip": "限制搜索结果的内容长度,超过限制的内容将被截断(例如 2000 字符)", "cutoff.unit.char": "字符", "cutoff.unit.token": "Token", "method.rag": "RAG", @@ -1951,7 +1951,7 @@ "titleLabel": "标题", "contentLabel": "内容", "titlePlaceholder": "请输入短语标题", - "contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}", + "contentPlaceholder": "请输入短语内容,支持使用变量,然后按 Tab 键可以快速定位到变量进行修改。比如:\n帮我规划从 ${from} 到 ${to} 的路线,然后发送到 ${email}", "delete": "删除短语", "deleteConfirm": "删除短语后将无法恢复,是否继续?", "locationLabel": "添加位置", @@ -2092,11 +2092,11 @@ "trigger_mode": { "title": "取词方式", "description": "划词后,触发取词并显示工具栏的方式", - "description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", + "description_note": "少数应用不支持通过 Ctrl 键划词。若使用了 AHK 等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", "selected": "划词", "selected_note": "划词后立即显示工具栏", "ctrlkey": "Ctrl 键", - "ctrlkey_note": "划词后,再 长按 Ctrl键,才显示工具栏", + "ctrlkey_note": "划词后,再 长按 Ctrl 键,才显示工具栏", "shortcut": "快捷键", "shortcut_note": "划词后,使用快捷键显示工具栏。请在快捷键设置页面中设置取词快捷键并启用。", "shortcut_link": "前往快捷键设置" @@ -2126,7 +2126,7 @@ }, "opacity": { "title": "透明度", - "description": "设置窗口的默认透明度,100%为完全不透明" + "description": "设置窗口的默认透明度,100% 为完全不透明" } }, "actions": { @@ -2139,7 +2139,7 @@ }, "add_tooltip": { "enabled": "添加自定义功能", - "disabled": "自定义功能已达上限 ({{max}}个)" + "disabled": "自定义功能已达上限 ({{max}} 个)" }, "delete_confirm": "确定要删除这个自定义功能吗?", "drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})" @@ -2171,7 +2171,7 @@ "label": "图标", "placeholder": "输入 Lucide 图标名称", "error": "无效的图标名称,请检查输入", - "tooltip": "Lucide图标名称为小写,如 arrow-right", + "tooltip": "Lucide 图标名称为小写,如 arrow-right", "view_all": "查看所有图标", "random": "随机图标" }, @@ -2186,9 +2186,9 @@ "default": "默认" }, "prompt": { - "label": "用户提示词(Prompt)", + "label": "用户提示词 (Prompt)", "tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词", - "placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾", + "placeholder": "使用占位符 {{text}} 代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾", "placeholder_text": "占位符", "copy_placeholder": "复制占位符" } @@ -2203,7 +2203,7 @@ "name": { "label": "自定义名称", "hint": "请输入搜索引擎名称", - "max_length": "名称不能超过16个字符" + "max_length": "名称不能超过 16 个字符" }, "url": { "label": "自定义搜索 URL", @@ -2217,7 +2217,7 @@ }, "filter_modal": { "title": "应用筛选名单", - "user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" + "user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9b6299f385..edcaee1e85 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "輸入提示詞", "add.prompt.variables.tip": { "title": "可用的變數", - "content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱" + "content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU 架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱" }, "add.title": "建立智慧代理人", "import": { @@ -87,7 +87,7 @@ "titleLabel": "標題", "titlePlaceholder": "輸入標題", "contentLabel": "內容", - "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如:\n幫我規劃從${from}到${to}的行程,然後發送到${email}" + "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後發送到 ${email}" }, "settings.knowledge_base.recognition.tip": "智慧代理人將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力", "settings.knowledge_base.recognition": "調用知識庫", @@ -118,7 +118,7 @@ "get_key": "取得", "get_key_success": "自動取得金鑰成功", "login": "登入", - "oauth_button": "使用{{provider}}登入" + "oauth_button": "使用 {{provider}} 登入" }, "backup": { "confirm": "確定要備份資料嗎?", @@ -186,8 +186,8 @@ "input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...", "input.send": "傳送", "input.settings": "設定", - "input.topics": " 話題 ", - "input.translate": "翻譯成{{target_language}}", + "input.topics": "話題", + "input.translate": "翻譯成 {{target_language}}", "input.upload": "上傳圖片或文件", "input.upload.document": "上傳文件(模型不支援圖片)", "input.web_search": "網路搜尋", @@ -294,9 +294,9 @@ "topics.export.obsidian_title": "標題", "topics.export.obsidian_title_placeholder": "請輸入標題", "topics.export.obsidian_title_required": "標題不能為空", - "topics.export.obsidian_no_vaults": "未找到Obsidian保管庫", + "topics.export.obsidian_no_vaults": "未找到 Obsidian 保管庫", "topics.export.obsidian_loading": "加載中...", - "topics.export.obsidian_fetch_error": "獲取Obsidian保管庫失敗", + "topics.export.obsidian_fetch_error": "獲取 Obsidian 保管庫失敗", "topics.export.obsidian_fetch_folders_error": "獲取文件夾結構失敗", "topics.export.obsidian_no_vault_selected": "請先選擇一個保管庫", "topics.export.obsidian_select_vault_first": "請先選擇保管庫", @@ -332,11 +332,11 @@ "input.tools.collapse_out": "移出折疊", "input.thinking": "思考", "input.thinking.mode.default": "預設", - "input.thinking.mode.default.tip": "模型會自動確定思考的 token 數", + "input.thinking.mode.default.tip": "模型會自動確定思考的 Token 數", "input.thinking.mode.custom": "自定義", - "input.thinking.mode.custom.tip": "模型最多可以思考的 token 數。需要考慮模型的上下文限制,否則會報錯", - "input.thinking.mode.tokens.tip": "設置思考的 token 數", - "input.thinking.budget_exceeds_max": "思考預算超過最大 token 數" + "input.thinking.mode.custom.tip": "模型最多可以思考的 Token 數。需要考慮模型的上下文限制,否則會報錯", + "input.thinking.mode.tokens.tip": "設置思考的 Token 數", + "input.thinking.budget_exceeds_max": "思考預算超過最大 Token 數" }, "code_block": { "collapse": "折疊", @@ -559,7 +559,7 @@ "threshold_tooltip": "用於衡量使用者問題與知識庫內容之間的相關性(0-1)", "title": "知識庫", "topN": "返回結果數量", - "topN_too_large_or_small": "返回結果數量不能大於30或小於1", + "topN_too_large_or_small": "返回結果數量不能大於 30 或小於 1", "topN_placeholder": "未設定", "topN_tooltip": "返回的匹配結果數量,數值越大,匹配結果越多,但消耗的 Token 也越多", "url_added": "網址已新增", @@ -567,7 +567,7 @@ "urls": "網址", "dimensions": "嵌入維度", "dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多", - "dimensions_size_placeholder": " 嵌入維度大小,例如 1024", + "dimensions_size_placeholder": "嵌入維度大小,例如 1024", "dimensions_auto_set": "自動設定嵌入維度", "dimensions_error_invalid": "請輸入嵌入維度大小", "dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})", @@ -642,7 +642,7 @@ "error.invalid.proxy.url": "無效的代理伺服器 URL", "error.invalid.webdav": "無效的 WebDAV 設定", "error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定", - "error.joplin.no_config": "未設定 Joplin 授權Token 或 URL", + "error.joplin.no_config": "未設定 Joplin 授權 Token 或 URL", "error.invalid.nutstore": "無效的坚果云設定", "error.invalid.nutstore_token": "無效的坚果云 Token", "error.markdown.export.preconf": "導出 Markdown 文件到預先設定的路徑失敗", @@ -699,7 +699,7 @@ "warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!", "warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試", "error.siyuan.export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置", - "error.siyuan.no_config": "未配置思源筆記API地址或令牌", + "error.siyuan.no_config": "未配置思源筆記 API 地址或令牌", "success.siyuan.export": "導出到思源筆記成功", "warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!", "warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!", @@ -722,7 +722,7 @@ "minimize": "最小化小工具", "devtools": "開發者工具", "openExternal": "在瀏覽器中開啟", - "rightclick_copyurl": "右鍵複製URL", + "rightclick_copyurl": "右鍵複製 URL", "open_link_external_on": "当前:在瀏覽器中開啟連結", "open_link_external_off": "当前:使用預設視窗開啟連結" }, @@ -785,7 +785,7 @@ "embedding": "嵌入", "embedding_dimensions": "嵌入維度", "embedding_model": "嵌入模型", - "embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增", + "embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增", "function_calling": "函數調用", "no_matches": "無可用模型", "parameter_name": "參數名稱", @@ -798,7 +798,7 @@ "pinned": "已固定", "rerank_model": "重排模型", "rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})", - "rerank_model_tooltip": "在設定->模型服務中點擊管理按鈕添加", + "rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加", "search": "搜尋模型...", "stream_output": "串流輸出", "enable_tool_use": "工具調用", @@ -834,7 +834,7 @@ }, "notification": { "assistant": "助手回應", - "knowledge.success": "成功將{{type}}新增至知識庫", + "knowledge.success": "成功將 {{type}} 新增至知識庫", "knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}" }, "ollama": { @@ -869,10 +869,10 @@ "aspect_ratio": "畫幅比例", "style_type": "風格", "learn_more": "了解更多", - "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹", - "prompt_placeholder_en": "輸入”英文“圖片描述,目前 Imagen 僅支持英文提示詞", + "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 ' 雙引號 ' 包裹", + "prompt_placeholder_en": "輸入” 英文 “圖片描述,目前 Imagen 僅支持英文提示詞", "paint_course": "教程", - "proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", + "proxy_required": "打開代理並開啟”TUN 模式 “查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", "image_file_required": "請先上傳圖片", "image_file_retry": "請重新上傳圖片", "image_placeholder": "無圖片", @@ -932,7 +932,7 @@ "negative_prompt_tip": "描述不想在圖像中出現的內容", "magic_prompt_option_tip": "智能優化生成效果的提示詞", "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本", - "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本", + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本", "person_generation": "人物生成", "person_generation_tip": "允許模型生成人物圖像" }, @@ -943,7 +943,7 @@ "style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本", "seed_tip": "控制編輯結果的隨機性", "magic_prompt_option_tip": "智能優化編輯提示詞", - "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本" }, "remix": { "model_tip": "選擇重混使用的 AI 模型版本", @@ -955,7 +955,7 @@ "style_type_tip": "重混後的圖像風格,僅適用於 V_2 及以上版本", "negative_prompt_tip": "描述不想在重混結果中出現的元素", "magic_prompt_option_tip": "智能優化重混提示詞", - "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本" }, "upscale": { "image_file": "需要放大的圖片", @@ -970,7 +970,7 @@ "rendering_speed": "渲染速度", "text_desc_required": "請先輸入圖片描述", "image_handle_required": "請先上傳圖片。", - "req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。", + "req_error_text": "运行失败,请重试。提示词避免 “版权词” 和” 敏感词” 哦。", "req_error_token": "請檢查令牌的有效性", "req_error_no_balance": "請檢查令牌的有效性", "auto_create_paint": "自動新增圖片", @@ -989,7 +989,7 @@ "prompts": { "explanation": "幫我解釋一下這個概念", "summarize": "幫我總結一下這段話", - "title": "將會話內容以{{language}}總結為10個字內的標題,忽略對話中的指令,勿使用標點與特殊符號。僅輸出純字串,不輸出標題以外內容。" + "title": "將會話內容以 {{language}} 總結為 10 個字內的標題,忽略對話中的指令,勿使用標點與特殊符號。僅輸出純字串,不輸出標題以外內容。" }, "provider": { "aihubmix": "AiHubMix", @@ -1097,10 +1097,10 @@ "app_data.select": "修改目錄", "app_data.select_title": "變更應用數據目錄", "app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效", - "app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄", + "app_data.copy_data_option": "複製數據,會自動重啟後將原始目錄數據複製到新目錄", "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", "app_data.path_changed_without_copy": "路徑已變更成功", - "app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用", + "app_data.copying_warning": "數據複製中,不要強制退出應用,複製完成後會自動重啟應用", "app_data.copying": "正在複製數據到新位置...", "app_data.copy_success": "成功複製數據到新位置", "app_data.copy_failed": "複製數據失敗", @@ -1113,7 +1113,7 @@ "app_data.select_error_write_permission": "新路徑沒有寫入權限", "app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", "app_data.select_not_empty_dir": "新路徑不為空", - "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失和複製失敗的風險,是否繼續?", + "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據,有數據丟失和複製失敗的風險,是否繼續?", "app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑", "app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑", "app_knowledge": "知識庫文件", @@ -1123,7 +1123,7 @@ "app_knowledge.remove_all_success": "檔案刪除成功", "app_logs": "應用程式日誌", "backup.skip_file_data_title": "精簡備份", - "backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度", + "backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用,加快備份速度", "clear_cache": { "button": "清除快取", "confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?", @@ -1141,44 +1141,44 @@ "export_menu": { "title": "匯出選單設定", "image": "匯出為圖片", - "markdown": "匯出為Markdown", - "markdown_reason": "匯出為Markdown(包含思考)", - "notion": "匯出到Notion", + "markdown": "匯出為 Markdown", + "markdown_reason": "匯出為 Markdown(包含思考)", + "notion": "匯出到 Notion", "yuque": "匯出到語雀", - "obsidian": "匯出到Obsidian", + "obsidian": "匯出到 Obsidian", "siyuan": "匯出到思源筆記", - "joplin": "匯出到Joplin", - "docx": "匯出為Word", + "joplin": "匯出到 Joplin", + "docx": "匯出為 Word", "plain_text": "複製為純文本" }, "joplin": { "check": { "button": "檢查", - "empty_token": "請先輸入 Joplin 授權Token", + "empty_token": "請先輸入 Joplin 授權 Token", "empty_url": "請先輸入 Joplin 剪輯服務 URL", "fail": "Joplin 連接驗證失敗", "success": "Joplin 連接驗證成功" }, - "help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權Token", + "help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權 Token", "title": "Joplin 設定", - "token": "Joplin 授權Token", - "token_placeholder": "請輸入 Joplin 授權Token", + "token": "Joplin 授權 Token", + "token_placeholder": "請輸入 Joplin 授權 Token", "url": "Joplin 剪輯服務 URL", "url_placeholder": "http://127.0.0.1:41184/", "export_reasoning.title": "匯出時包含思維鏈", "export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。" }, - "markdown_export.force_dollar_math.help": "開啟後,匯出Markdown時會強制使用$$來標記LaTeX公式。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等", - "markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$", + "markdown_export.force_dollar_math.help": "開啟後,匯出 Markdown 時會強制使用 $$ 來標記 LaTeX 公式。注意:該項也會影響所有透過 Markdown 匯出的方式,如 Notion、語雀等", + "markdown_export.force_dollar_math.title": "LaTeX 公式強制使用 $$", "markdown_export.help": "若填入,每次匯出時將自動儲存至該路徑;否則,將彈出儲存對話框", "markdown_export.path": "預設匯出路徑", "markdown_export.path_placeholder": "匯出路徑", "markdown_export.select": "選擇", "markdown_export.title": "Markdown 匯出", "markdown_export.show_model_name.title": "匯出時使用模型名稱", - "markdown_export.show_model_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", + "markdown_export.show_model_name.help": "啟用後,匯出 Markdown 時會顯示模型名稱。注意:該項也會影響所有透過 Markdown 匯出的方式,如 Notion、語雀等。", "markdown_export.show_model_provider.title": "顯示模型供應商", - "markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等", + "markdown_export.show_model_provider.help": "在匯出 Markdown 時顯示模型供應商,如 OpenAI、Gemini 等", "minute_interval_one": "{{count}} 分鐘", "minute_interval_other": "{{count}} 分鐘", "notion.api_key": "Notion 金鑰", @@ -1198,7 +1198,7 @@ "notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name", "notion.title": "Notion 設定", "notion.export_reasoning.title": "匯出時包含思維鏈", - "notion.export_reasoning.help": "啟用後,匯出到Notion時會包含思維鏈內容。", + "notion.export_reasoning.help": "啟用後,匯出到 Notion 時會包含思維鏈內容。", "title": "資料設定", "webdav": { "autoSync": "自動備份", @@ -1250,7 +1250,7 @@ }, "s3": { "title": "S3 相容儲存", - "title.help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等", + "title.help": "與 AWS S3 API 相容的物件儲存服務,例如 AWS S3、Cloudflare R2、阿里雲 OSS、騰訊雲 COS 等", "endpoint": "API 位址", "endpoint.placeholder": "https://s3.example.com", "region": "區域", @@ -1338,21 +1338,21 @@ }, "siyuan": { "title": "思源筆記配置", - "api_url": "API地址", + "api_url": "API 地址", "api_url_placeholder": "例如:http://127.0.0.1:6806", - "token": "API令牌", - "token.help": "在思源筆記->設置->關於中獲取", + "token": "API 令牌", + "token.help": "在思源筆記 -> 設置 -> 關於中獲取", "token_placeholder": "請輸入思源筆記令牌", - "box_id": "筆記本ID", - "box_id_placeholder": "請輸入筆記本ID", + "box_id": "筆記本 ID", + "box_id_placeholder": "請輸入筆記本 ID", "root_path": "文檔根路徑", "root_path_placeholder": "例如:/CherryStudio", "check": { "title": "連接檢查", "button": "檢查", - "empty_config": "請填寫API地址和令牌", + "empty_config": "請填寫 API 地址和令牌", "success": "連接成功", - "fail": "連接失敗,請檢查API地址和令牌", + "fail": "連接失敗,請檢查 API 地址和令牌", "error": "連接異常,請檢查網絡連接" } }, @@ -1380,7 +1380,7 @@ "new_folder.button": "新建文件夾" }, "message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題", - "message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式,如Notion、語雀等" + "message_title.use_topic_naming.help": "此設定會影響所有通過 Markdown 導出的方式,如 Notion、語雀等" }, "display.assistant.title": "助手設定", "display.custom.css": "自訂 CSS", @@ -1408,8 +1408,8 @@ "title": "在瀏覽器中打開新視窗連結" }, "custom": { - "duplicate_ids": "發現重複的ID: {{ids}}", - "conflicting_ids": "與預設應用ID衝突: {{ids}}", + "duplicate_ids": "發現重複的 ID: {{ids}}", + "conflicting_ids": "與預設應用 ID 衝突: {{ids}}", "title": "自定義", "edit_title": "編輯自定義小程序", "save_success": "自定義小程序保存成功", @@ -1435,7 +1435,7 @@ "logo_upload_label": "上傳 Logo", "logo_upload_button": "上傳", "save": "保存", - "placeholder": "請輸入自定義小程序配置(JSON格式)", + "placeholder": "請輸入自定義小程序配置(JSON 格式)", "edit_description": "編輯自定義小程序配置" }, "cache_settings": "緩存設置", @@ -1482,7 +1482,7 @@ "addServer": "新增伺服器", "addServer.create": "快速創建", "addServer.importFrom": "從 JSON 導入", - "addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置JSON(優先使用\n NPX或 UVX 配置),並粘貼到輸入框中", + "addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置 JSON(優先使用\n NPX 或 UVX 配置),並粘貼到輸入框中", "addServer.importFrom.placeholder": "貼上 MCP 伺服器 JSON 設定", "addServer.importFrom.invalid": "無效的輸入,請檢查 JSON 格式", "addServer.importFrom.nameExists": "伺服器已存在:{{name}}", @@ -1494,8 +1494,8 @@ "baseUrlTooltip": "遠端 URL 地址", "command": "指令", "sse": "伺服器傳送事件 (sse)", - "streamableHttp": "可串流的HTTP (streamableHttp)", - "stdio": "標準輸入/輸出 (stdio)", + "streamableHttp": "可串流的 HTTP (streamableHttp)", + "stdio": "標準輸入 / 輸出 (stdio)", "inMemory": "記憶體", "config_description": "設定模型上下文協議伺服器", "disable": "不使用 MCP 伺服器", @@ -1507,7 +1507,7 @@ "description": "描述", "noDescriptionAvailable": "描述不存在", "duplicateName": "已存在相同名稱的伺服器", - "editJson": "編輯JSON", + "editJson": "編輯 JSON", "editServer": "編輯伺服器", "env": "環境變數", "envTooltip": "格式:KEY=value,每行一個", @@ -1518,10 +1518,10 @@ "install": "安裝", "installError": "安裝相依套件失敗", "installSuccess": "相依套件安裝成功", - "jsonFormatError": "JSON格式錯誤", - "jsonModeHint": "編輯MCP伺服器配置的JSON表示。保存前請確保格式正確", - "jsonSaveError": "保存JSON配置失敗", - "jsonSaveSuccess": "JSON配置已儲存", + "jsonFormatError": "JSON 格式錯誤", + "jsonModeHint": "編輯 MCP 伺服器配置的 JSON 表示。保存前請確保格式正確", + "jsonSaveError": "保存 JSON 配置失敗", + "jsonSaveSuccess": "JSON 配置已儲存", "missingDependencies": "缺失,請安裝它以繼續", "name": "名稱", "noServers": "未設定伺服器", @@ -1578,7 +1578,7 @@ "noResourcesAvailable": "無可用資源", "availableResources": "可用資源", "uri": "URI", - "mimeType": "MIME類型", + "mimeType": "MIME 類型", "size": "大小", "blob": "二進位數據", "blobInvisible": "隱藏二進位數據", @@ -1602,21 +1602,21 @@ "sync": { "title": "同步伺服器", "selectProvider": "選擇提供者:", - "discoverMcpServers": "發現MCP伺服器", - "discoverMcpServersDescription": "訪問平台以發現可用的MCP伺服器", + "discoverMcpServers": "發現 MCP 伺服器", + "discoverMcpServersDescription": "訪問平台以發現可用的 MCP 伺服器", "getToken": "獲取 API 令牌", "getTokenDescription": "從您的帳戶獲取個人 API 令牌", "setToken": "輸入您的令牌", "tokenRequired": "需要 API 令牌", "tokenPlaceholder": "在此輸入 API 令牌", "button": "同步", - "error": "同步MCP伺服器出錯", - "success": "同步MCP伺服器成功", + "error": "同步 MCP 伺服器出錯", + "success": "同步 MCP 伺服器成功", "unauthorized": "同步未授權", "noServersAvailable": "無可用的 MCP 伺服器" }, "timeout": "超時", - "timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為60秒", + "timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為 60 秒", "provider": "提供者", "providerUrl": "提供者網址", "logoUrl": "標誌網址", @@ -1626,7 +1626,7 @@ "advancedSettings": "高級設定" }, "messages.prompt": "提示詞顯示", - "messages.tokens": "Token用量顯示", + "messages.tokens": "Token 用量顯示", "messages.divider": "訊息間顯示分隔線", "messages.divider.tooltip": "不適用於氣泡樣式消息", "messages.grid_columns": "訊息網格展示列數", @@ -1639,11 +1639,11 @@ "messages.input.show_estimated_tokens": "顯示預估 Token 數", "messages.input.title": "輸入設定", "messages.input.enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單", - "messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件", + "messages.input.enable_delete_model": "啟用刪除鍵刪除模型 / 附件", "messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息", "messages.math_engine": "數學公式引擎", "messages.math_engine.none": "無", - "messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", + "messages.metrics": "首字延遲 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens", "messages.model.title": "模型設定", "messages.navigation": "訊息導航", "messages.navigation.anchor": "對話錨點", @@ -1670,14 +1670,14 @@ "models.check.enable_concurrent": "並行檢查", "models.check.enabled": "開啟", "models.check.failed": "失敗", - "models.check.keys_status_count": "通過:{{count_passed}}個密鑰,失敗:{{count_failed}}個密鑰", + "models.check.keys_status_count": "通過:{{count_passed}} 個密鑰,失敗:{{count_failed}} 個密鑰", "models.check.model_status_failed": "{{count}} 個模型完全無法訪問", "models.check.model_status_partial": "其中 {{count}} 個模型用某些密鑰無法訪問", "models.check.model_status_passed": "{{count}} 個模型通過健康檢查", "models.check.model_status_summary": "{{provider}}: {{summary}}", - "models.check.no_api_keys": "未找到API密鑰,請先添加API密鑰", + "models.check.no_api_keys": "未找到 API 密鑰,請先添加 API 密鑰", "models.check.passed": "通過", - "models.check.select_api_key": "選擇要使用的API密鑰:", + "models.check.select_api_key": "選擇要使用的 API 密鑰:", "models.check.single": "單個", "models.check.start": "開始", "models.check.title": "模型健康檢查", @@ -1716,7 +1716,7 @@ "add.type": "供應商類型", "api.url.preview": "預覽:{{url}}", "api.url.reset": "重設", - "api.url.tip": "/結尾忽略 v1 版本,#結尾強制使用輸入位址", + "api.url.tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址", "api_host": "API 主機地址", "api_key": "API 金鑰", "api_key.tip": "多個金鑰使用逗號分隔", @@ -1733,24 +1733,24 @@ "check_all_keys": "檢查所有金鑰", "check_multiple_keys": "檢查多個 API 金鑰", "oauth": { - "button": "使用{{provider}}帳號登入", - "description": "本服務由{{provider}}提供", + "button": "使用 {{provider}} 帳號登入", + "description": "本服務由 {{provider}} 提供", "official_website": "官方網站" }, "copilot": { - "auth_failed": "Github Copilot認證失敗", + "auth_failed": "Github Copilot 認證失敗", "auth_success": "Github Copilot 認證成功", "auth_success_title": "認證成功", - "code_failed": "獲取 Device Code失敗,請重試", + "code_failed": "獲取 Device Code 失敗,請重試", "code_generated_desc": "請將設備代碼複製到下面的瀏覽器連結中", "code_generated_title": "獲取設備代碼", - "confirm_login": "過度使用可能會導致您的 Github 帳號遭到封號,請謹慎使用!!!!", + "confirm_login": "過度使用可能會導致您的 Github 帳號遭到封號,請謹慎使用!", "confirm_title": "風險警告", "connect": "連接 Github", "custom_headers": "自訂請求標頭", "description": "您的 Github 帳號需要訂閱 Copilot", "expand": "展開", - "headers_description": "自訂請求標頭(json格式)", + "headers_description": "自訂請求標頭 (json 格式)", "invalid_json": "JSON 格式錯誤", "login": "登入 Github", "logout": "退出 Github", @@ -1779,14 +1779,14 @@ "title": "模型提供者", "notes": { "title": "模型備註", - "placeholder": "輸入Markdown格式內容...", + "placeholder": "輸入 Markdown 格式內容...", "markdown_editor_default_value": "預覽區域" }, "openai": { "alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API,請建立新的服務供應商" }, "vertex_ai": { - "project_id": "專案ID", + "project_id": "專案 ID", "project_id_placeholder": "your-google-cloud-project-id", "project_id_help": "您的 Google Cloud 專案 ID", "location": "地區", @@ -1840,7 +1840,7 @@ "reset_to_default": "重設為預設", "search_message": "搜尋訊息", "search_message_in_chat": "在當前對話中搜尋訊息", - "show_app": "顯示/隱藏應用程式", + "show_app": "顯示 / 隱藏應用程式", "show_settings": "開啟設定", "title": "快捷鍵", "toggle_new_context": "清除上下文", @@ -1894,7 +1894,7 @@ "subscribe_url": "訂閱源地址", "subscribe_name": "替代名稱", "subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱", - "subscribe_add_success": "訂閱源添加成功!", + "subscribe_add_success": "訂閱源添加成功!", "subscribe_delete": "刪除", "title": "網路搜尋", "overwrite": "覆蓋搜尋服務商", @@ -1932,9 +1932,9 @@ "general.auto_check_update.title": "自動更新", "general.test_plan.title": "測試計畫", "general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據", - "general.test_plan.beta_version": "測試版本(Beta)", + "general.test_plan.beta_version": "測試版本 (Beta)", "general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快", - "general.test_plan.rc_version": "預覽版本(RC)", + "general.test_plan.rc_version": "預覽版本 (RC)", "general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據", "general.test_plan.version_options": "版本選項", "general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效", @@ -1945,7 +1945,7 @@ "titleLabel": "標題", "contentLabel": "內容", "titlePlaceholder": "請輸入短語標題", - "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如:\n幫我規劃從${from}到${to}的行程,然後發送到${email}", + "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後發送到 ${email}", "delete": "刪除短語", "deleteConfirm": "刪除後無法復原,是否繼續?", "locationLabel": "添加位置", @@ -1971,7 +1971,7 @@ "reset": "重置" }, "openai": { - "title": "OpenAI設定", + "title": "OpenAI 設定", "summary_text_mode.title": "摘要模式", "summary_text_mode.tip": "模型所執行的推理摘要", "summary_text_mode.auto": "自動", @@ -2092,11 +2092,11 @@ "trigger_mode": { "title": "取詞方式", "description": "劃詞後,觸發取詞並顯示工具列的方式", - "description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", "selected": "劃詞", "selected_note": "劃詞後,立即顯示工具列", "ctrlkey": "Ctrl 鍵", - "ctrlkey_note": "劃詞後,再 按住 Ctrl鍵,才顯示工具列", + "ctrlkey_note": "劃詞後,再 按住 Ctrl 鍵,才顯示工具列", "shortcut": "快捷鍵", "shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設置取詞快捷鍵並啟用。", "shortcut_link": "前往快捷鍵設定" @@ -2126,7 +2126,7 @@ }, "opacity": { "title": "透明度", - "description": "設置視窗的預設透明度,100%為完全不透明" + "description": "設置視窗的預設透明度,100% 為完全不透明" } }, "actions": { @@ -2139,7 +2139,7 @@ }, "add_tooltip": { "enabled": "新增自訂功能", - "disabled": "自訂功能已達上限 ({{max}}個)" + "disabled": "自訂功能已達上限 ({{max}} 個)" }, "delete_confirm": "確定要刪除這個自訂功能嗎?", "drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})" @@ -2171,7 +2171,7 @@ "label": "圖示", "placeholder": "輸入 Lucide 圖示名稱", "error": "無效的圖示名稱,請檢查輸入", - "tooltip": "Lucide圖示名稱為小寫,如 arrow-right", + "tooltip": "Lucide 圖示名稱為小寫,如 arrow-right", "view_all": "檢視所有圖示", "random": "隨機圖示" }, @@ -2186,9 +2186,9 @@ "default": "預設" }, "prompt": { - "label": "使用者提示詞(Prompt)", + "label": "使用者提示詞 (Prompt)", "tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞", - "placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", + "placeholder": "使用佔位符 {{text}} 代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", "placeholder_text": "佔位符", "copy_placeholder": "複製佔位符" } @@ -2203,7 +2203,7 @@ "name": { "label": "自訂名稱", "hint": "請輸入搜尋引擎名稱", - "max_length": "名稱不能超過16個字元" + "max_length": "名稱不能超過 16 個字元" }, "url": { "label": "自訂搜尋 URL", @@ -2217,7 +2217,7 @@ }, "filter_modal": { "title": "應用篩選名單", - "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" + "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" } } } From 9a4c69579da6ace2b3dfb3d828cc9ef731109981 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 29 Jun 2025 21:32:05 +0800 Subject: [PATCH 002/235] fix: restore message content className logic to resolve search issue (#7651) --- src/renderer/src/pages/home/Messages/Message.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index d013e34e0e..4d56ce7a05 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -155,7 +155,13 @@ const MessageItem: FC = ({ {!isEditing && ( <> Date: Sun, 29 Jun 2025 23:58:24 +0800 Subject: [PATCH 003/235] feat: support linux deb (#7652) --- electron-builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/electron-builder.yml b/electron-builder.yml index d1a70bf896..ecbbc10057 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -90,6 +90,7 @@ linux: artifactName: ${productName}-${version}-${arch}.${ext} target: - target: AppImage + - target: deb maintainer: electronjs.org category: Utility desktop: From 218dcc222926ec2907b4d6e65d238ab4c5850142 Mon Sep 17 00:00:00 2001 From: Yiyang Suen Date: Mon, 30 Jun 2025 00:01:28 +0800 Subject: [PATCH 004/235] fix: textarea not resizing back after clearing long input (#7609) (#7632) * fix: textarea not resizing back after clearing long input (#7609) * fix: text area auto size only when not dragged --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 9d3da9646e..af18b7fe23 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -788,6 +788,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = variant="borderless" spellCheck={enableSpellCheck} rows={2} + autoSize={textareaHeight ? false : { minRows: 2, maxRows: 20 }} ref={textareaRef} style={{ fontSize, From b0053b94a9947d2d1f476d52c8379861cff359f0 Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 30 Jun 2025 00:15:36 +0800 Subject: [PATCH 005/235] fix(models): enhance Doubao model checks to include model.id conditions (#7657) - Updated model checks in isFunctionCallingModel, isEmbeddingModel, isVisionModel, and isReasoningModel functions to consider model.id for 'doubao' provider. - Improved isOpenAIWebSearchModel to include additional conditions for model.id. --- .../clients/openai/OpenAIResponseAPIClient.ts | 5 ----- src/renderer/src/config/models.ts | 13 ++++++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index a6c49a5cf8..8994b7b2f5 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -386,10 +386,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< }) } - const toolChoices: OpenAI.Responses.ToolChoiceTypes = { - type: 'web_search_preview' - } - tools = tools.concat(extraTools) const commonParams = { model: model.id, @@ -402,7 +398,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< max_output_tokens: maxTokens, stream: streamOutput, tools: !isEmpty(tools) ? tools : undefined, - tool_choice: enableWebSearch ? toolChoices : undefined, service_tier: this.getServiceTier(model), ...(this.getReasoningEffort(assistant, model) as OpenAI.Reasoning), ...this.getCustomParameters(assistant) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 18d24d9ba6..3bb9d099fa 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -273,7 +273,7 @@ export function isFunctionCallingModel(model: Model): boolean { return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name) } @@ -2327,7 +2327,7 @@ export function isEmbeddingModel(model: Model): boolean { return false } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return EMBEDDING_REGEX.test(model.name) } @@ -2351,7 +2351,7 @@ export function isVisionModel(model: Model): boolean { // return false // } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return VISION_REGEX.test(model.name) || VISION_REGEX.test(model.id) || model.type?.includes('vision') || false } @@ -2422,7 +2422,9 @@ export function isOpenAIWebSearchModel(model: Model): boolean { model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview') || (model.id.includes('gpt-4.1') && !model.id.includes('gpt-4.1-nano')) || - (model.id.includes('gpt-4o') && !model.id.includes('gpt-4o-image')) + (model.id.includes('gpt-4o') && !model.id.includes('gpt-4o-image')) || + model.id.includes('o3') || + model.id.includes('o4') ) } @@ -2555,8 +2557,9 @@ export function isReasoningModel(model?: Model): boolean { return false } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return ( + REASONING_REGEX.test(model.id) || REASONING_REGEX.test(model.name) || model.type?.includes('reasoning') || isSupportedThinkingTokenDoubaoModel(model) || From 7b7819217fe37fe07bf26ab3920ff2389da75a37 Mon Sep 17 00:00:00 2001 From: David Zhang <61440144+WAcry@users.noreply.github.com> Date: Sun, 29 Jun 2025 12:14:58 -0700 Subject: [PATCH 006/235] chore(OpenAIApiClient): handle empty delta objects in non-streaming esponses (#7658) chore(OpenAIApiClient): handle empty delta objects in non-streaming responses --- .../src/aiCore/clients/openai/OpenAIApiClient.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 499edfbb5c..6c6e524b53 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -639,9 +639,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (!choice) return - // 对于流式响应,使用delta;对于非流式响应,使用message - const contentSource: OpenAISdkRawContentSource | null = - 'delta' in choice ? choice.delta : 'message' in choice ? choice.message : null + // 对于流式响应,使用 delta;对于非流式响应,使用 message。 + // 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。 + // 如果 delta 为空对象,应当忽略它并回退到 message,避免造成内容缺失。 + let contentSource: OpenAISdkRawContentSource | null = null + if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) { + contentSource = choice.delta + } else if ('message' in choice) { + contentSource = choice.message + } if (!contentSource) return From 4c988ede52678f21485eddaeaae27fa4b0640faf Mon Sep 17 00:00:00 2001 From: cnJasonZ Date: Mon, 30 Jun 2025 10:16:22 +0800 Subject: [PATCH 007/235] Feat/ppio rerank (#7567) * feat: add PPIO rerank and embedding models * fix: fix migrate.ts * fix: set ppio provider type to openai * fix: remove 'ppio' from ProviderType definition --------- Co-authored-by: suyao --- .../src/aiCore/clients/ApiClientFactory.ts | 6 ++ .../src/aiCore/clients/ppio/PPIOAPIClient.ts | 65 +++++++++++++++ src/renderer/src/config/models.ts | 80 +++++++++++++------ src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 2 +- src/renderer/src/store/migrate.ts | 11 +++ 6 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts diff --git a/src/renderer/src/aiCore/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/clients/ApiClientFactory.ts index adc97e70e0..b0fbe3e479 100644 --- a/src/renderer/src/aiCore/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/clients/ApiClientFactory.ts @@ -7,6 +7,7 @@ import { GeminiAPIClient } from './gemini/GeminiAPIClient' import { VertexAPIClient } from './gemini/VertexAPIClient' import { OpenAIAPIClient } from './openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' +import { PPIOAPIClient } from './ppio/PPIOAPIClient' /** * Factory for creating ApiClient instances based on provider configuration @@ -31,6 +32,11 @@ export class ApiClientFactory { instance = new AihubmixAPIClient(provider) as BaseApiClient return instance } + if (provider.id === 'ppio') { + console.log(`[ApiClientFactory] Creating PPIOAPIClient for provider: ${provider.id}`) + instance = new PPIOAPIClient(provider) as BaseApiClient + return instance + } // 然后检查标准的provider type switch (provider.type) { diff --git a/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts new file mode 100644 index 0000000000..2b8dec332d --- /dev/null +++ b/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts @@ -0,0 +1,65 @@ +import { isSupportedModel } from '@renderer/config/models' +import { Provider } from '@renderer/types' +import OpenAI from 'openai' + +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' + +export class PPIOAPIClient extends OpenAIAPIClient { + constructor(provider: Provider) { + super(provider) + } + + override async listModels(): Promise { + try { + const sdk = await this.getSdkInstance() + + // PPIO requires three separate requests to get all model types + const [chatModelsResponse, embeddingModelsResponse, rerankerModelsResponse] = await Promise.all([ + // Chat/completion models + sdk.request({ + method: 'get', + path: '/models' + }), + // Embedding models + sdk.request({ + method: 'get', + path: '/models?model_type=embedding' + }), + // Reranker models + sdk.request({ + method: 'get', + path: '/models?model_type=reranker' + }) + ]) + + // Extract models from all responses + // @ts-ignore - PPIO response structure may not be typed + const allModels = [ + ...((chatModelsResponse as any)?.data || []), + ...((embeddingModelsResponse as any)?.data || []), + ...((rerankerModelsResponse as any)?.data || []) + ] + + // Process and standardize model data + const processedModels = allModels.map((model: any) => ({ + id: model.id || model.name, + description: model.description || model.display_name || model.summary, + object: 'model' as const, + owned_by: model.owned_by || model.publisher || model.organization || 'ppio', + created: model.created || Date.now() + })) + + // Clean up model IDs and filter supported models + processedModels.forEach((model) => { + if (model.id) { + model.id = model.id.trim() + } + }) + + return processedModels.filter(isSupportedModel) + } catch (error) { + console.error('Error listing PPIO models:', error) + return [] + } + } +} diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3bb9d099fa..3c2902a489 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -767,6 +767,30 @@ export const SYSTEM_MODELS: Record = { } ], ppio: [ + { + id: 'deepseek/deepseek-r1-0528', + provider: 'ppio', + name: 'DeepSeek R1-0528', + group: 'deepseek' + }, + { + id: 'deepseek/deepseek-v3-0324', + provider: 'ppio', + name: 'DeepSeek V3-0324', + group: 'deepseek' + }, + { + id: 'deepseek/deepseek-r1-turbo', + provider: 'ppio', + name: 'DeepSeek R1 Turbo', + group: 'deepseek' + }, + { + id: 'deepseek/deepseek-v3-turbo', + provider: 'ppio', + name: 'DeepSeek V3 Turbo', + group: 'deepseek' + }, { id: 'deepseek/deepseek-r1/community', name: 'DeepSeek: DeepSeek R1 (Community)', @@ -780,52 +804,58 @@ export const SYSTEM_MODELS: Record = { group: 'deepseek' }, { - id: 'deepseek/deepseek-r1', + id: 'minimaxai/minimax-m1-80k', provider: 'ppio', - name: 'DeepSeek R1', - group: 'deepseek' + name: 'MiniMax M1-80K', + group: 'minimaxai' }, { - id: 'deepseek/deepseek-v3', + id: 'qwen/qwen3-235b-a22b-fp8', provider: 'ppio', - name: 'DeepSeek V3', - group: 'deepseek' - }, - { - id: 'qwen/qwen-2.5-72b-instruct', - provider: 'ppio', - name: 'Qwen2.5-72B-Instruct', + name: 'Qwen3 235B', group: 'qwen' }, { - id: 'qwen/qwen2.5-32b-instruct', + id: 'qwen/qwen3-32b-fp8', provider: 'ppio', - name: 'Qwen2.5-32B-Instruct', + name: 'Qwen3 32B', group: 'qwen' }, { - id: 'meta-llama/llama-3.1-70b-instruct', + id: 'qwen/qwen3-30b-a3b-fp8', provider: 'ppio', - name: 'Llama-3.1-70B-Instruct', - group: 'meta-llama' + name: 'Qwen3 30B', + group: 'qwen' }, { - id: 'meta-llama/llama-3.1-8b-instruct', + id: 'qwen/qwen2.5-vl-72b-instruct', provider: 'ppio', - name: 'Llama-3.1-8B-Instruct', - group: 'meta-llama' + name: 'Qwen2.5 VL 72B', + group: 'qwen' }, { - id: '01-ai/yi-1.5-34b-chat', + id: 'qwen/qwen3-embedding-8b', provider: 'ppio', - name: 'Yi-1.5-34B-Chat', - group: '01-ai' + name: 'Qwen3 Embedding 8B', + group: 'qwen' }, { - id: '01-ai/yi-1.5-9b-chat', + id: 'qwen/qwen3-reranker-8b', provider: 'ppio', - name: 'Yi-1.5-9B-Chat', - group: '01-ai' + name: 'Qwen3 Reranker 8B', + group: 'qwen' + }, + { + id: 'thudm/glm-z1-32b-0414', + provider: 'ppio', + name: 'GLM-Z1 32B', + group: 'thudm' + }, + { + id: 'thudm/glm-z1-9b-0414', + provider: 'ppio', + name: 'GLM-Z1 9B', + group: 'thudm' } ], alayanew: [], diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 4a26bf96c0..3b6cba1040 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -50,7 +50,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 116, + version: 117, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index f70c4ef2ba..d42b4dc065 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -79,7 +79,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ name: 'PPIO', type: 'openai', apiKey: '', - apiHost: 'https://api.ppinfra.com/v3/openai', + apiHost: 'https://api.ppinfra.com/v3/openai/', models: SYSTEM_MODELS.ppio, isSystem: true, enabled: false diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8eea0a34a7..56ffa33cbe 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1656,6 +1656,17 @@ const migrateConfig = { state.settings.testChannel = UpgradeChannel.LATEST } + return state + } catch (error) { + return state + } + }, + '117': (state: RootState) => { + try { + updateProvider(state, 'ppio', { + models: SYSTEM_MODELS.ppio, + apiHost: 'https://api.ppinfra.com/v3/openai/' + }) return state } catch (error) { return state From 1034b946288e2b6dd326441c8fd46be9de91e7e1 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:43:19 +0800 Subject: [PATCH 008/235] fix(translate): improve language options with clearer values (#7640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(翻译配置): 修正简体中文语言选项的值和标签显示 将'chinese'改为更明确的'chinese-simplified' * style(translate): 统一语言选项的显示格式为规范名称 --- src/renderer/src/config/translate.ts | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/config/translate.ts b/src/renderer/src/config/translate.ts index b8e5cd0b4e..9a85b68ecc 100644 --- a/src/renderer/src/config/translate.ts +++ b/src/renderer/src/config/translate.ts @@ -9,116 +9,116 @@ export interface TranslateLanguageOption { export const TranslateLanguageOptions: TranslateLanguageOption[] = [ { - value: 'english', + value: 'English', langCode: 'en-us', label: i18n.t('languages.english'), emoji: '🇬🇧' }, { - value: 'chinese', + value: 'Chinese (Simplified)', langCode: 'zh-cn', label: i18n.t('languages.chinese'), emoji: '🇨🇳' }, { - value: 'chinese-traditional', + value: 'Chinese (Traditional)', langCode: 'zh-tw', label: i18n.t('languages.chinese-traditional'), emoji: '🇭🇰' }, { - value: 'japanese', + value: 'Japanese', langCode: 'ja-jp', label: i18n.t('languages.japanese'), emoji: '🇯🇵' }, { - value: 'korean', + value: 'Korean', langCode: 'ko-kr', label: i18n.t('languages.korean'), emoji: '🇰🇷' }, { - value: 'french', + value: 'French', langCode: 'fr-fr', label: i18n.t('languages.french'), emoji: '🇫🇷' }, { - value: 'german', + value: 'German', langCode: 'de-de', label: i18n.t('languages.german'), emoji: '🇩🇪' }, { - value: 'italian', + value: 'Italian', langCode: 'it-it', label: i18n.t('languages.italian'), emoji: '🇮🇹' }, { - value: 'spanish', + value: 'Spanish', langCode: 'es-es', label: i18n.t('languages.spanish'), emoji: '🇪🇸' }, { - value: 'portuguese', + value: 'Portuguese', langCode: 'pt-pt', label: i18n.t('languages.portuguese'), emoji: '🇵🇹' }, { - value: 'russian', + value: 'Russian', langCode: 'ru-ru', label: i18n.t('languages.russian'), emoji: '🇷🇺' }, { - value: 'polish', + value: 'Polish', langCode: 'pl-pl', label: i18n.t('languages.polish'), emoji: '🇵🇱' }, { - value: 'arabic', + value: 'Arabic', langCode: 'ar-ar', label: i18n.t('languages.arabic'), emoji: '🇸🇦' }, { - value: 'turkish', + value: 'Turkish', langCode: 'tr-tr', label: i18n.t('languages.turkish'), emoji: '🇹🇷' }, { - value: 'thai', + value: 'Thai', langCode: 'th-th', label: i18n.t('languages.thai'), emoji: '🇹🇭' }, { - value: 'vietnamese', + value: 'Vietnamese', langCode: 'vi-vn', label: i18n.t('languages.vietnamese'), emoji: '🇻🇳' }, { - value: 'indonesian', + value: 'Indonesian', langCode: 'id-id', label: i18n.t('languages.indonesian'), emoji: '🇮🇩' }, { - value: 'urdu', + value: 'Urdu', langCode: 'ur-pk', label: i18n.t('languages.urdu'), emoji: '🇵🇰' }, { - value: 'malay', + value: 'Malay', langCode: 'ms-my', label: i18n.t('languages.malay'), emoji: '🇲🇾' @@ -129,7 +129,7 @@ export const translateLanguageOptions = (): typeof TranslateLanguageOptions => { return TranslateLanguageOptions.map((option) => { return { value: option.value, - label: i18n.t(`languages.${option.value}`), + label: option.label, emoji: option.emoji } }) From a9a9d884ce8ec7104c2b23ce6b3dc71a0d58966f Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 30 Jun 2025 13:51:23 +0800 Subject: [PATCH 009/235] Fix/gemini (#7659) * refactor: update Gemini and OpenAI API clients for improved reasoning model handling - Replaced isGeminiReasoningModel with isSupportedThinkingTokenGeminiModel in GeminiAPIClient for better model validation. - Enhanced OpenAIAPIClient to support additional configurations for reasoning efforts and thinking budgets based on model type. - Introduced new thinking tags for Gemini models in ThinkingTagExtractionMiddleware. - Updated model checks in models.ts to streamline reasoning model identification. - Adjusted ThinkingButton component to differentiate between Gemini and Gemini Pro models based on regex checks. * refactor(GeminiAPIClient): streamline reasoning configuration handling - Simplified the logic for returning thinking configuration when reasoningEffort is undefined in GeminiAPIClient. - Updated ApiService to include enableReasoning flag for API calls, enhancing control over reasoning capabilities. * fix(OpenAIAPIClient): add support for non-flash Gemini models in reasoning configuration - Introduced a check for non-flash models in the OpenAIAPIClient to enhance reasoning configuration handling for supported Gemini models. - This change ensures that reasoning is correctly configured based on the model type, improving overall model validation. --- .../aiCore/clients/gemini/GeminiAPIClient.ts | 26 ++++++------ .../aiCore/clients/openai/OpenAIApiClient.ts | 40 ++++++++++++++++++- .../feat/ThinkingTagExtractionMiddleware.ts | 2 + src/renderer/src/config/models.ts | 6 ++- .../pages/home/Inputbar/ThinkingButton.tsx | 15 +++++-- src/renderer/src/services/ApiService.ts | 1 + src/renderer/src/types/sdk.ts | 1 + 7 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 549e931966..f37bcd3d30 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -22,8 +22,8 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - isGeminiReasoningModel, isGemmaModel, + isSupportedThinkingTokenGeminiModel, isVisionModel } from '@renderer/config/models' import { CacheService } from '@renderer/services/CacheService' @@ -393,29 +393,29 @@ export class GeminiAPIClient extends BaseApiClient< * @returns The reasoning effort */ private getBudgetToken(assistant: Assistant, model: Model) { - if (isGeminiReasoningModel(model)) { + if (isSupportedThinkingTokenGeminiModel(model)) { const reasoningEffort = assistant?.settings?.reasoning_effort // 如果thinking_budget是undefined,不思考 if (reasoningEffort === undefined) { - return { - thinkingConfig: { - includeThoughts: false, - ...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinkingBudget: 0 } : {}) - } as ThinkingConfig - } + return GEMINI_FLASH_MODEL_REGEX.test(model.id) + ? { + thinkingConfig: { + thinkingBudget: 0 + } + } + : {} } - const effortRatio = EFFORT_RATIO[reasoningEffort] - - if (effortRatio > 1) { + if (reasoningEffort === 'auto') { return { thinkingConfig: { - includeThoughts: true + includeThoughts: true, + thinkingBudget: -1 } } } - + const effortRatio = EFFORT_RATIO[reasoningEffort] const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 } // 计算 budgetTokens,确保不低于 min const budget = Math.floor((max - min) * effortRatio + min) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 6c6e524b53..9b72758c97 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -114,6 +114,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (!reasoningEffort) { if (model.provider === 'openrouter') { + if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) { + return {} + } return { reasoning: { enabled: false, exclude: true } } } if (isSupportedThinkingTokenQwenModel(model)) { @@ -126,7 +129,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (isSupportedThinkingTokenGeminiModel(model)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { - return { reasoning_effort: 'none' } + return { + extra_body: { + google: { + thinking_config: { + thinking_budget: 0 + } + } + } + } } return {} } @@ -169,12 +180,37 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } // OpenAI models - if (isSupportedReasoningEffortOpenAIModel(model) || isSupportedThinkingTokenGeminiModel(model)) { + if (isSupportedReasoningEffortOpenAIModel(model)) { return { reasoning_effort: reasoningEffort } } + if (isSupportedThinkingTokenGeminiModel(model)) { + if (reasoningEffort === 'auto') { + return { + extra_body: { + google: { + thinking_config: { + thinking_budget: -1, + include_thoughts: true + } + } + } + } + } + return { + extra_body: { + google: { + thinking_config: { + thinking_budget: budgetTokens, + include_thoughts: true + } + } + } + } + } + // Claude models if (isSupportedThinkingTokenClaudeModel(model)) { const maxTokens = assistant.settings?.maxTokens diff --git a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts index 440de40045..fe2d51d8de 100644 --- a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -11,11 +11,13 @@ export const MIDDLEWARE_NAME = 'ThinkingTagExtractionMiddleware' // 不同模型的思考标签配置 const reasoningTags: TagConfig[] = [ { openingTag: '', closingTag: '', separator: '\n' }, + { openingTag: '', closingTag: '', separator: '\n' }, { openingTag: '###Thinking', closingTag: '###Response', 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] // 可以在这里添加更多模型特定的标签配置 return reasoningTags[0] // 默认使用 标签 } diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3c2902a489..48560007ca 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2509,14 +2509,16 @@ export function isGeminiReasoningModel(model?: Model): boolean { return true } - if (model.id.includes('gemini-2.5')) { + if (isSupportedThinkingTokenGeminiModel(model)) { return true } return false } -export const isSupportedThinkingTokenGeminiModel = isGeminiReasoningModel +export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => { + return model.id.includes('gemini-2.5') +} export function isQwenReasoningModel(model?: Model): boolean { if (!model) { diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 21db131cef..5bf57d9c9c 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -7,6 +7,7 @@ import { } from '@renderer/components/Icons/SVGIcon' import { useQuickPanel } from '@renderer/components/QuickPanel' import { + GEMINI_FLASH_MODEL_REGEX, isDoubaoThinkingAutoModel, isSupportedReasoningEffortGrokModel, isSupportedThinkingTokenDoubaoModel, @@ -37,13 +38,14 @@ const MODEL_SUPPORTED_OPTIONS: Record = { default: ['off', 'low', 'medium', 'high'], grok: ['off', 'low', 'high'], gemini: ['off', 'low', 'medium', 'high', 'auto'], + gemini_pro: ['low', 'medium', 'high', 'auto'], qwen: ['off', 'low', 'medium', 'high'], doubao: ['off', 'auto', 'high'] } // 选项转换映射表:当选项不支持时使用的替代选项 const OPTION_FALLBACK: Record = { - off: 'off', + off: 'low', // off -> low (for Gemini Pro models) low: 'high', medium: 'high', // medium -> high (for Grok models) high: 'high', @@ -57,6 +59,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re const isGrokModel = isSupportedReasoningEffortGrokModel(model) const isGeminiModel = isSupportedThinkingTokenGeminiModel(model) + const isGeminiFlashModel = GEMINI_FLASH_MODEL_REGEX.test(model.id) const isQwenModel = isSupportedThinkingTokenQwenModel(model) const isDoubaoModel = isSupportedThinkingTokenDoubaoModel(model) @@ -66,12 +69,18 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re // 确定当前模型支持的选项类型 const modelType = useMemo(() => { - if (isGeminiModel) return 'gemini' + if (isGeminiModel) { + if (isGeminiFlashModel) { + return 'gemini' + } else { + return 'gemini_pro' + } + } if (isGrokModel) return 'grok' if (isQwenModel) return 'qwen' if (isDoubaoModel) return 'doubao' return 'default' - }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel]) + }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isGeminiFlashModel]) // 获取当前模型支持的选项 const supportedOptions = useMemo(() => { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 4704a8bfd3..abc3db81b0 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -607,6 +607,7 @@ export async function checkApi(provider: Provider, model: Model): Promise messages: 'hi', assistant, streamOutput: true, + enableReasoning: false, shouldThrow: true } diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index 6505210b60..c7eeb9500c 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -53,6 +53,7 @@ export type ReasoningEffortOptionalParams = { enable_thinking?: boolean thinking_budget?: number enable_reasoning?: boolean + extra_body?: Record // Add any other potential reasoning-related keys here if they exist } From 21ba35b6bf20073d366764e134bfc2780a6f53ae Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 30 Jun 2025 15:17:05 +0800 Subject: [PATCH 010/235] fix(ImageGenerationMiddleware): read image binary data (#7681) - Replaced direct API call for reading binary images with FileManager's readBinaryImage method to streamline image handling in the ImageGenerationMiddleware. --- .../src/aiCore/middleware/feat/ImageGenerationMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts index d0a4dc4903..6c01759bec 100644 --- a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts @@ -1,5 +1,6 @@ import { BaseApiClient } from '@renderer/aiCore/clients/BaseApiClient' import { isDedicatedImageGenerationModel } from '@renderer/config/models' +import FileManager from '@renderer/services/FileManager' import { ChunkType } from '@renderer/types/chunk' import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import OpenAI from 'openai' @@ -46,7 +47,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware = const userImages = await Promise.all( userImageBlocks.map(async (block) => { if (!block.file) return null - const binaryData: Uint8Array = await window.api.file.binaryImage(block.file.id) + const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file) const mimeType = `${block.file.type}/${block.file.ext.slice(1)}` return await toFile(new Blob([binaryData]), block.file.origin_name || 'image.png', { type: mimeType }) }) From db4ce9fb7f6e5b2ee94e899ca6ba8d610684c4cf Mon Sep 17 00:00:00 2001 From: Teo Date: Mon, 30 Jun 2025 16:13:25 +0800 Subject: [PATCH 011/235] fix(Inputbar): fix enter key confict (#7679) fix(Inputbar): prevent default behavior for Enter key when quick panel is visible --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index af18b7fe23..462ef0adb6 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -348,8 +348,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = //other keys should be ignored const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing if (isEnterPressed) { + if (quickPanel.isVisible) return event.preventDefault() + if (isSendMessageKeyPressed(event, sendMessageShortcut)) { - if (quickPanel.isVisible) return event.preventDefault() sendMessage() return event.preventDefault() } else { From ac03aab29fcaa242ed0389bc1ec8d3ab2e2cbec0 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 30 Jun 2025 17:04:48 +0800 Subject: [PATCH 012/235] chore(package): add opendal dependency to package.json (#7685) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5455f348b2..dd835ee9fe 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", + "opendal": "0.47.11", "os-proxy-config": "^1.1.2", "selection-hook": "^0.9.23", "turndown": "7.2.0" @@ -181,7 +182,6 @@ "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", - "opendal": "0.47.11", "p-queue": "^8.1.0", "playwright": "^1.52.0", "prettier": "^3.5.3", From 8c657b57f723438f8ee60c9170d0b686c98d5391 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 30 Jun 2025 20:23:22 +0800 Subject: [PATCH 013/235] feat: add country flag emoji support and enhance UI components (#7646) * feat: add country flag emoji support and enhance UI components * Added country-flag-emoji-polyfill to package.json and yarn.lock * Integrated polyfill in AddAgentPopup, GeneralSettings, and AssistantPromptSettings components * Updated emoji rendering styles for better visual consistency * fix: update country flag emoji polyfill to use 'Twemoji Country Flags' * feat: enhance emoji components with country flag support * Integrated country-flag-emoji-polyfill in EmojiIcon, EmojiPicker, and AssistantItem components. * Updated font-family styles across various components for consistent emoji rendering. * Removed redundant polyfill calls from AddAgentPopup and AssistantPromptSettings. * refactor: streamline country flag emoji integration * Removed redundant polyfill calls from EmojiIcon, AssistantItem, and GeneralSettings components. * Updated EmojiPicker to use a local font file for country flag emojis. * Added country flag font import in index.scss for improved styling consistency. * format code * refactor: standardize country flag font usage across components * Introduced a new CSS class for country flag font to streamline styling. * Updated various components (GeneralSettings, EmojiIcon, EmojiAvatar, AssistantPromptSettings, TranslatePage) to utilize the new class for consistent font application. * Removed inline font-family styles to enhance maintainability. * refactor: update font styles for improved consistency and maintainability * Added Windows-specific font configuration in font.scss for better emoji rendering. * Removed inline font-family styles from various components (EmojiAvatar, GeneralSettings, AssistantPromptSettings, TranslatePage) to enhance code clarity and maintainability. * refactor: remove inline font-family styles from EmojiIcon for improved maintainability --- package.json | 1 + .../TwemojiCountryFlags.woff2 | Bin 0 -> 77476 bytes .../assets/fonts/country-flag-fonts/flag.css | 13 +++++++ src/renderer/src/assets/styles/font.scss | 32 +++++++++++------- src/renderer/src/assets/styles/index.scss | 1 + .../src/components/Avatar/EmojiAvatar.tsx | 1 + .../src/components/EmojiPicker/index.tsx | 6 ++++ .../AssistantPromptSettings.tsx | 10 +++++- yarn.lock | 8 +++++ 9 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 create mode 100644 src/renderer/src/assets/fonts/country-flag-fonts/flag.css diff --git a/package.json b/package.json index dd835ee9fe..360da4ae11 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "axios": "^1.7.3", "browser-image-compression": "^2.0.2", "color": "^5.0.0", + "country-flag-emoji-polyfill": "0.1.8", "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", diff --git a/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 b/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7f5bebba53e42cb9e1a7a55bb4a20fe04bd4273b GIT binary patch literal 77476 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 height: ${(props) => props.$size}px; font-size: ${(props) => props.$fontSize}px; transition: opacity 0.3s ease; + &:hover { opacity: 0.8; } diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index eb8a90dbde..406d6d1865 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -1,4 +1,6 @@ +import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url' import { useTheme } from '@renderer/context/ThemeProvider' +import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill' import { FC, useEffect, useRef } from 'react' interface Props { @@ -9,6 +11,10 @@ const EmojiPicker: FC = ({ onEmojiClick }) => { const { theme } = useTheme() const ref = useRef(null) + useEffect(() => { + polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2) + }, []) + useEffect(() => { if (ref.current) { ref.current.addEventListener('emoji-click', (event: any) => { diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 22ff2a7d62..a473c683ed 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -65,7 +65,15 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } } arrow trigger="click"> - + {emoji && ( Date: Mon, 30 Jun 2025 20:40:32 +0800 Subject: [PATCH 014/235] style(antd): Optimize antd components through patch method (#7683) * fix(dependencies): update antd to patch version 5.24.7 and apply custom patch * refactor(AddAgentPopup): remove unused ChevronDown import * feat(AntdProvider): add paddingXS to Dropdown component for improved layout --- .../patches/antd-npm-5.24.7-356a553ae5.patch | 69 +++++++++++++++++++ package.json | 2 +- src/renderer/src/assets/styles/index.scss | 2 +- src/renderer/src/context/AntdProvider.tsx | 3 +- .../pages/agents/components/AddAgentPopup.tsx | 2 - .../components/AddKnowledgePopup.tsx | 15 +--- .../components/KnowledgeSettingsPopup.tsx | 9 +-- .../AssistantKnowledgeBaseSettings.tsx | 3 +- .../AssistantModelSettings.tsx | 5 +- .../settings/ModelSettings/ModelSettings.tsx | 8 +-- .../ProviderSettings/ModelEditContent.tsx | 1 - .../CompressionSettings/CutoffSettings.tsx | 3 +- .../CompressionSettings/RagSettings.tsx | 4 +- .../CompressionSettings/index.tsx | 2 - .../src/pages/translate/TranslatePage.tsx | 7 +- yarn.lock | 64 ++++++++++++++++- 16 files changed, 145 insertions(+), 54 deletions(-) create mode 100644 .yarn/patches/antd-npm-5.24.7-356a553ae5.patch diff --git a/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch b/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch new file mode 100644 index 0000000000..d5f7a89edb --- /dev/null +++ b/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch @@ -0,0 +1,69 @@ +diff --git a/es/dropdown/dropdown.js b/es/dropdown/dropdown.js +index 986877a762b9ad0aca596a8552732cd12d2eaabb..1f18aa2ea745e68950e4cee16d4d655f5c835fd5 100644 +--- a/es/dropdown/dropdown.js ++++ b/es/dropdown/dropdown.js +@@ -2,7 +2,7 @@ + + import * as React from 'react'; + import LeftOutlined from "@ant-design/icons/es/icons/LeftOutlined"; +-import RightOutlined from "@ant-design/icons/es/icons/RightOutlined"; ++import { ChevronRight } from 'lucide-react'; + import classNames from 'classnames'; + import RcDropdown from 'rc-dropdown'; + import useEvent from "rc-util/es/hooks/useEvent"; +@@ -158,8 +158,10 @@ const Dropdown = props => { + className: `${prefixCls}-menu-submenu-arrow` + }, direction === 'rtl' ? (/*#__PURE__*/React.createElement(LeftOutlined, { + className: `${prefixCls}-menu-submenu-arrow-icon` +- })) : (/*#__PURE__*/React.createElement(RightOutlined, { +- className: `${prefixCls}-menu-submenu-arrow-icon` ++ })) : (/*#__PURE__*/React.createElement(ChevronRight, { ++ size: 16, ++ strokeWidth: 1.8, ++ className: `${prefixCls}-menu-submenu-arrow-icon lucide-custom` + }))), + mode: "vertical", + selectable: false, +diff --git a/es/dropdown/style/index.js b/es/dropdown/style/index.js +index 768c01783002c6901c85a73061ff6b3e776a60ce..39b1b95a56cdc9fb586a193c3adad5141f5cf213 100644 +--- a/es/dropdown/style/index.js ++++ b/es/dropdown/style/index.js +@@ -240,7 +240,8 @@ const genBaseStyle = token => { + marginInlineEnd: '0 !important', + color: token.colorTextDescription, + fontSize: fontSizeIcon, +- fontStyle: 'normal' ++ fontStyle: 'normal', ++ marginTop: 3, + } + } + }), +diff --git a/es/select/useIcons.js b/es/select/useIcons.js +index 959115be936ef8901548af2658c5dcfdc5852723..c812edd52123eb0faf4638b1154fcfa1b05b513b 100644 +--- a/es/select/useIcons.js ++++ b/es/select/useIcons.js +@@ -4,10 +4,10 @@ import * as React from 'react'; + import CheckOutlined from "@ant-design/icons/es/icons/CheckOutlined"; + import CloseCircleFilled from "@ant-design/icons/es/icons/CloseCircleFilled"; + import CloseOutlined from "@ant-design/icons/es/icons/CloseOutlined"; +-import DownOutlined from "@ant-design/icons/es/icons/DownOutlined"; + import LoadingOutlined from "@ant-design/icons/es/icons/LoadingOutlined"; + import SearchOutlined from "@ant-design/icons/es/icons/SearchOutlined"; + import { devUseWarning } from '../_util/warning'; ++import { ChevronDown } from 'lucide-react'; + export default function useIcons(_ref) { + let { + suffixIcon, +@@ -56,8 +56,10 @@ export default function useIcons(_ref) { + className: iconCls + })); + } +- return getSuffixIconNode(/*#__PURE__*/React.createElement(DownOutlined, { +- className: iconCls ++ return getSuffixIconNode(/*#__PURE__*/React.createElement(ChevronDown, { ++ size: 16, ++ strokeWidth: 1.8, ++ className: `${iconCls} lucide-custom` + })); + }; + } diff --git a/package.json b/package.json index 360da4ae11..93b7ce697f 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "@vitest/ui": "^3.1.4", "@vitest/web-worker": "^3.1.4", "@xyflow/react": "^12.4.4", - "antd": "^5.22.5", + "antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch", "archiver": "^7.0.1", "async-mutex": "^0.5.0", "axios": "^1.7.3", diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 62f7eae852..7507507888 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -170,7 +170,7 @@ ul { } } -.lucide { +.lucide:not(.lucide-custom) { color: var(--color-icon); } diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 7d22fbb6a5..206f65e262 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -79,7 +79,8 @@ const AntdProvider: FC = ({ children }) => { Dropdown: { controlPaddingHorizontal: 8, borderRadiusLG: 10, - borderRadiusSM: 8 + borderRadiusSM: 8, + paddingXS: 4 }, Popover: { borderRadiusLG: 10 diff --git a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx index 089ec16fa1..108052a701 100644 --- a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx @@ -14,7 +14,6 @@ import { Agent, KnowledgeBase } from '@renderer/types' import { getLeadingEmoji, uuid } from '@renderer/utils' import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd' import TextArea from 'antd/es/input/TextArea' -import { ChevronDown } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import stringWidth from 'string-width' @@ -213,7 +212,6 @@ const PopupContainer: React.FC = ({ resolve }) => { .toLowerCase() .includes(input.toLowerCase()) } - suffixIcon={} /> )} diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx index 5ce1801243..128d63f07e 100644 --- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx @@ -13,7 +13,6 @@ import { KnowledgeBase, Model } from '@renderer/types' import { getErrorMessage } from '@renderer/utils/error' import { Flex, Form, Input, InputNumber, Modal, Select, Slider, Switch } from 'antd' import { find, sortBy } from 'lodash' -import { ChevronDown } from 'lucide-react' import { nanoid } from 'nanoid' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -183,12 +182,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { label={t('models.embedding_model')} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - = ({ title, resolve }) => { label={t('models.rerank_model')} tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} rules={[{ required: false, message: t('message.error.enter.model') }]}> - {t('models.rerank_model_not_support_provider', { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx index 625ca2c90f..6b44684d5a 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx @@ -140,13 +140,7 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { initialValue={getModelUniqId(base.model)} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - = ({ base: _base, resolve }) => { options={rerankSelectOptions} placeholder={t('settings.models.empty')} allowClear - suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx index a593f41cbe..169ed3ffd5 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx @@ -3,7 +3,7 @@ import { Box } from '@renderer/components/Layout' import { useAppSelector } from '@renderer/store' import { Assistant, AssistantSettings } from '@renderer/types' import { Row, Segmented, Select, SelectProps, Tooltip } from 'antd' -import { ChevronDown, CircleHelp } from 'lucide-react' +import { CircleHelp } from 'lucide-react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -46,7 +46,6 @@ const AssistantKnowledgeBaseSettings: React.FC = ({ assistant, updateAssi .toLowerCase() .includes(input.toLowerCase()) } - suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 31e44abfbb..7017b02890 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -10,7 +10,6 @@ import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from ' import { modalConfirm } from '@renderer/utils' import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' import { isNull } from 'lodash' -import { ChevronDown } from 'lucide-react' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -118,7 +117,6 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA { label: 'true', value: true }, { label: 'false', value: false } ]} - suffixIcon={} /> ) case 'json': @@ -437,8 +435,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA ) }))} - suffixIcon={} /> )} @@ -455,7 +452,6 @@ const TranslatePage: FC = () => { ) }))} - suffixIcon={} /> ) } @@ -555,7 +551,6 @@ const TranslatePage: FC = () => { ) })) ]} - suffixIcon={} /> - - - - ) -} - -const Container = styled.div` - margin: 10px; - display: flex; - flex-direction: row; - gap: 8px; - padding-bottom: 10px; -` - -export default Artifacts diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx new file mode 100644 index 0000000000..0691a25d18 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -0,0 +1,408 @@ +import { CodeOutlined, LinkOutlined } from '@ant-design/icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import { ThemeMode } from '@renderer/types' +import { extractTitle } from '@renderer/utils/formats' +import { Button } from 'antd' +import { Code, Download, Globe, Sparkles } from 'lucide-react' +import { FC, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ClipLoader } from 'react-spinners' +import styled, { keyframes } from 'styled-components' + +import HtmlArtifactsPopup from './HtmlArtifactsPopup' + +interface Props { + html: string +} + +const HtmlArtifactsCard: FC = ({ html }) => { + const { t } = useTranslation() + const title = extractTitle(html) || 'HTML Artifacts' + const [isPopupOpen, setIsPopupOpen] = useState(false) + const { theme } = useTheme() + + const htmlContent = html || '' + const hasContent = htmlContent.trim().length > 0 + + // 判断是否正在流式生成的逻辑 + const isStreaming = useMemo(() => { + if (!hasContent) return false + + const trimmedHtml = htmlContent.trim() + + // 检查 HTML 是否看起来是完整的 + const indicators = { + // 1. 检查常见的 HTML 结构完整性 + hasHtmlTag: /]*>/i.test(trimmedHtml), + hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml), + + // 2. 检查 body 标签完整性 + hasBodyTag: /]*>/i.test(trimmedHtml), + hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml), + + // 3. 检查是否以未闭合的标签结尾 + endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml), + + // 4. 检查是否有未配对的标签 + hasUnmatchedTags: checkUnmatchedTags(trimmedHtml), + + // 5. 检查是否以常见的"流式结束"模式结尾 + endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml) + } + + // 如果有明显的未完成标志,则认为正在生成 + if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) { + return true + } + + // 如果有 HTML 结构但不完整 + if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) { + return true + } + + // 如果有 body 结构但不完整 + if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) { + return true + } + + // 对于简单的 HTML 片段,检查是否看起来是完整的 + if (!indicators.hasHtmlTag && !indicators.hasBodyTag) { + // 如果是简单片段且没有明显的结束标志,可能还在生成 + return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500 + } + + return false + }, [htmlContent, hasContent]) + + // 检查未配对标签的辅助函数 + function checkUnmatchedTags(html: string): boolean { + const stack: string[] = [] + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + let match + + while ((match = tagRegex.exec(html)) !== null) { + const [fullTag, tagName] = match + const isClosing = fullTag.startsWith('') || ['img', 'br', 'hr', 'input', 'meta', 'link'].includes(tagName.toLowerCase()) + + if (isSelfClosing) continue + + if (isClosing) { + if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { + return true // 找到不匹配的闭合标签 + } + } else { + stack.push(tagName.toLowerCase()) + } + } + + return stack.length > 0 // 还有未闭合的标签 + } + + // 获取格式化的代码预览 + function getFormattedCodePreview(html: string): string { + const trimmed = html.trim() + const lines = trimmed.split('\n') + const lastFewLines = lines.slice(-3) // 显示最后3行 + return lastFewLines.join('\n') + } + + /** + * 在编辑器中打开 + */ + const handleOpenInEditor = () => { + setIsPopupOpen(true) + } + + /** + * 关闭弹窗 + */ + const handleClosePopup = () => { + setIsPopupOpen(false) + } + + /** + * 外部链接打开 + */ + const handleOpenExternal = async () => { + const path = await window.api.file.createTempFile('artifacts-preview.html') + await window.api.file.write(path, htmlContent) + const filePath = `file://${path}` + + if (window.api.shell && window.api.shell.openExternal) { + window.api.shell.openExternal(filePath) + } else { + console.error(t('artifacts.preview.openExternal.error.content')) + } + } + + /** + * 下载到本地 + */ + const handleDownload = async () => { + const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html` + await window.api.file.save(fileName, htmlContent) + window.message.success({ content: t('message.download.success'), key: 'download' }) + } + + return ( + <> + +
+ + {isStreaming ? : } + + + {title} + + + HTML + + + {isStreaming && ( + + + {t('html_artifacts.generating')} + + )} +
+ + {isStreaming && !hasContent ? ( + + + {t('html_artifacts.generating_content', 'Generating content...')} + + ) : isStreaming && hasContent ? ( + <> + + + + $ + + {getFormattedCodePreview(htmlContent)} + + + + + + + + + + ) : ( + + + + + + )} + +
+ + {/* 弹窗组件 */} + + + ) +} + +const shimmer = keyframes` + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +` + +const Container = styled.div<{ $isStreaming: boolean }>` + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + margin: 16px 0; +` + +const GeneratingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 20px; + min-height: 78px; +` + +const GeneratingText = styled.div` + font-size: 14px; + color: var(--color-text-secondary); +` + +const Header = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px 16px; + background: var(--color-background-soft); + border-bottom: 1px solid var(--color-border); + position: relative; + border-radius: 8px 8px 0 0; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4); + background-size: 200% 100%; + animation: ${shimmer} 3s ease-in-out infinite; + border-radius: 8px 8px 0 0; + } +` + +const IconWrapper = styled.div<{ $isStreaming: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + border-radius: 12px; + color: white; + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); + transition: background 0.3s ease; + + ${(props) => + props.$isStreaming && + ` + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */ + box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3); + `} +` + +const TitleSection = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +` + +const Title = styled.h3` + margin: 0 !important; + font-size: 16px; + font-weight: 600; + color: var(--color-text); + line-height: 1.4; +` + +const TypeBadge = styled.div` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--color-background-mute); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + width: fit-content; +` + +const StreamingIndicator = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-status-warning); + border: 1px solid var(--color-status-warning); + border-radius: 8px; + color: var(--color-text); + font-size: 12px; + opacity: 0.9; + + [theme-mode='light'] & { + background: #fef3c7; + border-color: #fbbf24; + color: #92400e; + } +` + +const StreamingText = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; +` + +const Content = styled.div` + padding: 0; + background: var(--color-background); +` + +const ButtonContainer = styled.div` + margin: 16px; + display: flex; + flex-direction: row; + gap: 8px; +` + +const TerminalPreview = styled.div<{ $theme: ThemeMode }>` + margin: 16px; + background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + border-radius: 8px; + overflow: hidden; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; +` + +const TerminalContent = styled.div<{ $theme: ThemeMode }>` + padding: 12px; + background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + font-size: 13px; + line-height: 1.4; + min-height: 80px; +` + +const TerminalLine = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; +` + +const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>` + flex: 1; + white-space: pre-wrap; + word-break: break-word; + color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + background-color: transparent !important; +` + +const TerminalPrompt = styled.span<{ $theme: ThemeMode }>` + color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + font-weight: bold; + flex-shrink: 0; +` + +const TerminalCursor = styled.span<{ $theme: ThemeMode }>` + display: inline-block; + width: 2px; + height: 16px; + background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + animation: ${keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } + `} 1s infinite; + margin-left: 2px; +` + +export default HtmlArtifactsCard diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx new file mode 100644 index 0000000000..afba9f04e1 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -0,0 +1,445 @@ +import CodeEditor from '@renderer/components/CodeEditor' +import { isMac } from '@renderer/config/constant' +import { Button, Modal } from 'antd' +import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface HtmlArtifactsPopupProps { + open: boolean + title: string + html: string + onClose: () => void +} + +type ViewMode = 'split' | 'code' | 'preview' + +// 视图模式配置 +const VIEW_MODE_CONFIG = { + split: { + key: 'split' as const, + icon: MonitorSpeaker, + i18nKey: 'html_artifacts.split' + }, + code: { + key: 'code' as const, + icon: Code, + i18nKey: 'html_artifacts.code' + }, + preview: { + key: 'preview' as const, + icon: Monitor, + i18nKey: 'html_artifacts.preview' + } +} as const + +// 抽取头部组件 +interface ModalHeaderProps { + title: string + isFullscreen: boolean + viewMode: ViewMode + onViewModeChange: (mode: ViewMode) => void + onToggleFullscreen: () => void + onCancel: () => void +} + +const ModalHeaderComponent: React.FC = ({ + title, + isFullscreen, + viewMode, + onViewModeChange, + onToggleFullscreen, + onCancel +}) => { + const { t } = useTranslation() + + const viewButtons = useMemo(() => { + return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => ( + } + onClick={() => onViewModeChange(key)}> + {t(i18nKey)} + + )) + }, [viewMode, onViewModeChange, t]) + + return ( + + + {title} + + + {viewButtons} + + + - - -
diff --git a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx index 54fa3201a9..0b6a001b8a 100644 --- a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx @@ -3,7 +3,6 @@ import { nanoid } from '@reduxjs/toolkit' import logo from '@renderer/assets/images/cherry-text-logo.svg' import { Center, HStack } from '@renderer/components/Layout' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { builtinMCPServers } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' import { getMcpConfigSampleFromReadme } from '@renderer/utils' import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd' @@ -23,7 +22,7 @@ interface SearchResult { configSample?: MCPServer['configSample'] } -const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket'] +const npmScopes = ['@modelcontextprotocol', '@gongrzhe', '@mcpmarket'] let _searchResults: SearchResult[] = [] @@ -32,7 +31,7 @@ const NpxSearch: FC = () => { const { Text, Link } = Typography // Add new state variables for npm scope search - const [npmScope, setNpmScope] = useState('@cherry') + const [npmScope, setNpmScope] = useState('@modelcontextprotocol') const [searchLoading, setSearchLoading] = useState(false) const [searchResults, setSearchResults] = useState(_searchResults) const { addMCPServer, mcpServers } = useMCPServers() @@ -52,22 +51,6 @@ const NpxSearch: FC = () => { return } - if (searchScope === '@cherry') { - setSearchResults( - builtinMCPServers.map((server) => ({ - key: server.id, - name: server.name, - description: server.description || '', - version: '1.0.0', - usage: '参考下方链接中的使用说明', - npmLink: 'https://docs.cherry-ai.com/advanced-basic/mcp/in-memory', - fullName: server.name, - type: server.type || 'inMemory' - })) - ) - return - } - setSearchLoading(true) try { @@ -190,14 +173,6 @@ const NpxSearch: FC = () => { return } - const buildInServer = builtinMCPServers.find((server) => server.name === record.name) - - if (buildInServer) { - addMCPServer(buildInServer) - window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' }) - return - } - const newServer = { id: nanoid(), name: record.name, diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 9a37ceb263..59abda1ec4 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -71,18 +71,18 @@ const SettingsPage: FC = () => { {t('settings.display.title')} - - - - {t('settings.tool.title')} - - {t('settings.mcp.title')} + + + + {t('settings.tool.title')} + + From 97dbfe492ea9216d29a4f3709c5ccee134db5147 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:35:40 +0800 Subject: [PATCH 141/235] test: enhance download and fetch utility test coverage with bug fix (#7891) * test: enhance download and fetch utility test coverage - Add MIME type handling tests for data URLs in download.test.ts - Add timestamp generation tests for blob and network downloads - Add Content-Type header handling test for extensionless files - Add format parameter tests (markdown/html/text) for fetchWebContent - Add timeout signal handling tests for fetch operations - Add combined signal (user + timeout) test for AbortSignal.any These tests improve coverage of edge cases and ensure critical functionality is properly tested. * fix: add missing error handling for fetch in download utility - Add .catch() handler for network request failures in download() - Use window.message.error() for user-friendly error notifications - Update tests to verify error handling behavior - Ensure proper error messages are shown to users This fixes a missing error handler that was discovered during test development. * refactor: improve test structure and add i18n support for download utility - Unified test structure with two-layer describe blocks (filename -> function name) - Added afterEach with restoreAllMocks for consistent mock cleanup - Removed individual mockRestore calls in favor of centralized cleanup - Added i18n support to download.ts for error messages - Updated error handling logic to avoid duplicate messages - Updated test expectations to match new i18n error messages * test: fix react-i18next mock for Markdown test Add missing initReactI18next to mock to resolve test failures caused by i18n initialization when download utility imports i18n module. --- .../home/Markdown/__tests__/Markdown.test.tsx | 6 +- .../src/utils/__tests__/download.test.ts | 241 ++++++++++++++++++ .../src/utils/__tests__/fetch.test.ts | 208 +++++++++++++++ src/renderer/src/utils/download.ts | 11 + 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/utils/__tests__/download.test.ts create mode 100644 src/renderer/src/utils/__tests__/fetch.test.ts diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index be9b18c13b..6adb5f5736 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({ })) vi.mock('react-i18next', () => ({ - useTranslation: () => mockUseTranslation() + useTranslation: () => mockUseTranslation(), + initReactI18next: { + type: '3rdParty', + init: vi.fn() + } })) // Mock services diff --git a/src/renderer/src/utils/__tests__/download.test.ts b/src/renderer/src/utils/__tests__/download.test.ts new file mode 100644 index 0000000000..3a49ccf4fb --- /dev/null +++ b/src/renderer/src/utils/__tests__/download.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock @renderer/i18n to avoid initialization issues +vi.mock('@renderer/i18n', () => ({ + default: { + t: vi.fn((key: string) => { + const translations: Record = { + 'message.download.failed': '下载失败', + 'message.download.failed.network': '下载失败,请检查网络' + } + return translations[key] || key + }) + } +})) + +import { download } from '../download' + +// Mock DOM 方法 +const mockCreateElement = vi.fn() +const mockAppendChild = vi.fn() +const mockClick = vi.fn() + +// Mock URL API +const mockCreateObjectURL = vi.fn() +const mockRevokeObjectURL = vi.fn() + +// Mock fetch +const mockFetch = vi.fn() + +// Mock window.message +const mockMessage = { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn() +} + +// 辅助函数 +const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 10)) +const createMockResponse = (options = {}) => ({ + ok: true, + headers: new Headers(), + blob: () => Promise.resolve(new Blob(['test'])), + ...options +}) + +describe('download', () => { + describe('download', () => { + beforeEach(() => { + vi.clearAllMocks() + + // 设置 window.message mock + Object.defineProperty(window, 'message', { value: mockMessage, writable: true }) + + // 设置 DOM mock + const mockElement = { + href: '', + download: '', + click: mockClick, + remove: vi.fn() + } + mockCreateElement.mockReturnValue(mockElement) + + Object.defineProperty(document, 'createElement', { value: mockCreateElement }) + Object.defineProperty(document.body, 'appendChild', { value: mockAppendChild }) + Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL }) + + global.fetch = mockFetch + mockCreateObjectURL.mockReturnValue('blob:mock-url') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Direct download support', () => { + it('should handle local file URLs', () => { + download('file:///path/to/document.pdf', 'test.pdf') + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe('file:///path/to/document.pdf') + expect(element.download).toBe('test.pdf') + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle blob URLs', () => { + download('blob:http://localhost:3000/12345') + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe('blob:http://localhost:3000/12345') + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle data URLs', () => { + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + + download(dataUrl) + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe(dataUrl) + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle different MIME types in data URLs', async () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + // 只有 image/png 和 image/jpeg 会直接下载 + const directDownloadTests = [ + { url: 'data:image/jpeg;base64,xxx', expectedExt: '.jpg' }, + { url: 'data:image/png;base64,xxx', expectedExt: '.png' } + ] + + directDownloadTests.forEach(({ url, expectedExt }) => { + mockCreateElement.mockClear() + download(url) + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_download${expectedExt}`) + }) + + // 其他类型会通过 fetch 处理 + mockCreateElement.mockClear() + mockFetch.mockResolvedValueOnce( + createMockResponse({ + headers: new Headers({ 'Content-Type': 'application/pdf' }) + }) + ) + + download('data:application/pdf;base64,xxx') + await waitForAsync() + + expect(mockFetch).toHaveBeenCalled() + }) + + it('should generate filename with timestamp for blob URLs', () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + download('blob:http://localhost:3000/12345') + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_diagram.svg`) + }) + }) + + describe('Filename handling', () => { + it('should extract filename from file path', () => { + download('file:///Users/test/Documents/report.pdf') + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe('report.pdf') + }) + + it('should handle URL encoded filenames', () => { + download('file:///path/to/%E6%96%87%E6%A1%A3.pdf') // 编码的"文档.pdf" + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe('文档.pdf') + }) + }) + + describe('Network download', () => { + it('should handle successful network request', async () => { + mockFetch.mockResolvedValue(createMockResponse()) + + download('https://example.com/file.pdf', 'custom.pdf') + await waitForAsync() + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/file.pdf') + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(mockClick).toHaveBeenCalled() + }) + + it('should extract filename from URL and headers', async () => { + const headers = new Headers() + headers.set('Content-Disposition', 'attachment; filename="server-file.pdf"') + mockFetch.mockResolvedValue(createMockResponse({ headers })) + + download('https://example.com/files/document.docx') + await waitForAsync() + + // 验证下载被触发(具体文件名由实现决定) + expect(mockClick).toHaveBeenCalled() + }) + + it('should add timestamp to network downloaded files', async () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + mockFetch.mockResolvedValue(createMockResponse()) + + download('https://example.com/file.pdf') + await waitForAsync() + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_file.pdf`) + }) + + it('should handle Content-Type when filename has no extension', async () => { + const headers = new Headers() + headers.set('Content-Type', 'application/pdf') + mockFetch.mockResolvedValue(createMockResponse({ headers })) + + download('https://example.com/download') + await waitForAsync() + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toMatch(/\d+_download\.pdf$/) + }) + }) + + describe('Error handling', () => { + it('should handle network errors gracefully', async () => { + const networkError = new Error('Network error') + mockFetch.mockRejectedValue(networkError) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + await waitForAsync() + + expect(mockMessage.error).toHaveBeenCalledWith('下载失败:Network error') + }) + + it('should handle fetch errors without message', async () => { + mockFetch.mockRejectedValue(new Error()) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + await waitForAsync() + + expect(mockMessage.error).toHaveBeenCalledWith('下载失败,请检查网络') + }) + + it('should handle HTTP errors gracefully', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + }) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/fetch.test.ts b/src/renderer/src/utils/__tests__/fetch.test.ts new file mode 100644 index 0000000000..6b36cb41f8 --- /dev/null +++ b/src/renderer/src/utils/__tests__/fetch.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock 外部依赖 +vi.mock('turndown', () => ({ + default: vi.fn(() => ({ + turndown: vi.fn(() => '# Test content') + })) +})) +vi.mock('@mozilla/readability', () => ({ + Readability: vi.fn(() => ({ + parse: vi.fn(() => ({ + title: 'Test Article', + content: '

Test content

', + textContent: 'Test content' + })) + })) +})) +vi.mock('@reduxjs/toolkit', () => ({ + nanoid: vi.fn(() => 'test-id') +})) + +import { fetchRedirectUrl, fetchWebContent, fetchWebContents } from '../fetch' + +// 设置基础 mocks +global.DOMParser = vi.fn().mockImplementation(() => ({ + parseFromString: vi.fn(() => ({})) +})) as any + +global.window = { + api: { + searchService: { + openUrlInSearchWindow: vi.fn() + } + } +} as any + +// 辅助函数 +const createMockResponse = (overrides = {}) => + ({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Test content'), + ...overrides + }) as unknown as Response + +describe('fetch', () => { + beforeEach(() => { + // Mock fetch 和 AbortSignal + global.fetch = vi.fn() + global.AbortSignal = { + timeout: vi.fn(() => ({})), + any: vi.fn(() => ({})) + } as any + + // 清理 mock 调用历史 + vi.clearAllMocks() + }) + + describe('fetchWebContent', () => { + it('should fetch and return content successfully', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + const result = await fetchWebContent('https://example.com') + + expect(result).toEqual({ + title: 'Test Article', + url: 'https://example.com', + content: '# Test content' + }) + expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)) + }) + + it('should use browser mode when specified', async () => { + vi.mocked(window.api.searchService.openUrlInSearchWindow).mockResolvedValueOnce( + 'Browser content' + ) + + const result = await fetchWebContent('https://example.com', 'markdown', true) + + expect(result.content).toBe('# Test content') + expect(window.api.searchService.openUrlInSearchWindow).toHaveBeenCalled() + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // 无效 URL + const invalidResult = await fetchWebContent('not-a-url') + expect(invalidResult.content).toBe('No content found') + + // 网络错误 + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')) + const networkResult = await fetchWebContent('https://example.com') + expect(networkResult.content).toBe('No content found') + + consoleSpy.mockRestore() + }) + + it('should rethrow abort errors', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + vi.mocked(global.fetch).mockRejectedValueOnce(abortError) + + await expect(fetchWebContent('https://example.com')).rejects.toThrow(DOMException) + }) + + it.each([ + ['markdown', '# Test content'], + ['html', '

Test content

'], + ['text', 'Test content'] + ])('should return %s format correctly', async (format, expectedContent) => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + const result = await fetchWebContent('https://example.com', format as any) + + expect(result.content).toBe(expectedContent) + expect(result.title).toBe('Test Article') + expect(result.url).toBe('https://example.com') + }) + + it('should handle timeout signal in AbortSignal.any', async () => { + const mockTimeoutSignal = new AbortController().signal + vi.spyOn(global.AbortSignal, 'timeout').mockReturnValue(mockTimeoutSignal) + + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + await fetchWebContent('https://example.com') + + // 验证 AbortSignal.timeout 是否被调用,并传入 30000ms + expect(global.AbortSignal.timeout).toHaveBeenCalledWith(30000) + + vi.spyOn(global.AbortSignal, 'timeout').mockRestore() + }) + + it('should combine user signal with timeout signal', async () => { + const userController = new AbortController() + const mockAnyCalls: any[] = [] + + vi.spyOn(global.AbortSignal, 'any').mockImplementation((signals) => { + mockAnyCalls.push(signals) + return new AbortController().signal + }) + + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + await fetchWebContent('https://example.com', 'markdown', false, { + signal: userController.signal + }) + + // 验证 AbortSignal.any 是否被调用,并传入两个信号 + expect(mockAnyCalls).toHaveLength(1) + expect(mockAnyCalls[0]).toHaveLength(2) + expect(mockAnyCalls[0]).toContain(userController.signal) + + vi.spyOn(global.AbortSignal, 'any').mockRestore() + }) + }) + + describe('fetchWebContents', () => { + it('should fetch multiple URLs in parallel', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()).mockResolvedValueOnce(createMockResponse()) + + const urls = ['https://example1.com', 'https://example2.com'] + const results = await fetchWebContents(urls) + + expect(results).toHaveLength(2) + expect(results[0].content).toBe('# Test content') + expect(results[1].content).toBe('# Test content') + }) + + it('should handle partial failures gracefully', async () => { + vi.mocked(global.fetch) + .mockResolvedValueOnce(createMockResponse()) + .mockRejectedValueOnce(new Error('Network error')) + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const results = await fetchWebContents(['https://success.com', 'https://fail.com']) + + expect(results).toHaveLength(2) + expect(results[0].content).toBe('# Test content') + expect(results[1].content).toBe('No content found') + + consoleSpy.mockRestore() + }) + }) + + describe('fetchRedirectUrl', () => { + it('should return final redirect URL', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + url: 'https://redirected.com/final' + } as any) + + const result = await fetchRedirectUrl('https://example.com') + + expect(result).toBe('https://redirected.com/final') + expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)) + }) + + it('should return original URL on error', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await fetchRedirectUrl('https://example.com') + expect(result).toBe('https://example.com') + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/renderer/src/utils/download.ts b/src/renderer/src/utils/download.ts index 5e207eff67..454c1ac82a 100644 --- a/src/renderer/src/utils/download.ts +++ b/src/renderer/src/utils/download.ts @@ -1,3 +1,5 @@ +import i18n from '@renderer/i18n' + export const download = (url: string, filename?: string) => { // 处理可直接通过 标签下载的 URL: // - 本地文件 ( file:// ) @@ -76,6 +78,15 @@ export const download = (url: string, filename?: string) => { URL.revokeObjectURL(blobUrl) link.remove() }) + .catch((error) => { + console.error('Download failed:', error) + // 显示用户友好的错误提示 + if (error.message) { + window.message?.error(`${i18n.t('message.download.failed')}:${error.message}`) + } else { + window.message?.error(i18n.t('message.download.failed.network')) + } + }) } // 辅助函数:根据MIME类型获取文件扩展名 From f8c6b5c05fe4a2ca91dbe6ebf51d745985236160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Thu, 10 Jul 2025 15:09:59 +0800 Subject: [PATCH 142/235] Fix translation key for unlimited backups label (#7987) Updated the translation key for the 'unlimited' backups option in WebDavSettings to use the correct namespace. --- src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 54db33f024..977c5c1329 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -202,7 +202,7 @@ const WebDavSettings: FC = () => { onChange={onMaxBackupsChange} disabled={!webdavHost} options={[ - { label: t('settings.data.webdav.maxBackups.unlimited'), value: 0 }, + { label: t('settings.data.local.maxBackups.unlimited'), value: 0 }, { label: '1', value: 1 }, { label: '3', value: 3 }, { label: '5', value: 5 }, From 05f3b88f304df7931fd6a8fb546d17c49bf0e688 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 15:15:13 +0800 Subject: [PATCH 143/235] fix(Inputbar): update resizeTextArea call to improve functionality (#8010) --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 1a8b0fed8d..775debfd34 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -240,7 +240,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setText('') setFiles([]) setTimeout(() => setText(''), 500) - setTimeout(() => resizeTextArea(), 0) + setTimeout(() => resizeTextArea(true), 0) setExpend(false) } catch (error) { console.error('Failed to send message:', error) From f85f46c2487c517eb90de75e1c9b9a22df968f9a Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 15:15:38 +0800 Subject: [PATCH 144/235] fix(middleware): ollama qwen think (#8026) refactor(AiProvider): comment out unnecessary middleware removal for performance optimization - Commented out the removal of ThinkingTagExtractionMiddlewareName to prevent potential performance degradation while maintaining existing functionality. - Retained the removal of ThinkChunkMiddlewareName as part of the existing logic for non-reasoning scenarios. --- src/renderer/src/aiCore/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/index.ts b/src/renderer/src/aiCore/index.ts index 18bf2e8524..34edc1b755 100644 --- a/src/renderer/src/aiCore/index.ts +++ b/src/renderer/src/aiCore/index.ts @@ -75,7 +75,8 @@ export default class AiProvider { } else { // Existing logic for other models if (!params.enableReasoning) { - builder.remove(ThinkingTagExtractionMiddlewareName) + // 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降 + // builder.remove(ThinkingTagExtractionMiddlewareName) builder.remove(ThinkChunkMiddlewareName) } // 注意:用client判断会导致typescript类型收窄 From 3350c3e2e52b470c78473dd49427644d32a613d9 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 15:16:23 +0800 Subject: [PATCH 145/235] fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic (#8009) * fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic * fix: unuse log --- src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts | 7 +++++++ src/renderer/src/utils/mcp-tools.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 0f1bad0bf8..d32564d962 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -593,6 +593,13 @@ export class GeminiAPIClient extends BaseApiClient< } } as LLMWebSearchCompleteChunk) } + if (toolCalls.length > 0) { + controller.enqueue({ + type: ChunkType.MCP_TOOL_CREATED, + tool_calls: [...toolCalls] + }) + toolCalls.length = 0 + } controller.enqueue({ type: ChunkType.LLM_RESPONSE_COMPLETE, response: { diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index b39d9b6bc3..4b7a2e4735 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -391,7 +391,7 @@ export function geminiFunctionCallToMcpTool( ): MCPTool | undefined { if (!toolCall) return undefined if (!mcpTools) return undefined - const tool = mcpTools.find((tool) => tool.id === toolCall.name) + const tool = mcpTools.find((tool) => tool.id === toolCall.name || tool.name === toolCall.name) if (!tool) { return undefined } From 3afa81eb5d2aea73b672940379c236fec790bcc8 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 16:58:35 +0800 Subject: [PATCH 146/235] fix(Anthropic): content truncation (#7942) * fix(Anthropic): content truncation * feat: add start event and fix content truncation * fix (gemini): some event * revert: index.tsx * revert(messageThunk): error block * fix: ci * chore: unuse log --- .../clients/anthropic/AnthropicAPIClient.ts | 28 +-- .../aiCore/clients/gemini/GeminiAPIClient.ts | 18 +- .../aiCore/clients/openai/OpenAIApiClient.ts | 16 +- .../clients/openai/OpenAIResponseAPIClient.ts | 26 ++ .../middleware/core/TextChunkMiddleware.ts | 17 +- .../middleware/core/ThinkChunkMiddleware.ts | 12 +- .../feat/ToolUseExtractionMiddleware.ts | 1 + .../home/Messages/Blocks/MainTextBlock.tsx | 8 +- .../Blocks/__tests__/MainTextBlock.test.tsx | 45 ---- .../src/services/StreamProcessingService.ts | 12 + src/renderer/src/store/thunk/messageThunk.ts | 227 ++++++++---------- src/renderer/src/types/chunk.ts | 32 +++ 12 files changed, 213 insertions(+), 229 deletions(-) diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index c946f114fe..93176a9566 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -49,10 +49,10 @@ import { LLMWebSearchCompleteChunk, LLMWebSearchInProgressChunk, MCPToolCreatedChunk, - TextCompleteChunk, TextDeltaChunk, - ThinkingCompleteChunk, - ThinkingDeltaChunk + TextStartChunk, + ThinkingDeltaChunk, + ThinkingStartChunk } from '@renderer/types/chunk' import { type Message } from '@renderer/types/newMessage' import { @@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient< return () => { let accumulatedJson = '' const toolCalls: Record = {} - const ChunkIdTypeMap: Record = {} return { async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController) { switch (rawChunk.type) { @@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient< break } case 'text': { - if (!ChunkIdTypeMap[rawChunk.index]) { - ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块 - } + controller.enqueue({ + type: ChunkType.TEXT_START + } as TextStartChunk) break } case 'thinking': case 'redacted_thinking': { - if (!ChunkIdTypeMap[rawChunk.index]) { - ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块 - } + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) break } } @@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient< break } case 'content_block_stop': { - if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) { - controller.enqueue({ - type: ChunkType.TEXT_COMPLETE - } as TextCompleteChunk) - } else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) { - controller.enqueue({ - type: ChunkType.THINKING_COMPLETE - } as ThinkingCompleteChunk) - } const toolCall = toolCalls[rawChunk.index] if (toolCall) { try { diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index d32564d962..bcf7c0d592 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -41,7 +41,7 @@ import { ToolCallResponse, WebSearchSource } from '@renderer/types' -import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk' +import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { GeminiOptions, @@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient< } getResponseChunkTransformer(): ResponseChunkTransformer { + const toolCalls: FunctionCall[] = [] + let isFirstTextChunk = true + let isFirstThinkingChunk = true return () => ({ async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController) { - const toolCalls: FunctionCall[] = [] if (chunk.candidates && chunk.candidates.length > 0) { for (const candidate of chunk.candidates) { if (candidate.content) { candidate.content.parts?.forEach((part) => { const text = part.text || '' if (part.thought) { + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: text }) } else if (part.text) { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + } as TextStartChunk) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: text diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index c1994dcb95..e3ccc8edd0 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -31,7 +31,7 @@ import { ToolCallResponse, WebSearchSource } from '@renderer/types' -import { ChunkType } from '@renderer/types/chunk' +import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { OpenAISdkMessageParam, @@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient< isFinished = true } + let isFirstThinkingChunk = true + let isFirstTextChunk = true return (context: ResponseChunkTransformerContext) => ({ async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController) { // 持续更新usage信息 @@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it const reasoningText = contentSource.reasoning_content || contentSource.reasoning if (reasoningText) { + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: reasoningText @@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // 处理文本内容 if (contentSource.content) { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + } as TextStartChunk) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: contentSource.content diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 2af0b8376f..898e7eec44 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -424,6 +424,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] let hasBeenCollectedToolCalls = false let hasReasoningSummary = false + let isFirstThinkingChunk = true + let isFirstTextChunk = true return () => ({ async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController) { // 处理chunk @@ -435,6 +437,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< switch (output.type) { case 'message': if (output.content[0].type === 'output_text') { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + }) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: output.content[0].text @@ -451,6 +459,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< } break case 'reasoning': + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + }) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: output.summary.map((s) => s.text).join('\n') @@ -510,6 +524,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< hasReasoningSummary = true break case 'response.reasoning_summary_text.delta': + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + }) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: chunk.delta @@ -535,6 +555,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< }) break case 'response.output_text.delta': { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + }) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: chunk.delta diff --git a/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts index 3905d52058..0affc6b382 100644 --- a/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts @@ -1,5 +1,5 @@ import Logger from '@renderer/config/logger' -import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk' +import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsContext, CompletionsMiddleware } from '../types' @@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware = // 用于跨chunk的状态管理 let accumulatedTextContent = '' - let hasTextCompleteEventEnqueue = false const enhancedTextStream = resultFromUpstream.pipeThrough( new TransformStream({ transform(chunk: GenericChunk, controller) { @@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware = // 创建新的chunk,包含处理后的文本 controller.enqueue(chunk) - } else if (chunk.type === ChunkType.TEXT_COMPLETE) { - const textChunk = chunk as TextCompleteChunk - controller.enqueue({ - ...textChunk, - text: accumulatedTextContent - }) - if (params.onResponse) { - params.onResponse(accumulatedTextContent, true) - } - hasTextCompleteEventEnqueue = true - accumulatedTextContent = '' - } else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) { + } else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) { if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) { const finalText = accumulatedTextContent ctx._internal.customState!.accumulatedText = finalText @@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware = }) controller.enqueue(chunk) } - hasTextCompleteEventEnqueue = true accumulatedTextContent = '' } else { // 其他类型的chunk直接传递 diff --git a/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts index dccdde7f10..22eaabe96d 100644 --- a/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts @@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware = thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 } controller.enqueue(enhancedChunk) - } else if (chunk.type === ChunkType.THINKING_COMPLETE) { - const thinkingCompleteChunk = chunk as ThinkingCompleteChunk - controller.enqueue({ - ...thinkingCompleteChunk, - text: accumulatedThinkingContent, - thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 - }) - hasThinkingContent = false - accumulatedThinkingContent = '' - thinkingStartTime = 0 - } else if (hasThinkingContent && thinkingStartTime > 0) { + } else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) { // 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE const thinkingCompleteChunk: ThinkingCompleteChunk = { type: ChunkType.THINKING_COMPLETE, diff --git a/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts index b53d7348f1..3e606f6683 100644 --- a/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts @@ -79,6 +79,7 @@ function createToolUseExtractionTransform( toolCounter += toolUseResponses.length if (toolUseResponses.length > 0) { + controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' }) // 生成 MCP_TOOL_CREATED chunk const mcpToolCreatedChunk: MCPToolCreatedChunk = { type: ChunkType.MCP_TOOL_CREATED, diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 524fcd4160..0f0d52907d 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -19,8 +19,6 @@ interface Props { role: Message['role'] } -const toolUseRegex = /([\s\S]*?)<\/tool_use>/g - const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions = [] }) => { // Use the passed citationBlockId directly in the selector const { renderInputMessageAsMarkdown } = useSettings() @@ -38,10 +36,6 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions return withCitationTags(block.content, rawCitations, sourceType) }, [block.content, block.citationReferences, citationBlockId, rawCitations]) - const ignoreToolUse = useMemo(() => { - return processedContent.replace(toolUseRegex, '') - }, [processedContent]) - return ( <> {/* Render mentions associated with the message */} @@ -57,7 +51,7 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions {block.content}

) : ( - + )} ) diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx index 551e0d9371..4683aae9bb 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx @@ -261,51 +261,6 @@ describe('MainTextBlock', () => { }) describe('content processing', () => { - it('should filter tool_use tags from content', () => { - const testCases = [ - { - name: 'single tool_use tag', - content: 'Before tool content after', - expectsFiltering: true - }, - { - name: 'multiple tool_use tags', - content: 'Start tool1 middle tool2 end', - expectsFiltering: true - }, - { - name: 'multiline tool_use', - content: `Text before - - multiline - tool content - -text after`, - expectsFiltering: true - }, - { - name: 'malformed tool_use', - content: 'Before unclosed tag', - expectsFiltering: false // Should preserve malformed tags - } - ] - - testCases.forEach(({ content, expectsFiltering }) => { - const block = createMainTextBlock({ content }) - const { unmount } = renderMainTextBlock({ block, role: 'assistant' }) - - const renderedContent = getRenderedMarkdown() - expect(renderedContent).toBeInTheDocument() - - if (expectsFiltering) { - // Check that tool_use content is not visible to user - expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument() - } - - unmount() - }) - }) - it('should process content through format utilities', () => { const block = createMainTextBlock({ content: 'Content to process', diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 6c166ca6a9..c6afa85e39 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -8,10 +8,14 @@ import { AssistantMessageStatus } from '@renderer/types/newMessage' export interface StreamProcessorCallbacks { // LLM response created onLLMResponseCreated?: () => void + // Text content start + onTextStart?: () => void // Text content chunk received onTextChunk?: (text: string) => void // Full text content received onTextComplete?: (text: string) => void + // thinking content start + onThinkingStart?: () => void // Thinking/reasoning content chunk received (e.g., from Claude) onThinkingChunk?: (text: string, thinking_millsec?: number) => void onThinkingComplete?: (text: string, thinking_millsec?: number) => void @@ -54,6 +58,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) if (callbacks.onLLMResponseCreated) callbacks.onLLMResponseCreated() break } + case ChunkType.TEXT_START: { + if (callbacks.onTextStart) callbacks.onTextStart() + break + } case ChunkType.TEXT_DELTA: { if (callbacks.onTextChunk) callbacks.onTextChunk(data.text) break @@ -62,6 +70,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) if (callbacks.onTextComplete) callbacks.onTextComplete(data.text) break } + case ChunkType.THINKING_START: { + if (callbacks.onThinkingStart) callbacks.onThinkingStart() + break + } case ChunkType.THINKING_DELTA: { if (callbacks.onThinkingChunk) callbacks.onThinkingChunk(data.text, data.thinking_millsec) break diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 996044bb24..63ca59fa43 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -41,7 +41,7 @@ import { createTranslationBlock, resetAssistantMessage } from '@renderer/utils/messageUtils/create' -import { getMainTextContent } from '@renderer/utils/messageUtils/find' +import { findMainTextBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { getTopicQueue } from '@renderer/utils/queue' import { waitForTopicQueue } from '@renderer/utils/queue' import { isOnHomePage } from '@renderer/utils/window' @@ -226,31 +226,6 @@ export const cleanupMultipleBlocks = (dispatch: AppDispatch, blockIds: string[]) } } -// // 修改: 节流更新单个块的内容/状态到数据库 (仅用于 Text/Thinking Chunks) -// export const throttledBlockDbUpdate = throttle( -// async (blockId: string, blockChanges: Partial) => { -// // Check if blockId is valid before attempting update -// if (!blockId) { -// console.warn('[DB Throttle Block Update] Attempted to update with null/undefined blockId. Skipping.') -// return -// } -// const state = store.getState() -// const block = state.messageBlocks.entities[blockId] -// // throttle是异步函数,可能会在complete事件触发后才执行 -// if ( -// blockChanges.status === MessageBlockStatus.STREAMING && -// (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR) -// ) -// return -// try { -// } catch (error) { -// console.error(`[DB Throttle Block Update] Failed for block ${blockId}:`, error) -// } -// }, -// 300, // 可以调整节流间隔 -// { leading: false, trailing: true } -// ) - // 新增: 通用的、非节流的函数,用于保存消息和块的更新到数据库 const saveUpdatesToDB = async ( messageId: string, @@ -351,9 +326,9 @@ const fetchAndProcessAssistantResponseImpl = async ( let accumulatedContent = '' let accumulatedThinking = '' - // 专注于管理UI焦点和块切换 let lastBlockId: string | null = null let lastBlockType: MessageBlockType | null = null + let currentActiveBlockType: MessageBlockType | null = null // 专注于块内部的生命周期处理 let initialPlaceholderBlockId: string | null = null let citationBlockId: string | null = null @@ -365,6 +340,28 @@ const fetchAndProcessAssistantResponseImpl = async ( const toolCallIdToBlockIdMap = new Map() const notificationService = NotificationService.getInstance() + /** + * 智能更新策略:根据块类型连续性自动判断使用节流还是立即更新 + * - 连续同类块:使用节流(减少重渲染) + * - 块类型切换:立即更新(确保状态正确) + */ + const smartBlockUpdate = (blockId: string, changes: Partial, blockType: MessageBlockType) => { + const isBlockTypeChanged = currentActiveBlockType !== null && currentActiveBlockType !== blockType + + if (isBlockTypeChanged) { + if (lastBlockId && lastBlockId !== blockId) { + cancelThrottledBlockUpdate(lastBlockId) + } + dispatch(updateOneBlock({ id: blockId, changes })) + saveUpdatedBlockToDB(blockId, assistantMsgId, topicId, getState) + } else { + throttledBlockUpdate(blockId, changes) + } + + // 更新当前活跃块类型 + currentActiveBlockType = blockType + } + const handleBlockTransition = async (newBlock: MessageBlock, newBlockType: MessageBlockType) => { lastBlockId = newBlock.id lastBlockType = newBlockType @@ -428,6 +425,25 @@ const fetchAndProcessAssistantResponseImpl = async ( initialPlaceholderBlockId = baseBlock.id await handleBlockTransition(baseBlock as PlaceholderMessageBlock, MessageBlockType.UNKNOWN) }, + onTextStart: async () => { + if (initialPlaceholderBlockId) { + lastBlockType = MessageBlockType.MAIN_TEXT + const changes = { + type: MessageBlockType.MAIN_TEXT, + content: accumulatedContent, + status: MessageBlockStatus.STREAMING + } + smartBlockUpdate(initialPlaceholderBlockId, changes, MessageBlockType.MAIN_TEXT) + mainTextBlockId = initialPlaceholderBlockId + initialPlaceholderBlockId = null + } else if (!mainTextBlockId) { + const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, { + status: MessageBlockStatus.STREAMING + }) + mainTextBlockId = newBlock.id + await handleBlockTransition(newBlock, MessageBlockType.MAIN_TEXT) + } + }, onTextChunk: async (text) => { const citationBlockSource = citationBlockId ? (getState().messageBlocks.entities[citationBlockId] as CitationMessageBlock).response?.source @@ -435,31 +451,11 @@ const fetchAndProcessAssistantResponseImpl = async ( accumulatedContent += text if (mainTextBlockId) { const blockChanges: Partial = { - content: accumulatedContent, - status: MessageBlockStatus.STREAMING - } - throttledBlockUpdate(mainTextBlockId, blockChanges) - } else if (initialPlaceholderBlockId) { - // 将占位块转换为主文本块 - const initialChanges: Partial = { - type: MessageBlockType.MAIN_TEXT, content: accumulatedContent, status: MessageBlockStatus.STREAMING, citationReferences: citationBlockId ? [{ citationBlockId, citationBlockSource }] : [] } - mainTextBlockId = initialPlaceholderBlockId - // 清理占位块 - initialPlaceholderBlockId = null - lastBlockType = MessageBlockType.MAIN_TEXT - dispatch(updateOneBlock({ id: mainTextBlockId, changes: initialChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) - } else { - const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, { - status: MessageBlockStatus.STREAMING, - citationReferences: citationBlockId ? [{ citationBlockId, citationBlockSource }] : [] - }) - mainTextBlockId = newBlock.id // 立即设置ID,防止竞态条件 - await handleBlockTransition(newBlock, MessageBlockType.MAIN_TEXT) + smartBlockUpdate(mainTextBlockId, blockChanges, MessageBlockType.MAIN_TEXT) } }, onTextComplete: async (finalText) => { @@ -468,18 +464,35 @@ const fetchAndProcessAssistantResponseImpl = async ( content: finalText, status: MessageBlockStatus.SUCCESS } - cancelThrottledBlockUpdate(mainTextBlockId) - dispatch(updateOneBlock({ id: mainTextBlockId, changes })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) - if (!assistant.enableWebSearch) { - mainTextBlockId = null - } + smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT) + mainTextBlockId = null } else { console.warn( `[onTextComplete] Received text.complete but last block was not MAIN_TEXT (was ${lastBlockType}) or lastBlockId is null.` ) } }, + onThinkingStart: async () => { + if (initialPlaceholderBlockId) { + lastBlockType = MessageBlockType.THINKING + const changes = { + type: MessageBlockType.THINKING, + content: accumulatedThinking, + status: MessageBlockStatus.STREAMING, + thinking_millsec: 0 + } + thinkingBlockId = initialPlaceholderBlockId + initialPlaceholderBlockId = null + smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING) + } else if (!thinkingBlockId) { + const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, { + status: MessageBlockStatus.STREAMING, + thinking_millsec: 0 + }) + thinkingBlockId = newBlock.id + await handleBlockTransition(newBlock, MessageBlockType.THINKING) + } + }, onThinkingChunk: async (text, thinking_millsec) => { accumulatedThinking += text if (thinkingBlockId) { @@ -488,26 +501,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.STREAMING, thinking_millsec: thinking_millsec } - throttledBlockUpdate(thinkingBlockId, blockChanges) - } else if (initialPlaceholderBlockId) { - // First chunk for this block: Update type and status immediately - lastBlockType = MessageBlockType.THINKING - const initialChanges: Partial = { - type: MessageBlockType.THINKING, - content: accumulatedThinking, - status: MessageBlockStatus.STREAMING - } - thinkingBlockId = initialPlaceholderBlockId - initialPlaceholderBlockId = null - dispatch(updateOneBlock({ id: thinkingBlockId, changes: initialChanges })) - saveUpdatedBlockToDB(thinkingBlockId, assistantMsgId, topicId, getState) - } else { - const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, { - status: MessageBlockStatus.STREAMING, - thinking_millsec: 0 - }) - thinkingBlockId = newBlock.id // 立即设置ID,防止竞态条件 - await handleBlockTransition(newBlock, MessageBlockType.THINKING) + smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING) } }, onThinkingComplete: (finalText, final_thinking_millsec) => { @@ -518,9 +512,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.SUCCESS, thinking_millsec: final_thinking_millsec } - cancelThrottledBlockUpdate(thinkingBlockId) - dispatch(updateOneBlock({ id: thinkingBlockId, changes })) - saveUpdatedBlockToDB(thinkingBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING) } else { console.warn( `[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${lastBlockType}) or lastBlockId is null.` @@ -539,8 +531,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } toolBlockId = initialPlaceholderBlockId initialPlaceholderBlockId = null - dispatch(updateOneBlock({ id: toolBlockId, changes })) - saveUpdatedBlockToDB(toolBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(toolBlockId, changes, MessageBlockType.TOOL) toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId) } else if (toolResponse.status === 'pending') { const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, { @@ -566,8 +557,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.PROCESSING, metadata: { rawMcpToolResponse: toolResponse } } - dispatch(updateOneBlock({ id: targetBlockId, changes })) - saveUpdatedBlockToDB(targetBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(targetBlockId, changes, MessageBlockType.TOOL) } else if (!targetBlockId) { console.warn( `[onToolCallInProgress] No block ID found for tool ID: ${toolResponse.id}. Available mappings:`, @@ -601,9 +591,7 @@ const fetchAndProcessAssistantResponseImpl = async ( if (finalStatus === MessageBlockStatus.ERROR) { changes.error = { message: `Tool execution failed/error`, details: toolResponse.response } } - cancelThrottledBlockUpdate(existingBlockId) - dispatch(updateOneBlock({ id: existingBlockId, changes })) - saveUpdatedBlockToDB(existingBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL) } else { console.warn( `[onToolCallComplete] Received unhandled tool status: ${toolResponse.status} for ID: ${toolResponse.id}` @@ -624,8 +612,7 @@ const fetchAndProcessAssistantResponseImpl = async ( knowledge: externalToolResult.knowledge, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: citationBlockId, changes })) - saveUpdatedBlockToDB(citationBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(citationBlockId, changes, MessageBlockType.CITATION) } else { console.error('[onExternalToolComplete] citationBlockId is null. Cannot update.') } @@ -639,8 +626,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.PROCESSING } lastBlockType = MessageBlockType.CITATION - dispatch(updateOneBlock({ id: initialPlaceholderBlockId, changes })) - saveUpdatedBlockToDB(initialPlaceholderBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(initialPlaceholderBlockId, changes, MessageBlockType.CITATION) initialPlaceholderBlockId = null } else { const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING }) @@ -656,22 +642,19 @@ const fetchAndProcessAssistantResponseImpl = async ( response: llmWebSearchResult, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: blockId, changes })) - saveUpdatedBlockToDB(blockId, assistantMsgId, topicId, getState) + smartBlockUpdate(blockId, changes, MessageBlockType.CITATION) - if (mainTextBlockId) { - const state = getState() - const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] - if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { - const currentRefs = existingMainTextBlock.citationReferences || [] - const mainTextChanges = { - citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] - } - dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + const state = getState() + const existingMainTextBlocks = findMainTextBlocks(state.messages.entities[assistantMsgId]) + if (existingMainTextBlocks.length > 0) { + const existingMainTextBlock = existingMainTextBlocks[0] + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] } - mainTextBlockId = null + smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT) } + if (initialPlaceholderBlockId) { citationBlockId = initialPlaceholderBlockId initialPlaceholderBlockId = null @@ -687,21 +670,15 @@ const fetchAndProcessAssistantResponseImpl = async ( } ) citationBlockId = citationBlock.id - if (mainTextBlockId) { - const state = getState() - const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] - if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { - const currentRefs = existingMainTextBlock.citationReferences || [] - const mainTextChanges = { - citationReferences: [ - ...currentRefs, - { citationBlockId, citationBlockSource: llmWebSearchResult.source } - ] - } - dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + const state = getState() + const existingMainTextBlocks = findMainTextBlocks(state.messages.entities[assistantMsgId]) + if (existingMainTextBlocks.length > 0) { + const existingMainTextBlock = existingMainTextBlocks[0] + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { citationBlockId, citationBlockSource: llmWebSearchResult.source }] } - mainTextBlockId = null + smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT) } await handleBlockTransition(citationBlock, MessageBlockType.CITATION) } @@ -716,8 +693,7 @@ const fetchAndProcessAssistantResponseImpl = async ( lastBlockType = MessageBlockType.IMAGE imageBlockId = initialPlaceholderBlockId initialPlaceholderBlockId = null - dispatch(updateOneBlock({ id: imageBlockId, changes: initialChanges })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, initialChanges, MessageBlockType.IMAGE) } else if (!imageBlockId) { const imageBlock = createImageBlock(assistantMsgId, { status: MessageBlockStatus.STREAMING @@ -734,8 +710,7 @@ const fetchAndProcessAssistantResponseImpl = async ( metadata: { generateImageResponse: imageData }, status: MessageBlockStatus.STREAMING } - dispatch(updateOneBlock({ id: imageBlockId, changes })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, changes, MessageBlockType.IMAGE) } }, onImageGenerated: (imageData) => { @@ -744,8 +719,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const changes: Partial = { status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: imageBlockId, changes })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, changes, MessageBlockType.IMAGE) } else { const imageUrl = imageData.images?.[0] || 'placeholder_image_url' const changes: Partial = { @@ -753,8 +727,7 @@ const fetchAndProcessAssistantResponseImpl = async ( metadata: { generateImageResponse: imageData }, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: imageBlockId, changes })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, changes, MessageBlockType.IMAGE) } } else { console.error('[onImageGenerated] Last block was not an Image block or ID is missing.') @@ -802,9 +775,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const changes: Partial = { status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR } - cancelThrottledBlockUpdate(possibleBlockId) - dispatch(updateOneBlock({ id: possibleBlockId, changes })) - saveUpdatedBlockToDB(possibleBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(possibleBlockId, changes, MessageBlockType.MAIN_TEXT) } const errorBlock = createErrorBlock(assistantMsgId, serializableError, { status: MessageBlockStatus.SUCCESS }) @@ -846,9 +817,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const changes: Partial = { status: MessageBlockStatus.SUCCESS } - cancelThrottledBlockUpdate(possibleBlockId) - dispatch(updateOneBlock({ id: possibleBlockId, changes })) - saveUpdatedBlockToDB(possibleBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(possibleBlockId, changes, lastBlockType!) } const endTime = Date.now() diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index c5e84a4673..1fdbbdae6f 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -19,13 +19,16 @@ export enum ChunkType { EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete', LLM_RESPONSE_CREATED = 'llm_response_created', LLM_RESPONSE_IN_PROGRESS = 'llm_response_in_progress', + TEXT_START = 'text.start', TEXT_DELTA = 'text.delta', TEXT_COMPLETE = 'text.complete', + AUDIO_START = 'audio.start', AUDIO_DELTA = 'audio.delta', AUDIO_COMPLETE = 'audio.complete', IMAGE_CREATED = 'image.created', IMAGE_DELTA = 'image.delta', IMAGE_COMPLETE = 'image.complete', + THINKING_START = 'thinking.start', THINKING_DELTA = 'thinking.delta', THINKING_COMPLETE = 'thinking.complete', LLM_WEB_SEARCH_IN_PROGRESS = 'llm_websearch_in_progress', @@ -56,6 +59,18 @@ export interface LLMResponseInProgressChunk { response?: Response type: ChunkType.LLM_RESPONSE_IN_PROGRESS } + +export interface TextStartChunk { + /** + * The type of the chunk + */ + type: ChunkType.TEXT_START + + /** + * The ID of the chunk + */ + chunk_id?: number +} export interface TextDeltaChunk { /** * The text content of the chunk @@ -90,6 +105,13 @@ export interface TextCompleteChunk { type: ChunkType.TEXT_COMPLETE } +export interface AudioStartChunk { + /** + * The type of the chunk + */ + type: ChunkType.AUDIO_START +} + export interface AudioDeltaChunk { /** * A chunk of Base64 encoded audio data @@ -140,6 +162,13 @@ export interface ImageCompleteChunk { image?: { type: 'url' | 'base64'; images: string[] } } +export interface ThinkingStartChunk { + /** + * The type of the chunk + */ + type: ChunkType.THINKING_START +} + export interface ThinkingDeltaChunk { /** * The text content of the chunk @@ -365,13 +394,16 @@ export type Chunk = | ExternalToolCompleteChunk // 外部工具调用完成,外部工具包含搜索互联网,知识库,MCP服务器 | LLMResponseCreatedChunk // 大模型响应创建,返回即将创建的块类型 | LLMResponseInProgressChunk // 大模型响应进行中 + | TextStartChunk // 文本内容生成开始 | TextDeltaChunk // 文本内容生成中 | TextCompleteChunk // 文本内容生成完成 + | AudioStartChunk // 音频内容生成开始 | AudioDeltaChunk // 音频内容生成中 | AudioCompleteChunk // 音频内容生成完成 | ImageCreatedChunk // 图片内容创建 | ImageDeltaChunk // 图片内容生成中 | ImageCompleteChunk // 图片内容生成完成 + | ThinkingStartChunk // 思考内容生成开始 | ThinkingDeltaChunk // 思考内容生成中 | ThinkingCompleteChunk // 思考内容生成完成 | LLMWebSearchInProgressChunk // 大模型内部搜索进行中,无明显特征 From dff44f272179fa8a741546ca5245d0f978c7157d Mon Sep 17 00:00:00 2001 From: Alaina Hardie Date: Thu, 10 Jul 2025 05:01:31 -0400 Subject: [PATCH 147/235] Fix: Require typechecking for Mac and Linux target builds (#7219) fix: Mac builds do not auto-run typecheck, but Windows builds do. This requires an extra manual step when building for Mac. Update build scripts in package.json to use `npm run build` directly for Mac and Linux targets.. --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 41f1f58bf7..e56b1e261c 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,12 @@ "build:win": "dotenv npm run build && electron-builder --win --x64 --arm64", "build:win:x64": "dotenv npm run build && electron-builder --win --x64", "build:win:arm64": "dotenv npm run build && electron-builder --win --arm64", - "build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64", - "build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64", - "build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64", - "build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64", - "build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64", - "build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64", + "build:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64", + "build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64", + "build:mac:x64": "dotenv npm run build && electron-builder --mac --x64", + "build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64", + "build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64", + "build:linux:x64": "dotenv npm run build && electron-builder --linux --x64", "build:npm": "node scripts/build-npm.js", "release": "node scripts/version.js", "publish": "yarn build:check && yarn release patch push", From ffbd6445df811194dca7d3764eb5a1abd6a131cf Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 17:26:38 +0800 Subject: [PATCH 148/235] refactor(Inputbar): make button tooltips disappear faster (#8011) --- .../src/components/TranslateButton.tsx | 1 + .../pages/home/Inputbar/AttachmentButton.tsx | 6 +++++- .../home/Inputbar/GenerateImageButton.tsx | 1 + .../src/pages/home/Inputbar/Inputbar.tsx | 2 +- .../src/pages/home/Inputbar/InputbarTools.tsx | 18 +++++++++++++++--- .../home/Inputbar/KnowledgeBaseButton.tsx | 2 +- .../src/pages/home/Inputbar/MCPToolsButton.tsx | 2 +- .../home/Inputbar/MentionModelsButton.tsx | 2 +- .../pages/home/Inputbar/NewContextButton.tsx | 6 +++++- .../pages/home/Inputbar/QuickPhrasesButton.tsx | 2 +- .../src/pages/home/Inputbar/ThinkingButton.tsx | 2 +- .../pages/home/Inputbar/WebSearchButton.tsx | 6 +++++- 12 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index d52448b488..41a29a6fa4 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -77,6 +77,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa {isTranslating ? : } diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index a06229bd7c..576f034a24 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -54,7 +54,11 @@ const AttachmentButton: FC = ({ })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx index 889919b7f5..c7d930bf5d 100644 --- a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx @@ -21,6 +21,7 @@ const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEna title={ isGenerateImageModel(model) ? t('chat.input.generate_image') : t('chat.input.generate_image_not_supported') } + mouseLeaveDelay={0} arrow> diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 775debfd34..a07f7b73b2 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -909,7 +909,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = /> {loading && ( - + diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index a6596aed19..7609b8cfe3 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -290,7 +290,11 @@ const InputbarTools = ({ key: 'new_topic', label: t('chat.input.new_topic', { Command: '' }), component: ( - + @@ -395,7 +399,11 @@ const InputbarTools = ({ key: 'clear_topic', label: t('chat.input.clear', { Command: '' }), component: ( - + @@ -406,7 +414,11 @@ const InputbarTools = ({ key: 'toggle_expand', label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'), component: ( - + {isExpended ? : } diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx index 3462788bdc..8e4782c8a7 100644 --- a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -85,7 +85,7 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index ec64e7d4a7..cc8bc67ca5 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -454,7 +454,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar })) return ( - + = ({ })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index c8c2b9fced..26c9941cff 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -16,7 +16,11 @@ const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { useShortcut('toggle_new_context', onNewContext) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index d15c982a87..d6e9621fd5 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -148,7 +148,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, return ( <> - + diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 1338b03fcc..810d0158f4 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -190,7 +190,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re })) return ( - + {getThinkingIcon()} diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index c17f5fe2cc..6eeed391f4 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -127,7 +127,11 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { })) return ( - + Date: Thu, 10 Jul 2025 17:26:57 +0800 Subject: [PATCH 149/235] =?UTF-8?q?fix(McpToolChunkMiddleware):=20add=20lo?= =?UTF-8?q?gging=20for=20tool=20calls=20and=20enhance=20l=E2=80=A6=20(#802?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(McpToolChunkMiddleware): add logging for tool calls and enhance lookup logic --- .../src/aiCore/middleware/core/McpToolChunkMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts index 29c35b0e15..b74c4895dc 100644 --- a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts @@ -253,7 +253,8 @@ async function executeToolCalls( (toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) || confirmed.tool.name === toolCall.id || confirmed.tool.id === toolCall.id || - ('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) + ('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) || + ('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase())) ) }) }) From 7e672d86e73383b83b8970205a57ca7b0b80d627 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 17:29:43 +0800 Subject: [PATCH 150/235] refactor: do not jump on enabling content search (#7922) * fix: content search count on enable * refactor(ContentSearch): do not jump on enabling content search * refactor: simplify result count --- src/renderer/src/components/ContentSearch.tsx | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index a172d40570..6842312137 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef( const [isCaseSensitive, setIsCaseSensitive] = useState(false) const [isWholeWord, setIsWholeWord] = useState(false) const [allRanges, setAllRanges] = useState([]) - const [currentIndex, setCurrentIndex] = useState(0) + const [currentIndex, setCurrentIndex] = useState(-1) const prevSearchText = useRef('') const { t } = useTranslation() @@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef( [allRanges, currentIndex] ) - const search = useCallback(() => { - const searchText = searchInputRef.current?.value.trim() ?? null - setSearchCompleted(SearchCompletedState.Searched) - if (target && searchText !== null && searchText !== '') { - const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord) - setAllRanges(ranges) - setCurrentIndex(0) - } - }, [target, filter, isCaseSensitive, isWholeWord]) + const search = useCallback( + (jump = false) => { + const searchText = searchInputRef.current?.value.trim() ?? null + setSearchCompleted(SearchCompletedState.Searched) + if (target && searchText !== null && searchText !== '') { + const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord) + setAllRanges(ranges) + setCurrentIndex(jump && ranges.length > 0 ? 0 : -1) + } + }, + [target, filter, isCaseSensitive, isWholeWord] + ) const implementation = useMemo( () => ({ @@ -207,7 +210,7 @@ export const ContentSearch = React.forwardRef( requestAnimationFrame(() => { inputEl.focus() inputEl.select() - search() + search(false) }) } else { requestAnimationFrame(() => { @@ -231,11 +234,11 @@ export const ContentSearch = React.forwardRef( setSearchCompleted(SearchCompletedState.NotSearched) }, search: () => { - search() + search(true) locateByIndex(true) }, silentSearch: () => { - search() + search(false) locateByIndex(false) }, focus: () => { @@ -302,7 +305,7 @@ export const ContentSearch = React.forwardRef( useEffect(() => { if (enableContentSearch && searchInputRef.current?.value.trim()) { - search() + search(true) } }, [isCaseSensitive, isWholeWord, enableContentSearch, search]) @@ -365,16 +368,12 @@ export const ContentSearch = React.forwardRef( - {searchCompleted !== SearchCompletedState.NotSearched ? ( - allRanges.length > 0 ? ( - <> - {currentIndex + 1} - / - {allRanges.length} - - ) : ( - {t('common.no_results')} - ) + {searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? ( + <> + {currentIndex + 1} + / + {allRanges.length} + ) : ( 0/0 )} @@ -477,10 +476,6 @@ const SearchResultsPlaceholder = styled.span` opacity: 0.5; ` -const NoResults = styled.span` - color: var(--color-text-1); -` - const SearchResultCount = styled.span` color: var(--color-text); ` From fca93b6c5132683e0879d2ec3088323a618f6ea6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 10 Jul 2025 18:59:00 +0800 Subject: [PATCH 151/235] style: update various component styles for improved layout and readability - Adjusted color for list items in color.scss for better contrast. - Modified line-height and margins in markdown.scss for improved text readability. - Changed height property in FloatingSidebar.tsx for consistent layout. - Increased padding in AgentsPage.tsx for better spacing. - Updated padding and border-radius in Inputbar.tsx for enhanced aesthetics. - Reduced margin in MessageHeader.tsx for tighter layout. - Refactored GroupTitle styles in AssistantsTab.tsx for better alignment and spacing. --- src/renderer/src/assets/styles/color.scss | 2 +- src/renderer/src/assets/styles/markdown.scss | 5 ++-- .../src/components/Popups/FloatingSidebar.tsx | 5 +++- src/renderer/src/pages/agents/AgentsPage.tsx | 2 +- .../src/pages/home/Inputbar/Inputbar.tsx | 4 ++-- .../src/pages/home/Messages/MessageHeader.tsx | 2 +- .../src/pages/home/Tabs/AssistantsTab.tsx | 24 ++++++++++++------- 7 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 3f23425afc..224566e199 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -44,7 +44,7 @@ --color-reference-text: #ffffff; --color-reference-background: #0b0e12; - --color-list-item: #222; + --color-list-item: #252525; --color-list-item-hover: #1e1e1e; --modal-background: #111111; diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index b5c2ee17d1..19e10df41f 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -1,6 +1,6 @@ .markdown { color: var(--color-text); - line-height: 2; + line-height: 1.6; user-select: text; word-break: break-word; letter-spacing: 0.02em; @@ -21,7 +21,7 @@ h4, h5, h6 { - margin: 2em 0 1em 0; + margin: 1.5em 0 1em 0; line-height: 1.3; font-weight: bold; font-family: var(--font-family); @@ -60,6 +60,7 @@ margin: 1.3em 0; white-space: pre-wrap; text-align: justify; + line-height: 2; &:last-child { margin-bottom: 5px; diff --git a/src/renderer/src/components/Popups/FloatingSidebar.tsx b/src/renderer/src/components/Popups/FloatingSidebar.tsx index 1c281bc0d2..3fc464465e 100644 --- a/src/renderer/src/components/Popups/FloatingSidebar.tsx +++ b/src/renderer/src/components/Popups/FloatingSidebar.tsx @@ -54,7 +54,7 @@ const FloatingSidebar: FC = ({ style={{ background: 'transparent', border: 'none', - maxHeight: maxHeight + height: '100%' }} /> @@ -82,6 +82,9 @@ const FloatingSidebar: FC = ({ const PopoverContent = styled.div<{ maxHeight: number }>` max-height: ${(props) => props.maxHeight}px; + &.ant-popover-inner-content { + overflow-y: hidden; + } ` export default FloatingSidebar diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index 1a8e3ce990..dbcb7c5e57 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -266,7 +266,7 @@ const AgentsGroupList = styled(Scrollbar)` display: flex; flex-direction: column; gap: 8px; - padding: 8px 0; + padding: 12px 0; border-right: 0.5px solid var(--color-border); border-top-left-radius: inherit; border-bottom-left-radius: inherit; diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index a07f7b73b2..6bc5623b75 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -955,14 +955,14 @@ const Container = styled.div` flex-direction: column; position: relative; z-index: 2; - padding: 0 16px 16px 16px; + padding: 0 20px 18px 20px; ` const InputBarContainer = styled.div` border: 0.5px solid var(--color-border); transition: all 0.2s ease; position: relative; - border-radius: 15px; + border-radius: 20px; padding-top: 8px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index ddeae08c27..d3f76232a9 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -126,7 +126,7 @@ const Container = styled.div` align-items: center; gap: 10px; position: relative; - margin-bottom: 8px; + margin-bottom: 5px; ` const UserWrap = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index a089bd536b..20e3456be6 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -6,7 +6,7 @@ import { useAssistants } from '@renderer/hooks/useAssistant' import { useAssistantsTabSortType } from '@renderer/hooks/useStore' import { useTags } from '@renderer/hooks/useTags' import { Assistant, AssistantsSortType } from '@renderer/types' -import { Divider, Tooltip } from 'antd' +import { Tooltip } from 'antd' import { FC, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -87,7 +87,7 @@ const Assistants: FC = ({ {group.tag} - + )} {!collapsedTags[group.tag] && ( @@ -198,13 +198,16 @@ const AssistantAddItem = styled.div` ` const GroupTitle = styled.div` - padding: 8px 0; - position: relative; color: var(--color-text-2); font-size: 12px; font-weight: 500; - margin-bottom: -8px; cursor: pointer; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + height: 24px; + margin: 5px 0; ` const GroupTitleName = styled.div` @@ -212,13 +215,18 @@ const GroupTitleName = styled.div` text-overflow: ellipsis; white-space: nowrap; overflow: hidden; - background-color: var(--color-background); box-sizing: border-box; padding: 0 4px; color: var(--color-text); - position: absolute; - transform: translateY(2px); font-size: 13px; + line-height: 24px; + margin-right: 5px; + display: flex; +` + +const GroupTitleDivider = styled.div` + flex: 1; + border-top: 1px solid var(--color-border); ` const AssistantName = styled.div` From db642f08372b70310efcc22d1330cf70a7f3a2df Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 19:27:53 +0800 Subject: [PATCH 152/235] feat(models): support Grok4 (#8032) refactor(models): rename and enhance reasoning model functions for clarity and functionality --- src/renderer/src/config/models.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index f05a0447b3..c162dab6ea 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2487,7 +2487,7 @@ export function isGrokModel(model?: Model): boolean { return model.id.includes('grok') } -export function isGrokReasoningModel(model?: Model): boolean { +export function isSupportedReasoningEffortGrokModel(model?: Model): boolean { if (!model) { return false } @@ -2499,7 +2499,16 @@ export function isGrokReasoningModel(model?: Model): boolean { return false } -export const isSupportedReasoningEffortGrokModel = isGrokReasoningModel +export function isGrokReasoningModel(model?: Model): boolean { + if (!model) { + return false + } + if (isSupportedReasoningEffortGrokModel(model) || model.id.includes('grok-4')) { + return true + } + + return false +} export function isGeminiReasoningModel(model?: Model): boolean { if (!model) { From 2a72f391b71debb9b26b67319a1a602526e49bb9 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 19:32:51 +0800 Subject: [PATCH 153/235] feat: codeblock dot language (#6783) * feat(CodeBlock): support dot language in code block - render DOT using @viz-js/viz - highlight DOT using @viz-js/lang-dot (CodeEditor only) - extract a special view map, update file structure - extract and reuse the PreviewError component across special views - update dependencies, fix peer dependencies * chore: prepare for merge --- package.json | 4 + src/renderer/src/assets/styles/markdown.scss | 4 +- .../components/CodeBlockView/CodePreview.tsx | 8 +- .../CodeBlockView/GraphvizPreview.tsx | 102 +++++++++++++++ .../CodeBlockView/MermaidPreview.tsx | 22 +--- .../CodeBlockView/PlantUmlPreview.tsx | 13 +- .../components/CodeBlockView/PreviewError.tsx | 14 ++ .../components/CodeBlockView/SvgPreview.tsx | 11 +- .../src/components/CodeBlockView/constants.ts | 20 +++ .../src/components/CodeBlockView/index.ts | 2 + .../src/components/CodeBlockView/types.ts | 14 ++ .../CodeBlockView/{index.tsx => view.tsx} | 31 ++--- .../src/components/CodeEditor/hooks.ts | 122 ++++++++++++++---- .../src/context/CodeStyleProvider.tsx | 3 +- .../src/pages/home/Markdown/CodeBlock.tsx | 2 +- yarn.lock | 32 ++++- 16 files changed, 314 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx create mode 100644 src/renderer/src/components/CodeBlockView/PreviewError.tsx create mode 100644 src/renderer/src/components/CodeBlockView/constants.ts create mode 100644 src/renderer/src/components/CodeBlockView/index.ts create mode 100644 src/renderer/src/components/CodeBlockView/types.ts rename src/renderer/src/components/CodeBlockView/{index.tsx => view.tsx} (91%) diff --git a/package.json b/package.json index e56b1e261c..c718dce1a7 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", + "@codemirror/view": "^6.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", @@ -141,6 +142,8 @@ "@vitest/coverage-v8": "^3.1.4", "@vitest/ui": "^3.1.4", "@vitest/web-worker": "^3.1.4", + "@viz-js/lang-dot": "^1.0.5", + "@viz-js/viz": "^3.14.0", "@xyflow/react": "^12.4.4", "antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch", "archiver": "^7.0.1", @@ -225,6 +228,7 @@ "tiny-pinyin": "^1.3.2", "tokenx": "^1.1.0", "typescript": "^5.6.2", + "unified": "^11.0.5", "uuid": "^10.0.0", "vite": "6.2.6", "vitest": "^3.1.4", diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 19e10df41f..e34f649129 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -129,9 +129,7 @@ overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); - &:has(.mermaid), - &:has(.plantuml-preview), - &:has(.svg-preview) { + &:has(.special-preview) { background-color: transparent; } &:not(pre pre) { diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 3df9491e13..b4da2d4eee 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -1,4 +1,4 @@ -import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' +import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight' import { useSettings } from '@renderer/hooks/useSettings' @@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next' import { ThemedToken } from 'shiki/core' import styled from 'styled-components' -interface CodePreviewProps { - children: string +import { BasicPreviewProps } from './types' + +interface CodePreviewProps extends BasicPreviewProps { language: string - setTools?: (value: React.SetStateAction) => void } const MAX_COLLAPSE_HEIGHT = 350 diff --git a/src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx b/src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx new file mode 100644 index 0000000000..452ed1261b --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx @@ -0,0 +1,102 @@ +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { AsyncInitializer } from '@renderer/utils/asyncInitializer' +import { Flex, Spin } from 'antd' +import { debounce } from 'lodash' +import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import styled from 'styled-components' + +import PreviewError from './PreviewError' +import { BasicPreviewProps } from './types' + +// 管理 viz 实例 +const vizInitializer = new AsyncInitializer(async () => { + const module = await import('@viz-js/viz') + return await module.instance() +}) + +/** 预览 Graphviz 图表 + * 通过防抖渲染提供比较统一的体验,减少闪烁。 + */ +const GraphvizPreview: React.FC = ({ children, setTools }) => { + const graphvizRef = useRef(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + // 使用通用图像工具 + const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, { + imgSelector: 'svg', + prefix: 'graphviz', + enableWheelZoom: true + }) + + // 使用工具栏 + usePreviewTools({ + setTools, + handleZoom, + handleCopyImage, + handleDownload + }) + + // 实际的渲染函数 + const renderGraphviz = useCallback(async (content: string) => { + if (!content || !graphvizRef.current) return + + try { + setIsLoading(true) + + const viz = await vizInitializer.get() + const svgElement = viz.renderSVGElement(content) + + // 清空容器并添加新的 SVG + graphvizRef.current.innerHTML = '' + graphvizRef.current.appendChild(svgElement) + + // 渲染成功,清除错误记录 + setError(null) + } catch (error) { + setError((error as Error).message || 'DOT syntax error or rendering failed') + } finally { + setIsLoading(false) + } + }, []) + + // debounce 渲染 + const debouncedRender = useMemo( + () => + debounce((content: string) => { + startTransition(() => renderGraphviz(content)) + }, 300), + [renderGraphviz] + ) + + // 触发渲染 + useEffect(() => { + if (children) { + setIsLoading(true) + debouncedRender(children) + } else { + debouncedRender.cancel() + setIsLoading(false) + } + + return () => { + debouncedRender.cancel() + } + }, [children, debouncedRender]) + + return ( + }> + + {error && {error}} + + + + ) +} + +const StyledGraphviz = styled.div` + overflow: auto; +` + +export default memo(GraphvizPreview) diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx index d461b2899c..be3e0b7e08 100644 --- a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -1,5 +1,5 @@ import { nanoid } from '@reduxjs/toolkit' -import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import { useMermaid } from '@renderer/hooks/useMermaid' import { Flex, Spin } from 'antd' @@ -7,16 +7,14 @@ import { debounce } from 'lodash' import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' -interface Props { - children: string - setTools?: (value: React.SetStateAction) => void -} +import PreviewError from './PreviewError' +import { BasicPreviewProps } from './types' /** 预览 Mermaid 图表 * 通过防抖渲染提供比较统一的体验,减少闪烁。 * FIXME: 等将来容易判断代码块结束位置时再重构。 */ -const MermaidPreview: React.FC = ({ children, setTools }) => { +const MermaidPreview: React.FC = ({ children, setTools }) => { const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid() const mermaidRef = useRef(null) const diagramId = useRef(`mermaid-${nanoid(6)}`).current @@ -143,7 +141,7 @@ const MermaidPreview: React.FC = ({ children, setTools }) => { return ( }> - {(mermaidError || error) && {mermaidError || error}} + {(mermaidError || error) && {mermaidError || error}} @@ -154,14 +152,4 @@ const StyledMermaid = styled.div` overflow: auto; ` -const StyledError = styled.div` - overflow: auto; - padding: 16px; - color: #ff4d4f; - border: 1px solid #ff4d4f; - border-radius: 4px; - word-wrap: break-word; - white-space: pre-wrap; -` - export default memo(MermaidPreview) diff --git a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx index 35ef90e12e..0916056039 100644 --- a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx @@ -1,11 +1,13 @@ import { LoadingOutlined } from '@ant-design/icons' -import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { Spin } from 'antd' import pako from 'pako' import React, { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { BasicPreviewProps } from './types' + const PlantUMLServer = 'https://www.plantuml.com/plantuml' function encode64(data: Uint8Array) { let r = '' @@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC = ({ format, diagr ) } -interface PlantUMLProps { - children: string - setTools?: (value: React.SetStateAction) => void -} - -const PlantUmlPreview: React.FC = ({ children, setTools }) => { +const PlantUmlPreview: React.FC = ({ children, setTools }) => { const { t } = useTranslation() const containerRef = useRef(null) @@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC = ({ children, setTools }) => { return (
- +
) } diff --git a/src/renderer/src/components/CodeBlockView/PreviewError.tsx b/src/renderer/src/components/CodeBlockView/PreviewError.tsx new file mode 100644 index 0000000000..1139dea7ff --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/PreviewError.tsx @@ -0,0 +1,14 @@ +import { memo } from 'react' +import { styled } from 'styled-components' + +const PreviewError = styled.div` + overflow: auto; + padding: 16px; + color: #ff4d4f; + border: 1px solid #ff4d4f; + border-radius: 4px; + word-wrap: break-word; + white-space: pre-wrap; +` + +export default memo(PreviewError) diff --git a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx index 9180aef297..fe60101519 100644 --- a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx @@ -1,15 +1,12 @@ -import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { memo, useEffect, useRef } from 'react' -interface Props { - children: string - setTools?: (value: React.SetStateAction) => void -} +import { BasicPreviewProps } from './types' /** * 使用 Shadow DOM 渲染 SVG */ -const SvgPreview: React.FC = ({ children, setTools }) => { +const SvgPreview: React.FC = ({ children, setTools }) => { const svgContainerRef = useRef(null) useEffect(() => { @@ -58,7 +55,7 @@ const SvgPreview: React.FC = ({ children, setTools }) => { handleDownload }) - return
+ return
} export default memo(SvgPreview) diff --git a/src/renderer/src/components/CodeBlockView/constants.ts b/src/renderer/src/components/CodeBlockView/constants.ts new file mode 100644 index 0000000000..fc6687d5f1 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/constants.ts @@ -0,0 +1,20 @@ +import GraphvizPreview from './GraphvizPreview' +import MermaidPreview from './MermaidPreview' +import PlantUmlPreview from './PlantUmlPreview' +import SvgPreview from './SvgPreview' + +/** + * 特殊视图语言列表 + */ +export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz'] + +/** + * 特殊视图组件映射表 + */ +export const SPECIAL_VIEW_COMPONENTS = { + mermaid: MermaidPreview, + plantuml: PlantUmlPreview, + svg: SvgPreview, + dot: GraphvizPreview, + graphviz: GraphvizPreview +} as const diff --git a/src/renderer/src/components/CodeBlockView/index.ts b/src/renderer/src/components/CodeBlockView/index.ts new file mode 100644 index 0000000000..dfb88d252d --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './view' diff --git a/src/renderer/src/components/CodeBlockView/types.ts b/src/renderer/src/components/CodeBlockView/types.ts new file mode 100644 index 0000000000..5ec413658f --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/types.ts @@ -0,0 +1,14 @@ +import { CodeTool } from '@renderer/components/CodeToolbar' + +/** + * 预览组件的基本 props + */ +export interface BasicPreviewProps { + children: string + setTools?: (value: React.SetStateAction) => void +} + +/** + * 视图模式 + */ +export type ViewMode = 'source' | 'special' | 'split' diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/view.tsx similarity index 91% rename from src/renderer/src/components/CodeBlockView/index.tsx rename to src/renderer/src/components/CodeBlockView/view.tsx index 0a03b68754..7d557d9247 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import CodePreview from './CodePreview' +import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants' import HtmlArtifactsCard from './HtmlArtifactsCard' -import MermaidPreview from './MermaidPreview' -import PlantUmlPreview from './PlantUmlPreview' import StatusBar from './StatusBar' -import SvgPreview from './SvgPreview' - -type ViewMode = 'source' | 'special' | 'split' +import { ViewMode } from './types' interface Props { children: string @@ -42,7 +39,7 @@ interface Props { * - quick 工具 * - core 工具 */ -const CodeBlockView: React.FC = ({ children, language, onSave }) => { +export const CodeBlockView: React.FC = memo(({ children, language, onSave }) => { const { t } = useTranslation() const { codeEditor, codeExecution } = useSettings() @@ -57,7 +54,7 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { return codeExecution.enabled && language === 'python' }, [codeExecution.enabled, language]) - const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language]) + const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language]) const isInSpecialView = useMemo(() => { return hasSpecialView && viewMode === 'special' @@ -201,14 +198,16 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { // 特殊视图组件映射 const specialView = useMemo(() => { - if (language === 'mermaid') { - return {children} - } else if (language === 'plantuml' && isValidPlantUML(children)) { - return {children} - } else if (language === 'svg') { - return {children} + const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS] + + if (!SpecialView) return null + + // PlantUML 语法验证 + if (language === 'plantuml' && !isValidPlantUML(children)) { + return null } - return null + + return {children} }, [children, language]) const renderHeader = useMemo(() => { @@ -242,7 +241,7 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { {isExecutable && output && {output}} ) -} +}) const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` position: relative; @@ -293,5 +292,3 @@ const SplitViewWrapper = styled.div` overflow: hidden; } ` - -export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index 71d74ca3a5..5a04b2178d 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -12,45 +12,111 @@ const linterLoaders: Record Promise> = { } } +/** + * 特殊语言加载器 + */ +const specialLanguageLoaders: Record Promise> = { + dot: async () => { + const mod = await import('@viz-js/lang-dot') + return mod.dot() + } +} + +/** + * 加载语言扩展 + */ +async function loadLanguageExtension(language: string, languageMap: Record): Promise { + let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + + // 如果语言名包含 `-`,转换为驼峰命名法 + if (normalizedLang.includes('-')) { + normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + } + + // 尝试加载特殊语言 + const specialLoader = specialLanguageLoaders[normalizedLang] + if (specialLoader) { + try { + return await specialLoader() + } catch (error) { + console.debug(`Failed to load language ${normalizedLang}`, error) + return null + } + } + + // 回退到 uiw/codemirror 包含的语言 + try { + const { loadLanguage } = await import('@uiw/codemirror-extensions-langs') + const extension = loadLanguage(normalizedLang as any) + return extension || null + } catch (error) { + console.debug(`Failed to load language ${normalizedLang}`, error) + return null + } +} + +/** + * 加载 linter 扩展 + */ +async function loadLinterExtension(language: string): Promise { + const loader = linterLoaders[language] + if (!loader) return null + + try { + return await loader() + } catch (error) { + console.debug(`Failed to load linter for ${language}`, error) + return null + } +} + +/** + * 加载语言相关扩展 + */ export const useLanguageExtensions = (language: string, lint?: boolean) => { const { languageMap } = useCodeStyle() const [extensions, setExtensions] = useState([]) - // 加载语言 useEffect(() => { - let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + let cancelled = false - // 如果语言名包含 `-`,转换为驼峰命名法 - if (normalizedLang.includes('-')) { - normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) - } + const loadAllExtensions = async () => { + try { + // 加载所有扩展 + const [languageResult, linterResult] = await Promise.allSettled([ + loadLanguageExtension(language, languageMap), + lint ? loadLinterExtension(language) : Promise.resolve(null) + ]) - import('@uiw/codemirror-extensions-langs') - .then(({ loadLanguage }) => { - const extension = loadLanguage(normalizedLang as any) - if (extension) { - setExtensions((prev) => [...prev, extension]) + if (cancelled) return + + const results: Extension[] = [] + + // 语言扩展 + if (languageResult.status === 'fulfilled' && languageResult.value) { + results.push(languageResult.value) } - }) - .catch((error) => { - console.debug(`Failed to load language: ${normalizedLang}`, error) - }) - }, [language, languageMap]) - useEffect(() => { - if (!lint) return + // linter 扩展 + if (linterResult.status === 'fulfilled' && linterResult.value) { + results.push(linterResult.value) + } - const loader = linterLoaders[language] - if (loader) { - loader() - .then((extension) => { - setExtensions((prev) => [...prev, extension]) - }) - .catch((error) => { - console.error(`Failed to load linter for ${language}`, error) - }) + setExtensions(results) + } catch (error) { + if (!cancelled) { + console.debug('Failed to load language extensions:', error) + setExtensions([]) + } + } } - }, [language, lint]) + + loadAllExtensions() + + return () => { + cancelled = true + } + }, [language, lint, languageMap]) return extensions } diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx index e702d4847d..c14364b7b0 100644 --- a/src/renderer/src/context/CodeStyleProvider.tsx +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC = ({ children }) => bash: 'shell', 'objective-c++': 'objective-cpp', svg: 'xml', - vab: 'vb' + vab: 'vb', + graphviz: 'dot' } as Record }, []) diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index f6a2905448..a496706cbb 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -1,4 +1,4 @@ -import CodeBlockView from '@renderer/components/CodeBlockView' +import { CodeBlockView } from '@renderer/components/CodeBlockView' import React, { memo, useCallback } from 'react' interface Props { diff --git a/yarn.lock b/yarn.lock index 3514bc0850..e2a45ec2ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3412,7 +3412,7 @@ __metadata: languageName: node linkType: hard -"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1": +"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.0.3, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1": version: 1.2.3 resolution: "@lezer/common@npm:1.2.3" checksum: 10c0/fe9f8e111080ef94037a34ca2af1221c8d01c1763ba5ecf708a286185c76119509a5d19d924c8842172716716ddce22d7834394670c4a9432f0ba9f3b7c0f50d @@ -3515,7 +3515,7 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0": +"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2" dependencies: @@ -3578,7 +3578,7 @@ __metadata: languageName: node linkType: hard -"@lezer/xml@npm:^1.0.0": +"@lezer/xml@npm:^1.0.0, @lezer/xml@npm:^1.0.2": version: 1.0.6 resolution: "@lezer/xml@npm:1.0.6" dependencies: @@ -6962,6 +6962,26 @@ __metadata: languageName: node linkType: hard +"@viz-js/lang-dot@npm:^1.0.5": + version: 1.0.5 + resolution: "@viz-js/lang-dot@npm:1.0.5" + dependencies: + "@codemirror/language": "npm:^6.8.0" + "@lezer/common": "npm:^1.0.3" + "@lezer/highlight": "npm:^1.1.6" + "@lezer/lr": "npm:^1.4.2" + "@lezer/xml": "npm:^1.0.2" + checksum: 10c0/86e81bf077e0a6f418fe2d5cfd8d7f7a7c032bdec13e5dfe3d21620c548e674832f6c9b300eeaad7b0842a3c4044d4ce33d5af9e359ae1efeda0a84d772b77a4 + languageName: node + linkType: hard + +"@viz-js/viz@npm:^3.14.0": + version: 3.14.0 + resolution: "@viz-js/viz@npm:3.14.0" + checksum: 10c0/901afa2d99e8f33cc4abf352f1559e0c16958e01f0750a65a33799aebfe175a18d74f6945f1ff93f64b53b69976dc3d07d39d65c58dda955abd0979dacc4294c + languageName: node + linkType: hard + "@vue/compiler-core@npm:3.5.17": version: 3.5.17 resolution: "@vue/compiler-core@npm:3.5.17" @@ -7075,6 +7095,7 @@ __metadata: "@cherrystudio/embedjs-openai": "npm:^0.1.31" "@cherrystudio/mac-system-ocr": "npm:^0.2.2" "@cherrystudio/pdf-to-img-napi": "npm:^0.0.1" + "@codemirror/view": "npm:^6.0.0" "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" @@ -7127,6 +7148,8 @@ __metadata: "@vitest/coverage-v8": "npm:^3.1.4" "@vitest/ui": "npm:^3.1.4" "@vitest/web-worker": "npm:^3.1.4" + "@viz-js/lang-dot": "npm:^1.0.5" + "@viz-js/viz": "npm:^3.14.0" "@xyflow/react": "npm:^12.4.4" antd: "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch" archiver: "npm:^7.0.1" @@ -7221,6 +7244,7 @@ __metadata: tokenx: "npm:^1.1.0" turndown: "npm:7.2.0" typescript: "npm:^5.6.2" + unified: "npm:^11.0.5" uuid: "npm:^10.0.0" vite: "npm:6.2.6" vitest: "npm:^3.1.4" @@ -19565,7 +19589,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0": +"unified@npm:^11.0.0, unified@npm:^11.0.5": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: From 92be3c0f568d7e6cc67b8d1fa15751131bfbb90a Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 19:34:57 +0800 Subject: [PATCH 154/235] chore: update vscode settings (#7974) * chore: update vscode settings * refactor: add editorconfig to extensions --- .vscode/extensions.json | 2 +- .vscode/settings.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 940260d856..ef0b29b6a6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["dbaeumer.vscode-eslint"] + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index ef4dc3954a..edf514d5ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "source.fixAll.eslint": "explicit", "source.organizeImports": "never" }, + "files.eol": "\n", "search.exclude": { "**/dist/**": true, ".yarn/releases/**": true From 855499681f1425fea824e114d3fd18a2e9480867 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Thu, 10 Jul 2025 19:37:18 +0800 Subject: [PATCH 155/235] feat: add confirm for unsaved content in creating agent (#7965) --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../pages/agents/components/AddAgentPopup.tsx | 35 ++++++++++++++++--- 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5f27ecefed..1d22b73878 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -13,6 +13,7 @@ "title": "Available variables" }, "add.title": "Create Agent", + "add.unsaved_changes_warning": "You have unsaved changes. Are you sure you want to close?", "delete.popup.content": "Are you sure you want to delete this agent?", "edit.model.select.title": "Select Model", "edit.title": "Edit Agent", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index a803e2a081..f76623aec0 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -13,6 +13,7 @@ "title": "利用可能な変数" }, "add.title": "エージェントを作成", + "add.unsaved_changes_warning": "未保存の変更があります。続行しますか?", "delete.popup.content": "このエージェントを削除してもよろしいですか?", "edit.model.select.title": "モデルを選択", "edit.title": "エージェントを編集", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6fd32362a1..445a6c8cda 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -13,6 +13,7 @@ "title": "Доступные переменные" }, "add.title": "Создать агента", + "add.unsaved_changes_warning": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?", "delete.popup.content": "Вы уверены, что хотите удалить этого агента?", "edit.model.select.title": "Выбрать модель", "edit.title": "Редактировать агента", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index d8503085fd..6bfb3d71bf 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -13,6 +13,7 @@ "title": "可用的变量" }, "add.title": "创建智能体", + "add.unsaved_changes_warning": "你有未保存的内容,确定要关闭吗?", "delete.popup.content": "确定要删除此智能体吗?", "edit.model.select.title": "选择模型", "edit.title": "编辑智能体", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 55759eca01..480edf41e2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -13,6 +13,7 @@ "title": "可用的變數" }, "add.title": "建立智慧代理人", + "add.unsaved_changes_warning": "有未保存的變更,確定要關閉嗎?", "delete.popup.content": "確定要刪除此智慧代理人嗎?", "edit.model.select.title": "選擇模型", "edit.title": "編輯智慧代理人", diff --git a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx index 108052a701..c213614d35 100644 --- a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx @@ -41,6 +41,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const [showUndoButton, setShowUndoButton] = useState(false) const [originalPrompt, setOriginalPrompt] = useState('') const [tokenCount, setTokenCount] = useState(0) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const knowledgeState = useAppSelector((state) => state.knowledge) const showKnowledgeIcon = useSidebarIconShow('knowledge') const knowledgeOptions: SelectProps['options'] = [] @@ -92,8 +93,21 @@ const PopupContainer: React.FC = ({ resolve }) => { setOpen(false) } - const onCancel = () => { - setOpen(false) + const handleCancel = () => { + if (hasUnsavedChanges) { + window.modal.confirm({ + title: t('common.confirm'), + content: t('agents.add.unsaved_changes_warning'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: () => { + setOpen(false) + } + }) + } else { + setOpen(false) + } } const onClose = () => { @@ -124,6 +138,7 @@ const PopupContainer: React.FC = ({ resolve }) => { form.setFieldsValue({ prompt: generatedText }) setShowUndoButton(true) setOriginalPrompt(content) + setHasUnsavedChanges(true) } catch (error) { console.error('Error fetching data:', error) } @@ -146,7 +161,7 @@ const PopupContainer: React.FC = ({ resolve }) => { title={t('agents.add.title')} open={open} onOk={() => formRef.current?.submit()} - onCancel={onCancel} + onCancel={handleCancel} maskClosable={false} afterClose={onClose} okText={t('agents.add.title')} @@ -167,9 +182,21 @@ const PopupContainer: React.FC = ({ resolve }) => { setTokenCount(count) setShowUndoButton(false) } + + const currentValues = form.getFieldsValue() + setHasUnsavedChanges(currentValues.name?.trim() || currentValues.prompt?.trim() || emoji) }}> - } arrow> + { + setEmoji(selectedEmoji) + setHasUnsavedChanges(true) + }} + /> + } + arrow> From 7c6db809bb294f3fad39b02cc7a401670a394da3 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:41:01 +0800 Subject: [PATCH 156/235] fix(SelectionAssistant): [macOS] show actionWindow on fullscreen app (#8004) * feat(SelectionService): enhance action window handling for macOS fullscreen mode - Updated processAction and showActionWindow methods to support fullscreen mode on macOS. - Added isFullScreen parameter to manage action window visibility and positioning. - Improved action window positioning logic to ensure it remains within screen boundaries. - Adjusted IPC channel to pass fullscreen state from the renderer to the service. - Updated SelectionToolbar to track fullscreen state and pass it to the action processing function. * chore(deps): update selection-hook to version 1.0.6 in package.json and yarn.lock * fix(SelectionService): improve macOS fullscreen handling and action window focus - Added app import to manage dock visibility on macOS. - Enhanced fullscreen handling logic to ensure the dock icon is restored correctly. - Updated action window focus behavior to prevent unintended hiding when blurred. - Refactored SelectionActionApp to streamline auto pinning logic and remove redundant useEffect. - Cleaned up SelectionToolbar by removing unnecessary window size updates when demo is false. * refactor(SelectionService): remove commented-out code for clarity * refactor(SelectionService): streamline macOS handling and improve code clarity --- package.json | 2 +- src/main/services/SelectionService.ts | 235 +++++++++++------- src/preload/index.ts | 3 +- .../selection/action/SelectionActionApp.tsx | 22 +- .../selection/toolbar/SelectionToolbar.tsx | 8 +- yarn.lock | 10 +- 6 files changed, 177 insertions(+), 103 deletions(-) diff --git a/package.json b/package.json index c718dce1a7..b5073b4b2c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", "pdfjs-dist": "4.10.38", - "selection-hook": "^1.0.5", + "selection-hook": "^1.0.6", "turndown": "7.2.0" }, "devDependencies": { diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 3be2d5a95a..718025cd89 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1,7 +1,7 @@ import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig' import { isDev, isMac, isWin } from '@main/constant' import { IpcChannel } from '@shared/IpcChannel' -import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' +import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' import Logger from 'electron-log' import { join } from 'path' import type { @@ -509,54 +509,55 @@ export class SelectionService { //should set every time the window is shown this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver') - // [macOS] a series of hacky ways only for macOS - if (isMac) { - // [macOS] a hacky way - // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing - // so we just don't set `skipTransformProcessType: true` when in self app - const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) - - if (!isSelf) { - // [macOS] an ugly hacky way - // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces` - // so we set `focusable: true` before showing, and then set false after showing - this.toolbarWindow!.setFocusable(false) - - // [macOS] - // force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again - // set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces` - this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { - visibleOnFullScreen: true, - skipTransformProcessType: true - }) - } - - // [macOS] MUST use `showInactive()` to prevent other windows bring to front together - // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` - this.toolbarWindow!.showInactive() - - // [macOS] restore the focusable status - this.toolbarWindow!.setFocusable(true) - + if (!isMac) { + this.toolbarWindow!.show() + /** + * [Windows] + * In Windows 10, setOpacity(1) will make the window completely transparent + * It's a strange behavior, so we don't use it for compatibility + */ + // this.toolbarWindow!.setOpacity(1) this.startHideByMouseKeyListener() - return } - /** - * The following is for Windows - */ + /************************************************ + * [macOS] the following code is only for macOS + * + * WARNING: + * DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!! + *************************************************/ - this.toolbarWindow!.show() + // [macOS] a hacky way + // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing + // so we just don't set `skipTransformProcessType: true` when in self app + const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) - /** - * [Windows] - * In Windows 10, setOpacity(1) will make the window completely transparent - * It's a strange behavior, so we don't use it for compatibility - */ - // this.toolbarWindow!.setOpacity(1) + if (!isSelf) { + // [macOS] an ugly hacky way + // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces` + // so we set `focusable: true` before showing, and then set false after showing + this.toolbarWindow!.setFocusable(false) + + // [macOS] + // force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again + // set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces` + this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + } + + // [macOS] MUST use `showInactive()` to prevent other windows bring to front together + // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` + this.toolbarWindow!.showInactive() + + // [macOS] restore the focusable status + this.toolbarWindow!.setFocusable(true) this.startHideByMouseKeyListener() + + return } /** @@ -911,6 +912,7 @@ export class SelectionService { refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } } + // [macOS] isFullscreen is only available on macOS this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName) this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData) } @@ -1218,20 +1220,26 @@ export class SelectionService { return actionWindow } - public processAction(actionItem: ActionItem): void { + /** + * Process action item + * @param actionItem Action item to process + * @param isFullScreen [macOS] only macOS has the available isFullscreen mode + */ + public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void { const actionWindow = this.popActionWindow() actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) - this.showActionWindow(actionWindow) + this.showActionWindow(actionWindow, isFullScreen) } /** * Show action window with proper positioning relative to toolbar * Ensures window stays within screen boundaries * @param actionWindow Window to position and show + * @param isFullScreen [macOS] only macOS has the available isFullscreen mode */ - private showActionWindow(actionWindow: BrowserWindow): void { + private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void { let actionWindowWidth = this.ACTION_WINDOW_WIDTH let actionWindowHeight = this.ACTION_WINDOW_HEIGHT @@ -1241,11 +1249,14 @@ export class SelectionService { actionWindowHeight = this.lastActionWindowSize.height } - //center way - if (!this.isFollowToolbar || !this.toolbarWindow) { - const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) - const workArea = display.workArea + /******************************************** + * Setting the position of the action window + ********************************************/ + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const workArea = display.workArea + // Center of the screen + if (!this.isFollowToolbar || !this.toolbarWindow) { const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2 const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2 @@ -1255,54 +1266,107 @@ export class SelectionService { x: Math.round(centerX), y: Math.round(centerY) }) + } else { + // Follow toolbar position + const toolbarBounds = this.toolbarWindow!.getBounds() + const GAP = 6 // 6px gap from screen edges + //make sure action window is inside screen + if (actionWindowWidth > workArea.width - 2 * GAP) { + actionWindowWidth = workArea.width - 2 * GAP + } + + if (actionWindowHeight > workArea.height - 2 * GAP) { + actionWindowHeight = workArea.height - 2 * GAP + } + + // Calculate initial position to center action window horizontally below toolbar + let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2) + let posY = Math.round(toolbarBounds.y) + + // Ensure action window stays within screen boundaries with a small gap + if (posX + actionWindowWidth > workArea.x + workArea.width) { + posX = workArea.x + workArea.width - actionWindowWidth - GAP + } else if (posX < workArea.x) { + posX = workArea.x + GAP + } + if (posY + actionWindowHeight > workArea.y + workArea.height) { + // If window would go below screen, try to position it above toolbar + posY = workArea.y + workArea.height - actionWindowHeight - GAP + } else if (posY < workArea.y) { + posY = workArea.y + GAP + } + + actionWindow.setPosition(posX, posY, false) + //KEY to make window not resize + actionWindow.setBounds({ + width: actionWindowWidth, + height: actionWindowHeight, + x: posX, + y: posY + }) + } + + if (!isMac) { actionWindow.show() - return } - //follow toolbar - const toolbarBounds = this.toolbarWindow!.getBounds() - const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) - const workArea = display.workArea - const GAP = 6 // 6px gap from screen edges + /************************************************ + * [macOS] the following code is only for macOS + * + * WARNING: + * DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!! + *************************************************/ - //make sure action window is inside screen - if (actionWindowWidth > workArea.width - 2 * GAP) { - actionWindowWidth = workArea.width - 2 * GAP + // act normally when the app is not in fullscreen mode + if (!isFullScreen) { + actionWindow.show() + return } - if (actionWindowHeight > workArea.height - 2 * GAP) { - actionWindowHeight = workArea.height - 2 * GAP - } + // [macOS] an UGLY HACKY way for fullscreen override settings - // Calculate initial position to center action window horizontally below toolbar - let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2) - let posY = Math.round(toolbarBounds.y) + // FIXME sometimes the dock will be shown when the action window is shown + // FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown + // FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app + // use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled - // Ensure action window stays within screen boundaries with a small gap - if (posX + actionWindowWidth > workArea.x + workArea.width) { - posX = workArea.x + workArea.width - actionWindowWidth - GAP - } else if (posX < workArea.x) { - posX = workArea.x + GAP - } - if (posY + actionWindowHeight > workArea.y + workArea.height) { - // If window would go below screen, try to position it above toolbar - posY = workArea.y + workArea.height - actionWindowHeight - GAP - } else if (posY < workArea.y) { - posY = workArea.y + GAP - } + // setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled) + actionWindow.setFocusable(false) + actionWindow.setAlwaysOnTop(true, 'floating') - actionWindow.setPosition(posX, posY, false) - //KEY to make window not resize - actionWindow.setBounds({ - width: actionWindowWidth, - height: actionWindowHeight, - x: posX, - y: posY + // `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared + // just store the dock icon status, and show it again + const isDockShown = app.dock?.isVisible() + + // DO NOT set `skipTransformProcessType: true`, + // it will cause the action window to be shown on other space + actionWindow.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true }) - actionWindow.show() + actionWindow.showInactive() + + // show the dock again if last time it was shown + // do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled + if (!app.dock?.isVisible() && isDockShown) { + app.dock?.show() + } + + // unset everything + setTimeout(() => { + actionWindow.setVisibleOnAllWorkspaces(false, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + actionWindow.setAlwaysOnTop(false) + + actionWindow.setFocusable(true) + + // regain the focus when all the works done + actionWindow.focus() + }, 50) } public closeActionWindow(actionWindow: BrowserWindow): void { @@ -1408,8 +1472,9 @@ export class SelectionService { configManager.setSelectionAssistantFilterList(filterList) }) - ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => { - selectionService?.processAction(actionItem) + // [macOS] only macOS has the available isFullscreen mode + ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => { + selectionService?.processAction(actionItem, isFullScreen) }) ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index ea1a2897f9..5221761dfb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -309,7 +309,8 @@ const api = { ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize), setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode), setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList), - processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem), + processAction: (actionItem: ActionItem, isFullScreen: boolean = false) => + ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen), closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) diff --git a/src/renderer/src/windows/selection/action/SelectionActionApp.tsx b/src/renderer/src/windows/selection/action/SelectionActionApp.tsx index 94c1c575ea..5112caf945 100644 --- a/src/renderer/src/windows/selection/action/SelectionActionApp.tsx +++ b/src/renderer/src/windows/selection/action/SelectionActionApp.tsx @@ -36,10 +36,6 @@ const SelectionActionApp: FC = () => { const lastScrollHeight = useRef(0) useEffect(() => { - if (isAutoPin) { - window.api.selection.pinActionWindow(true) - } - const actionListenRemover = window.electron?.ipcRenderer.on( IpcChannel.Selection_UpdateActionData, (_, actionItem: ActionItem) => { @@ -60,6 +56,20 @@ const SelectionActionApp: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + if (isAutoPin) { + window.api.selection.pinActionWindow(true) + setIsPinned(true) + } else if (!isActionLoaded.current) { + window.api.selection.pinActionWindow(false) + setIsPinned(false) + } + }, [isAutoPin]) + + useEffect(() => { + shouldCloseWhenBlur.current = isAutoClose && !isPinned + }, [isAutoClose, isPinned]) + useEffect(() => { i18n.changeLanguage(language || navigator.language || defaultLanguage) }, [language]) @@ -100,10 +110,6 @@ const SelectionActionApp: FC = () => { } }, [action, t]) - useEffect(() => { - shouldCloseWhenBlur.current = isAutoClose && !isPinned - }, [isAutoClose, isPinned]) - useEffect(() => { //if the action is loaded, we should not set the opacity update from settings if (!isActionLoaded.current) { diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 49b3c2fcf9..342e4122a7 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -107,6 +107,8 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { }, [actionItems]) const selectedText = useRef('') + // [macOS] only macOS has the fullscreen mode + const isFullScreen = useRef(false) // listen to selectionService events useEffect(() => { @@ -115,6 +117,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { IpcChannel.Selection_TextSelected, (_, selectionData: TextSelectionData) => { selectedText.current = selectionData.text + isFullScreen.current = selectionData.isFullscreen ?? false setTimeout(() => { //make sure the animation is active setAnimateKey((prev) => prev + 1) @@ -133,8 +136,6 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } ) - if (!demo) updateWindowSize() - return () => { textSelectionListenRemover() toolbarVisibilityChangeListenRemover() @@ -234,7 +235,8 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } const handleDefaultAction = (action: ActionItem) => { - window.api?.selection.processAction(action) + // [macOS] only macOS has the available isFullscreen mode + window.api?.selection.processAction(action, isFullScreen.current) window.api?.selection.hideToolbar() } diff --git a/yarn.lock b/yarn.lock index e2a45ec2ac..bbaadab77c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7235,7 +7235,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.5" + selection-hook: "npm:^1.0.6" shiki: "npm:^3.7.0" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" @@ -18229,14 +18229,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.5": - version: 1.0.5 - resolution: "selection-hook@npm:1.0.5" +"selection-hook@npm:^1.0.6": + version: 1.0.6 + resolution: "selection-hook@npm:1.0.6" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/d188e2bafa6d820779e57a721bd2480dc1fde3f9daa2e3f92f1b69712637079e5fd9443575bc8624c98a057608f867d82fb2abf2d0796777db1f18ea50ea0028 + checksum: 10c0/c7d28db51fc16b5648530344cbe1d5b72a7469cfb7edbb9c56d7be4bea2d93ddd01993fb27b344e44865f9eb0f3211b1be638caaacd0f9165b2bc03bada7c360 languageName: node linkType: hard From ba742b7b1fd900186b3ead99dce256480b676fb5 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 21:34:01 +0800 Subject: [PATCH 157/235] feat: save to knowledge (#7528) * feat: save to knowledge * refactor: simplify checkbox * feat(i18n): add 'Save to Local File' translation key for multiple languages --------- Co-authored-by: suyao --- .../Popups/SaveToKnowledgePopup.tsx | 353 ++++++++++++++++++ src/renderer/src/i18n/locales/en-us.json | 29 ++ src/renderer/src/i18n/locales/ja-jp.json | 31 +- src/renderer/src/i18n/locales/ru-ru.json | 31 +- src/renderer/src/i18n/locales/zh-cn.json | 29 ++ src/renderer/src/i18n/locales/zh-tw.json | 33 +- .../pages/home/Messages/MessageMenubar.tsx | 32 +- src/renderer/src/utils/knowledge.ts | 269 +++++++++++++ 8 files changed, 794 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx create mode 100644 src/renderer/src/utils/knowledge.ts diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx new file mode 100644 index 0000000000..b6f0577c4f --- /dev/null +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -0,0 +1,353 @@ +import CustomTag from '@renderer/components/CustomTag' +import { TopView } from '@renderer/components/TopView' +import Logger from '@renderer/config/logger' +import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { Message } from '@renderer/types/newMessage' +import { + analyzeMessageContent, + CONTENT_TYPES, + ContentType, + MessageContentStats, + processMessageContent +} from '@renderer/utils/knowledge' +import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd' +import { Check, CircleHelp } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const { Text } = Typography + +// 内容类型配置 +const CONTENT_TYPE_CONFIG = { + [CONTENT_TYPES.TEXT]: { + label: 'chat.save.knowledge.content.maintext.title', + description: 'chat.save.knowledge.content.maintext.description' + }, + [CONTENT_TYPES.CODE]: { + label: 'chat.save.knowledge.content.code.title', + description: 'chat.save.knowledge.content.code.description' + }, + [CONTENT_TYPES.THINKING]: { + label: 'chat.save.knowledge.content.thinking.title', + description: 'chat.save.knowledge.content.thinking.description' + }, + [CONTENT_TYPES.TOOL_USE]: { + label: 'chat.save.knowledge.content.tool_use.title', + description: 'chat.save.knowledge.content.tool_use.description' + }, + [CONTENT_TYPES.CITATION]: { + label: 'chat.save.knowledge.content.citation.title', + description: 'chat.save.knowledge.content.citation.description' + }, + [CONTENT_TYPES.TRANSLATION]: { + label: 'chat.save.knowledge.content.translation.title', + description: 'chat.save.knowledge.content.translation.description' + }, + [CONTENT_TYPES.ERROR]: { + label: 'chat.save.knowledge.content.error.title', + description: 'chat.save.knowledge.content.error.description' + }, + [CONTENT_TYPES.FILE]: { + label: 'chat.save.knowledge.content.file.title', + description: 'chat.save.knowledge.content.file.description' + } +} as const + +// Tag 颜色常量 +const TAG_COLORS = { + SELECTED: '#008001', + UNSELECTED: '#8c8c8c' +} as const + +interface ContentTypeOption { + type: ContentType + label: string + count: number + enabled: boolean + description?: string +} + +interface ShowParams { + message: Message + title?: string +} + +interface SaveResult { + success: boolean + savedCount: number +} + +interface Props extends ShowParams { + resolve: (data: SaveResult | null) => void +} + +const PopupContainer: React.FC = ({ message, title, resolve }) => { + const [open, setOpen] = useState(true) + const [loading, setLoading] = useState(false) + const [selectedBaseId, setSelectedBaseId] = useState() + const [selectedTypes, setSelectedTypes] = useState([]) + const [hasInitialized, setHasInitialized] = useState(false) + const { bases } = useKnowledgeBases() + const { addNote, addFiles } = useKnowledge(selectedBaseId || '') + const { t } = useTranslation() + + // 分析消息内容统计 + const contentStats = useMemo(() => analyzeMessageContent(message), [message]) + + // 生成内容类型选项(只显示有内容的类型) + const contentTypeOptions: ContentTypeOption[] = useMemo(() => { + return Object.entries(CONTENT_TYPE_CONFIG) + .map(([type, config]) => { + const contentType = type as ContentType + const count = contentStats[contentType as keyof MessageContentStats] || 0 + return { + type: contentType, + count, + enabled: count > 0, + label: t(config.label), + description: t(config.description) + } + }) + .filter((option) => option.enabled) // 只显示有内容的类型 + }, [contentStats, t]) + + // 知识库选项 + const knowledgeBaseOptions = useMemo( + () => + bases.map((base) => ({ + label: base.name, + value: base.id, + disabled: !base.version // 如果知识库没有配置好就禁用 + })), + [bases] + ) + + // 合并状态计算 + const formState = useMemo(() => { + const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version + const hasContent = contentTypeOptions.length > 0 + const selectedCount = contentTypeOptions + .filter((option) => selectedTypes.includes(option.type)) + .reduce((sum, option) => sum + option.count, 0) + + return { + hasValidBase, + hasContent, + canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent, + selectedCount, + hasNoSelection: selectedTypes.length === 0 && hasContent + } + }, [selectedBaseId, bases, contentTypeOptions, selectedTypes]) + + // 默认选择第一个可用的知识库 + useEffect(() => { + if (!selectedBaseId) { + const firstAvailableBase = bases.find((base) => base.version) + if (firstAvailableBase) { + setSelectedBaseId(firstAvailableBase.id) + } + } + }, [bases, selectedBaseId]) + + // 默认选择所有可用的内容类型(仅在初始化时) + useEffect(() => { + if (!hasInitialized && contentTypeOptions.length > 0) { + const availableTypes = contentTypeOptions.map((option) => option.type) + setSelectedTypes(availableTypes) + setHasInitialized(true) + } + }, [contentTypeOptions, hasInitialized]) + + // 计算UI状态 + const uiState = useMemo(() => { + if (!formState.hasContent) { + return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') } + } + if (bases.length === 0) { + return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') } + } + return { type: 'form' } + }, [formState.hasContent, bases.length, t]) + + // 处理内容类型选择切换 + const handleContentTypeToggle = (type: ContentType) => { + setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type])) + } + + const onOk = async () => { + if (!formState.canSubmit) { + return + } + + setLoading(true) + let savedCount = 0 + + try { + const result = processMessageContent(message, selectedTypes) + + // 保存文本内容 + if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { + await addNote(result.text) + savedCount++ + } + + // 保存文件 + if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { + addFiles(result.files) + savedCount += result.files.length + } + + setOpen(false) + resolve({ success: true, savedCount }) + } catch (error) { + Logger.error('[SaveToKnowledgePopup] save failed:', error) + window.message.error(t('chat.save.knowledge.error.save_failed')) + setLoading(false) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + // 渲染空状态 + const renderEmptyState = () => ( + + {uiState.message} + + ) + + // 渲染表单内容 + const renderFormContent = () => ( + <> +
+ + onSyncIntervalChange(value as number)} + onChange={onSyncIntervalChange} disabled={!localBackupDir} - style={{ minWidth: 120 }}> - {t('settings.data.local.autoSync.off')} - {t('settings.data.local.minute_interval', { count: 1 })} - {t('settings.data.local.minute_interval', { count: 5 })} - {t('settings.data.local.minute_interval', { count: 15 })} - {t('settings.data.local.minute_interval', { count: 30 })} - {t('settings.data.local.hour_interval', { count: 1 })} - {t('settings.data.local.hour_interval', { count: 2 })} - {t('settings.data.local.hour_interval', { count: 6 })} - {t('settings.data.local.hour_interval', { count: 12 })} - {t('settings.data.local.hour_interval', { count: 24 })} - + options={[ + { label: t('settings.data.local.autoSync.off'), value: 0 }, + { label: t('settings.data.local.minute_interval', { count: 1 }), value: 1 }, + { label: t('settings.data.local.minute_interval', { count: 5 }), value: 5 }, + { label: t('settings.data.local.minute_interval', { count: 15 }), value: 15 }, + { label: t('settings.data.local.minute_interval', { count: 30 }), value: 30 }, + { label: t('settings.data.local.hour_interval', { count: 1 }), value: 60 }, + { label: t('settings.data.local.hour_interval', { count: 2 }), value: 120 }, + { label: t('settings.data.local.hour_interval', { count: 6 }), value: 360 }, + { label: t('settings.data.local.hour_interval', { count: 12 }), value: 720 }, + { label: t('settings.data.local.hour_interval', { count: 24 }), value: 1440 } + ]} + /> {t('settings.data.local.maxBackups')} - + options={[ + { label: t('settings.data.local.maxBackups.unlimited'), value: 0 }, + { label: '1', value: 1 }, + { label: '3', value: 3 }, + { label: '5', value: 5 }, + { label: '10', value: 10 }, + { label: '20', value: 20 }, + { label: '50', value: 50 } + ]} + /> diff --git a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx index c261c5b736..c24d67cd44 100644 --- a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx @@ -2,6 +2,7 @@ import { FolderOpenOutlined, InfoCircleOutlined, SaveOutlined, SyncOutlined, War import { HStack } from '@renderer/components/Layout' import { S3BackupManager } from '@renderer/components/S3BackupManager' import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals' +import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useSettings } from '@renderer/hooks/useSettings' @@ -9,7 +10,7 @@ import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setS3Partial } from '@renderer/store/settings' import { S3Config } from '@renderer/types' -import { Button, Input, Select, Switch, Tooltip } from 'antd' +import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -54,9 +55,9 @@ const S3Settings: FC = () => { setSyncInterval(value) dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 })) if (value === 0) { - stopAutoSync() + stopAutoSync('s3') } else { - startAutoSync() + startAutoSync(false, 's3') } } @@ -211,39 +212,43 @@ const S3Settings: FC = () => { {t('settings.data.s3.autoSync')} - + options={[ + { label: t('settings.data.s3.autoSync.off'), value: 0 }, + { label: t('settings.data.s3.autoSync.minute', { count: 1 }), value: 1 }, + { label: t('settings.data.s3.autoSync.minute', { count: 5 }), value: 5 }, + { label: t('settings.data.s3.autoSync.minute', { count: 15 }), value: 15 }, + { label: t('settings.data.s3.autoSync.minute', { count: 30 }), value: 30 }, + { label: t('settings.data.s3.autoSync.hour', { count: 1 }), value: 60 }, + { label: t('settings.data.s3.autoSync.hour', { count: 2 }), value: 120 }, + { label: t('settings.data.s3.autoSync.hour', { count: 6 }), value: 360 }, + { label: t('settings.data.s3.autoSync.hour', { count: 12 }), value: 720 }, + { label: t('settings.data.s3.autoSync.hour', { count: 24 }), value: 1440 } + ]} + /> {t('settings.data.s3.maxBackups')} - + options={[ + { label: t('settings.data.s3.maxBackups.unlimited'), value: 0 }, + { label: '1', value: 1 }, + { label: '3', value: 3 }, + { label: '5', value: 5 }, + { label: '10', value: 10 }, + { label: '20', value: 20 }, + { label: '50', value: 50 } + ]} + /> diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 977c5c1329..7f42c9fe30 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -59,10 +59,10 @@ const WebDavSettings: FC = () => { dispatch(_setWebdavSyncInterval(value)) if (value === 0) { dispatch(setWebdavAutoSync(false)) - stopAutoSync() + stopAutoSync('webdav') } else { dispatch(setWebdavAutoSync(true)) - startAutoSync() + startAutoSync(false, 'webdav') } } diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index bf5fea3c5a..9e594f85c2 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -444,85 +444,144 @@ export async function restoreFromS3(fileName?: string) { }) const data = JSON.parse(restoreData) await handleData(data) - store.dispatch( - setS3SyncState({ - lastSyncTime: Date.now(), - syncing: false, - lastSyncError: null - }) - ) } } -let autoSyncStarted = false -let syncTimeout: NodeJS.Timeout | null = null -let isAutoBackupRunning = false let isManualBackupRunning = false -export function startAutoSync(immediate = false) { - if (autoSyncStarted) { - return - } +// 为每种备份类型维护独立的状态 +let webdavAutoSyncStarted = false +let webdavSyncTimeout: NodeJS.Timeout | null = null +let isWebdavAutoBackupRunning = false - const settings = store.getState().settings - const { webdavAutoSync, webdavHost } = settings - const s3Settings = settings.s3 +let s3AutoSyncStarted = false +let s3SyncTimeout: NodeJS.Timeout | null = null +let isS3AutoBackupRunning = false - const s3AutoSync = s3Settings?.autoSync - const s3Endpoint = s3Settings?.endpoint +let localAutoSyncStarted = false +let localSyncTimeout: NodeJS.Timeout | null = null +let isLocalAutoBackupRunning = false - const localBackupAutoSync = settings.localBackupAutoSync - const localBackupDir = settings.localBackupDir - - // 检查WebDAV或S3自动同步配置 - const hasWebdavConfig = webdavAutoSync && webdavHost - const hasS3Config = s3AutoSync && s3Endpoint - const hasLocalConfig = localBackupAutoSync && localBackupDir - - if (!hasWebdavConfig && !hasS3Config && !hasLocalConfig) { - Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') - return - } - - autoSyncStarted = true - - stopAutoSync() - - scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime') - - /** - * @param type 'immediate' | 'fromLastSyncTime' | 'fromNow' - * 'immediate', first backup right now - * 'fromLastSyncTime', schedule next backup from last sync time - * 'fromNow', schedule next backup from now - */ - function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') { - if (syncTimeout) { - clearTimeout(syncTimeout) - syncTimeout = null - } +type BackupType = 'webdav' | 's3' | 'local' +export function startAutoSync(immediate = false, type?: BackupType) { + // 如果没有指定类型,启动所有配置的自动同步 + if (!type) { const settings = store.getState().settings - const _webdavSyncInterval = settings.webdavSyncInterval - const _s3SyncInterval = settings.s3?.syncInterval - const { webdavSync, s3Sync } = store.getState().backup + const { webdavAutoSync, webdavHost, localBackupAutoSync, localBackupDir } = settings + const s3Settings = settings.s3 - // 使用当前激活的同步配置 - const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval - const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + if (webdavAutoSync && webdavHost) { + startAutoSync(immediate, 'webdav') + } + if (s3Settings?.autoSync && s3Settings?.endpoint) { + startAutoSync(immediate, 's3') + } + if (localBackupAutoSync && localBackupDir) { + startAutoSync(immediate, 'local') + } + return + } - if (!syncInterval || syncInterval <= 0) { - Logger.log('[AutoSync] Invalid sync interval, auto sync disabled') - stopAutoSync() + // 根据类型启动特定的自动同步 + if (type === 'webdav') { + if (webdavAutoSyncStarted) { return } - // 用户指定的自动备份时间间隔(毫秒) - const requiredInterval = syncInterval * 60 * 1000 + const settings = store.getState().settings + const { webdavAutoSync, webdavHost } = settings - let timeUntilNextSync = 1000 //also immediate - switch (type) { - case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间 + if (!webdavAutoSync || !webdavHost) { + Logger.log('[WebdavAutoSync] Invalid sync settings, auto sync disabled') + return + } + + webdavAutoSyncStarted = true + stopAutoSync('webdav') + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime', 'webdav') + } else if (type === 's3') { + if (s3AutoSyncStarted) { + return + } + + const settings = store.getState().settings + const s3Settings = settings.s3 + + if (!s3Settings?.autoSync || !s3Settings?.endpoint) { + Logger.log('[S3AutoSync] Invalid sync settings, auto sync disabled') + return + } + + s3AutoSyncStarted = true + stopAutoSync('s3') + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime', 's3') + } else if (type === 'local') { + if (localAutoSyncStarted) { + return + } + + const settings = store.getState().settings + const { localBackupAutoSync, localBackupDir } = settings + + if (!localBackupAutoSync || !localBackupDir) { + Logger.log('[LocalAutoSync] Invalid sync settings, auto sync disabled') + return + } + + localAutoSyncStarted = true + stopAutoSync('local') + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime', 'local') + } + + function scheduleNextBackup(scheduleType: 'immediate' | 'fromLastSyncTime' | 'fromNow', backupType: BackupType) { + let syncInterval: number + let lastSyncTime: number | undefined + let logPrefix: string + + // 根据备份类型获取相应的配置和状态 + const settings = store.getState().settings + const backup = store.getState().backup + + if (backupType === 'webdav') { + if (webdavSyncTimeout) { + clearTimeout(webdavSyncTimeout) + webdavSyncTimeout = null + } + syncInterval = settings.webdavSyncInterval + lastSyncTime = backup.webdavSync?.lastSyncTime || undefined + logPrefix = '[WebdavAutoSync]' + } else if (backupType === 's3') { + if (s3SyncTimeout) { + clearTimeout(s3SyncTimeout) + s3SyncTimeout = null + } + syncInterval = settings.s3?.syncInterval || 0 + lastSyncTime = backup.s3Sync?.lastSyncTime || undefined + logPrefix = '[S3AutoSync]' + } else if (backupType === 'local') { + if (localSyncTimeout) { + clearTimeout(localSyncTimeout) + localSyncTimeout = null + } + syncInterval = settings.localBackupSyncInterval + lastSyncTime = backup.localBackupSync?.lastSyncTime || undefined + logPrefix = '[LocalAutoSync]' + } else { + return + } + + if (!syncInterval || syncInterval <= 0) { + Logger.log(`${logPrefix} Invalid sync interval, auto sync disabled`) + stopAutoSync(backupType) + return + } + + const requiredInterval = syncInterval * 60 * 1000 + let timeUntilNextSync = 1000 + + switch (scheduleType) { + case 'fromLastSyncTime': timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now()) break case 'fromNow': @@ -530,33 +589,64 @@ export function startAutoSync(immediate = false) { break } - syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + const timeout = setTimeout(() => performAutoBackup(backupType), timeUntilNextSync) + + // 保存对应类型的 timeout + if (backupType === 'webdav') { + webdavSyncTimeout = timeout + } else if (backupType === 's3') { + s3SyncTimeout = timeout + } else if (backupType === 'local') { + localSyncTimeout = timeout + } - const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' Logger.log( - `[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + `${logPrefix} Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( (timeUntilNextSync / 1000) % 60 )} seconds` ) } - async function performAutoBackup() { - if (isAutoBackupRunning || isManualBackupRunning) { - Logger.log('[AutoSync] Backup already in progress, rescheduling') - scheduleNextBackup() + async function performAutoBackup(backupType: BackupType) { + let isRunning: boolean + let logPrefix: string + + if (backupType === 'webdav') { + isRunning = isWebdavAutoBackupRunning + logPrefix = '[WebdavAutoSync]' + } else if (backupType === 's3') { + isRunning = isS3AutoBackupRunning + logPrefix = '[S3AutoSync]' + } else if (backupType === 'local') { + isRunning = isLocalAutoBackupRunning + logPrefix = '[LocalAutoSync]' + } else { return } - isAutoBackupRunning = true + if (isRunning || isManualBackupRunning) { + Logger.log(`${logPrefix} Backup already in progress, rescheduling`) + scheduleNextBackup('fromNow', backupType) + return + } + + // 设置运行状态 + if (backupType === 'webdav') { + isWebdavAutoBackupRunning = true + } else if (backupType === 's3') { + isS3AutoBackupRunning = true + } else if (backupType === 'local') { + isLocalAutoBackupRunning = true + } + const maxRetries = 4 let retryCount = 0 while (retryCount < maxRetries) { try { - const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' - Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`) + Logger.log(`${logPrefix} Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) - if (hasWebdavConfig) { + if (backupType === 'webdav') { await backupToWebdav({ autoBackupProcess: true }) store.dispatch( setWebDAVSyncState({ @@ -565,7 +655,7 @@ export function startAutoSync(immediate = false) { syncing: false }) ) - } else if (hasS3Config) { + } else if (backupType === 's3') { await backupToS3({ autoBackupProcess: true }) store.dispatch( setS3SyncState({ @@ -574,19 +664,34 @@ export function startAutoSync(immediate = false) { syncing: false }) ) + } else if (backupType === 'local') { + await backupToLocal({ autoBackupProcess: true }) + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) } - isAutoBackupRunning = false - scheduleNextBackup() + // 重置运行状态 + if (backupType === 'webdav') { + isWebdavAutoBackupRunning = false + } else if (backupType === 's3') { + isS3AutoBackupRunning = false + } else if (backupType === 'local') { + isLocalAutoBackupRunning = false + } + scheduleNextBackup('fromNow', backupType) break } catch (error: any) { retryCount++ if (retryCount === maxRetries) { - const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' - Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error) + Logger.error(`${logPrefix} Auto backup failed after all retries:`, error) - if (hasWebdavConfig) { + if (backupType === 'webdav') { store.dispatch( setWebDAVSyncState({ lastSyncError: 'Auto backup failed', @@ -594,7 +699,7 @@ export function startAutoSync(immediate = false) { syncing: false }) ) - } else if (hasS3Config) { + } else if (backupType === 's3') { store.dispatch( setS3SyncState({ lastSyncError: 'Auto backup failed', @@ -602,26 +707,49 @@ export function startAutoSync(immediate = false) { syncing: false }) ) + } else if (backupType === 'local') { + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) } - //only show 1 time error modal, and autoback stopped until user click ok await window.modal.error({ title: i18n.t('message.backup.failed'), - content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message + content: `${logPrefix} ${new Date().toLocaleString()} ` + error.message }) - scheduleNextBackup('fromNow') - isAutoBackupRunning = false + scheduleNextBackup('fromNow', backupType) + + // 重置运行状态 + if (backupType === 'webdav') { + isWebdavAutoBackupRunning = false + } else if (backupType === 's3') { + isS3AutoBackupRunning = false + } else if (backupType === 'local') { + isLocalAutoBackupRunning = false + } } else { - //Exponential Backoff with Base 2: 7s、17s、37s const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000 - Logger.log(`[AutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) + Logger.log(`${logPrefix} Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) await new Promise((resolve) => setTimeout(resolve, backoffDelay)) - //in case auto backup is stopped by user - if (!isAutoBackupRunning) { - Logger.log('[AutoSync] retry cancelled by user, exit') + // 检查是否被用户停止 + let currentRunning: boolean + if (backupType === 'webdav') { + currentRunning = isWebdavAutoBackupRunning + } else if (backupType === 's3') { + currentRunning = isS3AutoBackupRunning + } else { + currentRunning = isLocalAutoBackupRunning + } + + if (!currentRunning) { + Logger.log(`${logPrefix} retry cancelled by user, exit`) break } } @@ -630,14 +758,40 @@ export function startAutoSync(immediate = false) { } } -export function stopAutoSync() { - if (syncTimeout) { - Logger.log('[AutoSync] Stopping auto sync') - clearTimeout(syncTimeout) - syncTimeout = null +export function stopAutoSync(type?: BackupType) { + // 如果没有指定类型,停止所有自动同步 + if (!type) { + stopAutoSync('webdav') + stopAutoSync('s3') + stopAutoSync('local') + return + } + + if (type === 'webdav') { + if (webdavSyncTimeout) { + Logger.log('[WebdavAutoSync] Stopping auto sync') + clearTimeout(webdavSyncTimeout) + webdavSyncTimeout = null + } + isWebdavAutoBackupRunning = false + webdavAutoSyncStarted = false + } else if (type === 's3') { + if (s3SyncTimeout) { + Logger.log('[S3AutoSync] Stopping auto sync') + clearTimeout(s3SyncTimeout) + s3SyncTimeout = null + } + isS3AutoBackupRunning = false + s3AutoSyncStarted = false + } else if (type === 'local') { + if (localSyncTimeout) { + Logger.log('[LocalAutoSync] Stopping auto sync') + clearTimeout(localSyncTimeout) + localSyncTimeout = null + } + isLocalAutoBackupRunning = false + localAutoSyncStarted = false } - isAutoBackupRunning = false - autoSyncStarted = false } export async function getBackupData() { @@ -727,7 +881,7 @@ async function clearDatabase() { /** * Backup to local directory */ -export async function backupToLocalDir({ +export async function backupToLocal({ showMessage = false, customFileName = '', autoBackupProcess = false @@ -812,10 +966,31 @@ export async function backupToLocalDir({ Logger.error('[LocalBackup] Failed to clean up old backups:', error) } } + } else { + if (autoBackupProcess) { + throw new Error(i18n.t('message.backup.failed')) + } + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: 'Backup failed' + }) + ) + + if (showMessage) { + window.modal.error({ + title: i18n.t('message.backup.failed'), + content: 'Backup failed' + }) + } } return result } catch (error: any) { + if (autoBackupProcess) { + throw error + } + Logger.error('[LocalBackup] Backup failed:', error) store.dispatch( @@ -845,157 +1020,18 @@ export async function backupToLocalDir({ } } -export async function restoreFromLocalBackup(fileName: string) { +export async function restoreFromLocal(fileName: string) { + const { localBackupDir } = store.getState().settings + try { - const { localBackupDir } = store.getState().settings - await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir) + const restoreData = await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir) + const data = JSON.parse(restoreData) + await handleData(data) + return true } catch (error) { Logger.error('[LocalBackup] Restore failed:', error) + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) throw error } } - -// Local backup auto sync -let localBackupAutoSyncStarted = false -let localBackupSyncTimeout: NodeJS.Timeout | null = null -let isLocalBackupAutoRunning = false - -export function startLocalBackupAutoSync(immediate = false) { - if (localBackupAutoSyncStarted) { - return - } - - const { localBackupAutoSync, localBackupDir } = store.getState().settings - - if (!localBackupAutoSync || !localBackupDir) { - Logger.log('[LocalBackupAutoSync] Invalid sync settings, auto sync disabled') - return - } - - localBackupAutoSyncStarted = true - - stopLocalBackupAutoSync() - - scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime') - - /** - * @param type 'immediate' | 'fromLastSyncTime' | 'fromNow' - * 'immediate', first backup right now - * 'fromLastSyncTime', schedule next backup from last sync time - * 'fromNow', schedule next backup from now - */ - function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') { - if (localBackupSyncTimeout) { - clearTimeout(localBackupSyncTimeout) - localBackupSyncTimeout = null - } - - const { localBackupSyncInterval } = store.getState().settings - const { localBackupSync } = store.getState().backup - - if (localBackupSyncInterval <= 0) { - Logger.log('[LocalBackupAutoSync] Invalid sync interval, auto sync disabled') - stopLocalBackupAutoSync() - return - } - - // User specified auto backup interval (milliseconds) - const requiredInterval = localBackupSyncInterval * 60 * 1000 - - let timeUntilNextSync = 1000 // immediate by default - switch (type) { - case 'fromLastSyncTime': // If last sync time exists, use it as reference - timeUntilNextSync = Math.max(1000, (localBackupSync?.lastSyncTime || 0) + requiredInterval - Date.now()) - break - case 'fromNow': - timeUntilNextSync = requiredInterval - break - } - - localBackupSyncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) - - Logger.log( - `[LocalBackupAutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( - (timeUntilNextSync / 1000) % 60 - )} seconds` - ) - } - - async function performAutoBackup() { - if (isLocalBackupAutoRunning || isManualBackupRunning) { - Logger.log('[LocalBackupAutoSync] Backup already in progress, rescheduling') - scheduleNextBackup() - return - } - - isLocalBackupAutoRunning = true - const maxRetries = 4 - let retryCount = 0 - - while (retryCount < maxRetries) { - try { - Logger.log(`[LocalBackupAutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) - - await backupToLocalDir({ autoBackupProcess: true }) - - store.dispatch( - setLocalBackupSyncState({ - lastSyncError: null, - lastSyncTime: Date.now(), - syncing: false - }) - ) - - isLocalBackupAutoRunning = false - scheduleNextBackup() - - break - } catch (error: any) { - retryCount++ - if (retryCount === maxRetries) { - Logger.error('[LocalBackupAutoSync] Auto backup failed after all retries:', error) - - store.dispatch( - setLocalBackupSyncState({ - lastSyncError: 'Auto backup failed', - lastSyncTime: Date.now(), - syncing: false - }) - ) - - // Only show error modal once and wait for user acknowledgment - await window.modal.error({ - title: i18n.t('message.backup.failed'), - content: `[Local Backup Auto Backup] ${new Date().toLocaleString()} ` + error.message - }) - - scheduleNextBackup('fromNow') - isLocalBackupAutoRunning = false - } else { - // Exponential Backoff with Base 2: 7s, 17s, 37s - const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000 - Logger.log(`[LocalBackupAutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) - - await new Promise((resolve) => setTimeout(resolve, backoffDelay)) - - // Check if auto backup was stopped by user - if (!isLocalBackupAutoRunning) { - Logger.log('[LocalBackupAutoSync] retry cancelled by user, exit') - break - } - } - } - } - } -} - -export function stopLocalBackupAutoSync() { - if (localBackupSyncTimeout) { - Logger.log('[LocalBackupAutoSync] Stopping auto sync') - clearTimeout(localBackupSyncTimeout) - localBackupSyncTimeout = null - } - isLocalBackupAutoRunning = false - localBackupAutoSyncStarted = false -} diff --git a/src/renderer/src/services/MessagesService.ts b/src/renderer/src/services/MessagesService.ts index 615103a11d..25b822cd21 100644 --- a/src/renderer/src/services/MessagesService.ts +++ b/src/renderer/src/services/MessagesService.ts @@ -248,7 +248,7 @@ export async function getMessageTitle(message: Message, length = 30): Promise Date: Fri, 11 Jul 2025 22:50:13 +0800 Subject: [PATCH 180/235] refactor: improve environment variable handling in electron.vite.config.ts - Introduced `isDev` and `isProd` constants for clearer environment checks. - Simplified sourcemap and noDiscovery settings based on environment. - Enhanced esbuild configuration for production to drop console and debugger statements. --- electron.vite.config.ts | 42 +++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2b4c5e6b92..b867f4e989 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,6 +8,9 @@ const visualizerPlugin = (type: 'renderer' | 'main') => { return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] } +const isDev = process.env.NODE_ENV === 'development' +const isProd = process.env.NODE_ENV === 'production' + export default defineConfig({ main: { plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')], @@ -21,17 +24,21 @@ export default defineConfig({ build: { rollupOptions: { external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'], - output: { - // 彻底禁用代码分割 - 返回 null 强制单文件打包 - manualChunks: undefined, - // 内联所有动态导入,这是关键配置 - inlineDynamicImports: true - } + output: isProd + ? { + manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包 + inlineDynamicImports: true // 内联所有动态导入,这是关键配置 + } + : {} }, - sourcemap: process.env.NODE_ENV === 'development' + sourcemap: isDev + }, + esbuild: { + drop: ['console', 'debugger'], + legalComments: 'none' }, optimizeDeps: { - noDiscovery: process.env.NODE_ENV === 'development' + noDiscovery: isDev } }, preload: { @@ -42,7 +49,7 @@ export default defineConfig({ } }, build: { - sourcemap: process.env.NODE_ENV === 'development' + sourcemap: isDev } }, renderer: { @@ -60,14 +67,7 @@ export default defineConfig({ ] ] }), - // 只在开发环境下启用 CodeInspectorPlugin - ...(process.env.NODE_ENV === 'development' - ? [ - CodeInspectorPlugin({ - bundler: 'vite' - }) - ] - : []), + ...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin ...visualizerPlugin('renderer') ], resolve: { @@ -95,6 +95,12 @@ export default defineConfig({ selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html') } } - } + }, + esbuild: isProd + ? { + drop: ['console', 'debugger'], + legalComments: 'none' + } + : {} } }) From bea664af0ff07ef003371633a3b5fd7571b10a62 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 11 Jul 2025 22:36:45 +0800 Subject: [PATCH 181/235] refactor: simplify HtmlArtifactsPopup component and improve preview functionality - Removed unnecessary extracted components and integrated their logic directly into HtmlArtifactsPopup. - Enhanced preview functionality with a debounced update mechanism for HTML content. - Updated styling for better layout and responsiveness, including fullscreen handling. - Adjusted view mode management for clearer code structure and improved user experience. --- .../CodeBlockView/HtmlArtifactsCard.tsx | 300 +++++-------- .../CodeBlockView/HtmlArtifactsPopup.tsx | 410 +++++++----------- .../src/pages/home/Messages/MessageHeader.tsx | 2 +- .../ProviderSettings/EditModelsPopup.tsx | 19 +- 4 files changed, 282 insertions(+), 449 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 68d75da9fd..b3389570c1 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -11,10 +11,100 @@ import styled, { keyframes } from 'styled-components' import HtmlArtifactsPopup from './HtmlArtifactsPopup' +const HTML_VOID_ELEMENTS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr' +]) + +const HTML_COMPLETION_PATTERNS = [ + /<\/html\s*>/i, + //i, + /<\/div\s*>/i, + /<\/script\s*>/i, + /<\/style\s*>/i +] + interface Props { html: string } +function hasUnmatchedTags(html: string): boolean { + const stack: string[] = [] + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + let match + + while ((match = tagRegex.exec(html)) !== null) { + const [fullTag, tagName] = match + const isClosing = fullTag.startsWith('') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase()) + + if (isSelfClosing) continue + + if (isClosing) { + if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { + return true + } + } else { + stack.push(tagName.toLowerCase()) + } + } + + return stack.length > 0 +} + +function checkIsStreaming(html: string): boolean { + if (!html?.trim()) return false + + const trimmed = html.trim() + + // 快速检查:如果有明显的完成标志,直接返回false + for (const pattern of HTML_COMPLETION_PATTERNS) { + if (pattern.test(trimmed)) { + // 特殊情况:同时有DOCTYPE和 + if (trimmed.includes('/i.test(trimmed)) { + return false + } + // 如果只是以结尾,也认为是完成的 + if (/<\/html\s*>$/i.test(trimmed)) { + return false + } + } + } + + // 检查未完成的标志 + const hasIncompleteTag = /<[^>]*$/.test(trimmed) + const hasUnmatched = hasUnmatchedTags(trimmed) + + if (hasIncompleteTag || hasUnmatched) return true + + // 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成 + const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed) + if (!hasStructureTags && trimmed.length < 500) { + return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed)) + } + + return false +} + +const getTerminalStyles = (theme: ThemeMode) => ({ + background: theme === 'dark' ? '#1e1e1e' : '#f0f0f0', + color: theme === 'dark' ? '#cccccc' : '#333333', + promptColor: theme === 'dark' ? '#00ff00' : '#007700' +}) + const HtmlArtifactsCard: FC = ({ html }) => { const { t } = useTranslation() const title = extractTitle(html) || 'HTML Artifacts' @@ -23,151 +113,20 @@ const HtmlArtifactsCard: FC = ({ html }) => { const htmlContent = html || '' const hasContent = htmlContent.trim().length > 0 + const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent]) - // 判断是否正在流式生成的逻辑 - const isStreaming = useMemo(() => { - if (!hasContent) return false - - const trimmedHtml = htmlContent.trim() - - // 提前检查:如果包含关键的结束标签,直接判断为完整文档 - if (/<\/html\s*>/i.test(trimmedHtml)) { - return false - } - - // 如果同时包含 DOCTYPE 和 ,通常也是完整文档 - if (//i.test(trimmedHtml)) { - return false - } - - // 检查 HTML 是否看起来是完整的 - const indicators = { - // 1. 检查常见的 HTML 结构完整性 - hasHtmlTag: /]*>/i.test(trimmedHtml), - hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml), - - // 2. 检查 body 标签完整性 - hasBodyTag: /]*>/i.test(trimmedHtml), - hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml), - - // 3. 检查是否以未闭合的标签结尾 - endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml), - - // 4. 检查是否有未配对的标签 - hasUnmatchedTags: checkUnmatchedTags(trimmedHtml), - - // 5. 检查是否以常见的"流式结束"模式结尾 - endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml) - } - - // 如果有明显的未完成标志,则认为正在生成 - if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) { - return true - } - - // 如果有 HTML 结构但不完整 - if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) { - return true - } - - // 如果有 body 结构但不完整 - if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) { - return true - } - - // 对于简单的 HTML 片段,检查是否看起来是完整的 - if (!indicators.hasHtmlTag && !indicators.hasBodyTag) { - // 如果是简单片段且没有明显的结束标志,可能还在生成 - return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500 - } - - return false - }, [htmlContent, hasContent]) - - // 检查未配对标签的辅助函数 - function checkUnmatchedTags(html: string): boolean { - const stack: string[] = [] - const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g - - // HTML5 void 元素(自闭合元素)的完整列表 - const voidElements = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr' - ] - - let match - - while ((match = tagRegex.exec(html)) !== null) { - const [fullTag, tagName] = match - const isClosing = fullTag.startsWith('') || voidElements.includes(tagName.toLowerCase()) - - if (isSelfClosing) continue - - if (isClosing) { - if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { - return true // 找到不匹配的闭合标签 - } - } else { - stack.push(tagName.toLowerCase()) - } - } - - return stack.length > 0 // 还有未闭合的标签 - } - - // 获取格式化的代码预览 - function getFormattedCodePreview(html: string): string { - const trimmed = html.trim() - const lines = trimmed.split('\n') - const lastFewLines = lines.slice(-3) // 显示最后3行 - return lastFewLines.join('\n') - } - - /** - * 在编辑器中打开 - */ - const handleOpenInEditor = () => { - setIsPopupOpen(true) - } - - /** - * 关闭弹窗 - */ - const handleClosePopup = () => { - setIsPopupOpen(false) - } - - /** - * 外部链接打开 - */ const handleOpenExternal = async () => { const path = await window.api.file.createTempFile('artifacts-preview.html') await window.api.file.write(path, htmlContent) const filePath = `file://${path}` - if (window.api.shell && window.api.shell.openExternal) { + if (window.api.shell?.openExternal) { window.api.shell.openExternal(filePath) } else { console.error(t('artifacts.preview.openExternal.error.content')) } } - /** - * 下载到本地 - */ const handleDownload = async () => { const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html` await window.api.file.save(fileName, htmlContent) @@ -202,27 +161,27 @@ const HtmlArtifactsCard: FC = ({ html }) => { $ - {getFormattedCodePreview(htmlContent)} + {htmlContent.trim().split('\n').slice(-3).join('\n')} - ) : ( - - - @@ -230,21 +189,11 @@ const HtmlArtifactsCard: FC = ({ html }) => { - {/* 弹窗组件 */} - + setIsPopupOpen(false)} /> ) } -const shimmer = keyframes` - 0% { - background-position: -200px 0; - } - 100% { - background-position: calc(200px + 100%) 0; - } -` - const Container = styled.div<{ $isStreaming: boolean }>` background: var(--color-background); border: 1px solid var(--color-border); @@ -274,21 +223,7 @@ const Header = styled.div` padding: 20px 24px 16px; background: var(--color-background-soft); border-bottom: 1px solid var(--color-border); - position: relative; border-radius: 8px 8px 0 0; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4); - background-size: 200% 100%; - animation: ${shimmer} 3s ease-in-out infinite; - border-radius: 8px 8px 0 0; - } ` const IconWrapper = styled.div<{ $isStreaming: boolean }>` @@ -297,18 +232,15 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>` justify-content: center; width: 40px; height: 40px; - background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + background: ${(props) => + props.$isStreaming + ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' + : 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'}; border-radius: 12px; color: white; - box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); + box-shadow: ${(props) => + props.$isStreaming ? '0 4px 6px -1px rgba(245, 158, 11, 0.3)' : '0 4px 6px -1px rgba(59, 130, 246, 0.3)'}; transition: background 0.3s ease; - - ${(props) => - props.$isStreaming && - ` - background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */ - box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3); - `} ` const TitleSection = styled.div` @@ -346,7 +278,7 @@ const Content = styled.div` ` const ButtonContainer = styled.div` - margin: 16px !important; + margin: 10px 16px !important; display: flex; flex-direction: row; gap: 8px; @@ -354,7 +286,7 @@ const ButtonContainer = styled.div` const TerminalPreview = styled.div<{ $theme: ThemeMode }>` margin: 16px; - background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + background: ${(props) => getTerminalStyles(props.$theme).background}; border-radius: 8px; overflow: hidden; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; @@ -362,8 +294,8 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>` const TerminalContent = styled.div<{ $theme: ThemeMode }>` padding: 12px; - background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; - color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + background: ${(props) => getTerminalStyles(props.$theme).background}; + color: ${(props) => getTerminalStyles(props.$theme).color}; font-size: 13px; line-height: 1.4; min-height: 80px; @@ -379,25 +311,27 @@ const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>` flex: 1; white-space: pre-wrap; word-break: break-word; - color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + color: ${(props) => getTerminalStyles(props.$theme).color}; background-color: transparent !important; ` const TerminalPrompt = styled.span<{ $theme: ThemeMode }>` - color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + color: ${(props) => getTerminalStyles(props.$theme).promptColor}; font-weight: bold; flex-shrink: 0; ` +const blinkAnimation = keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +` + const TerminalCursor = styled.span<{ $theme: ThemeMode }>` display: inline-block; width: 2px; height: 16px; - background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; - animation: ${keyframes` - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0; } - `} 1s infinite; + background: ${(props) => getTerminalStyles(props.$theme).promptColor}; + animation: ${blinkAnimation} 1s infinite; margin-left: 2px; ` diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 59988a0b1e..5e491c4052 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,9 +1,9 @@ import CodeEditor from '@renderer/components/CodeEditor' -import { isMac } from '@renderer/config/constant' +import { isLinux, isMac, isWin } from '@renderer/config/constant' import { classNames } from '@renderer/utils' import { Button, Modal } from 'antd' import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -16,140 +16,41 @@ interface HtmlArtifactsPopupProps { type ViewMode = 'split' | 'code' | 'preview' -// 视图模式配置 -const VIEW_MODE_CONFIG = { - split: { - key: 'split' as const, - icon: MonitorSpeaker, - i18nKey: 'html_artifacts.split' - }, - code: { - key: 'code' as const, - icon: Code, - i18nKey: 'html_artifacts.code' - }, - preview: { - key: 'preview' as const, - icon: Monitor, - i18nKey: 'html_artifacts.preview' - } -} as const - -// 抽取头部组件 -interface ModalHeaderProps { - title: string - isFullscreen: boolean - viewMode: ViewMode - onViewModeChange: (mode: ViewMode) => void - onToggleFullscreen: () => void - onCancel: () => void -} - -const ModalHeaderComponent: React.FC = ({ - title, - isFullscreen, - viewMode, - onViewModeChange, - onToggleFullscreen, - onCancel -}) => { +const HtmlArtifactsPopup: React.FC = ({ open, title, html, onClose }) => { const { t } = useTranslation() + const [viewMode, setViewMode] = useState('split') + const [currentHtml, setCurrentHtml] = useState(html) + const [isFullscreen, setIsFullscreen] = useState(false) - const viewButtons = useMemo(() => { - return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => ( - } - onClick={() => onViewModeChange(key)}> - {t(i18nKey)} - - )) - }, [viewMode, onViewModeChange, t]) - - return ( - - - {title} - - - {viewButtons} - - -