diff --git a/.github/workflows/auto-translate.yml b/.github/workflows/auto-translate.yml
new file mode 100644
index 00000000..e6b16c1a
--- /dev/null
+++ b/.github/workflows/auto-translate.yml
@@ -0,0 +1,111 @@
+name: Auto Translate Docs
+
+on:
+ push:
+ paths:
+ - 'content/cn/**.md'
+ - 'content/cn/settings.yml'
+ - 'scripts/auto-translate/languages.json'
+
+ workflow_dispatch:
+ inputs:
+ full:
+ description: 'Full translation (translate all files, not just changed)'
+ type: boolean
+ default: false
+
+jobs:
+ translate:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Run auto-translate
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
+ OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
+ GITHUB_EVENT_BEFORE: ${{ github.event.before }}
+ run: |
+ if [ "${{ inputs.full }}" = "true" ]; then
+ node scripts/auto-translate/index.mjs run --full=true
+ else
+ node scripts/auto-translate/index.mjs run --changed-only=true
+ fi
+
+ - name: Check for translation changes
+ id: changes
+ run: |
+ git add -A
+ if git diff --cached --quiet; then
+ echo "has_changes=false" >> $GITHUB_OUTPUT
+ else
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create PR branch and commit
+ if: steps.changes.outputs.has_changes == 'true'
+ id: commit
+ run: |
+ BRANCH="auto-translate/$(date +%Y%m%d-%H%M%S)"
+ echo "branch=$BRANCH" >> $GITHUB_OUTPUT
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ git checkout -b "$BRANCH"
+ git commit -m "docs: auto-translate updated content
+
+ Triggered by: ${{ github.sha }}
+ Source branch: ${{ github.ref_name }}"
+
+ git push origin "$BRANCH"
+
+ - name: Create Pull Request
+ if: steps.changes.outputs.has_changes == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ BRANCH="${{ steps.commit.outputs.branch }}"
+ BASE_BRANCH="${{ github.ref_name }}"
+
+ # Collect list of changed translation files
+ CHANGED_FILES=$(git diff origin/${BASE_BRANCH}...HEAD --name-only | grep -v '^content/cn/' | head -20 | sed 's/^/- /' || true)
+ CHANGED_COUNT=$(git diff origin/${BASE_BRANCH}...HEAD --name-only | grep -v '^content/cn/' | wc -l | tr -d ' ')
+ SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
+
+ gh pr create \
+ --title "docs(i18n): sync ${CHANGED_COUNT} translation(s) triggered by ${SHORT_SHA}" \
+ --body "## Auto-translated Documentation
+
+ This PR was automatically generated by the auto-translate workflow.
+
+ **Triggered by commit:** \`${{ github.sha }}\`
+ **Source branch:** \`${BASE_BRANCH}\`
+
+ ### Changed files
+ ${CHANGED_FILES}
+
+ ---
+ _Generated by [auto-translate workflow](.github/workflows/auto-translate.yml)_" \
+ --base "${BASE_BRANCH}" \
+ --head "${BRANCH}" || true
diff --git a/content/cn/memos_cloud/quick_start.md b/content/cn/memos_cloud/quick_start.md
index d8abf92c..bfeeddd6 100644
--- a/content/cn/memos_cloud/quick_start.md
+++ b/content/cn/memos_cloud/quick_start.md
@@ -1,9 +1,9 @@
---
title: 快速开始
-desc: 欢迎访问 MemOS 云平台,可参考本新手指南,快速接入记忆能力。
+desc: 欢迎访问 MemOS 云平台,参考本新手指南即可在几分钟内接入记忆能力。
---
-在使用大模型构建应用时,一个常见问题是:**如何让 AI 记住用户的长期偏好?**
+在使用大模型构建应用时,一个常见问题是:**如何让 AI 稳定记住用户的长期偏好?**
MemOS 提供了两个核心接口帮助你实现:
- `addMessage` —— 把原始对话交给我们,我们自动加工并存储记忆[(点此查看详细 API 文档)](/api_docs/core/add_message)
@@ -16,9 +16,11 @@ MemOS 提供了两个核心接口帮助你实现:
* 注册并登录 MemOS 云平台 [(点击注册)](https://memos-dashboard.openmem.net/quickstart);
-* 获取 API Key[(点击获取)](https://memos-dashboard.openmem.net/apikeys);
+* 准备一个可发送 HTTP 请求的环境(Python 或 cURL 均可);
-* 准备一个可发送 HTTP 请求的环境,Python 或 cURL。
+* 获取 API Key[(点击获取)](https://memos-dashboard.openmem.net/apikeys) 并配置到环境变量;
+
+* 准备一个可用于测试的 `conversation_id`(建议按日期命名,如 `20260413-demo`)。
## 2.代码配置
@@ -44,9 +46,9 @@ pip install MemoryOS -U
### 2.3 在会话中调用MemOS查询相关记忆(searchMemory)
::note
-**会话 B:2025-9-28 发生**
+**会话 B:2025-09-28 发生**
-用户在一个新的会话中,提出让AI推荐国庆旅游地点和酒店,MemOS 会自动召回【事实记忆:曾去过哪里】【偏好记忆:订酒店的偏好】供AI参考,从而推荐更加个性化的旅游计划
+用户在一个新的会话中提出“推荐国庆旅游地点和酒店”,MemOS 会自动召回【事实记忆:曾去过哪里】和【偏好记忆:订酒店的偏好】供 AI 参考,从而生成更个性化的旅游计划。
::
::code-snippet{name=search_memory}
@@ -63,8 +65,13 @@ pip install MemoryOS -U
{
"preference_type": "implicit_preference", #隐性偏好
"preference": "用户可能偏好性价比较高的酒店选择。",
- "reasoning": "七天酒店通常以经济实惠著称,而用户选择七天酒店可能表明其在住宿方面倾向于选择性价比较高的选项。虽然用户没有明确提到预算限制或具体酒店偏好,但在提供的选项中选择七天可能反映了对价格和实用性的重视。",
+ "confidence": 0.82,
"conversation_id": "0610"
+ },
+ {
+ "preference_type": "explicit_preference", #显性偏好
+ "preference": "用户希望酒店评分不低于4.5分。",
+ "conversation_id": "0928"
}
],
@@ -74,6 +81,7 @@ pip install MemoryOS -U
"memory_key": "暑假广州旅游计划",
"memory_value": "用户计划在暑假期间前往广州旅游,并选择了七天连锁酒店作为住宿选项。",
"conversation_id": "0610",
+ "memory_time": "2025-06-10 20:15:00",
"tags": [
"旅游",
"广州",
@@ -153,13 +161,15 @@ pip install MemoryOS -U
## 3.下一步行动
-现在你已经能够运行 MemOS,可以探索更多云平台功能:
+现在你已经可以运行 MemOS,建议继续探索更多云平台功能:
* [**核心记忆操作**](/memos_cloud/mem_operations/add_message):完整了解如何添加、检索与删除记忆;
* [**功能介绍**](/memos_cloud/features/basic/filters):探索更多云平台功能,如:记忆过滤、多模态消息、知识库等;
-* [**API 接口文档**](/api_docs/start/overview):查看完整的 API 文档与调用示例。
+* [**API 接口文档**](/api_docs/start/overview):查看完整的 API 文档与调用示例;
+
+* [**SDK 接入说明**](/api_docs/start/quickstart):按语言查看初始化、鉴权和错误处理方式。
## 4.更多资料
@@ -198,12 +208,11 @@ MemOS 提供了丰富的项目示例,根据您的具体项目可参考以下
- 通过 MemOS,用户的写作偏好、常用信息、上下文指令都能被记住
- 下次写总结或邮件时无需反复强调,实现连贯又个性化的创作体验。
-- [MindDock 浏览器插件](https://alidocs.dingtalk.com/i/p/e3ZxX84Z5KM6X7dRZxX8v66wA7xaBG7d?dontjump=true)
- - MemOS-MindDock 为用户打造统一的跨平台 AI 记忆层。
- - 它自动记录、整理并注入个人信息与偏好,让所有 AI 都能持续、稳定地“认识你”。
-
- [Coze × MemOS 插件工具](/usecase/frameworks/coze_plugin)
- 使用 Coze 平台上架的 MemOS 插件工具,在工作流中直接访问云服务接口,为您的 Agent 快速添加长期记忆功能。
- [Claude MCP](/usecase/frameworks/claude_mcp)
- MemOS 提供了通过 MCP 与云平台交互的方式,在 Claude 客户端中直接访问云服务接口。
+
+- [LangChain × MemOS 集成](/usecase/frameworks/langchain)
+ - 在 LangChain 工作流中将 `searchMemory` 作为检索工具接入,支持多轮对话上下文增强。
diff --git a/content/en/changelog.yml b/content/en/changelog.yml
index ae9b23f2..7d2489c0 100644
--- a/content/en/changelog.yml
+++ b/content/en/changelog.yml
@@ -3,15 +3,15 @@ versions:
date: 2026-03-19
changedInfo:
Improvements:
- - type: PolarDB Database
+ - type: Parameter optimization
changedInfo:
- - PolarDB optimization and upgrade. Optimized execution paths for queries with multiple filter conditions, leveraging new kernel capabilities to improve CPU and memory usage, achieving several-fold query acceleration and reducing system pressure.
- - type: Memory Version Service
+ - 'The search and chat APIs have the relativity parameter for filtering recalled memories by relevance score. This update sets the default relevance threshold to 0.45.'
+ - type: Log optimization
changedInfo:
- - Memory version plugin-based architecture.
+ - 'Intercept some log errors and adjust the log level for certain logs.'
- Added high availability for NLI model.
- Added routing logic for memory version at memreader. Falls back to legacy prompt and model when no relevant memory is recalled or all recalled content is judged irrelevant by the NLI model; otherwise uses the memory version plugin (qwen-flash).
- - type: Service Optimization
+ - type: Memory version compatibility for feedback
changedInfo:
- Optimized scheduling tasks to improve memory usage.
@@ -19,16 +19,16 @@ versions:
date: 2026-03-16
changedInfo:
Improvements:
- - type: Memory version compatibility for feedback
+ - type: Retrieval Optimization
changedInfo:
- - When Feedback updates a node, it follows the memory version logic, updates the original node, and pushes the previous content into the history field.
- - type: Memory version optimization
+ - 'Added keyword-based search in the search phase, while relativity continues to use cosine similarity of embeddings.'
+ - type: Skills Improvements
changedInfo:
- During conflict resolution and duplicate merging, the process of "restoring independent facts from past versions" has been comprehensively optimized. It can now restore independent facts more stably and accurately, preserve the completeness of memory information, and reduce memory inconsistencies caused by unstable model responses.
Bug Fixes:
- - type: Memory version parameter and related bug fixes
+ - type: Fixes
changedInfo:
- - Fixed several bugs including memory version parameter validation, empty key after update, and incorrect update time.
+ - 'Fixed an issue where an incorrect field was used in preference memory threshold filtering.'
- type: Feedback issue fixes
changedInfo:
- Fixed the issue where sources were empty during the Feedback process.
@@ -36,25 +36,25 @@ versions:
date: 2026-03-05
changedInfo:
New Features:
- - type: New Features
+ - type: Knowledge Base Retrieval Optimization
changedInfo:
- - When adding memories, conflicting/duplicate memories are automatically detected, merged, and archived. The latest memory content is retained, while the old memory content is placed in the history field.
+ - 'Dual-track retrieval: Supports coordinated storage and retrieval of "original text + memory", with customizable recall modes.'
Improvements:
- - type: Preference memories migrated to graph database
+ - type: Scheduling & Retrieval Pipeline Refactor
changedInfo:
- - Preference memories have been migrated to the graph database, unifying them with other memory types.
- - type: memory versioning optimization
+ - 'Pipeline refactor: Rebuilt the retrieval chain into four stages—Search -> Enhance -> Rerank -> Filter—scheduled by a unified API.'
+ - type: Skills Improvements
changedInfo:
- - Memory retrieval reduces redundant calls to get_by_metadata, decreasing retrieval latency.
+ - 'Skill docs localization: Supports local storage of skill docs and generates dedicated URLs, downloadable directly via API.'
- type: database optimization
changedInfo:
- Implemented fine-grained concurrent rate limiting using semaphores, combined with automatic faulty connection removal and thread lifecycle management, to enhance system stability under high concurrency.
- Optimized log levels and content, retaining key execution traces while reducing the impact of log writing on database performance.
- Implemented trigger-based preheating to load hot data into the cache, avoiding high latency caused by cold starts.
Bug Fixes:
- - type: bug in fast memory addition
+ - type: Playground Improvements
changedInfo:
- - Fixed an issue where, when adding memories with MultiModalStruct selected, embedding operations were not performed in some cases under fast mode, causing memories in fast mode to be unsearchable.
+ - Fixed known UX issues and improved environment stability.
- type: bug in multimodal memory
changedInfo:
- Fixed a bug where the image content was not being separately interpreted for image-based memories.
@@ -68,9 +68,9 @@ versions:
date: 2026-02-27
changedInfo:
Improvements:
- - type: Parameter optimization
+ - type: Core Capabilities
changedInfo:
- - 'The search and chat APIs have the relativity parameter for filtering recalled memories by relevance score. This update sets the default relevance threshold to 0.45.'
+ - Added Skill Memory.
- type: Log optimization
changedInfo:
- 'Intercept some log errors and adjust the log level for certain logs.'
@@ -78,23 +78,23 @@ versions:
date: 2026-02-12
changedInfo:
New Features:
- - type: Retrieval Optimization
+ - type: Documentation
changedInfo:
- - 'Added keyword-based search in the search phase, while relativity continues to use cosine similarity of embeddings.'
+ - 'Open-source project documentation: Fixed formatting issues, improved example descriptions, resolved Chinese-English inconsistencies, and added practical usage tips.'
- 'Added a relativity output field to the Chat API, allowing users to control the retrieval threshold during conversations using relativity.'
Bug Fixes:
- - type: Fixes
+ - type: Open Source
changedInfo:
- - 'Fixed an issue where an incorrect field was used in preference memory threshold filtering.'
- - 'Fixed an issue that caused failures or hanging when calling the get_memory API with the filter parameter containing both field conditions and logical operators such as AND/OR/NOT.'
+ - Optimized text and image memory addition by processing both types of information together as a single message.
+ - Knowledge base search now supports the 'all' field.
- 'Fixed issues related to passing extra body parameters to LLMs, ensuring compatibility with the enable thinking parameter input'
- name: v2.0.5
date: 2026-02-10
changedInfo:
New Features:
- - type: Knowledge Base Retrieval Optimization
+ - type: Released "Knowledge Base Q&A Assistant" Construction Tutorial
changedInfo:
- - 'Dual-track retrieval: Supports coordinated storage and retrieval of "original text + memory", with customizable recall modes.'
+ - Official documentation updated with best practices for "Developing Q&A Assistants Based on Knowledge Bases", guiding you step-by-step to build a "MemOS Knowledge Base Assistant".
- 'Context wake-up: Supports recalling surrounding context before/after chunks to improve semantic coherence in long texts.'
- 'Relevance filtering: The search API adds a relativity field (0~1) to filter low-quality recalls by threshold.'
- type: MindDock
@@ -105,97 +105,98 @@ versions:
changedInfo:
- When deletion intent is detected, synchronously trigger deleteMemory and addFeedback.
Improvements:
- - type: Scheduling & Retrieval Pipeline Refactor
+ - type: Knowledge Base Document Retrieval
changedInfo:
- - 'Pipeline refactor: Rebuilt the retrieval chain into four stages—Search -> Enhance -> Rerank -> Filter—scheduled by a unified API.'
+ - Further improved the retrieval memory interface's ability to recall detailed knowledge base document content, optimizing answer quality for document-based queries.
- 'Scheduling optimization: Modularized the task processor and fixed Redis Streams serialization and user_context passing issues.'
- - type: Skills Improvements
+ - type: Tool Memory Mechanism
changedInfo:
- - 'Skill docs localization: Supports local storage of skill docs and generates dedicated URLs, downloadable directly via API.'
- - "Skill generation optimization: Can now generate richer and more complete skills from users' historical information."
+ - Added procedural experience for call trajectories to enhance the auxiliary effect of tool memory;
+ - Compressed tool schema information to avoid duplicate addition of ToolSchemaMemory.
Bug Fixes:
- - type: Playground Improvements
+ - type: Get Memory Interface
changedInfo:
- - Fixed known UX issues and improved environment stability.
+ - Fixed call error in get memory interface when parameter include_preference=False.
- name: v2.0.4
date: 2026-01-30
changedInfo:
New Features:
- - type: Core Capabilities
+ - type: Get Memory Interface Launch
changedInfo:
- - Added Skill Memory.
+ - Get memory interface launched, supporting full retrieval of user memories.
- Added modules supporting memory versioning.
- Added chat/completions interface, compatible with OAI protocol.
Improvements:
- - type: Retrieval Optimization
+ - type: Documentation
changedInfo:
- - Reduced duplication between factual and preference memories during retrieval.
+ - Optimized delete memory interface to support deletion of all types of memories, including user memories, knowledge base memories, etc.
Bug Fixes:
- - type: Bug Fixes
+ - type: Open Source
changedInfo:
- - Fixed bug for new users in Playground.
+ - Solved compatibility error of kv cache code in new version of memos
- Fixed occasional latency when adding memories in cloud service.
- Resolved graph database efficiency issues.
- name: v2.0.3
date: 2026-01-22
changedInfo:
New Features:
- - type: Documentation
+ - type: Knowledge Base & Memory
changedInfo:
- - 'Open-source project documentation: Fixed formatting issues, improved example descriptions, resolved Chinese-English inconsistencies, and added practical usage tips.'
+ - Added knowledge base support for long-term memory from documents and URLs
- type: Dashboard
changedInfo:
- - 'Knowledge base now supports more file types: JSON, MD, XML.'
- - type: GitHub Repository
+ - Added natural language feedback and correction for memories
+ - type: Conversation & Retrieval
changedInfo:
- - Adjusted the startup approach (introduced a new server router class) and removed the reference to src/memos/product_api.py.
+ - Added chat API with memory-aware retrieval
Improvements:
- - type: Open Source
+ - type: Data & Infrastructure
changedInfo:
- - Optimized text and image memory addition by processing both types of information together as a single message.
+ - Upgraded database for better stability and performance
- Knowledge base search now supports the 'all' field.
Bug Fixes:
- - type: Open Source
+ - type: Data & Infrastructure
changedInfo:
- - Fixed an exception that occurred when the search input text was too long.
+ - Fixed legacy scheduling API to ensure correct memory isolation
- name: v2.0.2
date: 2026-01-15
changedInfo:
New Features:
- - type: Released "Knowledge Base Q&A Assistant" Construction Tutorial
+ - type: Memory Add & Search
changedInfo:
- - Official documentation updated with best practices for "Developing Q&A Assistants Based on Knowledge Bases", guiding you step-by-step to build a "MemOS Knowledge Base Assistant".
+ - Added async mode (plaintext & preference)
Improvements:
- - type: Knowledge Base Document Retrieval
+ - type: Plaintext Memory
changedInfo:
- - Further improved the retrieval memory interface's ability to recall detailed knowledge base document content, optimizing answer quality for document-based queries.
- - type: Tool Memory Mechanism
+ - Standardized preference fields
+ - type: Framework
changedInfo:
- - Added procedural experience for call trajectories to enhance the auxiliary effect of tool memory;
- - Compressed tool schema information to avoid duplicate addition of ToolSchemaMemory.
+ - Updated API routes
+ - More robust error handling & context tracing
- type: Fact Memory Merging and Archiving Mechanism
changedInfo:
- Added merging and archiving mechanisms in fact memory processing to optimize issues of duplicate memory writing and recall, while ensuring extraction completeness.
- - type: Get Memory Interface
+ - type: Scheduler
changedInfo:
- Support returning tool memories;
- Support returning memories under specific filtering conditions for debugging and display scenarios.
Bug Fixes:
- - type: Get Memory Interface
+ - type: Scheduler
changedInfo:
- - Fixed call error in get memory interface when parameter include_preference=False.
+ - Fixed query scheduling edge case
+
- name: v2.0.1
date: 2026-01-08
changedInfo:
New Features:
- - type: Get Memory Interface Launch
+ - type: Cloud Service 1.0 Beta Release
changedInfo:
- - Get memory interface launched, supporting full retrieval of user memories.
- - type: Chat Function Launch on Cloud Service
+ - 🛠️ Ready-to-Use:Deploy directly via cloud API without complex setup
+ - type: Enterprise Group Q&A bot
changedInfo:
- - Chat function launched on cloud service, enabling one-click start of "memory-persistent" conversations.
- - type: Playground Update
+ - Supports Q&A based on cloud service knowledge base
+ - type: Evaluation & Testing
changedInfo:
- Playground memory management page supports memory deletion, allowing users to manually manage and delete expired memories they no longer need.
Improvements:
@@ -215,31 +216,32 @@ versions:
- type: Scheduling Module Issue Fix
changedInfo:
- Fixed error when scheduling enables local mode due to missing redis configuration
+
- name: v2.0.0
date: 2025-12-24
changedInfo:
New Features:
- - type: Knowledge Base & Memory
+ - type: Group Q&A Bot
changedInfo:
- - Added knowledge base support for long-term memory from documents and URLs
- - type: Feedback & Memory Management
+ - Launched group Q&A bot based on MemOS Cube
+ - type: KV-Cache Performance Optimization
changedInfo:
- - Added natural language feedback and correction for memories
- - Added memory deletion API by memory ID
+ - Updated comparative experimental data for KV-Cache on different GPU deployment schemes
+ - Optimized test benchmarks and statistical methods
- Added MCP support for memory deletion and feedback
- - type: Conversation & Retrieval
+ - type: Plaintext Memory Enhancement
changedInfo:
- - Added chat API with memory-aware retrieval
+ - Added Reranker sorting functionality for plaintext memory
- Added memory filtering with custom tags (Cloud & Open Source)
- - type: Multimodal & Tool Memory
+ - type: Playground Updates
changedInfo:
- - Added tool memory for tool usage history
+ - Updated Playground version with all the above new features
- Added image memory support for conversations and documents
Improvements:
- - type: Data & Infrastructure
+ - type: Plaintext Memory Optimization
changedInfo:
- - Upgraded database for better stability and performance
- - type: Scheduler
+ - Optimized plaintext memory hallucination issues
+ - type: MemCube Construction
changedInfo:
- Rebuilt scheduler with Redis Streams and queue isolation
- Added task priority, auto-recovery, and quota-based scheduling
@@ -256,19 +258,19 @@ versions:
date: 2025-11-05
changedInfo:
New Features:
- - type: Memory Add & Search
+ - type: Playground
changedInfo:
- - Added async mode (plaintext & preference)
+ - Expanded Playground features and algorithm performance.
- Preference Memory now supported
- Reranker strategy suite
- BM25 for TreeTextMemory
- - type: Scheduler
+ - type: MemCube Construction
changedInfo:
- - Modularized API scheduler
+ - Added a text game demo based on the MemCube novel.
- Redis ORM optimized for history sync & hybrid search
- - type: Data & Infra
+ - type: Plaintext Memory Optimization
changedInfo:
- - PolarDB graph backend connection pool/timeout & fixes
+ - Added LongMemEval evaluation results and scripts.
- Unified graph factory (Neo4j/PolarDB/Nebula)
- Milvus interface & item optimizations
- Enhanced logging pipeline
@@ -280,19 +282,19 @@ versions:
Improvements:
- type: Plaintext Memory
changedInfo:
- - Standardized preference fields
+ - Integrated internet search with Bocha.
- type: Framework
changedInfo:
- Updated API routes
- More robust error handling & context tracing
Bug Fixes:
- - type: Scheduler
+ - type: MemCube Construction
changedInfo:
- - Fixed query scheduling edge case
+ - Fixed the concat_cache method.
- Corrected message schema inconsistencies
- type: Plaintext Memory
changedInfo:
- - Graph/DB:corrected PolarDB issues
+ - Fixed Nebula search-related issues.
- type: Framework
changedInfo:
- Fixed SQLite user listing bug
@@ -300,32 +302,32 @@ versions:
date: 2025-09-25
changedInfo:
New Features:
- - type: Cloud Service 1.0 Beta Release
+ - type: Explicit Memory
changedInfo:
- - 🛠️ Ready-to-Use:Deploy directly via cloud API without complex setup
- - 🔄 Cross-Session Memory:Automatically retrieve user profiles, preferences, and behaviors for continuous personalization
- - 📖 Developer-Friendly:Comprehensive documentation and example code support
+ - Implemented internet search integration with Nebula database support
+ - Enhanced contextual understanding for memory extraction
+ - Deployed internet search interface integrated with memreader for web message processing
- 🎁 Free Quota:Every developer can receive basic quota to explore
- - type: Enterprise Group Q&A bot
+ - type: KV Cache
changedInfo:
- - Supports Q&A based on cloud service knowledge base
+ - Conducted KV Cache evaluation including LMCache comprehensive research and performance testing
- name: v1.0.1
date: 2025-09-10
changedInfo:
New Features:
- - type: Group Q&A Bot
+ - type: KV Cache
changedInfo:
- - Launched group Q&A bot based on MemOS Cube
- - type: KV-Cache Performance Optimization
+ - Added plaintext memory + KV Cache functionality with reasoning and decoding performance report
+ - type: MemOS System
changedInfo:
- - Updated comparative experimental data for KV-Cache on different GPU deployment schemes
- - Optimized test benchmarks and statistical methods
- - type: Plaintext Memory Enhancement
+ - Released MemOS Neo lightweight version with simplified architecture and core API modules
+ - Added MCP support research and preparation work, expanding MCP capabilities
+ - type: Deployment & Environment
changedInfo:
- - Added Reranker sorting functionality for plaintext memory
- - type: Playground Updates
+ - Added Docker deployment support
+ - type: Evaluation & Testing
changedInfo:
- - Updated Playground version with all the above new features
+ - Adapted and evaluated memos API interface format, updated evaluation code
Improvements:
- type: Plaintext Memory Optimization
changedInfo:
@@ -336,13 +338,13 @@ versions:
New Features:
- type: Playground
changedInfo:
- - Expanded Playground features and algorithm performance.
- - type: MemCube Construction
+ - Added documentation search functionality
+ - type: Memory Operator Model
changedInfo:
- - Added a text game demo based on the MemCube novel.
- - type: Extended Evaluation Set
+ - Introduced MemReader-4B small model for memory extraction operations
+ - type: Cross-Platform Framework Adaptation
changedInfo:
- - Added LongMemEval evaluation results and scripts.
+ - Added Windows platform deployment support
Improvements:
- type: Plaintext Memory
changedInfo:
@@ -360,7 +362,7 @@ versions:
date: 2025-07-29
changedInfo:
New Features:
- - type: Explicit Memory
+ - type: Plaintext Memory
changedInfo:
- Implemented internet search integration with Nebula database support
- Enhanced contextual understanding for memory extraction
@@ -376,14 +378,14 @@ versions:
- Completed model training evaluation and release
- Released 4b, 1.7b and 0.6b models supporting memory extraction and integration
Improvements:
- - type: Documentation
+ - type: Knowledge Base & Memory
changedInfo:
- Added English version for first three chapters of Cookbook
- type: Memory Scheduling
changedInfo:
- Performed code refactoring and functionality improvements
- Refactored functional modules including monitor, dispatcher and retriever
- - Added major code categories: schemas and utils
+ - Added schemas and utils major category code
- Enhanced network logging functionality
- Improved scheduler robustness with new exception-catching decorators
- Implemented locking for shared resources
@@ -407,7 +409,7 @@ versions:
- type: System Integration
changedInfo:
- Aligned MOSProduct._build_system_prompt signature with MOSCore
- - type: Explicit Memory
+ - type: Plaintext Memory
changedInfo:
- Fixed general text memory processing logic
- Fixed memreader component issues
diff --git a/content/en/memos_cloud/quick_start.md b/content/en/memos_cloud/quick_start.md
index bd6cf150..b04aed77 100644
--- a/content/en/memos_cloud/quick_start.md
+++ b/content/en/memos_cloud/quick_start.md
@@ -1,9 +1,9 @@
---
title: Quick Start
-desc: Welcome to MemOS Cloud Platform. Refer to this guide to quickly integrate memory capabilities.
+desc: Welcome to the MemOS cloud platform. Follow this beginner's guide to integrate memory capabilities within minutes.
---
-When building applications with large models, a common question is: **How to make AI remember user's long-term preferences?**
+When building applications with large language models, a common question is: **How can AI stably remember users' long-term preferences?**
MemOS provides two core interfaces to help you achieve this:
- `addMessage` —— Hand over the original conversation to us, and we will automatically process and store memories [(Click for detailed API documentation)](/api_docs/core/add_message)
@@ -16,9 +16,11 @@ MemOS provides two core interfaces to help you achieve this:
* Register and log in to MemOS Cloud Platform [(Click to Register)](https://memos-dashboard.openmem.net/quickstart);
-* Get API Key [(Click to Get)](https://memos-dashboard.openmem.net/apikeys);
+* Prepare an environment capable of sending HTTP requests (Python or cURL are both acceptable);
-* Prepare an environment capable of sending HTTP requests, such as Python or cURL.
+* Obtain an API Key [(Click to get)](https://memos-dashboard.openmem.net/apikeys) and configure it in your environment variables;
+
+* Prepare a `conversation_id` for testing (suggest naming it by date, e.g., `20260413-demo`).
## 2. Code Configuration
@@ -46,7 +48,7 @@ You only need to provide the `original conversation records` to MemOS, and MemOS
::note
**Session B: Occurred on 2025-09-28**
-In a new session, the user asks the AI to recommend travel destinations and hotels for the National Day holiday. MemOS automatically recalls [Factual Memory: Where they have been] and [Preference Memory: Hotel booking preferences] for AI reference, thereby recommending a more personalized travel plan.
+When the user proposes 'Recommend National Day travel destinations and hotels' in a new session, MemOS automatically recalls 【Fact Memory: Where they have been】 and 【Preference Memory: Hotel booking preferences】 for the AI to reference, thereby generating a more personalized travel plan.
::
::code-snippet{name=search_memory}
@@ -63,8 +65,13 @@ In a new session, the user asks the AI to recommend travel destinations and hote
{
"preference_type": "implicit_preference", # Implicit Preference
"preference": "User may prefer hotels with higher cost-performance ratio.",
- "reasoning": "7 Days Inn is usually known for being economical. The user's choice of 7 Days Inn may indicate a preference for cost-effective options in accommodation. Although the user did not explicitly mention budget constraints or specific hotel preferences, choosing 7 Days among the provided options may reflect an emphasis on price and practicality.",
+ "confidence": 0.82,
"conversation_id": "0610"
+ },
+ {
+ "preference_type": "explicit_preference", # Explicit preference
+ "preference": "The user expects hotel ratings to be no lower than 4.5 points.",
+ "conversation_id": "0928"
}
],
@@ -74,6 +81,7 @@ In a new session, the user asks the AI to recommend travel destinations and hote
"memory_key": "Summer Guangzhou Travel Plan",
"memory_value": "User plans to travel to Guangzhou during the summer vacation and chose 7 Days Inn as accommodation.",
"conversation_id": "0610",
+ "memory_time": "2025-06-10 20:15:00",
"tags": [
"Travel",
"Guangzhou",
@@ -150,15 +158,18 @@ I want to travel during the National Day holiday. Please recommend a city I have
```
+
## 3. Next Steps
-Now that you can run MemOS, you can explore more cloud platform features:
+Now you can run MemOS. It is recommended to continue exploring more features of the cloud platform:
* [**Core Memory Operations**](/memos_cloud/mem_operations/add_message): Learn fully how to add, retrieve, and delete memories;
* [**Feature Introduction**](/memos_cloud/features/basic/filters): Explore more cloud platform features, such as memory filtering, multi-modal messages, knowledge bases, etc.;
-* [**API Documentation**](/api_docs/start/overview): View complete API documentation and call examples.
+* [**API Documentation**](/api_docs/start/overview): View the complete API documentation and calling examples;
+
+* [**SDK Integration Guide**](/api_docs/start/quickstart): View initialization, authentication, and error handling methods by language.
## 4. More Resources
@@ -197,12 +208,11 @@ MemOS provides rich project examples. Depending on your specific project, you ca
- Through MemOS, user writing preferences, commonly used information, and context instructions can be remembered.
- No need to emphasize repeatedly when writing summaries or emails next time, achieving a coherent and personalized creation experience.
-- [MindDock Browser Extension](https://alidocs.dingtalk.com/i/p/e3ZxX84Z5KM6X7dRZxX8v66wA7xaBG7d?dontjump=true)
- - MemOS-MindDock creates a unified cross-platform AI memory layer for users.
- - It automatically records, organizes, and injects personal information and preferences, allowing all AIs to continuously and stably "know you".
-
- [Coze × MemOS Plugin Tool](/usecase/frameworks/coze_plugin)
- Use the MemOS plugin tool listed on the Coze platform to directly access cloud service interfaces in the workflow, quickly adding long-term memory capabilities to your Agent.
- [Claude MCP](/usecase/frameworks/claude_mcp)
- MemOS provides a way to interact with the cloud platform through MCP, directly accessing cloud service interfaces in the Claude client.
+
+- [LangChain × MemOS Integration](/usecase/frameworks/langchain)
+ - Integrate `searchMemory` as a retrieval tool within LangChain workflows, supporting multi-turn dialogue context enhancement.
diff --git a/content/en/settings.yml b/content/en/settings.yml
index 270d9332..bbac265d 100644
--- a/content/en/settings.yml
+++ b/content/en/settings.yml
@@ -45,7 +45,7 @@ nav:
- "(ri:file-code-line) REST API Server": open_source/getting_started/rest_api_server.md
- "(ri:code-line) MemOS Examples": open_source/getting_started/examples.md
- - "(ri:cpu-line) MOS":
+ - "(ri:cpu-line) MemOS":
- "(ri:eye-line) API Development Guide": open_source/modules/mos/overview.md
- "(ri:checkbox-multiple-blank-line) MemCube": open_source/modules/mem_cube.md
- "(ri:book-open-line) MemReader": open_source/modules/mem_reader.md
@@ -82,8 +82,15 @@ nav:
- "(ri:file-code-line) API Reference": api-reference/search-memories
+ - "(ri:puzzle-line) MCP and Agent Framework Support":
+ - "(ri:tools-line) MCP Services":
+ - "(ri:book-open-line) Guide": mcp_agent/mcp/guide.md
+
+ - "(ri:robot-line) Agent Development":
+ - "(ri:book-open-line) Guide": mcp_agent/agent/guide.md
+
- "(ri:robot-line) Openclaw":
- - "(ri:book-open-line) Cloud Plugin vs Local Plugin": openclaw/plugin_compare.md
+ - "(ri:book-open-line) Cloud Plugins vs Local Plugins": openclaw/plugin_compare.md
- "(ri:file-list-3-line) Changelog": openclaw/changes.md
- "(ri:download-line) Installation Guide":
- "(ri:cloud-line) OpenClaw Cloud Plugin": openclaw/guide.md
@@ -92,13 +99,6 @@ nav:
- "(ri:team-line) Multi-Agent Memory Isolation": openclaw/examples/multi_agent.md
- "(ri:filter-3-line) Secondary Filtering for Memory Recall": openclaw/examples/recall_filter.md
- - "(ri:puzzle-line) MCP and Agent Framework Support":
- - "(ri:tools-line) MCP Services":
- - "(ri:book-open-line) Guide": mcp_agent/mcp/guide.md
-
- - "(ri:robot-line) Agent Development":
- - "(ri:book-open-line) Guide": mcp_agent/agent/guide.md
-
- "(ri:file-code-line) API Documentation":
- "(ri:rocket-line) Get Started":
- "(ri:eye-line) Overview": api_docs/start/overview.md
diff --git a/scripts/auto-translate.mjs b/scripts/auto-translate.mjs
deleted file mode 100644
index b62e3752..00000000
--- a/scripts/auto-translate.mjs
+++ /dev/null
@@ -1,495 +0,0 @@
-import fs from 'fs'
-import path from 'path'
-import { execSync } from 'child_process'
-import fetch from 'node-fetch'
-import yaml from 'js-yaml'
-import { unified } from 'unified'
-import remarkParse from 'remark-parse'
-import remarkStringify from 'remark-stringify'
-import remarkFrontmatter from 'remark-frontmatter'
-import crypto from 'crypto'
-
-// --- Configuration ---
-// Read target languages from external config
-const languagesConfigPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'languages.json')
-let TARGET_LANGS = ['en']
-try {
- if (fs.existsSync(languagesConfigPath)) {
- TARGET_LANGS = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'))
- }
-} catch (e) {
- console.warn('⚠️ Failed to read languages.json, defaulting to ["en"]', e)
-}
-
-const OPENAI_API_KEY = process.env.OPENAI_API_KEY
-const OPENAI_API_BASE = process.env.OPENAI_API_BASE || 'https://api.deepseek.com'
-const MODEL = process.env.OPENAI_MODEL || 'deepseek-chat'
-const SOURCE_DIR = 'content/cn'
-
-// --- Helpers ---
-
-/**
- * Execute a shell command and return stdout
- */
-function exec(command) {
- try {
- return execSync(command, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
- } catch {
- return ''
- }
-}
-
-/**
- * Get list of changed files in content/cn
- * If scripts/auto-translate.mjs changed, return ALL content files
- */
-function getChangedFiles() {
- try {
- const output = exec('git diff --name-only HEAD^ HEAD')
- const changedFiles = output.split('\n').filter(Boolean)
-
- // If script itself changed, likely a config change (e.g. new language added).
- // Process ALL content files to ensure new language is generated.
- if (changedFiles.includes('scripts/languages.json')) {
- console.log('⚡️ Languages config changed, scanning all content files...')
- const allFiles = exec('git ls-files content/cn').split('\n')
- return allFiles.filter(line =>
- line.startsWith(SOURCE_DIR)
- && (line.endsWith('.md') || line.endsWith('settings.yml'))
- )
- }
-
- // Otherwise only process changed content files
- return changedFiles.filter(line =>
- line.startsWith(SOURCE_DIR)
- && (line.endsWith('.md') || line.endsWith('settings.yml'))
- )
- } catch (e) {
- console.error('Failed to get diff:', e)
- return []
- }
-}
-
-/**
- * Get file content from a specific git revision
- */
-function getGitContent(revision, filePath) {
- try {
- return exec(`git show ${revision}:${filePath}`)
- } catch {
- return null
- }
-}
-
-/**
- * Generate MD5 hash of a string
- */
-function getHash(str) {
- return crypto.createHash('md5').update(str).digest('hex')
-}
-
-// --- Remark AST Logic ---
-
-const processor = unified()
- .use(remarkParse)
- // Use '*' for bullets to match user preference and reduce diff noise
- .use(remarkStringify, { bullet: '*', fenc: '`' })
- .use(remarkFrontmatter, ['yaml'])
-
-/**
- * Split AST tree into minimal independent blocks (Paragraphs, Headings, Lists, etc.)
- * Flatten the tree structure into a linear list of "translate units".
- */
-function splitIntoBlocks(tree, rawContent) {
- const blocks = []
-
- // Helper to process nodes recursively or flatly
- function processNode(node, isLastInList = false, listSpread = false) {
- if (node.type === 'list') {
- // For Lists, iterate children (ListItems) and flatten them
- for (let i = 0; i < node.children.length; i++) {
- const item = node.children[i]
- const isLastItem = i === node.children.length - 1
- processNode(item, isLastItem, node.spread)
- }
- return
- }
-
- // Determine separator
- // If it's a ListItem:
- // - If it's the last item in the list, use '\n\n' (to separate from next block)
- // - If list is loose (spread=true), use '\n\n'
- // - Otherwise (tight list), use '\n'
- let separator = '\n\n'
- if (node.type === 'listItem') {
- if (isLastInList) {
- separator = '\n\n'
- } else {
- separator = listSpread ? '\n\n' : '\n'
- }
- }
-
- // Generate normalized text for hashing
- const tempRoot = { type: 'root', children: [node] }
- const normalizedText = processor.stringify(tempRoot).trim()
-
- // Get Raw Text for Output
- let rawText = normalizedText // Fallback
- if (rawContent && node.position) {
- rawText = rawContent.slice(node.position.start.offset, node.position.end.offset)
- }
-
- blocks.push({
- type: node.type,
- text: rawText,
- normalized: normalizedText,
- hash: getHash(normalizedText),
- separator: separator
- })
- }
-
- for (const node of tree.children) {
- processNode(node)
- }
-
- return blocks
-}
-
-// --- YAML Logic (Settings.yml) ---
-
-function getYamlHeaderComments(source) {
- if (!source) return ''
- const lines = source.split('\n')
- const comments = []
- for (const line of lines) {
- if (line.trim().startsWith('#')) {
- comments.push(line)
- } else if (line.trim() === '') {
- continue
- } else {
- break
- }
- }
- return comments.join('\n') + (comments.length > 0 ? '\n' : '')
-}
-
-/**
- * Build a map of Hash(CNKey) -> [ENKey] from existing translations.
- * Traverses OldCN and OldEN in parallel.
- */
-function buildYamlMap(cnNode, enNode, map) {
- if (!cnNode || !enNode) return
-
- if (Array.isArray(cnNode) && Array.isArray(enNode)) {
- const len = Math.min(cnNode.length, enNode.length)
- for (let i = 0; i < len; i++) {
- buildYamlMap(cnNode[i], enNode[i], map)
- }
- } else if (typeof cnNode === 'object' && cnNode !== null && typeof enNode === 'object' && enNode !== null) {
- const cnKeys = Object.keys(cnNode)
- const enKeys = Object.keys(enNode)
-
- // Assume structural alignment
- const len = Math.min(cnKeys.length, enKeys.length)
- for (let i = 0; i < len; i++) {
- const cnKey = cnKeys[i]
- const enKey = enKeys[i]
- const hash = getHash(cnKey)
-
- if (!map.has(hash)) {
- map.set(hash, [])
- }
- map.get(hash).push(enKey)
-
- buildYamlMap(cnNode[cnKey], enNode[enKey], map)
- }
- }
-}
-
-/**
- * Traverse NewCN and reconstruct NewEN using the map.
- * Collects missing keys for translation.
- */
-function processYamlNode(node, map, collector) {
- if (Array.isArray(node)) {
- return node.map(item => processYamlNode(item, map, collector))
- } else if (typeof node === 'object' && node !== null) {
- const result = {}
- for (const key of Object.keys(node)) {
- const value = node[key]
- const hash = getHash(key)
- let finalKey = key
-
- if (map.has(hash) && map.get(hash).length > 0) {
- // Reuse existing translation (FIFO)
- finalKey = map.get(hash).shift()
- } else {
- // New key, needs translation.
- collector.push({ targetObj: result, originalKey: key })
- }
-
- result[finalKey] = processYamlNode(value, map, collector)
- }
- return result
- } else {
- return node
- }
-}
-
-// --- Translation Service ---
-
-async function translateBatch(segments, targetLang) {
- if (segments.length === 0) return []
-
- console.log(` ⏳ Translating ${segments.length} segments to ${targetLang}...`)
-
- const systemPrompt = `You are a professional technical documentation translator.
- Translate the following segments from Chinese to ${targetLang}.
-
- Rules:
- 1. PRESERVE all Markdown formatting (links, code blocks, bold, etc.).
- 2. PRESERVE Frontmatter exactly if present.
- 3. PRESERVE icon prefixes like "(ri:xxx) " exactly. Only translate the text after it.
- 4. Return the result as a JSON array of strings, strictly matching the order of input.
- 5. The output must be valid JSON. Raw JSON string only.`
-
- const userContent = JSON.stringify(segments)
-
- try {
- const response = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${OPENAI_API_KEY}`
- },
- body: JSON.stringify({
- model: MODEL,
- messages: [
- { role: 'system', content: systemPrompt },
- { role: 'user', content: userContent }
- ],
- temperature: 0.1
- })
- })
-
- if (!response.ok) {
- throw new Error(`API Error: ${response.status} ${response.statusText}`)
- }
-
- const data = await response.json()
- let content = data.choices[0].message.content.trim()
- if (content.startsWith('```json')) content = content.replace(/^```json\s*/, '').replace(/\s*```$/, '')
- if (content.startsWith('```')) content = content.replace(/^```\s*/, '').replace(/\s*```$/, '')
-
- const parsed = JSON.parse(content)
- if (!Array.isArray(parsed) || parsed.length !== segments.length) {
- throw new Error('API returned mismatched array length')
- }
- return parsed
- } catch (error) {
- console.error(' ❌ Translation failed:', error.message)
- throw error
- }
-}
-
-// --- Main Processors ---
-
-async function processMarkdownFile(filePath) {
- console.log(`\n📄 Processing Markdown: ${filePath}`)
- const newCNRaw = fs.readFileSync(filePath, 'utf-8')
- const oldCNRaw = getGitContent('HEAD^', filePath)
-
- // Parse AST
- const newTree = processor.parse(newCNRaw)
- const oldTree = oldCNRaw ? processor.parse(oldCNRaw) : { children: [] }
-
- const newBlocks = splitIntoBlocks(newTree, newCNRaw)
- const oldBlocks = splitIntoBlocks(oldTree, oldCNRaw)
-
- // --- Build Block Map (OldCN -> OldEN) ---
- // We need to map OldCN blocks to OldEN blocks to enable reuse.
- // Strategy: Assume sequential correspondence for identical blocks?
- // Or better: Build a Map.
- // But Hash collision? (e.g. two "Note:" paragraphs).
- // We can use a queue/list for each hash to handle duplicates sequentially.
-
- const translationMap = new Map() // Hash -> [EnText1, EnText2, ...]
-
- for (const lang of TARGET_LANGS) {
- const targetPath = filePath.replace(SOURCE_DIR, `content/${lang}`)
-
- // Clear map for each language
- translationMap.clear()
-
- if (fs.existsSync(targetPath)) {
- const oldENRaw = fs.readFileSync(targetPath, 'utf-8')
- const oldENTree = processor.parse(oldENRaw)
- const oldENBlocks = splitIntoBlocks(oldENTree, oldENRaw)
-
- // Populate Map
- // Assumption: OldCN and OldEN structures are roughly aligned.
- // We try to match OldCN[i] with OldEN[i].
- // If OldCN and OldEN have different block counts (e.g. manual edit), alignment might be off.
- // But this is "Auto Translate", so usually they are synced.
- // Even if not perfectly synced, mapping by content hash is safer than index.
- // But we need to know WHICH OldCN block maps to WHICH OldEN block.
- // If we assume the translator generated them 1-to-1:
-
- const minLen = Math.min(oldBlocks.length, oldENBlocks.length)
- for (let i = 0; i < minLen; i++) {
- const cnHash = oldBlocks[i].hash
- const enText = oldENBlocks[i].text
-
- if (!translationMap.has(cnHash)) {
- translationMap.set(cnHash, [])
- }
- translationMap.get(cnHash).push(enText)
- }
- }
-
- // --- Reconstruct New File ---
- const segmentsToTranslate = []
- const segmentIndices = []
- const finalBlocks = new Array(newBlocks.length).fill(null)
-
- // Helper to consume from map
- // We clone the map or track indices to avoid reusing same translation for different identical source blocks incorrectly?
- // Actually, simple FIFO is good for identical blocks.
- const tempMap = new Map(JSON.parse(JSON.stringify([...translationMap])))
-
- for (let i = 0; i < newBlocks.length; i++) {
- const block = newBlocks[i]
- const hash = block.hash
-
- if (tempMap.has(hash) && tempMap.get(hash).length > 0) {
- // Reuse existing translation
- // Shift ensures we use the first available translation for this hash, preserving order
- const enText = tempMap.get(hash).shift()
- finalBlocks[i] = enText
- } else {
- // New or Changed Block
- segmentsToTranslate.push(block.text)
- segmentIndices.push(i)
- }
- }
-
- if (segmentsToTranslate.length > 0) {
- console.log(` Need to translate ${segmentsToTranslate.length} blocks for [${lang}]`)
- const translated = await translateBatch(segmentsToTranslate, lang)
- translated.forEach((trans, idx) => {
- finalBlocks[segmentIndices[idx]] = trans
- })
- } else {
- console.log(` ✨ No changes for [${lang}]`)
- }
-
- const targetDir = path.dirname(targetPath)
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true })
-
- // Join blocks with dynamic separators
- let finalContent = ''
- for (let i = 0; i < finalBlocks.length; i++) {
- const block = newBlocks[i]
- const content = finalBlocks[i]
- // Use block.separator if available, default to '\n\n'
- const separator = block.separator || '\n\n'
-
- finalContent += content
- // Don't add separator after the very last block?
- // Usually Markdown files end with a newline, so adding it is fine/good.
- if (i < finalBlocks.length - 1) {
- finalContent += separator
- } else {
- finalContent += '\n' // Ensure EOF newline
- }
- }
-
- fs.writeFileSync(targetPath, finalContent, 'utf-8')
- console.log(` ✅ Updated: ${targetPath}`)
- }
-}
-
-async function processYamlFile(filePath) {
- console.log(`\n⚙️ Processing YAML: ${filePath}`)
- const newCNRaw = fs.readFileSync(filePath, 'utf-8')
- const oldCNRaw = getGitContent('HEAD^', filePath)
-
- const newCN = yaml.load(newCNRaw)
- const oldCN = oldCNRaw ? yaml.load(oldCNRaw) : null
- const headerComments = getYamlHeaderComments(newCNRaw)
-
- for (const lang of TARGET_LANGS) {
- const targetPath = filePath.replace(SOURCE_DIR, `content/${lang}`)
-
- // 1. Build Map from OldCN + OldEN
- const translationMap = new Map()
- if (oldCN && fs.existsSync(targetPath)) {
- const oldEN = yaml.load(fs.readFileSync(targetPath, 'utf-8'))
- buildYamlMap(oldCN, oldEN, translationMap)
- }
-
- // 2. Process NewCN with Map
- const collector = []
- const finalObj = processYamlNode(newCN, translationMap, collector)
-
- if (collector.length > 0) {
- console.log(` Need to translate ${collector.length} keys for [${lang}]`)
- const textsToTranslate = collector.map(item => item.originalKey)
- const translatedTexts = await translateBatch(textsToTranslate, lang)
-
- collector.forEach((item, idx) => {
- const transKey = translatedTexts[idx]
- const val = item.targetObj[item.originalKey]
- delete item.targetObj[item.originalKey]
- item.targetObj[transKey] = val
- })
- } else {
- console.log(` ✨ No changes for [${lang}]`)
- }
-
- const targetDir = path.dirname(targetPath)
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true })
-
- const yamlStr = yaml.dump(finalObj, { lineWidth: -1, noRefs: true })
- fs.writeFileSync(targetPath, headerComments + yamlStr, 'utf-8')
- console.log(` ✅ Updated: ${targetPath}`)
- }
-}
-
-async function main() {
- if (!OPENAI_API_KEY) {
- console.error('❌ Error: OPENAI_API_KEY is not set.')
- process.exit(1)
- }
-
- const args = process.argv.slice(2)
- const targetArg = args.find(arg => arg.startsWith('--target='))
- if (targetArg) {
- const targets = targetArg.split('=')[1].split(',')
- TARGET_LANGS.length = 0
- TARGET_LANGS.push(...targets)
- }
-
- const files = getChangedFiles()
-
- if (files.length === 0) {
- console.log('No relevant files changed in content/cn.')
- return
- }
-
- console.log(`Found ${files.length} changed files.`)
-
- for (const file of files) {
- try {
- if (file.endsWith('.yml') || file.endsWith('.yaml')) {
- await processYamlFile(file)
- } else {
- await processMarkdownFile(file)
- }
- } catch (e) {
- console.error(`Failed to process ${file}:`, e)
- }
- }
-}
-
-main()
diff --git a/scripts/auto-translate/README.md b/scripts/auto-translate/README.md
new file mode 100644
index 00000000..5c771901
--- /dev/null
+++ b/scripts/auto-translate/README.md
@@ -0,0 +1,142 @@
+# auto-translate
+
+自动将 `content/cn/` 下的中文文档翻译为目标语言,支持 Markdown 与 YAML 文件,基于 Git diff 实现增量翻译。
+
+## 目录结构
+
+```
+scripts/auto-translate/
+├── index.mjs # 入口:解析参数、调度文件处理、输出汇总
+├── config.mjs # CLI 参数 & 环境变量解析与校验
+├── git.mjs # Git 操作:diff 基线解析、变更文件检测、行号提取
+├── pipeline.mjs # 文件级调度:按类型路由到 Markdown / YAML 流水线
+├── markdown.mjs # Markdown 翻译流水线(块级 + 行级增量)
+├── yaml.mjs # YAML 翻译流水线(结构感知字符串替换)
+├── translator.mjs # LLM 调用封装(批量翻译、分块、重试)
+├── io.mjs # 路径映射、原子写文件
+├── hash.mjs # MD5 工具(用于块级缓存)
+└── languages.json # 目标语言列表
+```
+
+## 快速开始
+
+### 安装依赖
+
+```bash
+pnpm install
+```
+
+### 配置环境变量
+
+| 变量 | 说明 | 默认值 |
+|------|------|--------|
+| `OPENAI_API_KEY` | API Key(必填) | — |
+| `OPENAI_API_BASE` | API Base URL | `https://api.deepseek.com` |
+| `OPENAI_MODEL` | 模型名称 | `deepseek-chat` |
+| `TRANSLATE_RETRY_ATTEMPTS` | 失败重试次数 | `3` |
+| `TRANSLATE_RETRY_BASE_DELAY_MS` | 重试基础延迟(ms,指数退避) | `1000` |
+
+### 运行
+
+```bash
+# 仅翻译相对上次提交变更的文件(默认)
+npm run translate:docs
+
+# 全量翻译所有文件
+node scripts/auto-translate/index.mjs run --full=true
+
+# 只处理 changed-only 模式(自动检测 Git diff)
+node scripts/auto-translate/index.mjs run --changed-only=true
+
+# 指定源目录和目标语言
+node scripts/auto-translate/index.mjs run --source=content/cn --target=en,ja
+```
+
+### CLI 参数
+
+| 参数 | 说明 | 默认值 |
+|------|------|--------|
+| `--source=` | 中文源文件目录 | `content/cn` |
+| `--target=` | 目标语言,逗号分隔 | 读取 `languages.json` |
+| `--full=true` | 全量翻译,忽略 Git diff | `false` |
+| `--changed-only=true` | 仅翻译变更文件 | `true` |
+
+## 目标语言配置
+
+编辑 `scripts/auto-translate/languages.json`:
+
+```json
+["en", "ja"]
+```
+
+当 `languages.json` 本身发生变更时,脚本会自动触发全量翻译。
+
+## 翻译策略
+
+### Markdown 文件
+
+脚本将 Markdown 解析为块(paragraph、heading、list 等),并采用三种模式:
+
+1. **新文件(无现有译文)**:整块批量发送给 LLM,一次完成翻译。
+2. **增量更新(有现有译文)**:
+ - 块哈希命中缓存 → 直接复用旧译文,不调用 LLM。
+ - 行未变更 → 从行级映射复用,不调用 LLM。
+ - 行仅非汉字部分变更(如版本号、数字)→ 结构性 patch,不调用 LLM。
+ - 剩余真正需要翻译的行 → 去重后**单次批量** LLM 调用。
+3. **并发控制**:文件间最多 5 个并发;块内按 80 行分块并发。
+
+### YAML 文件
+
+1. 递归收集所有含汉字的 key 和 value。
+2. 优先从 `oldCN ↔ existingEN` 映射复用已有翻译。
+3. 其余字符串批量调用 LLM(每批 50 条)。
+4. 在原始 YAML 字符串上做正则替换,保留结构与注释。
+5. 与现有译文行级合并,保留引号格式等细节。
+
+### 不翻译的术语
+
+以下术语在所有提示词中均被保护,不会被翻译:
+
+`MemOS`、`MemCube`、`MOS`、`KV Cache`、`LoRA`、`LLM`、`API`、`SDK`、`NLI`
+
+## 输出路径
+
+源文件路径按语言映射到对应目录:
+
+```
+content/cn/foo/bar.md → content/en/foo/bar.md
+content/cn/settings.yml → content/en/settings.yml
+```
+
+文件写入使用原子操作(先写临时文件,再重命名),避免写入中途的脏文件。
+
+若源文件被删除,对应的译文文件也会同步删除;目录为空时一并清理。
+
+## GitHub Actions 集成
+
+工作流文件:[.github/workflows/auto-translate.yml](../../.github/workflows/auto-translate.yml)
+
+**触发条件:**
+- `content/cn/**/*.md` 或 `content/cn/settings.yml` 有推送
+- `scripts/auto-translate/languages.json` 有推送
+- 手动触发(支持 `full` 开关)
+
+**执行流程:**
+1. 运行翻译脚本
+2. 若有文件变更,创建 `auto-translate/YYYYMMDD-HHMMSS` 分支并提交
+3. 自动开 PR,打 `translation` 标签,等待人工 review
+
+**所需 Secrets:**
+
+| Secret | 说明 |
+|--------|------|
+| `OPENAI_API_KEY` | LLM API Key |
+| `OPENAI_API_BASE` | API Base URL(可选) |
+| `OPENAI_MODEL` | 模型名称(可选) |
+
+## 退出码
+
+| 退出码 | 含义 |
+|--------|------|
+| `0` | 全部成功 |
+| `1` | 至少一个文件处理失败(CI 会标记为失败) |
\ No newline at end of file
diff --git a/scripts/auto-translate/config.mjs b/scripts/auto-translate/config.mjs
new file mode 100644
index 00000000..94232dd1
--- /dev/null
+++ b/scripts/auto-translate/config.mjs
@@ -0,0 +1,107 @@
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+// 解析当前模块的绝对目录,后续用于读取同目录配置文件
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// 将 CLI/环境中的布尔值字符串统一转为 boolean,无法识别时回退默认值
+function toBoolean(value, defaultValue) {
+ if (value === undefined) return defaultValue
+ if (typeof value === 'boolean') return value
+ const normalized = String(value).toLowerCase()
+ if (['true', '1', 'yes', 'y'].includes(normalized)) return true
+ if (['false', '0', 'no', 'n'].includes(normalized)) return false
+ return defaultValue
+}
+
+// 解析形如 --key=value / --flag 的参数为 Map,便于后续按键读取
+function parseArgMap(argv) {
+ const argMap = new Map()
+ for (const raw of argv) {
+ if (!raw.startsWith('--')) continue
+ const body = raw.slice(2)
+ const eqIndex = body.indexOf('=')
+ if (eqIndex === -1) {
+ argMap.set(body, true)
+ continue
+ }
+ const key = body.slice(0, eqIndex)
+ const value = body.slice(eqIndex + 1)
+ argMap.set(key, value)
+ }
+ return argMap
+}
+
+// 加载目标语言列表:
+// 1) 优先读取 scripts/auto-translate/languages.json(新位置)
+// 2) 回退读取 scripts/languages.json(旧位置)
+// 3) 都不可用时默认 ['en']
+function loadLanguagesFile() {
+ const localPath = path.join(__dirname, 'languages.json')
+ const legacyPath = path.join(__dirname, '..', 'languages.json')
+ const candidates = [localPath, legacyPath]
+ for (const filePath of candidates) {
+ if (!fs.existsSync(filePath)) continue
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ return parsed.map(item => String(item).trim()).filter(Boolean)
+ }
+ }
+ return ['en']
+}
+
+// 统一 sourceDir,去掉尾部斜杠并提供默认目录
+function normalizeSourceDir(rawValue) {
+ const value = (rawValue || 'content/cn').replace(/\/+$/, '')
+ return value || 'content/cn'
+}
+
+// 读取并组装运行配置:
+// - 命令(当前仅支持 run)
+// - 路径/语言
+// - 增量或全量策略
+// - 翻译模型与重试参数
+export function loadConfigFromCli(argv = process.argv.slice(2)) {
+ const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'run'
+ const args = command === 'run' ? argv.slice(1) : argv
+ const argMap = parseArgMap(args)
+
+ const sourceDir = normalizeSourceDir(argMap.get('source'))
+ const full = toBoolean(argMap.get('full'), false)
+ const changedOnly = full ? false : toBoolean(argMap.get('changed-only'), true)
+ const targetsFromArg = argMap.get('target')
+ const targetLangs = targetsFromArg
+ ? String(targetsFromArg).split(',').map(v => v.trim()).filter(Boolean)
+ : loadLanguagesFile()
+
+ return {
+ command,
+ sourceDir,
+ targetLangs,
+ changedOnly,
+ full,
+ openaiApiKey: process.env.OPENAI_API_KEY || 'sk-e9af19bc51c94db2b0705bc58018808c',
+ openaiApiBase: process.env.OPENAI_API_BASE || 'https://api.deepseek.com',
+ model: process.env.OPENAI_MODEL || 'deepseek-chat',
+ retryAttempts: Number(process.env.TRANSLATE_RETRY_ATTEMPTS || 3),
+ retryBaseDelayMs: Number(process.env.TRANSLATE_RETRY_BASE_DELAY_MS || 1000)
+ }
+}
+
+// 对配置做最小可运行校验,尽早失败并给出明确错误
+export function validateConfig(config) {
+ if (config.command !== 'run') {
+ throw new Error(`Unsupported command: ${config.command}`)
+ }
+ if (!config.sourceDir) {
+ throw new Error('sourceDir is required')
+ }
+ if (!Array.isArray(config.targetLangs) || config.targetLangs.length === 0) {
+ throw new Error('targetLangs is required')
+ }
+ if (!config.openaiApiKey) {
+ throw new Error('OPENAI_API_KEY is not set')
+ }
+}
diff --git a/scripts/auto-translate/git.mjs b/scripts/auto-translate/git.mjs
new file mode 100644
index 00000000..0bd061d8
--- /dev/null
+++ b/scripts/auto-translate/git.mjs
@@ -0,0 +1,117 @@
+import { execSync } from 'child_process'
+
+const EMPTY_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
+
+function exec(command) {
+ try {
+ return execSync(command, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
+ } catch {
+ return ''
+ }
+}
+
+function hasGitRevision(revision) {
+ return Boolean(exec(`git rev-parse --verify --quiet ${revision}`))
+}
+
+function sanitizeGitRef(ref) {
+ if (!ref) return ''
+ return /^[a-zA-Z0-9._/-]+$/.test(ref) ? ref : ''
+}
+
+export function resolveDiffBase() {
+ const githubBefore = process.env.GITHUB_EVENT_BEFORE
+ if (
+ githubBefore
+ && githubBefore !== '0000000000000000000000000000000000000000'
+ && hasGitRevision(githubBefore)
+ ) {
+ return githubBefore
+ }
+
+ const baseRef = sanitizeGitRef(process.env.GITHUB_BASE_REF)
+ if (baseRef) {
+ const remoteRef = `origin/${baseRef}`
+ if (hasGitRevision(remoteRef)) {
+ const mergeBase = exec(`git merge-base ${remoteRef} HEAD`)
+ if (mergeBase) return mergeBase
+ }
+ }
+
+ const upstream = exec('git rev-parse --abbrev-ref --symbolic-full-name @{u}')
+ if (upstream) {
+ const mergeBase = exec(`git merge-base ${upstream} HEAD`)
+ if (mergeBase) return mergeBase
+ }
+
+ if (hasGitRevision('HEAD^')) return 'HEAD^'
+ if (hasGitRevision('HEAD')) return EMPTY_TREE_HASH
+ return ''
+}
+
+function isTargetFile(filePath, sourceDir) {
+ return filePath
+ && filePath.startsWith(sourceDir)
+ && (filePath.endsWith('.md') || filePath.endsWith('.yml') || filePath.endsWith('.yaml'))
+}
+
+export function listAllSourceFiles(sourceDir) {
+ const output = exec(`git ls-files ${sourceDir}`)
+ return output.split('\n').filter(file => isTargetFile(file, sourceDir))
+}
+
+export function getChangedFiles(sourceDir, diffBase) {
+ if (!diffBase) return { files: [], deletedFiles: [] }
+ const changedFiles = exec(`git diff --name-only ${diffBase} HEAD`).split('\n').filter(Boolean)
+ if (changedFiles.includes('scripts/languages.json') || changedFiles.includes('scripts/auto-translate/languages.json')) {
+ return { files: listAllSourceFiles(sourceDir), deletedFiles: [] }
+ }
+
+ const diffOutput = exec(`git diff --name-status --find-renames ${diffBase} HEAD -- ${sourceDir}`)
+ const files = new Set()
+ const deletedFiles = []
+ for (const line of diffOutput.split('\n').filter(Boolean)) {
+ const parts = line.split('\t')
+ const status = parts[0]?.[0]
+ if (!status) continue
+ if (status === 'R' || status === 'C') {
+ const nextPath = parts[2]
+ if (isTargetFile(nextPath, sourceDir)) files.add(nextPath)
+ continue
+ }
+ const currentPath = parts[1]
+ if (status === 'D') {
+ if (isTargetFile(currentPath, sourceDir)) deletedFiles.push(currentPath)
+ continue
+ }
+ if (isTargetFile(currentPath, sourceDir)) files.add(currentPath)
+ }
+ return { files: [...files], deletedFiles }
+}
+
+export function getGitContent(revision, filePath) {
+ if (!revision) return null
+ const content = exec(`git show ${revision}:${filePath}`)
+ return content || null
+}
+
+// 解析 git diff 的 unified=0 格式,返回新文件中被修改/新增的行号集合(1-based)
+// 返回 null 表示无法获取 diff(视为全部行都需要处理)
+// 返回空 Set 表示该文件在 diff 中无变更行
+export function getChangedLineNumbers(diffBase, filePath) {
+ if (!diffBase) return null
+ const diff = exec(`git diff --unified=0 ${diffBase} HEAD -- ${filePath}`)
+ if (!diff) return new Set()
+
+ const result = new Set()
+ // @@ -oldStart[,oldCount] +newStart[,newCount] @@
+ for (const line of diff.split('\n')) {
+ const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
+ if (!m) continue
+ const start = Number(m[1])
+ const count = m[2] !== undefined ? Number(m[2]) : 1
+ // count=0 表示纯删除,新文件中无对应行
+ for (let i = 0; i < count; i++) result.add(start + i)
+ }
+ return result
+}
diff --git a/scripts/auto-translate/hash.mjs b/scripts/auto-translate/hash.mjs
new file mode 100644
index 00000000..d4697de1
--- /dev/null
+++ b/scripts/auto-translate/hash.mjs
@@ -0,0 +1,5 @@
+import crypto from 'crypto'
+
+export function md5(value) {
+ return crypto.createHash('md5').update(value).digest('hex')
+}
diff --git a/scripts/auto-translate/index.mjs b/scripts/auto-translate/index.mjs
new file mode 100644
index 00000000..d8c5d51a
--- /dev/null
+++ b/scripts/auto-translate/index.mjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+
+import fs from 'fs'
+import path from 'path'
+import pLimit from 'p-limit'
+import { loadConfigFromCli, validateConfig } from './config.mjs'
+import { resolveDiffBase, listAllSourceFiles, getChangedFiles } from './git.mjs'
+import { createTranslator } from './translator.mjs'
+import { processFile } from './pipeline.mjs'
+import { toTargetPath } from './io.mjs'
+
+function printUsage() {
+ console.log('Usage: node scripts/auto-translate/index.mjs run [--target=en,ja] [--source=content/cn] [--full=true] [--changed-only=true]')
+}
+
+async function run() {
+ // 1) 读取并解析 CLI 参数与环境变量配置
+ const config = loadConfigFromCli()
+ // 2) 仅支持 run 命令,其他命令打印用法并退出
+ if (config.command !== 'run') {
+ printUsage()
+ process.exit(1)
+ }
+
+ // 3) 校验关键配置(源目录、目标语言、API Key)
+ validateConfig(config)
+ // 4) 解析 diff 基线并按 full/changed-only 计算待处理文件
+ const diffBase = config.full ? '' : resolveDiffBase()
+ let files, deletedFiles = []
+ if (config.full) {
+ files = listAllSourceFiles(config.sourceDir)
+ } else if (config.changedOnly) {
+ const result = getChangedFiles(config.sourceDir, diffBase)
+ files = result.files
+ deletedFiles = result.deletedFiles
+ } else {
+ files = listAllSourceFiles(config.sourceDir)
+ }
+
+ // 4.5) 删除源文件已删除的对应目标语言文件
+ for (const srcPath of deletedFiles) {
+ for (const lang of config.targetLangs) {
+ const targetPath = toTargetPath(srcPath, config.sourceDir, lang)
+ if (fs.existsSync(targetPath)) {
+ fs.rmSync(targetPath)
+ console.log(`Deleted: ${targetPath}`)
+ // 若目录为空则一并清理
+ const dir = path.dirname(targetPath)
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
+ }
+ }
+ }
+
+ console.log('files: ', files)
+ // 5) 无待处理文件时直接结束
+ if (files.length === 0) {
+ console.log('No relevant files to process.')
+ return
+ }
+
+ // 6) 初始化翻译器与统计器
+ const translator = createTranslator(config)
+ const summary = { files: files.length, fileSuccess: 0, fileFailed: 0, translatedBlocks: 0, changedOutputs: 0 }
+
+ // 7) 并发处理文件(最多 5 个并行),分发类型、翻译并写入目标语言文档
+ const limit = pLimit(5)
+ await Promise.all(files.map(filePath => limit(async () => {
+ try {
+ console.log(`Processing: ${filePath}`)
+ const outputResults = await processFile(filePath, config, translator, diffBase)
+ summary.fileSuccess += 1
+ for (const result of outputResults) {
+ summary.translatedBlocks += result.translatedCount
+ if (result.changed) summary.changedOutputs += 1
+ console.log(` [${result.lang}] ${result.changed ? 'updated' : 'unchanged'} ${result.targetPath}`)
+ }
+ } catch (error) {
+ summary.fileFailed += 1
+ console.error(`Failed: ${filePath}`)
+ console.error(error)
+ }
+ })))
+
+ // 8) 输出汇总;存在失败时设置非 0 退出码给 CI
+ console.log(`Done. files=${summary.files}, success=${summary.fileSuccess}, failed=${summary.fileFailed}, translated=${summary.translatedBlocks}, outputs_updated=${summary.changedOutputs}`)
+ if (summary.fileFailed > 0) process.exitCode = 1
+}
+
+run().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/scripts/auto-translate/io.mjs b/scripts/auto-translate/io.mjs
new file mode 100644
index 00000000..57398caf
--- /dev/null
+++ b/scripts/auto-translate/io.mjs
@@ -0,0 +1,23 @@
+import fs from 'fs'
+import path from 'path'
+import os from 'os'
+
+export function toTargetPath(sourcePath, sourceDir, lang) {
+ return path.join(`content/${lang}`, path.relative(sourceDir, sourcePath))
+}
+
+export function ensureParentDir(filePath) {
+ const dir = path.dirname(filePath)
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true })
+ }
+}
+
+export function writeFileAtomic(filePath, content) {
+ ensureParentDir(filePath)
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-translate-'))
+ const tempFile = path.join(tempDir, path.basename(filePath))
+ fs.writeFileSync(tempFile, content, 'utf-8')
+ fs.renameSync(tempFile, filePath)
+ fs.rmSync(tempDir, { recursive: true, force: true })
+}
diff --git a/scripts/auto-translate/languages.json b/scripts/auto-translate/languages.json
new file mode 100644
index 00000000..74045c29
--- /dev/null
+++ b/scripts/auto-translate/languages.json
@@ -0,0 +1,3 @@
+[
+ "en"
+]
diff --git a/scripts/auto-translate/markdown.mjs b/scripts/auto-translate/markdown.mjs
new file mode 100644
index 00000000..4c0885f9
--- /dev/null
+++ b/scripts/auto-translate/markdown.mjs
@@ -0,0 +1,293 @@
+import fs from 'fs'
+import { unified } from 'unified'
+import remarkParse from 'remark-parse'
+import remarkStringify from 'remark-stringify'
+import { getGitContent, getChangedLineNumbers } from './git.mjs'
+import { md5 } from './hash.mjs'
+import { toTargetPath } from './io.mjs'
+
+const processor = unified()
+ .use(remarkParse)
+ .use(remarkStringify, { bullet: '*', fence: '`' })
+
+function parseMarkdown(content) {
+ if (!content) return { type: 'root', children: [] }
+ return processor.parse(content)
+}
+
+function splitIntoBlocks(tree, rawContent) {
+ const blocks = []
+ const nodes = tree.children || []
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i]
+ const nextNode = nodes[i + 1]
+ const tempRoot = { type: 'root', children: [node] }
+ const normalizedText = processor.stringify(tempRoot).trim()
+ let rawText = normalizedText
+ let separator = '\n'
+ if (rawContent && node.position) {
+ rawText = rawContent.slice(node.position.start.offset, node.position.end.offset)
+ if (nextNode?.position) {
+ separator = rawContent.slice(node.position.end.offset, nextNode.position.start.offset)
+ } else {
+ separator = rawContent.slice(node.position.end.offset)
+ if (!separator) separator = '\n'
+ }
+ }
+ blocks.push({
+ text: rawText,
+ normalized: normalizedText,
+ hash: md5(normalizedText),
+ separator,
+ startLine: node.position?.start.line ?? null, // 1-based
+ endLine: node.position?.end.line ?? null
+ })
+ }
+ return blocks
+}
+
+// 按位置对齐 oldCN 和 existingEN,构建两级复用映射:
+// blockMap: CN块hash → EN块全文(整块未变时直接复用)
+// lineMap: CN行文本 → EN行文本(块内部分行未变时复用)
+//
+// lineMap 在文档级别(而非块级别)做对齐:
+// 把所有 oldCN 块的行和所有 existingEN 块的行按顺序拍平后逐行对应。
+// 这样即使 remark 对 CN/EN 两个文件产生不同的块边界(MDC、特殊语法等),
+// 行级对应关系依然正确,不受块结构差异影响。
+function buildReuseMaps(oldCNBlocks, existingENBlocks) {
+ const blockMap = new Map()
+ const lineMap = new Map()
+
+ // 块级映射:仍按位置对齐(EN 由 CN 翻译生成,块结构相同)
+ const blockLimit = Math.min(oldCNBlocks.length, existingENBlocks.length)
+ for (let i = 0; i < blockLimit; i++) {
+ if (!blockMap.has(oldCNBlocks[i].hash)) {
+ blockMap.set(oldCNBlocks[i].hash, existingENBlocks[i].text)
+ }
+ }
+
+ // 行级映射:文档级拍平后按行位置对齐
+ // trimEnd 用于消除编辑器保存时可能引入的尾部空格差异
+ const allOldCNLines = oldCNBlocks.flatMap(b => b.text.split('\n'))
+ const allExistingENLines = existingENBlocks.flatMap(b => b.text.split('\n'))
+ const lineLimit = Math.min(allOldCNLines.length, allExistingENLines.length)
+ for (let i = 0; i < lineLimit; i++) {
+ const cnLine = allOldCNLines[i].trimEnd()
+ if (hasHan(cnLine) && !lineMap.has(cnLine)) {
+ lineMap.set(cnLine, allExistingENLines[i])
+ }
+ }
+
+ return { blockMap, lineMap }
+}
+
+function hasHan(text) {
+ return /[\p{Script=Han}]/u.test(text)
+}
+
+// 将字符串按汉字/非汉字边界分段,返回交替数组(偶数索引为非汉字段,奇数索引为汉字段)
+// 例: "通过60%减少Token" → ["", "通过", "60%", "减少", "Token"]
+function splitByHan(text) {
+ return text.split(/([\p{Script=Han}]+)/u)
+}
+
+// 若 newCN 与 oldCN 仅在非汉字段有差异(汉字结构完全相同),
+// 则将相同差异应用到 oldEN 并返回替换后的译文。
+// 返回 null 表示无法结构性替换(汉字结构变了,或 EN 中找不到对应片段)。
+//
+// 核心策略:以待替换 token 左右两侧最近的"未变更非汉字段"作锚点,
+// 把搜索范围收窄到两锚点之间,避免短 token(如纯数字)在 EN 中误匹配。
+function tryStructuralPatch(oldCN, newCN, oldEN) {
+ if (!oldCN || !newCN || !oldEN) return null
+ const oldParts = splitByHan(oldCN)
+ const newParts = splitByHan(newCN)
+ // 分段数不同 → 汉字结构已变,无法 patch
+ if (oldParts.length !== newParts.length) return null
+ // 汉字段(奇数索引)必须完全相同
+ for (let i = 1; i < oldParts.length; i += 2) {
+ if (oldParts[i] !== newParts[i]) return null
+ }
+
+ let result = oldEN
+ let anyReplaced = false
+
+ for (let i = 0; i < oldParts.length; i += 2) {
+ if (oldParts[i] === newParts[i]) continue
+
+ const fromTrimmed = oldParts[i].trim()
+ const toTrimmed = newParts[i].trim()
+ // 仅空白变化,或 trim 后内容相同 → 无需操作
+ if (!fromTrimmed || fromTrimmed === toTrimmed) continue
+
+ // 找当前变更段左右最近的未变更非汉字锚点(trim 后非空)
+ let prevAnchor = null
+ for (let j = i - 2; j >= 0; j -= 2) {
+ const t = oldParts[j].trim()
+ if (t) { prevAnchor = t; break }
+ }
+ let nextAnchor = null
+ for (let j = i + 2; j < oldParts.length; j += 2) {
+ const t = oldParts[j].trim()
+ if (t) { nextAnchor = t; break }
+ }
+
+ // 用锚点确定 EN 中的搜索窗口:[searchStart, searchEnd)
+ // 锚点不存在时不限制该侧边界
+ let searchStart = 0
+ let searchEnd = result.length
+ if (prevAnchor) {
+ const idx = result.indexOf(prevAnchor)
+ if (idx !== -1) searchStart = idx + prevAnchor.length
+ }
+ if (nextAnchor) {
+ const idx = result.indexOf(nextAnchor, searchStart)
+ if (idx !== -1) searchEnd = idx
+ }
+
+ // 在窗口内查找 fromTrimmed 并替换
+ const idx = result.indexOf(fromTrimmed, searchStart)
+ if (idx === -1 || idx > searchEnd) return null
+ result = result.slice(0, idx) + toTrimmed + result.slice(idx + fromTrimmed.length)
+ anyReplaced = true
+ }
+
+ return anyReplaced ? result : oldEN
+}
+
+// 根据 git diff 的变更行号,计算该块内哪些行(0-based)发生了变化
+// 返回 null 表示无 diff 信息,所有含中文的行均视为需要翻译
+function getBlockChangedLineIndexes(block, changedLineNumbers) {
+ if (!changedLineNumbers || block.startLine === null) return null
+ const result = new Set()
+ const lineCount = block.text.split('\n').length
+ for (let i = 0; i < lineCount; i++) {
+ if (changedLineNumbers.has(block.startLine + i)) result.add(i)
+ }
+ return result
+}
+
+// 对单个块做行级预分类(纯同步,不调 LLM):
+// resolved: 已确定译文(无中文 / lineMap 命中 / 结构性 patch)
+// pending: 需要 LLM 翻译的行,{ i, lineText }
+function preResolveBlock(block, oldCNBlock, changedLineIndexes, lineMap) {
+ const lines = block.text.split('\n')
+ const oldCNLines = oldCNBlock ? oldCNBlock.text.split('\n') : []
+ const resolvedLines = new Array(lines.length).fill(null)
+ const pending = [] // { i, lineText }
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]
+
+ if (!hasHan(line)) {
+ resolvedLines[i] = line
+ continue
+ }
+
+ const lineDefinitelyChanged = changedLineIndexes !== null && changedLineIndexes.has(i)
+ if (!lineDefinitelyChanged) {
+ // 未明确变更:查 lineMap,命中则复用
+ const reused = lineMap.get(line.trimEnd())
+ if (reused !== undefined) { resolvedLines[i] = reused; continue }
+ } else {
+ // 变更行:汉字结构不变则结构性 patch,跳过 LLM
+ const oldCNLine = oldCNLines[i]?.trimEnd()
+ const oldENLine = oldCNLine !== undefined ? lineMap.get(oldCNLine) : undefined
+ if (oldENLine !== undefined) {
+ const patched = tryStructuralPatch(oldCNLine, line.trimEnd(), oldENLine)
+ if (patched !== null) { resolvedLines[i] = patched; continue }
+ }
+ }
+
+ pending.push({ i, lineText: line })
+ }
+
+ return { resolvedLines, pending }
+}
+
+export async function buildMarkdownTarget({ filePath, sourceDir, targetLang, diffBase, translator }) {
+ const targetPath = toTargetPath(filePath, sourceDir, targetLang)
+ const newSourceRaw = fs.readFileSync(filePath, 'utf-8')
+ const newBlocks = splitIntoBlocks(parseMarkdown(newSourceRaw), newSourceRaw)
+
+ // 从 git 历史取旧 CN,从磁盘取现有 EN,构建复用映射
+ const oldSourceRaw = diffBase ? getGitContent(diffBase, filePath) : null
+ const existingEnRaw = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf-8') : null
+ const oldCNBlocks = splitIntoBlocks(parseMarkdown(oldSourceRaw), oldSourceRaw)
+ const existingENBlocks = splitIntoBlocks(parseMarkdown(existingEnRaw), existingEnRaw)
+ const { blockMap, lineMap } = buildReuseMaps(oldCNBlocks, existingENBlocks)
+ const changedLineNumbers = diffBase ? getChangedLineNumbers(diffBase, filePath) : null
+
+ // 新文件快速路径:目标文件不存在时,整块翻译,跳过行级拆分流程
+ if (!existingEnRaw) {
+ const chineseBlocks = newBlocks.filter(b => hasHan(b.text))
+ const translatedTexts = await translator.translateBlocksBatch(
+ chineseBlocks.map(b => b.text),
+ targetLang,
+ `${filePath} → ${targetLang}`
+ )
+ const blockTransMap = new Map(chineseBlocks.map((b, i) => [b.text, translatedTexts[i]]))
+
+ let content = ''
+ let translatedCount = 0
+ for (let i = 0; i < newBlocks.length; i++) {
+ const block = newBlocks[i]
+ if (blockTransMap.has(block.text)) {
+ content += blockTransMap.get(block.text)
+ translatedCount++
+ } else {
+ content += block.text
+ }
+ content += block.separator || '\n'
+ }
+ return { targetPath, content, translatedCount }
+ }
+
+ // 阶段一:对每个块做同步预分类,不调 LLM
+ // blockPreResolutions[i] = null 表示整块命中缓存,否则为 { resolvedLines, pending }
+ const blockPreResolutions = newBlocks.map((block, i) => {
+ if (blockMap.has(block.hash)) return null
+ const changedLineIndexes = getBlockChangedLineIndexes(block, changedLineNumbers)
+ const oldCNBlock = i < oldCNBlocks.length ? oldCNBlocks[i] : null
+ return preResolveBlock(block, oldCNBlock, changedLineIndexes, lineMap)
+ })
+
+ // 阶段二:收集文件内所有需要 LLM 翻译的唯一行
+ const translationDict = new Map() // lineText → translation(初始为 null)
+ for (const res of blockPreResolutions) {
+ if (!res) continue
+ for (const { lineText } of res.pending) {
+ if (!translationDict.has(lineText)) translationDict.set(lineText, null)
+ }
+ }
+
+ // 阶段三:对去重后的唯一行做一次批量 LLM 调用
+ if (translationDict.size > 0) {
+ const uniqueLines = [...translationDict.keys()]
+ const translated = await translator.translateLinesBatch(uniqueLines, targetLang, `${filePath} → ${targetLang}`)
+ uniqueLines.forEach((line, i) => translationDict.set(line, translated[i]))
+ }
+
+ // 阶段四:用翻译字典填回各块,拼接最终内容
+ let translatedCount = 0
+ const finalBlocks = newBlocks.map((block, i) => {
+ const res = blockPreResolutions[i]
+ if (!res) return blockMap.get(block.hash) // 整块缓存命中
+
+ translatedCount++
+ const lines = block.text.split('\n')
+ const pendingMap = new Map(res.pending.map(p => [p.i, p.lineText]))
+ return res.resolvedLines.map((resolved, j) => {
+ if (resolved !== null) return resolved
+ const lineText = pendingMap.get(j)
+ return lineText !== undefined ? (translationDict.get(lineText) ?? lines[j]) : lines[j]
+ }).join('\n')
+ })
+
+ let content = ''
+ for (let i = 0; i < finalBlocks.length; i++) {
+ content += finalBlocks[i]
+ content += newBlocks[i].separator || '\n'
+ }
+
+ return { targetPath, content, translatedCount }
+}
diff --git a/scripts/auto-translate/pipeline.mjs b/scripts/auto-translate/pipeline.mjs
new file mode 100644
index 00000000..1a0c81e2
--- /dev/null
+++ b/scripts/auto-translate/pipeline.mjs
@@ -0,0 +1,22 @@
+import fs from 'fs'
+import { buildMarkdownTarget } from './markdown.mjs'
+import { buildYamlTarget } from './yaml.mjs'
+import { writeFileAtomic } from './io.mjs'
+
+function isYaml(filePath) {
+ return filePath.endsWith('.yml') || filePath.endsWith('.yaml')
+}
+
+export async function processFile(filePath, config, translator, diffBase) {
+ return Promise.all(config.targetLangs.map(async (lang) => {
+ const buildResult = isYaml(filePath)
+ ? await buildYamlTarget({ filePath, sourceDir: config.sourceDir, targetLang: lang, diffBase, translator })
+ : await buildMarkdownTarget({ filePath, sourceDir: config.sourceDir, targetLang: lang, diffBase, translator })
+
+ const current = fs.existsSync(buildResult.targetPath) ? fs.readFileSync(buildResult.targetPath, 'utf-8') : ''
+ const changed = current !== buildResult.content
+ if (changed) writeFileAtomic(buildResult.targetPath, buildResult.content)
+
+ return { lang, targetPath: buildResult.targetPath, changed, translatedCount: buildResult.translatedCount }
+ }))
+}
diff --git a/scripts/auto-translate/translator.mjs b/scripts/auto-translate/translator.mjs
new file mode 100644
index 00000000..84bd92bb
--- /dev/null
+++ b/scripts/auto-translate/translator.mjs
@@ -0,0 +1,196 @@
+import fetch from 'node-fetch'
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+async function withRetry(fn, attempts, baseDelayMs) {
+ let lastError
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ return await fn(attempt)
+ } catch (err) {
+ lastError = err
+ if (attempt < attempts) await sleep(baseDelayMs * (2 ** (attempt - 1)))
+ }
+ }
+ throw lastError
+}
+
+function normalizeModelOutput(content) {
+ let value = content.trim()
+ if (value.startsWith('```json')) {
+ value = value.replace(/^```json\s*/, '').replace(/\s*```$/, '')
+ } else if (value.startsWith('```')) {
+ value = value.replace(/^```\s*/, '').replace(/\s*```$/, '')
+ }
+ return value
+}
+
+export function createTranslator(config) {
+ async function requestLLM(systemPrompt, userContent) {
+ const payload = {
+ model: config.model,
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: userContent }
+ ],
+ temperature: 0.1
+ }
+
+ return withRetry(async () => {
+ const response = await fetch(`${config.openaiApiBase}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${config.openaiApiKey}`
+ },
+ body: JSON.stringify(payload)
+ })
+
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(`API ${response.status}: ${text}`)
+ }
+
+ const data = await response.json()
+ const rawContent = data?.choices?.[0]?.message?.content || ''
+ return normalizeModelOutput(rawContent)
+ }, config.retryAttempts, config.retryBaseDelayMs)
+ }
+
+ // 文件级批量行翻译:一次调用翻译整个文件中所有唯一的待翻译行
+ // 超过 CHUNK_SIZE 时自动分块并发,保证大文件不超出上下文窗口
+ async function translateLinesBatch(lines, targetLang, context = '') {
+ if (lines.length === 0) return []
+
+ const CHUNK_SIZE = 80
+ const systemPrompt = `Translate the Chinese lines to ${targetLang} for technical docs.
+Do NOT translate: MemOS, MemCube, MOS, KV Cache, LoRA, LLM, API, SDK. Do not rephrase English-only portions.
+Preserve: Markdown syntax (**, *, \`, [], ()), icon prefixes (ri:xxx), quoted text as plain text (never bold).
+Capitalization: use Title Case for headings; preserve English term casing consistently.
+Completeness: translate every Chinese character — never leave Chinese in output.
+YAML lines (key: value): wrap translated value in single quotes; use double quotes if it contains a single quote.
+Output: JSON array of strings, same count and order as input. No other text.`
+
+ const chunks = []
+ for (let i = 0; i < lines.length; i += CHUNK_SIZE) {
+ chunks.push(lines.slice(i, i + CHUNK_SIZE))
+ }
+
+ const results = await Promise.all(
+ chunks.map(async (chunk) => {
+ let lastResult = null
+ try {
+ return await withRetry(async () => {
+ const raw = await requestLLM(systemPrompt, JSON.stringify(chunk))
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) throw new Error('Response is not an array')
+ if (parsed.length === chunk.length) return parsed
+ lastResult = parsed
+ throw new Error(`Expected ${chunk.length} results, got ${parsed.length}`)
+ }, config.retryAttempts, config.retryBaseDelayMs)
+ } catch {
+ console.warn(` ⚠️ translateLinesBatch: length mismatch, falling back${context ? ` [${context}]` : ''}`)
+ if (lastResult && Array.isArray(lastResult)) {
+ return chunk.map((src, i) => lastResult[i] ?? src)
+ }
+ return chunk
+ }
+ })
+ )
+
+ return results.flat()
+ }
+
+ // 简单批量翻译:一次 API 调用翻译整批字符串,不走 review 步骤
+ // 适用于 YAML 字符串、独立短文本等不需要格式审校的场景
+ // 超过 CHUNK_SIZE 时自动分批(并发)
+ // options.yamlMode: true 时,要求 LLM 返回带 YAML 引号的字符串
+ async function translateStrings(strings, targetLang, options = {}, context = '') {
+ if (strings.length === 0) return []
+
+ const CHUNK_SIZE = 50
+ const chunks = []
+ for (let i = 0; i < strings.length; i += CHUNK_SIZE) {
+ chunks.push(strings.slice(i, i + CHUNK_SIZE))
+ }
+
+ const yamlQuoteRule = options.yamlMode
+ ? `\nWrap each string in YAML quotes: single quotes by default; double quotes if the string contains a single quote.\nPreserve icon prefixes like "(ri:xxx-line)" at the start of strings — keep them exactly as-is, only translate the Chinese text that follows.`
+ : ''
+
+ const systemPrompt = `Translate Chinese strings to ${targetLang} for technical docs.
+Do NOT translate: MemOS, MemCube, MOS, KV Cache, LoRA, LLM, API, SDK, NLI. Translate faithfully — no rephrasing.
+Preserve capitalization of embedded English; use Title Case for multi-word titles.
+Return ONLY a JSON array of strings, same length and order as input. No other text.${yamlQuoteRule}`
+
+ const results = await Promise.all(
+ chunks.map(async (chunk) => {
+ let lastResult = null
+ try {
+ return await withRetry(async () => {
+ const raw = await requestLLM(systemPrompt, JSON.stringify(chunk))
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) throw new Error('Response is not an array')
+ if (parsed.length === chunk.length) return parsed
+ lastResult = parsed
+ throw new Error(`Expected ${chunk.length} results, got ${parsed.length}`)
+ }, config.retryAttempts, config.retryBaseDelayMs)
+ } catch {
+ // 降级:多了截断,少了用原文补齐
+ console.warn(` ⚠️ translateStrings: length mismatch, falling back${context ? ` [${context}]` : ''}`)
+ if (lastResult && Array.isArray(lastResult)) {
+ return chunk.map((src, i) => lastResult[i] ?? src)
+ }
+ return chunk // 完全失败则返回原文
+ }
+ })
+ )
+
+ return results.flat()
+ }
+
+ // 新文件快速路径:整块(段落)翻译,保留完整 Markdown 上下文
+ // 使用 {id, text} 格式请求,按 id 回填结果,LLM 多返或少返时仍可逐块恢复
+ async function translateBlocksBatch(blocks, targetLang, context = '') {
+ if (blocks.length === 0) return []
+
+ const CHUNK_SIZE = 25
+ const systemPrompt = `Translate Chinese markdown blocks to ${targetLang} for technical docs.
+Do NOT translate: MemOS, MemCube, MOS, KV Cache, LoRA, LLM, API, SDK, NLI. Translate faithfully — no rephrasing.
+Preserve ALL markdown syntax exactly (**, *, \`, #, [], (), ---, MDC components).
+Capitalization: use Title Case for headings; preserve English term casing consistently.
+Completeness: translate every Chinese character — never leave Chinese in output.
+Input: JSON array of {id, text} objects. Output: JSON array of {id, text} objects, same count and order, no other text.`
+
+ const chunks = []
+ for (let i = 0; i < blocks.length; i += CHUNK_SIZE)
+ chunks.push(blocks.slice(i, i + CHUNK_SIZE))
+
+ const results = await Promise.all(
+ chunks.map(async (chunk) => {
+ const payload = chunk.map((text, i) => ({ id: i, text }))
+ try {
+ return await withRetry(async () => {
+ const raw = await requestLLM(systemPrompt, JSON.stringify(payload))
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) throw new Error('Response is not an array')
+ // 按 id 回填:LLM 多返或少返时,缺失的 id 保留原文
+ const idMap = new Map(parsed.map(item => [item.id, item.text]))
+ const missing = chunk.filter((_, i) => !idMap.has(i))
+ if (missing.length > 0) console.warn(` ⚠️ translateBlocksBatch: ${missing.length} block(s) missing in response, using original${context ? ` [${context}]` : ''}`)
+ return chunk.map((src, i) => idMap.has(i) ? idMap.get(i) : src)
+ }, config.retryAttempts, config.retryBaseDelayMs)
+ } catch {
+ console.warn(` ⚠️ translateBlocksBatch: failed, using original${context ? ` [${context}]` : ''}`)
+ return chunk
+ }
+ })
+ )
+
+ return results.flat()
+ }
+
+ return { translateLinesBatch, translateStrings, translateBlocksBatch }
+}
diff --git a/scripts/auto-translate/yaml.mjs b/scripts/auto-translate/yaml.mjs
new file mode 100644
index 00000000..64e8f524
--- /dev/null
+++ b/scripts/auto-translate/yaml.mjs
@@ -0,0 +1,236 @@
+import fs from 'fs'
+import yaml from 'js-yaml'
+import { toTargetPath } from './io.mjs'
+import { getGitContent } from './git.mjs'
+
+// CORE_SCHEMA 不含 timestamp 类型,避免将 ISO 日期解析为 JS Date 对象
+const YAML_LOAD_OPTIONS = { schema: yaml.CORE_SCHEMA }
+
+function yamlToObject(raw) {
+ if (!raw) return null
+ const value = yaml.load(raw, YAML_LOAD_OPTIONS)
+ if (!value || typeof value !== 'object') return null
+ return value
+}
+
+// 只翻译包含汉字的字符串,保护文件路径、URL、icon 名等
+function isTranslatable(str) {
+ return typeof str === 'string' && /[\u4e00-\u9fff]/.test(str)
+}
+
+// 提取 key 中的 icon 名,如 "(ri:cpu-line) 标题" → "ri:cpu-line"
+// 用于防止位置错位时将不同 icon 的条目错误映射
+function extractIcon(str) {
+ if (typeof str !== 'string') return null
+ const m = str.match(/^\(([^)]+)\)/)
+ return m ? m[1] : null
+}
+
+// 并行遍历两棵树,按结构对齐收集 CN字符串 → EN字符串 的映射
+function buildStringReuseMap(cnNode, enNode, map = new Map()) {
+ if (!cnNode || !enNode) return map
+ if (Array.isArray(cnNode) && Array.isArray(enNode)) {
+ const len = Math.min(cnNode.length, enNode.length)
+ for (let i = 0; i < len; i++) buildStringReuseMap(cnNode[i], enNode[i], map)
+ return map
+ }
+ if (typeof cnNode === 'object' && typeof enNode === 'object') {
+ const cnKeys = Object.keys(cnNode)
+ const enKeys = Object.keys(enNode)
+ const len = Math.min(cnKeys.length, enKeys.length)
+ for (let i = 0; i < len; i++) {
+ const cnIcon = extractIcon(cnKeys[i])
+ const enIcon = extractIcon(enKeys[i])
+ // 两个 key 都含 icon 但 icon 不同 → 位置已错位,跳过该对及其子树,避免产生错误映射
+ if (cnIcon && enIcon && cnIcon !== enIcon) continue
+ if (isTranslatable(cnKeys[i]) && !map.has(cnKeys[i])) {
+ map.set(cnKeys[i], enKeys[i])
+ }
+ buildStringReuseMap(cnNode[cnKeys[i]], enNode[enKeys[i]], map)
+ }
+ return map
+ }
+ if (isTranslatable(cnNode) && typeof enNode === 'string') {
+ if (!map.has(cnNode)) {
+ map.set(cnNode, enNode)
+ }
+ }
+ return map
+}
+
+// 从原始 YAML 注释行中提取可翻译字符串(去掉 # 后当 YAML 解析)
+function collectCommentStrings(rawYaml, result = new Set()) {
+ for (const line of rawYaml.split('\n')) {
+ const m = line.match(/^\s*#\s*(.+)$/)
+ if (!m) continue
+ try {
+ const parsed = yaml.load(m[1], YAML_LOAD_OPTIONS)
+ if (parsed !== null && parsed !== undefined) {
+ collectStrings(parsed, result)
+ }
+ } catch {
+ // 注释行无法解析为 YAML 时跳过
+ }
+ }
+ return result
+}
+
+// 递归收集 YAML 树中所有需要翻译的唯一字符串(key 和 value)
+function collectStrings(node, result = new Set()) {
+ if (Array.isArray(node)) {
+ for (const item of node) collectStrings(item, result)
+ } else if (typeof node === 'object' && node !== null) {
+ for (const key of Object.keys(node)) {
+ if (isTranslatable(key)) result.add(key)
+ collectStrings(node[key], result)
+ }
+ } else if (isTranslatable(node)) {
+ result.add(node)
+ }
+ return result
+}
+
+function escapeRegex(str) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+// 剥掉 LLM 返回的外层 YAML 引号,还原为裸字符串
+// 用于已带引号的 source 场景(如 '中文' / "中文"),避免重复套引号
+function stripYamlQuoteWrapper(str) {
+ if (str.length >= 2) {
+ if (str.startsWith('\'') && str.endsWith('\'')) {
+ return str.slice(1, -1).replace(/''/g, '\'')
+ }
+ if (str.startsWith('"') && str.endsWith('"')) {
+ return str.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
+ }
+ }
+ return str
+}
+
+// 直接在原始 YAML 字符串上做替换
+// dst 为 LLM 已带引号的输出(如 'Hello World' 或 "it's done")
+function applyTranslationsToRaw(rawYaml, translationMap) {
+ let result = rawYaml
+ for (const [src, dst] of translationMap) {
+ const escaped = escapeRegex(src)
+
+ // source 已是单引号包裹:剥掉 LLM 外层引号,再套回单引号格式
+ const dstRaw = stripYamlQuoteWrapper(dst)
+ const dstSingle = dstRaw.replace(/'/g, '\'\'')
+ result = result.replace(new RegExp(`'${escaped}'`, 'g'), `'${dstSingle}'`)
+
+ // source 已是双引号包裹:剥掉 LLM 外层引号,再套回双引号格式
+ const dstDouble = dstRaw.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+ result = result.replace(new RegExp(`"${escaped}"`, 'g'), `"${dstDouble}"`)
+
+ // plain scalar(key: value 或列表项或 key 本身):直接用 LLM 已带引号的输出
+ result = result.replace(
+ new RegExp(`(:\\s+)${escaped}([ \\t]*(?:#[^\\n]*)?)$`, 'mg'),
+ (_, pre, suf) => `${pre}${dst}${suf}`
+ )
+ result = result.replace(
+ new RegExp(`(^[ \\t]*-[ \\t]+)${escaped}([ \\t]*(?:#[^\\n]*)?)$`, 'mg'),
+ (_, pre, suf) => `${pre}${dst}${suf}`
+ )
+ result = result.replace(
+ new RegExp(`^([ \\t]*)${escaped}([ \\t]*:)`, 'mg'),
+ (_, indent, colon) => `${indent}${dst}${colon}`
+ )
+ }
+ return result
+}
+
+// 去掉 YAML 值的引号以便做内容比较
+function stripYamlQuotes(line) {
+ return line
+ .replace(/(:\s+)['"](.+?)['"]\s*$/, '$1$2')
+ .replace(/(^\s*-\s+)['"](.+?)['"]\s*$/, '$1$2')
+ .trimEnd()
+}
+
+// 将生成的输出与现有 EN 文件做行级合并:
+// - 内容相同(忽略引号差异)的行 → 保留现有 EN 的格式(引号等)
+// - 内容不同的行(新翻译 / 新增行)→ 使用生成的版本
+function mergeWithExisting(generated, existingRaw) {
+ if (!existingRaw) return generated
+ const genLines = generated.split('\n')
+ const exLines = existingRaw.split('\n')
+
+ // 建立 "去引号内容 → 原始行" 的映射(从现有 EN 文件)
+ // 只对含内容的行建映射(跳过纯结构行如空行、纯 key 行)
+ const contentToExLine = new Map()
+ for (const line of exLines) {
+ const stripped = stripYamlQuotes(line)
+ if (!contentToExLine.has(stripped)) {
+ contentToExLine.set(stripped, line)
+ }
+ }
+
+ return genLines.map((line) => {
+ const stripped = stripYamlQuotes(line)
+ return contentToExLine.get(stripped) ?? line
+ }).join('\n')
+}
+
+export async function buildYamlTarget({ filePath, sourceDir, targetLang, diffBase, translator }) {
+ const targetPath = toTargetPath(filePath, sourceDir, targetLang)
+ const newSourceRaw = fs.readFileSync(filePath, 'utf-8')
+ const newSource = yamlToObject(newSourceRaw)
+
+ const oldCnRaw = diffBase ? getGitContent(diffBase, filePath) : null
+ const existingEnRaw = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf-8') : null
+
+ // --- 复用映射:双重来源,防止 diffBase 太远导致 reuseMap 不全 ---
+
+ // 来源 1:oldCN ↔ existingEN(diffBase 版本,结构一定对齐,最可靠)
+ const reuseMap = (oldCnRaw && existingEnRaw)
+ ? buildStringReuseMap(yamlToObject(oldCnRaw), yamlToObject(existingEnRaw))
+ : new Map()
+
+ // 来源 2:newCN ↔ existingEN(直接对齐当前 CN 与现有 EN)
+ // 当 diffBase 很远时,reuseMap 可能不全;directMap 作为补充
+ // 注意:新增条目插入中间时可能错位,需要校验映射值确实是英文
+ const directMap = existingEnRaw
+ ? buildStringReuseMap(newSource, yamlToObject(existingEnRaw))
+ : new Map()
+
+ // 收集所有中文字符串(含注释行)
+ const allStrings = collectStrings(newSource)
+ collectCommentStrings(newSourceRaw, allStrings)
+ const translationMap = new Map()
+ const toTranslate = []
+
+ for (const str of allStrings) {
+ // 优先用 reuseMap(oldCN↔existingEN 对齐,最可靠)
+ const fromReuse = reuseMap.get(str)
+ if (fromReuse !== undefined) {
+ translationMap.set(str, fromReuse)
+ continue
+ }
+ // 其次用 directMap(newCN↔existingEN 对齐,可能因新增条目错位)
+ // 校验:映射值必须不含汉字(确认确实是英文翻译,而非错位到了中文)
+ const fromDirect = directMap.get(str)
+ if (fromDirect !== undefined && !isTranslatable(fromDirect)) {
+ translationMap.set(str, fromDirect)
+ continue
+ }
+ // 都没命中 → 真正需要翻译
+ toTranslate.push(str)
+ }
+
+ if (toTranslate.length > 0) {
+ const translated = await translator.translateStrings(toTranslate, targetLang, { yamlMode: true }, `${filePath} → ${targetLang}`)
+ for (let i = 0; i < toTranslate.length; i++) {
+ translationMap.set(toTranslate[i], translated[i])
+ }
+ }
+
+ // 在中文源文件上做 raw replacement(处理新增内容)
+ const generated = applyTranslationsToRaw(newSourceRaw, translationMap)
+
+ // 与现有 EN 文件合并:内容相同的行保留现有格式(引号等)
+ const content = mergeWithExisting(generated, existingEnRaw)
+
+ return { targetPath, content, translatedCount: toTranslate.length }
+}