From aff3b82998693ad391a88de1919de6c1fa7599d0 Mon Sep 17 00:00:00 2001 From: lofyer Date: Tue, 11 Aug 2026 12:01:58 +0800 Subject: [PATCH] feat: localize interface and enrich magic notes --- README.md | 67 +- package-lock.json | 89 +- package.json | 2 + src/main/assistant/assistant-database.ts | 19 +- src/main/magic-notes/rich-content.test.ts | 88 ++ src/main/magic-notes/rich-content.ts | 113 +- src/renderer/index.html | 2 +- src/renderer/src/ActivityPanel.test.tsx | 37 +- src/renderer/src/ActivityPanel.tsx | 364 ++++--- src/renderer/src/AgentQuestionCard.tsx | 16 +- src/renderer/src/App.test.tsx | 112 ++ src/renderer/src/App.tsx | 998 +++++++++++------- .../src/ChannelSettingsSection.test.tsx | 44 +- src/renderer/src/ChannelSettingsSection.tsx | 287 +++-- .../src/EmbeddingSettingsSection.test.tsx | 29 + src/renderer/src/EmbeddingSettingsSection.tsx | 135 ++- src/renderer/src/HeartbeatCenter.test.tsx | 36 +- src/renderer/src/HeartbeatCenter.tsx | 468 +++++--- src/renderer/src/HeartbeatSettings.tsx | 121 ++- src/renderer/src/KnowledgeGraphChart.tsx | 52 +- src/renderer/src/KnowledgePanel.tsx | 284 ----- src/renderer/src/KnowledgeWorkspace.test.tsx | 18 + src/renderer/src/KnowledgeWorkspace.tsx | 751 ++++++++----- src/renderer/src/MagicNoteContent.tsx | 5 +- src/renderer/src/MagicNoteEditor.test.tsx | 129 +++ src/renderer/src/MagicNoteEditor.tsx | 422 ++++++-- src/renderer/src/MagicNotesWorkspace.test.tsx | 30 + src/renderer/src/MagicNotesWorkspace.tsx | 487 ++++++--- src/renderer/src/MarkdownRenderer.test.tsx | 17 + src/renderer/src/MarkdownRenderer.tsx | 59 +- src/renderer/src/McpSettingsSection.tsx | 384 ++++--- .../src/PlatformFeaturesSettingsSection.tsx | 71 +- src/renderer/src/ProjectSwitcher.tsx | 106 +- src/renderer/src/RightAssistantSidebar.tsx | 265 +++-- .../src/RolePromptSettingsSection.test.tsx | 30 +- .../src/RolePromptSettingsSection.tsx | 193 ++-- src/renderer/src/SettingsPanel.test.tsx | 237 ++++- src/renderer/src/SettingsPanel.tsx | 798 +++++++++----- src/renderer/src/SkillsSettingsSection.tsx | 66 +- .../src/SpeechModelSettingsSection.test.tsx | 73 +- .../src/SpeechModelSettingsSection.tsx | 247 +++-- src/renderer/src/UpdateSettingsSection.tsx | 66 +- src/renderer/src/WorkspaceFilesPanel.test.tsx | 35 +- src/renderer/src/WorkspaceFilesPanel.tsx | 70 +- src/renderer/src/WorkspacePrimitives.tsx | 32 +- src/renderer/src/activity-store.test.ts | 23 + src/renderer/src/activity-store.ts | 7 +- src/renderer/src/i18n/UiLocaleProvider.tsx | 98 ++ src/renderer/src/i18n/i18n.test.ts | 44 + src/renderer/src/i18n/index.ts | 72 ++ src/renderer/src/i18n/locale.test.ts | 45 + src/renderer/src/i18n/locale.ts | 55 + .../src/i18n/locales/en-US/activity.ts | 92 ++ src/renderer/src/i18n/locales/en-US/app.ts | 407 +++++++ .../src/i18n/locales/en-US/heartbeat.ts | 202 ++++ .../src/i18n/locales/en-US/integrations.ts | 278 +++++ .../src/i18n/locales/en-US/knowledge.ts | 299 ++++++ .../src/i18n/locales/en-US/magicNotes.ts | 196 ++++ .../src/i18n/locales/en-US/settings.ts | 371 +++++++ .../i18n/locales/en-US/settingsSections.ts | 339 ++++++ .../src/i18n/locales/en-US/workspace.ts | 236 +++++ .../src/i18n/locales/zh-CN/activity.ts | 88 ++ src/renderer/src/i18n/locales/zh-CN/app.ts | 381 +++++++ .../src/i18n/locales/zh-CN/heartbeat.ts | 198 ++++ .../src/i18n/locales/zh-CN/integrations.ts | 263 +++++ .../src/i18n/locales/zh-CN/knowledge.ts | 289 +++++ .../src/i18n/locales/zh-CN/magicNotes.ts | 181 ++++ .../src/i18n/locales/zh-CN/settings.ts | 345 ++++++ .../i18n/locales/zh-CN/settingsSections.ts | 318 ++++++ .../src/i18n/locales/zh-CN/workspace.ts | 224 ++++ src/renderer/src/i18n/resource-types.ts | 11 + src/renderer/src/i18n/types.d.ts | 28 + src/renderer/src/knowledge-store.ts | 512 --------- src/renderer/src/magic-note-embeds.ts | 67 ++ src/renderer/src/main.tsx | 23 +- src/renderer/src/settings-categories.ts | 79 +- src/renderer/src/speech-recognition.test.ts | 9 + src/renderer/src/speech-recognition.ts | 46 +- src/renderer/src/styles.css | 157 +-- src/renderer/src/test-setup.ts | 9 +- src/renderer/src/token-usage.test.ts | 10 +- src/renderer/src/token-usage.ts | 16 +- src/shared/magic-notes-contracts.ts | 154 ++- 83 files changed, 10837 insertions(+), 3289 deletions(-) delete mode 100644 src/renderer/src/KnowledgePanel.tsx create mode 100644 src/renderer/src/MagicNoteEditor.test.tsx create mode 100644 src/renderer/src/i18n/UiLocaleProvider.tsx create mode 100644 src/renderer/src/i18n/i18n.test.ts create mode 100644 src/renderer/src/i18n/index.ts create mode 100644 src/renderer/src/i18n/locale.test.ts create mode 100644 src/renderer/src/i18n/locale.ts create mode 100644 src/renderer/src/i18n/locales/en-US/activity.ts create mode 100644 src/renderer/src/i18n/locales/en-US/app.ts create mode 100644 src/renderer/src/i18n/locales/en-US/heartbeat.ts create mode 100644 src/renderer/src/i18n/locales/en-US/integrations.ts create mode 100644 src/renderer/src/i18n/locales/en-US/knowledge.ts create mode 100644 src/renderer/src/i18n/locales/en-US/magicNotes.ts create mode 100644 src/renderer/src/i18n/locales/en-US/settings.ts create mode 100644 src/renderer/src/i18n/locales/en-US/settingsSections.ts create mode 100644 src/renderer/src/i18n/locales/en-US/workspace.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/activity.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/app.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/heartbeat.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/integrations.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/knowledge.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/magicNotes.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/settings.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/settingsSections.ts create mode 100644 src/renderer/src/i18n/locales/zh-CN/workspace.ts create mode 100644 src/renderer/src/i18n/resource-types.ts create mode 100644 src/renderer/src/i18n/types.d.ts delete mode 100644 src/renderer/src/knowledge-store.ts create mode 100644 src/renderer/src/magic-note-embeds.ts diff --git a/README.md b/README.md index 8844df7..b1dd493 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GoodBuddy -面向专业工作与国产化环境的安全桌面智能助手。 +面向全球专业工作场景的安全、跨平台桌面智能助手。 GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、远程消息通道、任务协作和持续成长能力组织在同一个桌面工作空间中。它不是简单的聊天窗口,而是一套可审计、可控制、可长期使用的个人智能工作环境。 @@ -25,63 +25,42 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode - 子进程使用环境变量白名单,避免继承无关凭据。 - 默认不依赖 GoodBuddy 云端账户,也不代理用户的模型流量。 -### 面向国产化环境交付 +### 跨平台、开放协议与自托管 -GoodBuddy 按操作系统、处理器架构、模型协议、消息通道和内网部署能力提供国产化适配。下表只列当前代码和发布流程已经提供的能力;具体国产操作系统、整机和外设组合仍应在目标环境完成安装、启动、模型调用和桌面集成验收。 +GoodBuddy 面向全球用户提供跨平台发布、开放模型协议、远程消息通道、离线语音和本地或私有网络部署能力。下表只列当前代码与发布流程覆盖的目标;具体操作系统版本、设备、桌面环境和网络组合仍应在目标环境完成安装、启动、模型调用和桌面集成验收。 -#### 操作系统与处理器 +#### 支持的平台 -| 类别 | 支持范围 | 交付形式 | +| 操作系统 | 处理器架构 | 交付形式 | | --- | --- | --- | -| Windows | Windows `x64`、Windows on Arm `arm64` | NSIS 安装包、便携 ZIP | -| Linux | Linux `x64`、Linux `arm64` | `deb`、AppImage | -| 国产 Linux | 银河麒麟、统信 UOS、开放麒麟、Deepin 等 | 优先使用 `deb`,也可用 AppImage 免安装验证 | -| macOS | Intel `x64`、Apple Silicon `arm64` | DMG、ZIP | -| x86-64 处理器 | 海光、兆芯及其他兼容 `x86_64` 的处理器 | 对应系统的 `x64` 包 | -| ARM64 处理器 | 鲲鹏、飞腾及其他兼容 `aarch64` 的处理器 | 对应系统的 `arm64` 包 | -| LoongArch | 暂无正式发布包 | 无 | +| Windows | `x64`、`arm64` | NSIS 安装包、便携 ZIP | +| macOS | `x64`、`arm64` | DMG、ZIP | +| Linux | `x64`、`arm64` | AppImage、DEB | -#### 国产模型与私有化服务 +六组系统与架构目标均由原生 GitHub Actions Runner 构建和校验,并生成包含 SHA-256 哈希的发布清单。其他操作系统和处理器架构目前不提供正式发布包。 -GoodBuddy 不把模型厂商写死在客户端中,而是通过标准协议连接用户选择的云端、企业网关或本机服务。下列厂商和模型只有在所用服务提供对应兼容接口时才能接入。 +#### 模型与服务连接 -| 接入对象 | 支持状态 | 接入方式 | 可用能力 | -| --- | --- | --- | --- | -| DeepSeek | 协议兼容 | OpenAI 兼容 Chat Completions,或由网关转换为已支持协议 | 对话、推理、受控工具调用 | -| 通义千问 / 阿里云百炼 | 协议兼容 | OpenAI 兼容 Chat Completions | 对话、推理、受控工具调用 | -| 智谱 GLM | 协议兼容 | OpenAI 兼容 Chat Completions | 对话、推理、受控工具调用 | -| Kimi / Moonshot | 协议兼容 | OpenAI 兼容 Chat Completions | 对话、推理、受控工具调用 | -| 豆包 / 火山方舟 | 协议兼容 | OpenAI 兼容 Chat Completions | 对话、推理、受控工具调用 | -| 腾讯混元 | 协议兼容 | OpenAI 兼容接口或企业网关 | 对话、推理、受控工具调用 | -| 百度千帆 / 文心 | 协议兼容 | OpenAI 兼容接口或企业网关 | 对话、推理、受控工具调用 | -| 百川、MiniMax | 协议兼容 | OpenAI 兼容接口或企业网关 | 对话、推理、受控工具调用 | -| 零一万物 Yi、阶跃星辰 Step | 协议兼容 | OpenAI 兼容接口或企业网关 | 对话、推理、受控工具调用 | -| 讯飞星火、华为盘古、商汤日日新 | 可经适配层接入 | 由企业网关转换为 OpenAI Responses、OpenAI 兼容 Chat Completions 或 Anthropic Messages | 按网关实现提供文本、推理和工具能力 | -| 硅基流动等聚合服务 | 协议兼容 | OpenAI 兼容 Chat Completions | 使用聚合服务中可用的文本模型 | -| Ollama | 已验证的本机连接方式 | OpenAI 兼容 Chat Completions,可选择“无需认证” | 本机文本模型,包括 Qwen、DeepSeek、GLM、Yi、MiniCPM 等 Ollama 模型 | -| Xinference、vLLM、LM Studio、LocalAI 等私有服务 | 协议兼容 | 自定义 OpenAI 兼容地址,可使用 API Key 或无需认证 | 本机或内网文本模型 | -| 企业模型网关与国产模型适配层 | 支持自定义连接 | OpenAI Responses、OpenAI 兼容 Chat Completions 或 Anthropic Messages | 按网关实现提供文本、推理和工具能力 | -| 通义万相、豆包图像、智谱 CogView 等国产图像模型 | 可经兼容接口接入 | 服务端或网关提供 OpenAI Images Generations 兼容接口 | 单图生成与本地成果保存 | -| BGE、GTE、text2vec、Qwen Embedding 等国产向量模型 | 可经兼容接口接入 | 使用 Xinference、vLLM、Ollama 或企业网关提供 OpenAI 兼容 Embeddings 接口 | 知识库语义检索、索引重建和 GraphRAG;失败时回退到 FTS5 与证据图谱 | +GoodBuddy 不绑定特定模型厂商。用户可以通过 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images Generations 和 OpenAI 兼容 Embeddings 接口连接云端服务、本机模型、私有服务或企业网关。支持自定义服务地址、API Key 和无需认证的受控连接;文本、推理、工具、图片和上下文能力取决于所连接服务的具体实现。 -#### 国产通信、语音与内网能力 +#### 消息通道、离线语音与自托管能力 | 类别 | 已支持项 | 说明 | | --- | --- | --- | -| 个人微信 | 微信 ClawBot | 本机扫码绑定;支持私聊文字、图片和文件,单条消息最多 4 个附件、解密后合计不超过 12MB | -| 企业通信 | 企业微信、钉钉 | 支持加密凭据、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断 | +| 消息通道 | 微信 ClawBot、企业微信、钉钉 | 支持独立通道项目与远程会话;提供加密凭据、连接测试、动态启停、发送者范围和状态诊断 | +| 微信附件 | 文字、图片和文件 | 微信 ClawBot 使用本机扫码绑定;单条消息最多 4 个附件,解密后合计不超过 12MB | | 远程 Runtime | 直连文本模型、OpenCode、Continue | 每个通道使用系统管理项目和独立远程会话,支持 Ask / Execute 与活动审计 | -| 中文离线语音 | SenseVoiceSmall INT8 | 从 ModelScope 固定版本校验下载;支持中文、粤语、英语、日语和韩语,适合本地 CPU | -| 多语言离线语音 | Whisper Tiny INT8 | 从 ModelScope 固定版本校验下载;支持中文、英语及其他语言 | -| 中文界面 | 简体中文、内置 Noto Sans SC Variable | 字体随应用打包,不依赖远程字体服务 | +| 离线语音(SenseVoice) | SenseVoiceSmall INT8 | 支持中文、粤语、英语、日语和韩语,适合本地 CPU | +| 中英及中粤英离线语音 | Paraformer 中英双语 INT8、Paraformer 中粤英三语 INT8 | 分别面向普通话与英语,以及普通话、粤语和英语的快速本地识别 | +| 多语言离线语音 | Whisper Tiny、Small、Medium 多语言 INT8 | 提供从轻量快速到高质量的多语言识别选择 | +| 界面语言与字体 | 简体中文、English;Inter Variable、Noto Sans SC Variable | 语言与字体资源随应用打包,不依赖远程字体服务 | | 本地数据 | SQLite、FTS5、本地知识库与知识图谱 | 会话、任务、成果、记忆和知识数据默认保存在本机 | -| 内网模型与网关 | 自定义 HTTP(S) 地址、API Key 或无需认证 | 可连接本机、局域网、企业网关和私有模型服务 | -| 内网兼容模式 | HTTP、自签名证书、无效或过期证书 | 默认开启,可关闭并恢复严格校验;微信凭据和媒体端点不适用该放宽策略 | -| MCP | `stdio`、Streamable HTTP、SSE | 可接入本机或内网 MCP Server;远程连接支持 Bearer Token | +| 自托管模型与网关 | 自定义 HTTP(S) 地址、API Key 或无需认证 | 可连接本机、私有网络、企业网关和自托管模型服务 | +| 私有网络连接兼容性 | HTTP、自签名证书、无效或过期证书 | GoodBuddy 进程管理的连接采用宽松证书策略;外部浏览器以及微信凭据和媒体端点仍执行各自的严格校验 | +| MCP | `stdio`、Streamable HTTP、SSE | 可接入本机或远程 MCP Server;远程连接支持 Bearer Token | | Agent Runtime | 内置 OpenCode、Continue | 支持自定义程序路径、配置路径、模型来源和服务地址;Linux 内置 OpenCode 可使用 bubblewrap 严格沙箱 | -| 发布校验 | 六组系统与架构目标、SHA-256 清单 | Windows、macOS、Linux 的 `x64` / `arm64` 包均由发布流程构建和校验 | -> “协议兼容”表示 GoodBuddy 已实现对应协议,并允许配置自定义服务地址,不等同于对每个厂商、模型版本或套餐逐一完成认证。工具调用、图片输入、思维过程和上下文长度还取决于具体服务端实现。 +> 自定义端点表示 GoodBuddy 已实现对应协议并允许用户配置服务地址,不等同于对每个服务商、模型版本或套餐逐一完成认证。 ## 核心功能 @@ -170,4 +149,4 @@ GoodBuddy 不把模型厂商写死在客户端中,而是通过标准协议连 ## 隐私说明 -模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。面向纯内网部署的“内网兼容模式”默认开启,允许 HTTP 并接受无效、自签名或过期的 HTTPS 证书;可在“安全与数据”中关闭并恢复严格校验。微信凭据和媒体端点不受该兼容模式放宽,始终只允许经过校验的腾讯微信 HTTPS 主机与重定向。 +模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。为兼容受控私有网络,GoodBuddy 进程管理的连接允许 HTTP,并接受无效、自签名或过期的 HTTPS 证书;交由外部浏览器打开的 URL 仍遵循浏览器自身的证书策略。微信凭据和媒体端点不受该策略放宽,始终只允许经过校验的腾讯微信 HTTPS 主机与重定向。 diff --git a/package-lock.json b/package-lock.json index 003e99c..1c334fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "dingtalk-stream": "^2.1.6-beta.1", "fflate": "^0.8.3", "html-to-text": "^10.0.0", + "i18next": "^25.10.10", "json5": "^2.2.3", "lucide-react": "^1.27.0", "pdfjs-dist": "^6.2.108", @@ -24,6 +25,7 @@ "quill": "^2.0.3", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-i18next": "^16.6.6", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "sherpa-onnx": "1.13.4", @@ -7343,6 +7345,15 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-to-text": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", @@ -7485,6 +7496,37 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -10426,6 +10468,33 @@ "react": "^19.2.8" } }, + "node_modules/react-i18next": { + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.9", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -11621,7 +11690,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11860,6 +11929,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -12569,6 +12647,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 4c244b9..37df79b 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "dingtalk-stream": "^2.1.6-beta.1", "fflate": "^0.8.3", "html-to-text": "^10.0.0", + "i18next": "^25.10.10", "json5": "^2.2.3", "lucide-react": "^1.27.0", "pdfjs-dist": "^6.2.108", @@ -152,6 +153,7 @@ "quill": "^2.0.3", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-i18next": "^16.6.6", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "sherpa-onnx": "1.13.4", diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 6cc35f8..5828be1 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -44,7 +44,7 @@ import { type RuntimeSelectionRepairSettings } from '../../shared/runtime-selection-contracts' import { - MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES, + MAGIC_NOTE_MAX_NOTE_EMBED_BYTES, type MagicNoteComment, type MagicNoteDetail, type MagicNoteEntry, @@ -57,6 +57,7 @@ import { import type { ComputerControlAuditEvent } from '../computer-control/audit' import { magicNoteChecklistItems, + magicNoteEmbeddedBytes, magicNoteImageBytes, magicNotePlainText, magicNotePreview, @@ -1917,7 +1918,7 @@ export class AssistantDatabase { const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { - this.assertMagicNoteImageBudget(input.noteId, input.content) + this.assertMagicNoteEmbedBudget(input.noteId, input.content) const noteResult = database .prepare( `UPDATE magic_notes @@ -1943,7 +1944,7 @@ export class AssistantDatabase { input.plainText, now, now, - magicNoteImageBytes(input.content) + magicNoteEmbeddedBytes(input.content) ) this.syncMagicNoteTodos( database, @@ -1976,7 +1977,7 @@ export class AssistantDatabase { const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { - this.assertMagicNoteImageBudget( + this.assertMagicNoteEmbedBudget( existing.note_id, input.content, input.entryId @@ -1993,7 +1994,7 @@ export class AssistantDatabase { JSON.stringify(input.content), input.plainText, now, - magicNoteImageBytes(input.content), + magicNoteEmbeddedBytes(input.content), input.entryId, input.expectedRevision ) @@ -4256,7 +4257,7 @@ export class AssistantDatabase { } } - private assertMagicNoteImageBudget( + private assertMagicNoteEmbedBudget( noteId: string, content: MagicNoteRichContent, excludedEntryId?: string @@ -4269,10 +4270,10 @@ export class AssistantDatabase { ) .get(noteId, excludedEntryId ?? '') as { image_bytes: number } if ( - existing.image_bytes + magicNoteImageBytes(content) > - MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES + existing.image_bytes + magicNoteEmbeddedBytes(content) > + MAGIC_NOTE_MAX_NOTE_EMBED_BYTES ) { - throw new Error('一篇笔记中的图片总大小不能超过 8 MB') + throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB') } } diff --git a/src/main/magic-notes/rich-content.test.ts b/src/main/magic-notes/rich-content.test.ts index 7f1906e..0d43a64 100644 --- a/src/main/magic-notes/rich-content.test.ts +++ b/src/main/magic-notes/rich-content.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { magicNoteChecklistItems, + magicNoteEmbeddedBytes, magicNoteImageBytes, magicNotePlainText, setMagicNoteChecklistCompletion, @@ -10,6 +11,13 @@ import { const pngDataUrl = `data:image/png;base64,${Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]).toString('base64')}` +const mp4Bytes = Buffer.from([ + 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d +]) +const mp4DataUrl = `data:video/mp4;base64,${mp4Bytes.toString('base64')}` +const attachmentBytes = Buffer.from('release notes') +const attachmentDataUrl = + `data:text/plain;base64,${attachmentBytes.toString('base64')}` describe('magic note rich content', () => { it('accepts bounded text formats and signature-checked local images', () => { @@ -59,6 +67,86 @@ describe('magic note rich content', () => { ).toThrow('图片内容与声明的格式不一致') }) + it('accepts bounded font formats, local videos, and attachments', () => { + const content = validateMagicNoteRichContent({ + version: 1, + ops: [ + { + insert: '重点', + attributes: { size: 'large', color: '#e60000' } + }, + { insert: '\n' }, + { + insert: { + localVideo: { + name: 'demo.mp4', + mimeType: 'video/mp4', + size: mp4Bytes.length, + dataUrl: mp4DataUrl + } + } + }, + { + insert: { + attachment: { + name: 'notes.txt', + mimeType: 'text/plain', + size: attachmentBytes.length, + dataUrl: attachmentDataUrl + } + } + }, + { insert: '\n' } + ] + }) + + expect(magicNotePlainText(content)).toBe( + '重点\n[视频:demo.mp4][附件:notes.txt]' + ) + expect(magicNoteImageBytes(content)).toBe(0) + expect(magicNoteEmbeddedBytes(content)).toBe( + mp4Bytes.length + attachmentBytes.length + ) + }) + + it('rejects spoofed videos and mismatched attachment metadata', () => { + expect(() => + validateMagicNoteRichContent({ + version: 1, + ops: [ + { + insert: { + localVideo: { + name: 'demo.mp4', + mimeType: 'video/mp4', + size: attachmentBytes.length, + dataUrl: `data:video/mp4;base64,${attachmentBytes.toString('base64')}` + } + } + } + ] + }) + ).toThrow('视频内容与声明的格式不一致') + + expect(() => + validateMagicNoteRichContent({ + version: 1, + ops: [ + { + insert: { + attachment: { + name: 'notes.txt', + mimeType: 'text/plain', + size: attachmentBytes.length + 1, + dataUrl: attachmentDataUrl + } + } + } + ] + }) + ).toThrow('附件内容与声明的大小不一致') + }) + it('rejects more than twelve images in one record', () => { expect(() => validateMagicNoteRichContent({ diff --git a/src/main/magic-notes/rich-content.ts b/src/main/magic-notes/rich-content.ts index c59707c..4c25202 100644 --- a/src/main/magic-notes/rich-content.ts +++ b/src/main/magic-notes/rich-content.ts @@ -1,6 +1,9 @@ import { + MAGIC_NOTE_MAX_ATTACHMENT_BYTES, MAGIC_NOTE_MAX_IMAGE_BYTES, - magicNoteImageDataBytes, + MAGIC_NOTE_MAX_VIDEO_BYTES, + MAGIC_NOTE_VIDEO_TYPES, + magicNoteDataBytes, magicNoteRichContentSchema, type MagicNoteRichContent } from '../../shared/magic-notes-contracts' @@ -52,6 +55,67 @@ function validateImage(dataUrl: string): void { } } +type EmbeddedFile = { + name: string + mimeType: string + size: number + dataUrl: string +} + +function decodeEmbeddedFile( + file: EmbeddedFile, + maxBytes: number +): Buffer { + const separatorIndex = file.dataUrl.indexOf(',') + const prefix = file.dataUrl.slice(0, separatorIndex) + const payload = file.dataUrl.slice(separatorIndex + 1) + if (prefix !== `data:${file.mimeType};base64`) { + throw new Error('附件内容与声明的类型不一致') + } + const bytes = Buffer.from(payload, 'base64') + if ( + bytes.length === 0 || + bytes.length > maxBytes || + bytes.length !== file.size + ) { + throw new Error('附件内容与声明的大小不一致') + } + if (bytes.toString('base64') !== payload) { + throw new Error('附件数据格式无效') + } + return bytes +} + +function validateVideo(file: EmbeddedFile): void { + const bytes = decodeEmbeddedFile(file, MAGIC_NOTE_MAX_VIDEO_BYTES) + const hasIsoBaseMediaSignature = + bytes.length >= 12 && + bytes.subarray(4, 8).toString('ascii') === 'ftyp' + const signatureMatches = + (file.mimeType === 'video/mp4' && hasIsoBaseMediaSignature) || + (file.mimeType === 'video/quicktime' && hasIsoBaseMediaSignature) || + (file.mimeType === 'video/webm' && + bytes.length >= 4 && + bytes.subarray(0, 4).equals( + Buffer.from([0x1a, 0x45, 0xdf, 0xa3]) + )) || + (file.mimeType === 'video/ogg' && + bytes.length >= 4 && + bytes.subarray(0, 4).toString('ascii') === 'OggS') + if ( + !MAGIC_NOTE_VIDEO_TYPES.includes( + file.mimeType as (typeof MAGIC_NOTE_VIDEO_TYPES)[number] + ) || + !signatureMatches + ) { + throw new Error('视频内容与声明的格式不一致') + } +} + +function validateAttachment(file: EmbeddedFile): void { + decodeEmbeddedFile(file, MAGIC_NOTE_MAX_ATTACHMENT_BYTES) +} + export function validateMagicNoteRichContent( input: unknown ): MagicNoteRichContent { @@ -61,9 +125,15 @@ export function validateMagicNoteRichContent( continue } if (operation.attributes !== undefined) { - throw new Error('图片嵌入不支持行内格式') + throw new Error('嵌入内容不支持行内格式') + } + if ('image' in operation.insert) { + validateImage(operation.insert.image) + } else if ('localVideo' in operation.insert) { + validateVideo(operation.insert.localVideo) + } else { + validateAttachment(operation.insert.attachment) } - validateImage(operation.insert.image) } return content } @@ -75,7 +145,11 @@ export function magicNotePlainText( .map((operation) => typeof operation.insert === 'string' ? operation.insert - : '[图片]' + : 'image' in operation.insert + ? '[图片]' + : 'localVideo' in operation.insert + ? `[视频:${operation.insert.localVideo.name}]` + : `[附件:${operation.insert.attachment.name}]` ) .join('') .replace(/\n{3,}/g, '\n\n') @@ -89,7 +163,29 @@ export function magicNoteImageBytes( if (typeof operation.insert === 'string') { return total } - return total + magicNoteImageDataBytes(operation.insert.image) + return ( + total + + ('image' in operation.insert + ? magicNoteDataBytes(operation.insert.image) + : 0) + ) + }, 0) +} + +export function magicNoteEmbeddedBytes( + content: MagicNoteRichContent +): number { + return content.ops.reduce((total, operation) => { + if (typeof operation.insert === 'string') { + return total + } + if ('image' in operation.insert) { + return total + magicNoteDataBytes(operation.insert.image) + } + if ('localVideo' in operation.insert) { + return total + magicNoteDataBytes(operation.insert.localVideo.dataUrl) + } + return total + magicNoteDataBytes(operation.insert.attachment.dataUrl) }, 0) } @@ -119,7 +215,12 @@ export function magicNoteChecklistItems( let sourceIndex = 0 for (const operation of content.ops) { if (typeof operation.insert !== 'string') { - line += '[图片]' + line += + 'image' in operation.insert + ? '[图片]' + : 'localVideo' in operation.insert + ? `[视频:${operation.insert.localVideo.name}]` + : `[附件:${operation.insert.attachment.name}]` continue } const segments = operation.insert.split(/(\n)/u) diff --git a/src/renderer/index.html b/src/renderer/index.html index e5bca1c..b4ae78b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index 14fd9b8..f40ede7 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -12,6 +12,7 @@ import { MAX_ACTIVITY_RECORDS, type ActivityRecord } from './activity-store' +import i18n from './i18n' function makeRecord( index: number, @@ -75,8 +76,42 @@ function makeTokenUsage(): TokenUsageSummary { } describe('ActivityPanel', () => { - afterEach(() => { + afterEach(async () => { cleanup() + await i18n.changeLanguage('zh-CN') + }) + + it('renders English interface copy while preserving activity content', async () => { + await i18n.changeLanguage('en-US') + const record = makeRecord(1, 'running') + render( + + ) + + const englishDate = new Intl.DateTimeFormat('en-US', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).format(new Date(record.createdAt)) + expect(screen.getAllByText(englishDate).length).toBeGreaterThan(0) + expect( + screen.getByRole('heading', { + level: 1, + name: 'Tasks and activity' + }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'In progress' }) + ).toBeInTheDocument() + expect(screen.getByText(record.title)).toBeInTheDocument() + expect(screen.getByText('Token usage')).toBeInTheDocument() }) it('filters active and unsuccessful activity and opens its conversation', () => { diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 29bd714..7cdef7c 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -1,5 +1,6 @@ import { Activity, Trash2 } from 'lucide-react' import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import type { TokenUsageSummary } from '../../shared/assistant-contracts' import { MAX_ACTIVITY_RECORDS, @@ -28,57 +29,6 @@ export type ActivityPanelProps = { onOpenConversation: (conversationId: string) => void } -const statusLabels: Record = { - pending: '等待中', - running: '进行中', - completed: '已完成', - failed: '失败', - denied: '已拒绝', - cancelled: '已取消', - interrupted: '已中断' -} - -const kindLabels: Record = { - request: '任务', - tool: '工具', - approval: '审批', - subagent: '子专家', - result: '结果' -} - -const filters: ReadonlyArray<{ - value: ActivityFilter - label: string -}> = [ - { value: 'all', label: '全部' }, - { value: 'active', label: '进行中' }, - { value: 'failed', label: '失败' } -] - -const tokenGroups: ReadonlyArray<{ - value: TokenUsageGroup - label: string - columnLabel: string -}> = [ - { value: 'project', label: '按项目', columnLabel: '项目' }, - { - value: 'conversation', - label: '按会话', - columnLabel: '会话' - }, - { value: 'model', label: '按模型', columnLabel: '模型' } -] - -const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' -}) - -const tokenCountFormatter = new Intl.NumberFormat('zh-CN') - function isActive(record: ActivityRecord): boolean { return record.status === 'pending' || record.status === 'running' } @@ -105,35 +55,29 @@ function matchesFilter( return true } -function formatTime(createdAt: number): { +function formatTime( + createdAt: number, + formatter: Intl.DateTimeFormat, + unknownTime: string +): { display: string machineReadable?: string } { if (!Number.isFinite(createdAt) || createdAt < 0) { - return { display: '时间未知' } + return { display: unknownTime } } const date = new Date(createdAt) if (Number.isNaN(date.getTime())) { - return { display: '时间未知' } + return { display: unknownTime } } return { - display: dateTimeFormatter.format(date), + display: formatter.format(date), machineReadable: date.toISOString() } } -function emptyMessage(filter: ActivityFilter): string { - if (filter === 'active') { - return '当前没有等待中或正在运行的活动。' - } - if (filter === 'failed') { - return '当前没有失败、取消或中断的活动。' - } - return '任务请求、子专家、工具调用和审批决定会显示在这里。' -} - type ActivityGroup = { conversationId: string title: string @@ -144,7 +88,8 @@ type ActivityGroup = { } function activityWorkspaceScope( - scope: ActivityRecord['scope'] + scope: ActivityRecord['scope'], + unavailableExplanation: string ): WorkspaceScope { if (scope.kind === 'project') { return { kind: 'project', projectName: scope.projectName } @@ -154,7 +99,7 @@ function activityWorkspaceScope( } return { kind: 'unavailable', - explanation: '创建此活动记录时未能确定其归属范围。' + explanation: unavailableExplanation } } @@ -202,10 +147,73 @@ export function ActivityPanel({ onClear, onOpenConversation }: ActivityPanelProps): React.JSX.Element { + const { t, i18n } = useTranslation('activity') const [filter, setFilter] = useState('all') const [tokenGroup, setTokenGroup] = useState('project') const [confirmingClear, setConfirmingClear] = useState(false) + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }), + [i18n.resolvedLanguage] + ) + const tokenCountFormatter = useMemo( + () => new Intl.NumberFormat(i18n.resolvedLanguage || 'zh-CN'), + [i18n.resolvedLanguage] + ) + const formatCount = (value: number): string => + tokenCountFormatter.format(value) + const statusLabels: Record = { + pending: t('statuses.pending'), + running: t('statuses.running'), + completed: t('statuses.completed'), + failed: t('statuses.failed'), + denied: t('statuses.denied'), + cancelled: t('statuses.cancelled'), + interrupted: t('statuses.interrupted') + } + const kindLabels: Record = { + request: t('kinds.request'), + tool: t('kinds.tool'), + approval: t('kinds.approval'), + subagent: t('kinds.subagent'), + result: t('kinds.result') + } + const filters: ReadonlyArray<{ + value: ActivityFilter + label: string + }> = [ + { value: 'all', label: t('filters.all') }, + { value: 'active', label: t('filters.active') }, + { value: 'failed', label: t('filters.failed') } + ] + const tokenGroups: ReadonlyArray<{ + value: TokenUsageGroup + label: string + columnLabel: string + }> = [ + { + value: 'project', + label: t('tokenUsage.groups.project'), + columnLabel: t('tokenUsage.columns.project') + }, + { + value: 'conversation', + label: t('tokenUsage.groups.conversation'), + columnLabel: t('tokenUsage.columns.conversation') + }, + { + value: 'model', + label: t('tokenUsage.groups.model'), + columnLabel: t('tokenUsage.columns.model') + } + ] const visibleRecords = useMemo( () => records.slice(0, MAX_ACTIVITY_RECORDS), @@ -231,7 +239,40 @@ export function ActivityPanel({ ) const tokenGroupLabel = tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ?? - '项目' + t('tokenUsage.columns.project') + const emptyDescription = + filter === 'active' + ? t('empty.active') + : filter === 'failed' + ? t('empty.failed') + : t('empty.all') + const localizeTokenRow = ( + row: (typeof tokenRows)[number] + ): { label: string; detail?: string } => { + const missingModel = row.key.endsWith(':') + const modelProvider = + row.key.match(/model:([^:]*):$/u)?.[1] ?? '' + const label = + tokenGroup === 'project' && + row.key.startsWith('project:unassigned:') + ? t('tokenUsage.fallbacks.unassignedProject') + : tokenGroup === 'conversation' && + row.key.startsWith('conversation:deleted:') + ? t('tokenUsage.fallbacks.deletedConversation') + : tokenGroup === 'model' && missingModel + ? t('tokenUsage.fallbacks.unknownModel') + : row.label + const detail = + missingModel && tokenGroup !== 'model' + ? [ + t('tokenUsage.fallbacks.unknownModel'), + modelProvider + ] + .filter(Boolean) + .join(' · ') + : row.detail + return { label, detail } + } return (