From bba50c318443796135952b28728615e78e407e28 Mon Sep 17 00:00:00 2001 From: antalike <> Date: Mon, 13 Apr 2026 10:38:15 +0800 Subject: [PATCH 1/5] feat: add auto transalte workflow --- .github/workflows/auto-translate.yml | 109 ++++++ scripts/auto-translate.mjs | 495 -------------------------- scripts/auto-translate/README.md | 142 ++++++++ scripts/auto-translate/config.mjs | 107 ++++++ scripts/auto-translate/git.mjs | 117 ++++++ scripts/auto-translate/hash.mjs | 5 + scripts/auto-translate/index.mjs | 92 +++++ scripts/auto-translate/io.mjs | 23 ++ scripts/auto-translate/languages.json | 3 + scripts/auto-translate/markdown.mjs | 293 +++++++++++++++ scripts/auto-translate/pipeline.mjs | 22 ++ scripts/auto-translate/translator.mjs | 196 ++++++++++ scripts/auto-translate/yaml.mjs | 236 ++++++++++++ 13 files changed, 1345 insertions(+), 495 deletions(-) create mode 100644 .github/workflows/auto-translate.yml delete mode 100644 scripts/auto-translate.mjs create mode 100644 scripts/auto-translate/README.md create mode 100644 scripts/auto-translate/config.mjs create mode 100644 scripts/auto-translate/git.mjs create mode 100644 scripts/auto-translate/hash.mjs create mode 100644 scripts/auto-translate/index.mjs create mode 100644 scripts/auto-translate/io.mjs create mode 100644 scripts/auto-translate/languages.json create mode 100644 scripts/auto-translate/markdown.mjs create mode 100644 scripts/auto-translate/pipeline.mjs create mode 100644 scripts/auto-translate/translator.mjs create mode 100644 scripts/auto-translate/yaml.mjs diff --git a/.github/workflows/auto-translate.yml b/.github/workflows/auto-translate.yml new file mode 100644 index 00000000..1838f5f9 --- /dev/null +++ b/.github/workflows/auto-translate.yml @@ -0,0 +1,109 @@ +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) + + gh pr create \ + --title "docs: auto-translate $(date +%Y-%m-%d)" \ + --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/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 } +} From 219b18f0bdca7d096ed490b7c41ab919ec7c11e8 Mon Sep 17 00:00:00 2001 From: antalike <> Date: Mon, 13 Apr 2026 10:52:01 +0800 Subject: [PATCH 2/5] test: auto transalte --- content/cn/memos_cloud/quick_start.md | 35 +++++++++++++++++---------- 1 file changed, 22 insertions(+), 13 deletions(-) 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` 作为检索工具接入,支持多轮对话上下文增强。 From 81c624f1182a0ec6a141d3c834bd8d15a9dd2095 Mon Sep 17 00:00:00 2001 From: antalike <> Date: Mon, 13 Apr 2026 15:25:47 +0800 Subject: [PATCH 3/5] Revert "test: auto transalte" This reverts commit f7b7c624f4d7e5a3221ccd1a7b3e9a11a041c983. --- content/cn/memos_cloud/quick_start.md | 35 ++++++++++----------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/content/cn/memos_cloud/quick_start.md b/content/cn/memos_cloud/quick_start.md index bfeeddd6..d8abf92c 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,11 +16,9 @@ MemOS 提供了两个核心接口帮助你实现: * 注册并登录 MemOS 云平台 [(点击注册)](https://memos-dashboard.openmem.net/quickstart); -* 准备一个可发送 HTTP 请求的环境(Python 或 cURL 均可); +* 获取 API Key[(点击获取)](https://memos-dashboard.openmem.net/apikeys); -* 获取 API Key[(点击获取)](https://memos-dashboard.openmem.net/apikeys) 并配置到环境变量; - -* 准备一个可用于测试的 `conversation_id`(建议按日期命名,如 `20260413-demo`)。 +* 准备一个可发送 HTTP 请求的环境,Python 或 cURL。 ## 2.代码配置 @@ -46,9 +44,9 @@ pip install MemoryOS -U ### 2.3 在会话中调用MemOS查询相关记忆(searchMemory) ::note -**会话 B:2025-09-28 发生**
+**会话 B:2025-9-28 发生**
-用户在一个新的会话中提出“推荐国庆旅游地点和酒店”,MemOS 会自动召回【事实记忆:曾去过哪里】和【偏好记忆:订酒店的偏好】供 AI 参考,从而生成更个性化的旅游计划。 +用户在一个新的会话中,提出让AI推荐国庆旅游地点和酒店,MemOS 会自动召回【事实记忆:曾去过哪里】【偏好记忆:订酒店的偏好】供AI参考,从而推荐更加个性化的旅游计划 :: ::code-snippet{name=search_memory} @@ -65,13 +63,8 @@ pip install MemoryOS -U { "preference_type": "implicit_preference", #隐性偏好 "preference": "用户可能偏好性价比较高的酒店选择。", - "confidence": 0.82, + "reasoning": "七天酒店通常以经济实惠著称,而用户选择七天酒店可能表明其在住宿方面倾向于选择性价比较高的选项。虽然用户没有明确提到预算限制或具体酒店偏好,但在提供的选项中选择七天可能反映了对价格和实用性的重视。", "conversation_id": "0610" - }, - { - "preference_type": "explicit_preference", #显性偏好 - "preference": "用户希望酒店评分不低于4.5分。", - "conversation_id": "0928" } ], @@ -81,7 +74,6 @@ pip install MemoryOS -U "memory_key": "暑假广州旅游计划", "memory_value": "用户计划在暑假期间前往广州旅游,并选择了七天连锁酒店作为住宿选项。", "conversation_id": "0610", - "memory_time": "2025-06-10 20:15:00", "tags": [ "旅游", "广州", @@ -161,15 +153,13 @@ pip install MemoryOS -U ## 3.下一步行动 -现在你已经可以运行 MemOS,建议继续探索更多云平台功能: +现在你已经能够运行 MemOS,可以探索更多云平台功能: * [**核心记忆操作**](/memos_cloud/mem_operations/add_message):完整了解如何添加、检索与删除记忆; * [**功能介绍**](/memos_cloud/features/basic/filters):探索更多云平台功能,如:记忆过滤、多模态消息、知识库等; -* [**API 接口文档**](/api_docs/start/overview):查看完整的 API 文档与调用示例; - -* [**SDK 接入说明**](/api_docs/start/quickstart):按语言查看初始化、鉴权和错误处理方式。 +* [**API 接口文档**](/api_docs/start/overview):查看完整的 API 文档与调用示例。 ## 4.更多资料 @@ -208,11 +198,12 @@ 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` 作为检索工具接入,支持多轮对话上下文增强。 From 037236ec1eda864fe6992d03a803902064133268 Mon Sep 17 00:00:00 2001 From: antalike <> Date: Mon, 13 Apr 2026 17:59:07 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=E6=B5=8B=E8=AF=95yml=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/cn/changelog.yml | 50 ++++++++++++++++++++++++++++++++++++++++ content/cn/settings.yml | 13 +++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/content/cn/changelog.yml b/content/cn/changelog.yml index c72ced9c..f0156adb 100644 --- a/content/cn/changelog.yml +++ b/content/cn/changelog.yml @@ -1,4 +1,54 @@ versions: + - name: v2.0.13 + date: 2026-04-09 + changedInfo: + Improvements: + - type: 调度日志分级优化 + changedInfo: + - 将高频、重复、逐条打印的日志从 info 下调到 debug,明显降低线上日志噪音和存储压力。 + - type: 队列日志结构化 + changedInfo: + - 本地队列和 Redis 队列日志统一为结构化字段(如 label/item_id/user_id/mem_cube_id/stream),避免大段内容日志,检索和聚合更稳定 + - type: 调度链路关键路径可观测 + changedInfo: + - 补齐“提交 -> 出队 -> 分发”关键节点的摘要日志,定位堆积、分发异常、任务拥堵更快。 + + - name: v2.0.12 + date: 2026-04-02 + changedInfo: + Improvements: + - type: 服务优化 + changedInfo: + - 新增最小线程数抽取为可配置参数,支持运行时动态调整线程池大小,有效控制活跃连接数,避免因瞬时流量激增导致系统负载飙升 + + - name: v2.0.11 + date: 2026-03-26 + changedInfo: + New Features: + - type: LLM 与搜索能力 + changedInfo: + - 新增 MiniMax 作为 LLM Provider,遵循与 Qwen、DeepSeek 相同的接入模式,通过 OpenAI 兼容 API 对接。 + - 新增 Tavily 作为可插拔的互联网搜索后端。通过配置即可启用 Tavily API 进行实时网络搜索,为记忆系统提供外部知识补充能力。 + - type: 部署与运维 + changedInfo: + - 新增 health check API 端点,用于服务状态监控。支持容器编排(如 Kubernetes liveness/readiness probe)和负载均衡器的健康探测。 + - 新增生产级 Dockerfile(多阶段构建)和 Kubernetes Helm Chart,包含 MemOS API 服务、Neo4j 图数据库、Qdrant 向量数据库的编排配置,以及 Ingress 和示例 values 文件,支持一键部署到 K8s 集群。 + - type: 接口能力扩展 + changedInfo: + - 此前 delete_memory 接口仅支持按 memory_id 删除。本次更新扩展了删除过滤条件,支持通过 user_id 和 conversation_id 批量删除记忆,并在 API 层增加了过滤参数校验,同时为 PostgreSQL 后端实现了 delete_node_by_params 方法。 + Bug Fixes: + - type: MCP 与消息处理 + changedInfo: + - 修复 MCP add_memory 工具消息格式问题; + - 修复 get_further_suggestion 无法处理 string 类型输入参数的问题。 + - type: 记忆与反馈 + changedInfo: + - 修复用户对记忆执行 feedback 操作后,相关记忆节点被错误归档(archived),导致后续检索时无法命中的问题; + - type: Neo4j + changedInfo: + - 修复 Neo4j 检索后过滤逻辑的问题; + - 修复 Neo4j 嵌套 metadata 写入问题。 + - name: v2.0.10 date: 2026-03-19 changedInfo: diff --git a/content/cn/settings.yml b/content/cn/settings.yml index d78835dd..60b79095 100644 --- a/content/cn/settings.yml +++ b/content/cn/settings.yml @@ -82,6 +82,10 @@ nav: - "(ri:file-code-line) API 参考文档": api-reference/search-memories + - "(ri:cpu-line) 自研模型说明": + - "(ri:flask-line) 抽取模型": + - "(ri:file-code-line) 使用示例": self_developed_model/extraction_usage_example.md + - "(ri:puzzle-line) MCP与Agent框架支持": - "(ri:tools-line) MCP 服务": - "(ri:book-open-line) 使用指南": mcp_agent/mcp/guide.md @@ -89,15 +93,17 @@ nav: - "(ri:robot-line) Agent开发": - "(ri:book-open-line) 使用指南": mcp_agent/agent/guide.md - - "(ri:robot-line) Openclaw": + - "(ri:robot-line) Agent": - "(ri:book-open-line) 云插件 vs 本地插件": openclaw/plugin_compare.md - "(ri:file-list-3-line) 更新日志": openclaw/changes.md - "(ri:download-line) 安装指南": - "(ri:cloud-line) OpenClaw 云插件": openclaw/guide.md - "(ri:computer-line) OpenClaw 本地插件": openclaw/local_plugin.md + - "(ri:server-line) Hermes 本地插件": openclaw/hermes_local_plugin.md - "(ri:flask-line) 使用示例": - "(ri:team-line) 多智能体记忆隔离": openclaw/examples/multi_agent.md - "(ri:filter-3-line) 记忆召回的二次过滤": openclaw/examples/recall_filter.md + - "(ri:terminal-box-line) Hermes 本地插件使用": openclaw/examples/hermes_usage.md - "(ri:file-code-line) API文档": - "(ri:rocket-line) 开始使用": @@ -111,6 +117,9 @@ nav: - "(ri:delete-bin-line) Delete Memory": api_docs/core/delete_memory.md - "(ri:feedback-line) Add Feedback": api_docs/message/add_feedback.md + - "(ri:cpu-line) 自研模型": + - "(ri:flask-line) Extract Memory": api_docs/core/extract_memory.md + - "(ri:chat-1-line) 消息": - "(ri:file-list-line) Get Message": api_docs/message/get_message.md - "(ri:task-line) Get Task Status": api_docs/message/get_status.md @@ -139,4 +148,4 @@ nav: - "(ri:layout-grid-line) 框架与平台": # - "(ri:chrome-line) 浏览器插件": usecase/frameworks/browser_extension.md - "(ri:brain-line) Claude MCP": usecase/frameworks/claude_mcp.md - - "(ri:puzzle-2-line) Coze 插件工具": usecase/frameworks/coze_plugin.md + - "(ri:puzzle-2-line) Coze 插件工具": usecase/frameworks/coze_plugin.md \ No newline at end of file From 13bec687710b6c214ec3ad7a4a8377cda0771d8f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 10:00:20 +0000 Subject: [PATCH 5/5] docs: auto-translate updated content Triggered by: 037236ec1eda864fe6992d03a803902064133268 Source branch: v2/auto-transalte-refactor --- content/en/changelog.yml | 68 +++++++++++++++++++++++++++++++++++----- content/en/settings.yml | 31 +++++++++++------- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/content/en/changelog.yml b/content/en/changelog.yml index ae9b23f2..28a535ba 100644 --- a/content/en/changelog.yml +++ b/content/en/changelog.yml @@ -1,4 +1,54 @@ versions: + - name: v2.0.13 + date: 2026-04-09 + changedInfo: + Improvements: + - type: PolarDB Database + 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 + changedInfo: + - Memory version plugin-based architecture. + - type: Service Optimization + changedInfo: + - Optimized scheduling tasks to improve memory usage. + + - name: v2.0.12 + date: 2026-04-02 + changedInfo: + Improvements: + - type: Service 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. + + - name: v2.0.11 + date: 2026-03-26 + changedInfo: + New Features: + - type: New Features + 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. + - Added Tavily as a pluggable internet search backend. Enable real-time web search via Tavily API configuration to provide external knowledge supplementation capabilities for the memory system. + - type: Deployment and Operations + changedInfo: + - Added health check API endpoint for service status monitoring. Supports container orchestration (e.g., Kubernetes liveness/readiness probes) and load balancer health probes. + - Added production-grade Dockerfile (multi-stage build) and Kubernetes Helm Chart, including orchestration configurations for the MemOS API service, Neo4j graph database, Qdrant vector database, as well as Ingress and example values files, supporting one-click deployment to K8s clusters. + - type: Interface Capability Expansion + changedInfo: + - Previously, the delete_memory interface only supported deletion by memory_id. This update extends deletion filter conditions to support batch deletion of memories by user_id and conversation_id, adds filter parameter validation at the API layer, and implements the delete_node_by_params method for the PostgreSQL backend. + Bug Fixes: + - type: Preference memories migrated to graph database + changedInfo: + - Preference memories have been migrated to the graph database, unifying them with other memory types. + - Fixed an issue where get_further_suggestion could not handle string-type input parameters. + - type: memory versioning optimization + changedInfo: + - Memory retrieval reduces redundant calls to get_by_metadata, decreasing retrieval latency. + - type: Neo4j + 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. + - name: v2.0.10 date: 2026-03-19 changedInfo: @@ -43,7 +93,7 @@ versions: - type: Preference memories migrated to graph database changedInfo: - Preference memories have been migrated to the graph database, unifying them with other memory types. - - type: memory versioning optimization + - type: Memory version optimization changedInfo: - Memory retrieval reduces redundant calls to get_by_metadata, decreasing retrieval latency. - type: database optimization @@ -131,7 +181,7 @@ versions: changedInfo: - Reduced duplication between factual and preference memories during retrieval. Bug Fixes: - - type: Bug Fixes + - type: Fixes changedInfo: - Fixed bug for new users in Playground. - Fixed occasional latency when adding memories in cloud service. @@ -185,6 +235,7 @@ versions: - type: Get Memory Interface changedInfo: - Fixed call error in get memory interface when parameter include_preference=False. + - name: v2.0.1 date: 2026-01-08 changedInfo: @@ -215,6 +266,7 @@ 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: @@ -266,7 +318,7 @@ versions: changedInfo: - Modularized API scheduler - Redis ORM optimized for history sync & hybrid search - - type: Data & Infra + - type: Data & Infrastructure changedInfo: - PolarDB graph backend connection pool/timeout & fixes - Unified graph factory (Neo4j/PolarDB/Nebula) @@ -313,7 +365,7 @@ versions: date: 2025-09-10 changedInfo: New Features: - - type: Group Q&A Bot + - type: Enterprise Group Q&A bot changedInfo: - Launched group Q&A bot based on MemOS Cube - type: KV-Cache Performance Optimization @@ -323,7 +375,7 @@ versions: - type: Plaintext Memory Enhancement changedInfo: - Added Reranker sorting functionality for plaintext memory - - type: Playground Updates + - type: Playground Update changedInfo: - Updated Playground version with all the above new features Improvements: @@ -360,7 +412,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 @@ -383,7 +435,7 @@ versions: 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 code categories - Enhanced network logging functionality - Improved scheduler robustness with new exception-catching decorators - Implemented locking for shared resources @@ -407,7 +459,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/settings.yml b/content/en/settings.yml index 270d9332..1e3b7550 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,15 +82,9 @@ nav: - "(ri:file-code-line) API Reference": api-reference/search-memories - - "(ri:robot-line) Openclaw": - - "(ri:book-open-line) Cloud Plugin vs Local Plugin": 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 - - "(ri:computer-line) OpenClaw Local Plugin": openclaw/local_plugin.md - - "(ri:flask-line) Usage Examples": - - "(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:cpu-line) Self-developed Model Description": + - "(ri:flask-line) Extraction Model": + - "(ri:file-code-line) Usage Examples": self_developed_model/extraction_usage_example.md - "(ri:puzzle-line) MCP and Agent Framework Support": - "(ri:tools-line) MCP Services": @@ -99,6 +93,18 @@ nav: - "(ri:robot-line) Agent Development": - "(ri:book-open-line) Guide": mcp_agent/agent/guide.md + - "(ri:robot-line) Agent": + - "(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 + - "(ri:computer-line) OpenClaw Local Plugin": openclaw/local_plugin.md + - "(ri:server-line) Hermes Local Plugin": openclaw/hermes_local_plugin.md + - "(ri:flask-line) Usage Examples": + - "(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:terminal-box-line) Hermes Local Plugin Usage": openclaw/examples/hermes_usage.md + - "(ri:file-code-line) API Documentation": - "(ri:rocket-line) Get Started": - "(ri:eye-line) Overview": api_docs/start/overview.md @@ -111,6 +117,9 @@ nav: - "(ri:delete-bin-line) Delete Memory": api_docs/core/delete_memory.md - "(ri:feedback-line) Add Feedback": api_docs/message/add_feedback.md + - "(ri:cpu-line) Self-developed Model": + - "(ri:flask-line) Extract Memory": api_docs/core/extract_memory.md + - "(ri:chat-1-line) Messages": - "(ri:file-list-line) Get Message": api_docs/message/get_message.md - "(ri:task-line) Get Task Status": api_docs/message/get_status.md @@ -139,4 +148,4 @@ nav: - "(ri:layout-grid-line) Frameworks & Platforms": # - "(ri:chrome-line) Browser Extension": usecase/frameworks/browser_extension.md - "(ri:brain-line) Claude MCP": usecase/frameworks/claude_mcp.md - - "(ri:puzzle-2-line) Coze Plugin Tools": usecase/frameworks/coze_plugin.md + - "(ri:puzzle-2-line) Coze Plugin Tools": usecase/frameworks/coze_plugin.md \ No newline at end of file