diff --git a/AXON.md b/AXON.md index 6dcf8213..08afb9a0 100644 --- a/AXON.md +++ b/AXON.md @@ -128,128 +128,3 @@ This is an educational reverse-engineering project that recreates @anthropic-ai/ - **用户视角审查**:以用户身份体验自己的产品,发现 UX 问题 - **操作流程**:Browser start → goto Web UI → 创建新对话 → 输入任务 → 克隆体独立工作 → 回来检查结果 - 这比 CLI 克隆(`node dist/cli.js -p "..."`)更优,因为可视化、可管理、可追踪 -## Development Commands - -```bash -# Development mode (live TypeScript execution) -npm run dev - -# Build TypeScript to dist/ -npm run build - -# Run compiled version -npm run start # or: node dist/cli.js - -# Type checking without compiling -npx tsc --noEmit -``` - -### Testing - -```bash -npm test # Run all tests (vitest) -npm run test:unit # Unit tests only (src/) -npm run test:integration # Integration tests (tests/integration/) -npm run test:e2e # End-to-end CLI tests -npm run test:coverage # Run with coverage report -npm run test:watch # Watch mode -npm run test:ui # Vitest UI -``` - -### CLI Usage - -```bash -node dist/cli.js # Interactive mode -node dist/cli.js "Analyze this code" # With initial prompt -node dist/cli.js -p "Explain this" # Print mode (non-interactive) -node dist/cli.js -m opus "Complex task" # Specify model (opus/sonnet/haiku) -node dist/cli.js --resume # Resume last session -``` - -## Architecture Overview - -### Core Three-Layer Design - -1. **Entry Layer** (`src/cli.ts`, `src/index.ts`) - - CLI argument parsing with Commander.js - - Main export barrel file - -2. **Core Engine** (`src/core/`) - - `client.ts` - Anthropic API wrapper with retry logic, token counting, cost calculation - - `session.ts` - Session state management, message history, cost tracking - - `loop.ts` - Main conversation orchestrator, handles tool filtering and multi-turn dialogues - -3. **Tool System** (`src/tools/`) - - All tools extend `BaseTool` and register in `ToolRegistry` - - 25+ tools: Bash, Read, Write, Edit, MultiEdit, Glob, Grep, WebFetch, WebSearch, TodoWrite, Task, NotebookEdit, MCP, Tmux, Skills, etc. - -### Key Data Flow - -``` -CLI Input → ConversationLoop → ClaudeClient (Anthropic API) - ↓ ↓ - ToolRegistry Session State - ↓ ↓ - Tool Execution Session Persistence (~/.axon/sessions/) -``` - -### Important Subsystems - -- **Session Management** (`src/session/`) - Persists conversations to `~/.axon/sessions/` with 30-day expiry -- **Configuration** (`src/config/`) - Loads from `~/.axon/settings.json` and environment variables -- **Context Management** (`src/context/`) - Token estimation, auto-summarization when hitting limits -- **Hooks System** (`src/hooks/`) - Pre/post tool execution hooks for customization -- **Plugin System** (`src/plugins/`) - Extensible plugin architecture -- **UI Components** (`src/ui/`) - React + Ink terminal UI framework -- **Code Parser** (`src/parser/`) - Tree-sitter WASM for multi-language parsing -- **Ripgrep** (`src/search/ripgrep.ts`) - Vendored ripgrep binary support -- **Streaming I/O** (`src/streaming/`) - JSON message streaming for Claude API - -## Tool System Architecture - -Tools are the core of the application. Each tool: -1. Extends `BaseTool` class -2. Defines input schema with Zod -3. Implements `execute()` method -4. Registers in `ToolRegistry` -5. Can be filtered via allow/disallow lists - -Tools communicate results back to the conversation loop, which feeds them to the Claude API for the next turn. - -## Configuration - -### Locations (Linux/macOS: `~/.axon/`, Windows: `%USERPROFILE%\.axon\`) - -- **API Key:** `ANTHROPIC_API_KEY` or `AXON_API_KEY` env var, or `settings.json` -- **Sessions:** `sessions/` directory (JSON files, 30-day expiry) -- **MCP Servers:** Defined in `settings.json` -- **Skills:** `~/.axon/skills/` and `./.axon/commands/` -- **Plugins:** `~/.axon/plugins/` and `./.axon/plugins/` - -### Key Environment Variables - -- `ANTHROPIC_API_KEY` / `AXON_API_KEY` - API key for Claude -- `USE_BUILTIN_RIPGREP` - Set to `1`/`true` to use system ripgrep instead of vendored -- `BASH_MAX_OUTPUT_LENGTH` - Max Bash output length (default: 30000) -- `AXON_MAX_OUTPUT_TOKENS` - Max output tokens (default: 32000) - -### Windows-Specific Notes - -- Bubblewrap sandbox: Linux-only (Windows needs WSL) -- Tmux: Linux/macOS only (use Windows Terminal tabs/panes) -- Hook scripts: Use `.bat` or `.ps1` instead of `.sh` -- JSON paths: Use double backslashes (e.g., `"C:\\Users\\user\\projects"`) - -## Key Design Patterns - -- **Registry Pattern** - `ToolRegistry` for dynamic tool management -- **Plugin Pattern** - `PluginManager` with lifecycle hooks -- **Strategy Pattern** - Multiple permission modes (acceptEdits, bypassPermissions, plan) -- **Observer Pattern** - Event-driven hook system - -## TypeScript Configuration - -- **Target:** ES2022, **Module:** NodeNext (ES Modules) -- **JSX:** React (for Ink UI components) -- **Output:** `dist/` with source maps and declarations -- **Strict:** Disabled (`"strict": false`) diff --git a/Dockerfile.railway b/Dockerfile.railway index 148c527c..e5b5e6d1 100644 --- a/Dockerfile.railway +++ b/Dockerfile.railway @@ -1,7 +1,7 @@ # ============================================ # Stage 1: Build # ============================================ -FROM node:18-slim AS builder +FROM node:20-slim AS builder RUN apt-get update && apt-get install -y --fix-missing \ python3 \ @@ -26,7 +26,7 @@ RUN npm --prefix src/web/client ci && \ # ============================================ # Stage 2: Production # ============================================ -FROM node:18-slim +FROM node:20-slim RUN apt-get update && apt-get install -y --fix-missing \ git \ diff --git a/docs/user-guide.md b/docs/user-guide.md index 86f228d0..a9035d59 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -37,6 +37,9 @@ - [7.3 蜂群协作 — 多个 AI 同时干活](#73-蜂群协作--多个-ai-同时干活) - [7.4 定时任务](#74-定时任务) - [7.5 斜杠命令](#75-斜杠命令) + - [7.6 知识库 — 你的个人知识管理系统](#76-知识库--你的个人知识管理系统) + - [7.10 AI 智能编辑 — 代码补全、导览、热力图等](#710-ai-智能编辑--代码补全导览热力图等) + - [7.11 时光回溯 — 文件快照与回滚](#711-时光回溯--文件快照与回滚) - [八、扩展功能](#八扩展功能) - [8.1 技能与插件](#81-技能与插件) - [8.2 MCP 工具扩展](#82-mcp-工具扩展) @@ -45,6 +48,15 @@ - [8.5 感知系统 — 让 AI 看到和听到](#85-感知系统--让-ai-看到和听到) - [8.6 API 代理 — 多设备共享密钥](#86-api-代理--多设备共享密钥) - [8.7 语音朗读](#87-语音朗读) + - [8.8 记忆系统 — AI 自动记住重要信息](#88-记忆系统--ai-自动记住重要信息) + - [8.9 数据采集 — 从热门网站获取结构化信息](#89-数据采集--从热门网站获取结构化信息) + - [8.17 Notebook & REPL — Jupyter 编辑与交互执行](#817-notebook--repl--jupyter-编辑与交互执行) + - [8.18 提示词片段 — 可复用 Prompt 模板](#818-提示词片段--可复用-prompt-模板) + - [8.19 远程协作 — Teleport 远程会话与公网分享](#819-远程协作--teleport-远程会话与公网分享) + - [8.20 自定义工具与 Agent](#820-自定义工具与-agent) + - [8.21 Vim 模式与浏览器扩展](#821-vim-模式与浏览器扩展) + - [8.22 向量数据库管理](#822-向量数据库管理) + - [8.23 高级工具集](#823-高级工具集) - [九、设置面板详解](#九设置面板详解) - [十、常见问题](#十常见问题) - [十一、获取帮助](#十一获取帮助) @@ -273,7 +285,7 @@ export ANTHROPIC_API_KEY=你的中转服务密钥 ┌──────────────────────────────────────────────────────┐ │ 项目选择 ▾ 会话选择 ▾ + 🔍 ⚙️ │ ← 顶部导航栏 ├──────────────────────────────────────────────────────┤ -│ 💬 聊天 📋 蓝图 🐝 蜂群 ⏰ 定时任务 │ ← 功能标签页 +│ 💬 聊天 📋 蓝图 🐝 蜂群 ⏰ 定时任务 🎨 作品 📚 知识库 🧩 自定义 │ ← 功能标签页 ├──────────────────────────────────────────────────────┤ │ │ │ 对话内容区域 │ @@ -305,6 +317,9 @@ export ANTHROPIC_API_KEY=你的中转服务密钥 | **蓝图** | 查看项目分析结果 | 想了解一个项目的整体结构 | | **蜂群** | 查看多 AI 协作进度 | 让多个 AI 一起完成大任务时 | | **定时任务** | 管理定时自动任务 | 需要定期自动执行某些操作 | +| **作品** | 查看 AI 帮你创建的项目和文件 | 想回顾或管理 AI 产出的成果 | +| **知识库** | 管理个人知识库,收藏网页、整理文章、搜索知识图谱 | 积累和检索个人知识资料 | +| **自定义** | 技能、MCP、通道、感知等扩展配置 | 需要扩展 AI 的能力或对接外部工具 | **右上角还有**: - **视图切换按钮** — 可以切换到"代码视图"(像 VS Code 那样左边文件树、中间代码、右边对话) @@ -501,6 +516,178 @@ AI 会自动判断这个任务需要蜂群模式。你也可以主动要求: | `/export` | 导出当前对话 | | `/resume` | 恢复之前的对话 | +### 7.6 知识库 — 你的个人知识管理系统 + +**什么是知识库?** + +你平时看到好文章、好教程,是不是经常收藏了就再也没看过?Axon 的知识库帮你解决这个问题。它不仅能保存内容,还能让 AI 把杂乱的原始素材整理成**结构化的 Wiki 文章**,方便日后随时搜索和回顾。 + +整个流程就像一条生产线: + +``` +剪藏/上传原始内容 → AI 智能编译 → 生成 Wiki 文章 → 搜索和浏览 + (raw) (compile) (wiki) (search) +``` + +--- + +**知识库界面有 4 个子标签**: + +| 标签 | 作用 | +|------|------| +| **文章(Articles)** | 编译后的 Wiki 文章列表,按分类分组展示 | +| **原始源(Raw Sources)** | 你导入的原始文件,可以查看哪些已编译、哪些还在等待 | +| **搜索(Search)** | 输入关键词实时搜索知识库内容 | +| **图谱(Graph)** | 用力导向图可视化展示文章之间的关联关系 | + +--- + +**第一步:往知识库里添加内容** + +有三种方式把内容放进知识库: + +**方式一:剪藏网页** + +在知识库页面点击右上角的 **"剪藏 URL"** 按钮,在弹出的对话框中: +1. 粘贴网页地址 +2. 填写标签(可选,用逗号分隔,比如 `AI, 机器学习`) +3. 选择分类(可选,比如 `AI`、`Web`、`数据库`) +4. 点击 **"剪藏"** + +Axon 会自动抓取网页内容并保存为原始文件。 + +**方式二:在聊天中告诉 AI** + +直接在对话中说: +``` +帮我把这个网页存到知识库:https://example.com/some-article +``` + +AI 会自动完成剪藏操作。 + +**方式三:上传本地文件** + +支持多种格式:PDF、Word、Excel、PPT、HTML、Markdown、JSON 等。直接把文件拖进来或通过上传按钮添加。 + +--- + +**第二步:编译为 Wiki 文章** + +原始内容存进来后,还是"生的"——需要 AI 加工。点击 **"编译全部"** 按钮,AI 会: + +- 提取原始内容中的关键信息 +- 整理成结构清晰的 Wiki 文章 +- 自动生成标题、标签和分类 +- 自动检测文章之间的关键词相似性,建立**反向链接** + +对于短文档,AI 一次性处理完成;对于长文档,AI 会先分块摘要,再合并成一篇完整的文章。 + +--- + +**第三步:搜索和浏览** + +知识库支持**关键词 + 语义混合搜索**。也就是说: +- 输入精确关键词能找到(比如"Transformer") +- 输入意思相近的描述也能找到(比如"注意力机制的神经网络") + +--- + +**知识图谱:看到知识之间的联系** + +切换到 **"图谱"** 标签,你会看到一个力导向可视化图: +- 每个**节点**代表一篇文章 +- 节点之间的**连线**表示文章内容存在关联(反向链接) +- 你可以拖拽节点来探索关系 + +--- + +**浏览器书签小工具** + +如果你经常需要从网页剪藏内容,可以把 Axon 提供的书签小工具拖到浏览器的书签栏。之后在任何网页上点一下这个书签,就能一键把当前页面保存到知识库。 + +--- + +**适合的场景**: +- 做技术调研时,把参考文章统一收集、整理 +- 学习新领域时,把学习资料编译成自己的知识库 +- 项目开发中,积累常用的代码模式、架构文档 +- 把散落各处的收藏夹、笔记集中管理 + +### 7.10 AI 智能编辑 — 让编辑器自己会思考 + +代码视图(点击右上角 `>`)内置了一系列 AI 驱动的编辑增强功能,让你的编辑器像 VS Code + Copilot 一样智能。 + +#### AI 自动补全 + +输入代码时,编辑器会自动显示灰色的建议文本(ghost text)。觉得建议合适,按 **Tab** 键直接接受;不想要就继续打字,建议会自动消失。 + +#### AI Hover 提示 + +把鼠标悬停在函数名或类名上,AI 会自动弹出该符号的文档说明,包括参数含义、返回值和使用示例。 + +#### Code Tour 代码导览 + +AI 分析项目结构,生成分步骤的代码导览路线。适合刚接触一个新项目时,跟着路线走一遍就能快速理解项目脉络。 + +#### 复杂度热力图 + +按行显示代码复杂度,用颜色标注:**红色** = 高复杂度(需要关注),**绿色** = 低复杂度(很清晰)。一眼看出哪些地方最需要重构。 + +#### 重构建议 + +AI 分析代码质量,给出具体的改进建议,比如命名优化、函数拆分、设计模式应用等。右键点击代码即可触发。 + +#### AI 代码气泡 + +AI 在代码旁边生成注释气泡,解释关键代码段的作用。适合阅读不熟悉的代码时使用。 + +#### 死代码检测 + +自动找出项目中没有被使用的函数、变量和导入语句。清理掉它们可以让代码更干净。 + +#### 测试生成 + +选中一段代码,右键选择测试生成,AI 会自动生成对应的测试用例。 + +--- + +**使用方式汇总**: + +| 功能 | 触发方式 | +|------|---------| +| 自动补全 | 输入代码时自动触发,Tab 接受 | +| Hover 提示 | 鼠标悬停在符号上自动触发 | +| Code Tour | 右键菜单 → AI 分析 | +| 复杂度热力图 | 右键菜单 → AI 分析 | +| 重构建议 | 右键菜单 → AI 分析 | +| 代码气泡 | 右键菜单 → AI 分析 | +| 死代码检测 | 右键菜单 → AI 分析 | +| 测试生成 | 选中代码 → 右键菜单 | + +> **提示**:这些功能都在代码视图中可用。如果你还在对话视图,先点击右上角 `>` 切换到代码视图。 + +### 7.11 时光回溯 — AI 改坏了代码?一键回到从前 + +**什么是时光回溯?** + +你让 AI 修改代码时,难免会遇到改坏的情况。时光回溯就是你的"后悔药":AI 每次修改文件前,系统都会自动保存一份快照,你可以随时回到任意一个快照点。 + +**和 Git 有什么区别?** + +| | Git | 时光回溯 | +|------|------|------| +| **粒度** | 按 commit 保存 | 按每次 AI 操作保存 | +| **操作** | 需要手动 commit | 全自动,无需任何操作 | +| **适用场景** | 代码版本管理 | AI 操作的细粒度撤销 | + +**查看和回滚步骤**: + +1. 打开**代码视图**(点击右上角 `>`) +2. 在代码编辑器中打开目标文件 +3. 查看文件的**变更时间线**,每一条记录对应 AI 的一次操作 +4. 点击任意时间点,**预览**该时刻的文件内容 +5. 确认无误后,点击**回滚**,文件就会恢复到那个状态 + --- ## 八、扩展功能 @@ -548,6 +735,7 @@ MCP(Model Context Protocol)是一种标准协议,让 AI 能连接各种外 | **Slack** | Slack 机器人 | | **Discord** | Discord 机器人 | | **WhatsApp** | WhatsApp Cloud API | +| **钉钉** | 钉钉机器人 | **配置方法**:打开自定义页面 > **"消息通道"** 面板,按照提示配置对应平台的 Bot Token。 @@ -612,6 +800,181 @@ AI 回复时可以自动朗读内容。在自定义页面 > **"感知"** 面板 - 说话时自动暂停麦克风(防回声) - 朗读结束后自动恢复麦克风 +### 8.8 记忆系统 — AI 自动记住重要信息 + +Axon 内置了记忆系统,AI 会自动把对话中的重要信息保存下来,下次对话时自动加载,不需要你重复说过的话。 + +**你不需要做任何配置**,记忆系统默认启用。 + +**它是怎么工作的?** + +1. 你和 AI 对话时,AI 会判断哪些信息值得记住 +2. 重要信息自动保存到 `~/.axon/auto-memory/` 目录 +3. 同时维护一个 `MEMORY.md` 索引文件,方便查阅 +4. 每次新对话开始时,AI 自动加载索引,回忆起之前的关键信息 + +**什么信息会被记住?** + +AI 会根据信息类型自动评分,只有足够重要的内容才会保存: + +| 信息类型 | 重要程度 | 举例 | +|---------|---------|------| +| 代码相关 | 最高 | 项目架构、关键函数、技术选型 | +| 设计决策 | 高 | 为什么选了这个方案、接口设计 | +| Bug 与踩坑 | 较高 | 遇到的问题、解决方法 | +| 文档笔记 | 中等 | 项目说明、使用指南 | + +得分低于 0.5 的信息不会被保存,避免记忆库被无关内容占满。 + +**记忆会过时吗?** + +会。系统内置了新鲜度衰减机制,越久远的记忆权重越低: + +- 3 天内的记忆:保留 95% 权重(几乎完整保留) +- 3-14 天前:保留 60% 权重 +- 14-90 天前:保留 20% 权重 +- 90 天以上:保留 5% 权重(基本淡出) + +**笔记本系统** + +除了自动记忆,AI 还维护三个笔记本: + +- **用户档案** — 记录你的偏好、习惯、常用技术栈 +- **经验笔记** — 记录踩坑经验、最佳实践、注意事项 +- **项目笔记** — 记录当前项目的架构、关键决策、待办事项 + +**搜索记忆** + +你可以让 AI 搜索以前记住的内容。系统支持两种搜索方式同时工作: + +- **关键词搜索** — 精确匹配你说的词 +- **语义搜索** — 理解你的意思,找到相关内容(即使用词不同) + +### 8.9 数据采集 — 从热门网站获取结构化信息 + +Axon 内置了 **OpenCLI** 数据采集技能,可以从 78+ 个热门网站提取结构化数据。你只需要用自然语言告诉 AI 你想看什么,它会自动获取并整理好。 + +**支持的网站(部分列举)** + +| 类别 | 网站 | +|------|------| +| **国际热门** | HackerNews、Reddit、Twitter、ProductHunt、StackOverflow、arXiv、LinkedIn | +| **中文社区** | 知乎、B站、小红书、豆瓣、微博、即刻、雪球 | + +**怎么使用?** + +直接在聊天中用自然语言描述你的需求即可: + +``` +帮我看看 HackerNews 今天的热门 +``` + +``` +搜索知乎关于 "大模型微调" 的帖子 +``` + +``` +看看 Reddit 上 r/programming 最近在讨论什么 +``` + +``` +帮我搜一下小红书上关于 MacBook 选购的笔记 +``` + +AI 会自动判断什么时候用哪种方式:简单查询用 WebSearch,需要结构化数据或登录态时自动切换到 OpenCLI。 + +### 8.17 Notebook & REPL — 交互式代码执行 + +Axon 不只能帮你写代码文件,还能直接编辑 Jupyter Notebook 和在交互式环境中运行代码。 + +#### Jupyter Notebook 编辑 + +AI 可以直接操作 `.ipynb` 文件中的单元格:替换、插入、删除。代码单元格被修改后,旧的运行结果会自动清除。 + +**怎么用?** 直接用自然语言告诉 AI: +``` +"帮我修改这个 notebook 的第 3 个单元格,把 pandas 的读取路径改成 data.csv" +``` + +#### REPL 交互式执行 + +支持 **Python 3** 和 **Node.js**,会话内状态持久化(变量、导入都会保留)。 + +**怎么用?** 直接跟 AI 说: +``` +"用 Python 帮我算一下 1 到 100 的平方和" +"先导入 numpy,然后生成一个 3x3 的随机矩阵" +``` + +> 你不需要告诉 AI "请用 REPL 工具"。直接描述你想做什么,AI 会自动判断该用 REPL 还是 Bash。 + +### 8.18 提示词片段 — 让常用指令自动生效 + +每次都要告诉 AI "用中文回复"?**提示词片段**帮你把这些常用规则固定下来,启用后每次发消息都会自动附带。 + +在自定义页面的 **"提示词"** 面板中管理。每个片段可设置名称、内容、是否启用、位置(前置/后置)、标签。 + +**使用场景**: +- "每次回复都用中文" → 创建一个前置片段 +- "代码用 2 空格缩进" → 创建一个前置片段 +- "回复末尾附上修改摘要" → 创建一个后置片段 + +### 8.19 远程协作 — 跨设备、跨网络使用 Axon + +#### Teleport 远程会话 + +通过 WebSocket 连接到另一台机器上运行的 Axon 实例。支持跨设备工作、多人结对编程、远程调试。 + +在聊天中说 `连接到 192.168.1.100:3456 的 Axon` 即可。网络中断会自动重连。 + +#### Cloudflare Tunnel 公网分享 + +一键将 Web UI 暴露到公网,无需公网 IP、无需域名。在设置中开启 Tunnel 即可生成临时公网 URL。URL 重启后会变化(安全考虑)。 + +### 8.20 自定义工具与 Agent — 让 AI 学会新技能 + +#### 自定义工具(CreateTool) + +直接在聊天中告诉 AI 你需要什么工具,AI 会动态创建 JavaScript 工具并保存到 `~/.axon/custom-tools/`,支持热重载。 + +示例:`帮我创建一个工具,可以把当前项目部署到我的服务器` + +#### 自定义 Agent(CreateAgent) + +在自定义页面的 **"Agent"** 面板中创建 AI 角色。每个 Agent 可配置名称、模型、工具、权限、系统提示词。用 `/agent 名字` 切换。 + +实用示例:代码审查员(Opus + 只读工具)、文档写手(Sonnet + 编辑工具)、测试专家(全部工具 + 自动执行)。 + +### 8.21 Vim 模式与浏览器扩展 + +#### Vim 模式 + +> 如果你不知道 Vim 是什么,可以跳过。 + +代码编辑器支持完整 Vim 键绑定(Normal/Insert/Visual 模式、motion、operator、text objects、宏录制)。在设置面板中开启。 + +#### 浏览器扩展(Chrome Extension) + +安装后可在任何网页上选中文字,右键 "Ask Axon" 发送给 AI 分析。安装方式:Chrome 开发者模式加载 `src/browser/extension/` 目录。 + +### 8.22 向量数据库管理 + +在自定义页面的 **"文档"** 面板中查看向量数据库状态(已索引文件数、Chunk 数量、同步状态)。支持手动同步和 Chunk 预览。 + +> 需在设置 > **向量搜索** 中配置 Embedding API 后启用。 + +### 8.23 高级工具集 + +| 功能 | 用途 | 触发方式 | +|------|------|----------| +| **E2E 测试** | 自动化浏览器测试 + 设计稿对比 | 蓝图自动触发 / AI 主动调用 | +| **LSP 分析** | 精确代码导航(定义、引用、调用链) | AI 在代码分析时自动使用 | +| **Ralph Loop** | 反复迭代直到目标达成 | 对话中描述迭代目标 | +| **Bash 补全** | 历史命令补全和搜索 | 终端中按 Tab 或输入 `!` | +| **模式预设** | 一键切换工作模式组合 | 设置面板 → 模式预设 | + +> 这些工具大多由 AI 在合适时机自动调用,无需手动操作。 + --- ## 九、设置面板详解 @@ -715,6 +1078,6 @@ AI 本身支持几乎所有主流编程语言。Axon 的代码分析功能对以 --- -*本手册最后更新:2026-03-08* +*本手册最后更新:2026-04-06 | 版本 2.6.3* *Axon — 让 AI 不只是聊天,而是真正帮你干活* diff --git a/landing-page/index.html b/landing-page/index.html index 2467a6d6..86160789 100644 --- a/landing-page/index.html +++ b/landing-page/index.html @@ -18,7 +18,7 @@
From CLI to Web UI, from single-agent to multi-agent, from local to multi-cloud — going far beyond the original Axon
File operations, search & analysis, Shell execution, web fetching, task management, scheduled tasks, Notebook, Plan mode, Skill system, LSP integration — a comprehensive toolchain
/doctor one-command health check (Node/Git/API/Provider validation). Auto-update system supporting npm/yarn/pnpm/Docker installs
Personal knowledge management platform. Clip web pages, upload documents — AI auto-compiles into structured Wiki articles. Hybrid keyword+semantic search, knowledge graph visualization, automatic backlinks
+Extract structured data from 78+ popular websites. HackerNews, Reddit, Twitter, arXiv, StackOverflow, Zhihu, Bilibili and more. Natural language driven with cookie-based auth support
+Four thinking depth levels (Low/Medium/High/XHigh). AI deeply reasons before answering complex questions, with up to 50K token thinking budget for significantly improved accuracy on hard tasks
+$ npm run dev
Axon v2.1.33
-✓ 已加载 35+ 工具 (含 Agent Teams, Blueprint)
+✓ 已加载 45+ 工具 (含 Agent Teams, Blueprint)
✓ Web UI 已启动 → http://localhost:3000
✓ MCP 服务器已连接 (3 个)
✓ 插件系统就绪 · i18n: zh-CN
@@ -257,7 +257,7 @@ 功能演示
核心功能
- 16 大核心能力
+ 20 大核心能力
从 CLI 到 Web UI,从单体到多智能体,从本地到多云 —— 全面超越原版 Axon
@@ -278,7 +278,7 @@ Blueprint 工作流引擎
- 35+ 内置工具
+ 45+ 内置工具
文件操作、搜索分析、Shell 执行、Web 抓取、任务管理、Notebook、Plan 模式、Skill 系统、LSP 集成等全方位工具链
@@ -341,6 +341,26 @@ 四层安全沙箱
诊断 & 自更新
/doctor 一键健康检查(Node/Git/API/Provider 验证)。自动更新系统支持 npm/yarn/pnpm/Docker 多种安装方式
+
+
+ 知识库系统
+ 个人知识管理平台。剪藏网页、上传文档,AI 自动编译为结构化 Wiki 文章。支持关键词+语义混合搜索、知识图谱可视化、反向链接自动关联
+
+
+
+ 数据采集 OpenCLI
+ 从 78+ 热门网站提取结构化数据。支持 HackerNews、Reddit、知乎、B站、小红书、arXiv 等。自然语言驱动,支持 Cookie 复用登录态
+
+
+
+ 扩展思考模式
+ 四档思考深度(Low/Medium/High/XHigh)。让 AI 在回答复杂问题前先深度推理,支持最高 50K token 思考预算,显著提升复杂任务准确率
+
+
+
+ 定时任务守护进程
+ 后台 Daemon 自动化 AI 任务。支持自然语言时间设定、文件变更监控、多通道通知(桌面 + 飞书)、SQLite 持久化存储 —— 7×24 AI 自动化助手
+
diff --git a/landing-page/zh/user-guide.html b/landing-page/zh/user-guide.html
index bc5a31f8..e92d93c6 100644
--- a/landing-page/zh/user-guide.html
+++ b/landing-page/zh/user-guide.html
@@ -466,6 +466,9 @@
定时任务
目标管理
创建作品
+ 知识库
+ AI 智能编辑
+ 时光回溯
斜杠命令
八、扩展功能
技能与插件
@@ -481,6 +484,16 @@
API 代理
语音朗读
自我进化
+ 数据采集
+ 扩展思考
+ 自动更新
+ Notebook & REPL
+ 提示词片段
+ 远程协作
+ 自定义工具与 Agent
+ Vim 模式与扩展
+ 向量数据库管理
+ 高级工具集
九、设置面板
十、常见问题
十一、获取帮助
@@ -714,19 +727,19 @@ 主界面布局
打开 Axon 后,你会看到这样的界面:
-┌──────────────────────────────────────────────────────┐
-│ 项目选择 ▾ 会话选择 ▾ + 🔍 ⚙️ │ ← 顶部导航栏
-├──────────────────────────────────────────────────────┤
-│ 💬 聊天 📋 蓝图 🐝 蜂群 ⏰ 定时 📂 作品 ⚙ 自定义│ ← 功能标签页
-├──────────────────────────────────────────────────────┤
-│ │
-│ 对话内容区域 │
-│ │
-│ 你看到 AI 的回复、工具调用过程都在这里显示 │
-│ │
-├──────────────────────────────────────────────────────┤
-│ [输入框] 模型 ▾ 权限 ▾ 📎 🎤 发送 │ ← 底部工具栏
-└──────────────────────────────────────────────────────┘
+┌──────────────────────────────────────────────────────────────┐
+│ 项目选择 ▾ 会话选择 ▾ + 🔍 ⚙️ │ ← 顶部导航栏
+├──────────────────────────────────────────────────────────────┤
+│ 💬 聊天 📋 蓝图 🐝 蜂群 ⏰ 定时 📂 作品 📚 知识库 ⚙ 自定义│ ← 功能标签页
+├──────────────────────────────────────────────────────────────┤
+│ │
+│ 对话内容区域 │
+│ │
+│ 你看到 AI 的回复、工具调用过程都在这里显示 │
+│ │
+├──────────────────────────────────────────────────────────────┤
+│ [输入框] 模型 ▾ 权限 ▾ 📎 🎤 发送 │ ← 底部工具栏
+└──────────────────────────────────────────────────────────────┘
顶部导航栏
@@ -746,6 +759,7 @@ 功能标签页
蜂群 查看多 AI 协作进度 让多个 AI 一起完成大任务时
定时任务 管理定时自动任务 需要定期自动执行某些操作
作品 查看 AI 帮你创建的项目和文件 回顾之前让 AI 做过的东西
+ 知识库 管理个人知识库,收藏网页和文档,搜索知识图谱 收藏有用的网页文章,建立个人知识体系
自定义 技能、MCP、通道、感知等扩展配置 配置扩展功能(技能、IM 通道等)
@@ -937,7 +951,126 @@ 7.6 创建作品 — 让 AI 帮你从零搭建项目
创建完成后,你可以在作品列表中看到之前所有的项目和文件操作历史。
-7.7 斜杠命令
+7.8 知识库 — 让 AI 帮你整理知识
+
+什么是知识库?
+想象一个由 AI 自动整理的 Notion 笔记本。你把网页、文档丢进去,AI 帮你编译成结构化的 Wiki 文章,自动提取要点、打标签、建立文章间的关联。再也不用手动整理收藏夹里吃灰的链接了。
+
+点击左侧导航栏的 "知识库" 进入,里面有四个子页面:
+
+
+ 文章
+ 编译完成的结构化文章列表。按分类分组展示,点击任意文章查看全文。每篇文章自动标注标签和关联文章,方便你快速跳转到相关内容。
+
+
+
+ 原始源
+ 已导入的原始文件列表。每条记录会显示编译状态:已编译(绿色)表示 AI 已经处理完毕,待编译(橙色)表示还在排队等待处理。
+
+
+
+ 搜索
+ 输入关键词即可实时搜索知识库内容。采用关键词 + 语义混合搜索,即使你记不清原文的精确用词,也能找到相关文章。
+
+
+
+ 图谱
+ 力导向可视化图,用节点和连线展示文章之间的关联关系。拖动节点可以探索知识网络,直观看到哪些文章主题相近、哪些文章互相引用。
+
+
+怎么往知识库里添加内容?
+
+
+ 剪藏网页 — 点击知识库页面右上角的 "剪藏 URL" 按钮,填入网址、标签、分类,可选是否自动编译。点击保存,AI 会自动抓取网页内容。
+ 上传文件 — 点击 "上传文件" 按钮,选择本地文件上传。支持多种格式(见下方表格)。
+ 聊天导入 — 直接在聊天中说 帮我把这个网页存到知识库,AI 会自动完成剪藏和编译。
+ 浏览器书签 — 使用浏览器书签小工具(Bookmarklet),在任何网页上一键剪藏到知识库,无需切换窗口。
+
+
+支持的文件格式:
+
+
+ 格式 说明
+ PDF 论文、报告、电子书等
+ Word (.docx) 文档、方案、笔记
+ Excel (.xlsx) 数据表格、清单
+ PPT (.pptx) 演示文稿、培训材料
+ HTML 网页内容
+ Markdown (.md) 技术文档、笔记
+
+
+AI 编译是怎么工作的?
+导入的原始内容不会直接变成文章,需要经过 AI "编译"处理:
+
+ - 短文档:AI 直接将原始内容转化为结构化文章,提取标题、要点、标签
+ - 长文档:AI 先分块摘要每个部分,再合并为一篇完整的文章
+ - 编译过程中会自动提取分类和标签,方便后续查找
+ - AI 会自动检测新文章与已有文章的关联,建立反向链接
+
+
+你可以逐篇点击 "编译" 按钮处理,也可以点击 "编译全部" 一键处理所有待编译的原始内容。
+
+知识库的搜索功能支持语义搜索,即使你用不同的词描述同一件事,AI 也能找到相关文章。比如搜索"机器学习"也能找到关于"深度学习"或"神经网络"的文章。
+
+7.10 AI 智能编辑 — 让编辑器自己会思考
+
+代码视图(点击右上角 </>)内置了一系列 AI 驱动的编辑增强功能,让你的编辑器像 VS Code + Copilot 一样智能。
+
+
+ AI 自动补全
+ 输入代码时,编辑器会自动显示灰色的建议文本(ghost text)。觉得建议合适,按 Tab 键直接接受;不想要就继续打字,建议会自动消失。
+
+
+
+ AI Hover 提示
+ 把鼠标悬停在函数名或类名上,AI 会自动弹出该符号的文档说明,包括参数含义、返回值和使用示例。不需要再翻文档了。
+
+
+
+ Code Tour 代码导览
+ AI 分析项目结构,生成分步骤的代码导览路线。适合刚接触一个新项目时,跟着路线走一遍就能快速理解项目脉络。
+
+
+
+ 复杂度热力图
+ 按行显示代码复杂度,用颜色标注:红色 = 高复杂度(需要关注),绿色 = 低复杂度(很清晰)。一眼看出哪些地方最需要重构。
+
+
+
+ 重构建议 / AI 代码气泡 / 死代码检测 / 测试生成
+ 右键点击代码即可触发 AI 分析菜单:获取重构建议、生成代码注释气泡、检测未使用的函数和变量、自动生成测试用例。
+
+
+
+ 功能 触发方式
+ 自动补全 输入代码时自动触发,Tab 接受
+ Hover 提示 鼠标悬停在符号上自动触发
+ Code Tour / 热力图 / 重构建议 / 气泡 / 死代码 / 测试 右键菜单 → AI 分析
+
+
+这些功能都在代码视图中可用。如果你还在对话视图,先点击右上角 </> 切换到代码视图。
+
+7.11 时光回溯 — AI 改坏了代码?一键回到从前
+
+什么是时光回溯?
+你让 AI 修改代码时,难免会遇到改坏的情况。时光回溯就是你的"后悔药":AI 每次修改文件前,系统都会自动保存一份快照,你可以随时回到任意一个快照点。
+
+
+ Git 时光回溯
+ 粒度 按 commit 保存 按每次 AI 操作保存
+ 操作 需要手动 commit 全自动,无需任何操作
+ 适用场景 代码版本管理 AI 操作的细粒度撤销
+
+
+
+ 打开代码视图(点击右上角 </>)
+ 在代码编辑器中打开目标文件
+ 查看文件的变更时间线,每一条记录对应 AI 的一次操作
+ 点击任意时间点,预览该时刻的文件内容
+ 确认无误后,点击回滚,文件就会恢复到那个状态
+
+
+7.12 斜杠命令
在输入框输入 / 会弹出命令列表,这些是快捷操作:
@@ -1046,6 +1179,7 @@ 8.6 消息通道 — 连接 IM 软件
Slack Slack 机器人
Discord Discord 机器人
WhatsApp WhatsApp Cloud API
+ 钉钉 钉钉机器人(需在钉钉开放平台创建机器人)
配置方法:打开自定义页面 > "消息通道" 面板,按照提示配置对应平台的 Bot Token。
@@ -1131,7 +1265,7 @@ 8.11 Agent 网络 — 多个 AI 实例协作
8.12 记忆系统 — AI 越用越懂你
-Axon 拥有持久化记忆能力,不会像普通聊天机器人那样"忘记一切":
+Axon 拥有持久化记忆能力,不会像普通聊天机器人那样"忘记一切"。它会自动记住你的偏好、项目知识和过往经验,越用越聪明:
笔记本(Notebook)
@@ -1143,7 +1277,22 @@ 8.12 记忆系统 — AI 越用越懂你
AI 能搜索过去的对话历史,回忆之前讨论过的内容。支持关键词搜索和向量语义搜索(需配置 Embedding 模型)。
-你不需要做任何配置,记忆系统默认启用。如果想要更精准的语义搜索,可以在设置中配置 Embedding API。
+
+ 自动记忆(Auto-Memory)
+ AI 会自动将对话中的重要信息保存到本地(~/.axon/auto-memory/ 目录)。每个项目有独立的记忆空间,互不干扰。记忆按主题分类存放——比如调试经验存在 debugging.md,代码模式存在 patterns.md。每次新对话开始时,AI 会自动加载索引文件(MEMORY.md,最多 200 行),快速回忆关键信息。你不需要手动整理,一切全自动。
+
+
+
+ 智能评分与自动清理
+ 不是所有信息都值得记住。AI 会根据内容类型自动打分:代码相关得分最高(90 分)、设计决策次之(85 分)、Bug 记录(80 分)、文档笔记(70 分)。同时,越久没用到的记忆分数越低——3 天内几乎不降分,3-14 天降到 60%,14-90 天降到 20%,超过 90 天只保留 5%。得分低于 50 的记忆会被自动过滤,确保 AI 脑子里只留真正有用的东西。
+
+
+
+ 时间感知排序
+ 记忆系统会跟踪每条记忆的创建时间、更新时间和最近访问时间。经常被用到的记忆会排在前面(类似"最近使用"排序),确保 AI 优先回忆你最近关心的内容,而不是翻出半年前的旧事。
+
+
+你不需要做任何配置,记忆系统(包括自动记忆)默认启用。如果想要更精准的语义搜索,可以在设置中配置 Embedding API。
8.13 自我进化 — AI 改进自己
@@ -1161,6 +1310,247 @@ 8.13 自我进化 — AI 改进自己
注意:自我进化功能需要使用 --evolve 参数启动 Axon。普通模式下此功能不可用。
+8.14 数据采集 — 从 78+ 网站提取结构化数据
+
+Axon 内置了 OpenCLI 技能,可以从全球 78+ 主流网站中提取结构化数据。你只需要用自然语言描述想要的内容,AI 会自动判断是否需要调用 OpenCLI,并为你整理好结果。
+
+支持的平台一览:
+
+
+ 分类 平台 能采集什么
+ 技术社区 HackerNews、Reddit、Lobsters、Dev.to、StackOverflow 热门帖子、讨论内容、高赞回答
+ 社交媒体(国际) Twitter/X、LinkedIn、ProductHunt 热门推文、产品发布、行业动态
+ 视频平台 YouTube、B站(Bilibili) 热门视频、频道内容、分区排行
+ 学术论文 arXiv 最新论文、按领域检索
+ 中文社区 知乎、小红书、豆瓣、微博、即刻 热门话题、高赞回答、笔记内容
+ 金融财经 Yahoo Finance、雪球 股票行情、财经资讯、投资讨论
+
+
+怎么用?直接在聊天框用自然语言说就行,不需要记任何命令:
+
+
+ 技术资讯
+
+ "帮我看看 HackerNews 今天的热门帖子"
+ "Reddit 上关于 Rust 语言的最新讨论有哪些?"
+ "获取 arXiv 最新的 LLM 论文"
+
+
+
+
+ 中文平台
+
+ "搜索知乎关于 AI 编程的高赞回答"
+ "获取 B站科技区今日热门视频"
+ "查看小红书上关于露营装备的笔记"
+
+
+
+
+ 金融数据
+
+ "查看雪球上关于新能源板块的讨论"
+ "获取 Yahoo Finance 上特斯拉的最新资讯"
+
+
+
+典型应用场景:
+
+ - 市场调研 — 快速收集竞品在各平台上的口碑和讨论
+ - 技术趋势监控 — 追踪 HackerNews、Reddit 上的热门技术话题
+ - 学术论文追踪 — 定期获取 arXiv 上特定领域的最新论文
+ - 社交媒体热点分析 — 了解微博、知乎上的舆论趋势
+
+
+
+ AI 会自动判断是否需要使用 OpenCLI。当普通网页搜索无法获取结构化数据时,AI 会自动切换到 OpenCLI 来提取更精确的结果。
+
+
+
+ Cookie 复用:OpenCLI 支持复用浏览器的登录状态。如果你在浏览器中已经登录了某个平台(如知乎、小红书),AI 采集时可以利用你的登录态获取更完整的内容。
+
+
+
+8.15 扩展思考 — 让 AI 深度思考再回答
+
+面对复杂问题(数学推理、架构设计、多步骤分析),普通的即时回答可能不够准确。开启扩展思考后,AI 会先在内部进行一轮深度思考,再给出更精准的回答。
+
+
+ 四个思考档位
+
+
+ - 低(Low) — 轻量思考,响应较快,适合日常使用
+ - 中(Medium) — 中等深度,适合一般复杂问题
+ - 高(High) — 深度推理,适合数学和逻辑难题
+ - 超高(XHigh) — 最高 50K token 思考预算,适合最复杂的场景
+
+
+
+
+如何开启:
+
+ - 点击右上角 ⚙️ 打开设置面板
+ - 找到 "思考模式" 选项
+ - 选择一个档位即可生效
+
+
+建议:日常对话保持关闭或使用低档。遇到复杂推理问题时,临时切换到高档或超高档,问完再切回来。
+
+注意:档位越高,token 消耗和响应时间越大。超高档单次思考最多消耗 50K token。云端部署(axon-cloud)默认关闭扩展思考,需在设置中手动开启。
+
+
+8.16 桌面应用自动更新
+
+Axon 桌面端(Electron 版本)内置了自动更新功能,保持应用始终最新,无需手动下载安装包。
+
+更新流程:
+
+ - 启动桌面应用时,自动检测是否有新版本
+ - 如果有新版本,后台静默下载更新包(不影响你正常使用)
+ - 下载完成后,状态栏会出现提示
+ - 点击 "重启更新",应用重启后即完成升级
+
+
+支持 Windows 和 macOS 平台。整个过程无需手动操作,你只需要在方便的时候点击重启即可。
+
+8.17 Notebook & REPL — 交互式代码执行
+
+Axon 不只能帮你写代码文件,还能直接编辑 Jupyter Notebook 和在交互式环境中运行代码。
+
+Jupyter Notebook 编辑
+
+AI 可以直接操作 .ipynb 文件中的单元格:替换、插入、删除。代码单元格被修改后,旧的运行结果会自动清除。
+
+
+ Notebook 操作示例
+
+ "帮我修改这个 notebook 的第 3 个单元格,把 pandas 的读取路径改成 data.csv"
+ "在第 2 个单元格后面插入一个新的代码单元格,内容是绘制柱状图"
+
+
+
+REPL 交互式执行
+
+支持 Python 3 和 Node.js,会话内状态持久化——变量、导入都会保留。
+
+
+ REPL 使用示例
+
+ "用 Python 帮我算一下 1 到 100 的平方和"
+ "先导入 numpy,然后生成一个 3x3 的随机矩阵"
+ "接着上面的结果,计算这个矩阵的特征值"
+
+
+
+你不需要告诉 AI "请用 REPL 工具"。直接描述你想做什么,AI 会自动判断该用 REPL 还是 Bash。
+
+8.18 提示词片段 — 让常用指令自动生效
+
+每次都要告诉 AI "用中文回复"?提示词片段帮你把常用规则固定下来,启用后每次发消息都会自动附带。
+
+在自定义页面的 "提示词" 面板中管理。每个片段可设置名称、内容、是否启用、位置(前置/后置)、标签。
+
+
+ 使用场景
+
+ 前置片段:"请用中文回复"、"代码缩进使用 2 个空格"
+ 后置片段:"在回复末尾用一句话总结本次修改"
+
+
+
+8.19 远程协作 — 跨设备、跨网络使用 Axon
+
+Teleport 远程会话
+
+通过 WebSocket 连接到另一台机器上运行的 Axon 实例,支持跨设备工作、多人结对编程、远程调试。
+
+
+ 连接远程 Axon
+ "连接到 192.168.1.100:3456 的 Axon"
+
+
+双方需要在同一局域网内。网络中断会自动重连(指数退避策略)。
+
+Cloudflare Tunnel 公网分享
+
+一键将 Web UI 暴露到公网,无需公网 IP 和域名。在设置中开启 Tunnel 即可生成临时 URL。
+
+安全提示:URL 在 Axon 重启后会变化。适合临时分享,不建议长期暴露。
+
+8.20 自定义工具与 Agent — 让 AI 学会新技能
+
+自定义工具(CreateTool)
+
+直接在聊天中告诉 AI 你需要什么工具,AI 会动态创建并保存到 ~/.axon/custom-tools/,支持热重载。
+
+
+ 示例
+ "帮我创建一个工具,可以把当前项目部署到我的服务器"
+
+
+自定义 Agent(CreateAgent)
+
+在自定义页面的 "Agent" 面板中创建 AI 角色,配置名称、模型、工具、权限、系统提示词。用 /agent 名字 切换。
+
+
+ 实用 Agent 示例
+
+ 代码审查员 — Opus + 只读工具 + 严格检查
+ 文档写手 — Sonnet + 编辑工具 + 专注文档
+ 测试专家 — 全部工具 + 自动执行 + 生成测试
+
+
+
+8.21 Vim 模式与浏览器扩展
+
+Vim 模式
+
+如果你不知道 Vim 是什么,可以放心跳过。
+
+代码编辑器支持完整 Vim 键绑定(Normal/Insert/Visual 模式、motion、operator、text objects、宏录制)。在设置面板 > 编辑器中开启。
+
+浏览器扩展(Chrome Extension)
+
+安装后可在任何网页上选中文字,右键 "Ask Axon" 发送给 AI 分析。
+安装:Chrome 开发者模式加载 src/browser/extension/ 目录。
+
+8.22 向量数据库管理
+
+在自定义页面的 "文档" 面板中查看向量数据库状态(已索引文件数、Chunk 数量、同步状态)。支持手动同步和 Chunk 预览。
+
+需在设置 > 向量搜索 中配置 Embedding API 后启用。
+
+8.23 高级工具集
+
+面向进阶用户的工具,能显著提升开发效率和自动化程度。
+
+
+ E2E 测试框架
+ AI 自动运行端到端浏览器测试(Playwright),支持设计稿像素级对比。测试失败时自动反馈给 AI 修复。
+
+
+
+ LSP 代码分析
+ 精确代码导航:跳转定义、查找引用、调用层次分析。比 grep 更准确,理解代码语义。
+
+
+
+ Ralph Loop 自迭代
+ 自动循环执行任务直到目标达成。典型场景:"把这个功能改到所有测试通过为止"。
+
+
+
+ Bash 历史补全
+ 终端中按 Tab 补全历史命令,用 ! 搜索历史记录。
+
+
+
+ 模式预设
+ 保存"权限+模型+Hook"组合预设,一键切换。如:开发模式(Sonnet+自动编辑)、审查模式(Opus+询问确认)。
+
+
+这些工具大多由 AI 在合适时机自动调用,无需手动操作。
+
@@ -1257,6 +1647,15 @@ Q: 怎么让 AI 记住我的偏好?
Q: 支持哪些编程语言?
AI 本身支持几乎所有主流编程语言。Axon 的代码分析功能对 JavaScript、TypeScript、Python、Java、Go、Rust、C/C++、Ruby、PHP、Swift、Kotlin 等有语法高亮和智能分析支持。
+Q: 知识库收藏的内容存在哪里?
+存在本地 ~/.axon/knowledge/ 目录下。原始内容在 raw/ 子目录,编译后的文章在 wiki/ 子目录。不会上传到任何云端服务。
+
+Q: 怎么在浏览器中一键收藏网页到知识库?
+在知识库页面底部有一个"书签小工具"链接,将它拖到浏览器书签栏。之后在任何网页上点击这个书签就能一键保存到 Axon 知识库。
+
+Q: 扩展思考模式有什么用?
+扩展思考让 AI 在回答前先进行深度推理,适合复杂的数学、逻辑、架构设计问题。会增加响应时间和费用,日常简单问题不建议开启。
+
@@ -1283,7 +1682,7 @@ 十一、获取帮助
diff --git a/src/auth/index.ts b/src/auth/index.ts
index 8158a0b0..09cb2867 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -1108,7 +1108,7 @@ export async function exchangeAuthorizationCode(
});
console.log('Response status:', response.status);
- console.log('Response headers:', Object.fromEntries(response.headers.entries()));
+ console.log('Response headers:', Object.fromEntries((response.headers as any).entries()));
if (!response.ok) {
const error = await response.text();
diff --git a/src/core/loop.ts b/src/core/loop.ts
index 26edeed5..d1ae1e34 100644
--- a/src/core/loop.ts
+++ b/src/core/loop.ts
@@ -2764,7 +2764,9 @@ export class ConversationLoop {
private async refreshPromptMemoryContext(userInput: string | AnyContentBlock[]): Promise {
try {
const notebookMgr = getNotebookManager() || initNotebookManager(this.promptContext.workingDir);
- const freshSummary = notebookMgr.getNotebookSummaryForPrompt();
+ // 传入用户查询上下文,让 project.md 按需加载相关 section
+ const queryText = this.extractRecallQuery(userInput);
+ const freshSummary = notebookMgr.getNotebookSummaryForPrompt(queryText || undefined);
this.promptContext.notebookSummary = freshSummary || undefined;
} catch {
// 笔记本加载失败不影响主流程
diff --git a/src/knowledge/compiler.ts b/src/knowledge/compiler.ts
new file mode 100644
index 00000000..4d332b38
--- /dev/null
+++ b/src/knowledge/compiler.ts
@@ -0,0 +1,501 @@
+/**
+ * KnowledgeCompiler - 知识编译器
+ * 将 raw 原始文件通过 LLM 编译为结构化 wiki 文章
+ *
+ * 编译策略:
+ * - 短文档(≤ 8K tokens):整篇一次性给 LLM → wiki .md(支持多模态图片)
+ * - 长文档(> 8K tokens):分块摘要 → 二次合并 → wiki .md(每块带关联图片)
+ * - 增量更新:检查已有文章,相关内容合并而非重复
+ */
+
+import { KnowledgeStore } from './store.js';
+import type { WikiArticle, CompileResult, CompileOptions, ExtractedImageRef } from './types.js';
+import { regenerateAllBacklinks } from './linker.js';
+
+/**
+ * 估算 token 数(粗略:1 token ≈ 4 字符英文 / 1.5 字符中文)
+ */
+function estimateTokens(text: string): number {
+ // CJK 字符计数
+ const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
+ const nonCjkChars = text.length - cjkChars;
+ return Math.ceil(cjkChars / 1.5 + nonCjkChars / 4);
+}
+
+/**
+ * 将文本分块,返回每个 chunk 及其关联的页面范围
+ */
+function chunkText(text: string, maxTokensPerChunk: number = 4000): Array<{ text: string; startLine: number; endLine: number }> {
+ const lines = text.split('\n');
+ const chunks: Array<{ text: string; startLine: number; endLine: number }> = [];
+ let current: string[] = [];
+ let currentTokens = 0;
+ let startLine = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const lineTokens = estimateTokens(line);
+ if (currentTokens + lineTokens > maxTokensPerChunk && current.length > 0) {
+ chunks.push({ text: current.join('\n'), startLine, endLine: i - 1 });
+ current = [];
+ currentTokens = 0;
+ startLine = i;
+ }
+ current.push(line);
+ currentTokens += lineTokens;
+ }
+
+ if (current.length > 0) {
+ chunks.push({ text: current.join('\n'), startLine, endLine: lines.length - 1 });
+ }
+
+ return chunks;
+}
+
+/**
+ * 根据 pageIndex 将图片分配给文本 chunk
+ * 没有 pageIndex 的图片分配给第一个 chunk
+ */
+function distributeImagesToChunks(
+ images: ExtractedImageRef[],
+ chunkCount: number,
+): ExtractedImageRef[][] {
+ const distributed: ExtractedImageRef[][] = Array.from({ length: chunkCount }, () => []);
+ for (const img of images) {
+ if (img.pageIndex !== undefined && img.pageIndex > 0) {
+ // 按 pageIndex 分配到对应 chunk(粗略映射)
+ const chunkIdx = Math.min(
+ Math.floor((img.pageIndex - 1) / Math.max(1, Math.ceil(20 / chunkCount))),
+ chunkCount - 1,
+ );
+ distributed[chunkIdx].push(img);
+ } else {
+ distributed[0].push(img);
+ }
+ }
+ return distributed;
+}
+
+// ============ 提示词模板 ============
+
+const COMPILE_SYSTEM_PROMPT = `你是一个知识编译器。你的任务是将原始文本编译为高质量的结构化 wiki 文章。
+
+输出要求:
+1. 文章开头第一行是标题(# 标题)
+2. 紧接一段 1-2 句的摘要
+3. 用清晰的 Markdown 格式组织正文,提取核心观点和关键信息
+4. 保留重要的代码示例、数据、引用
+5. 文章末尾输出元数据块(严格遵循格式):
+
+\`\`\`meta
+title: 文章标题
+tags: tag1, tag2, tag3
+category: 分类名
+\`\`\`
+
+注意:
+- 不要丢失关键信息,但去除噪音(导航、广告、无关链接)
+- 保持客观,不添加原文没有的观点
+- 如果原文是英文,wiki 文章也用英文;中文同理
+- category 应该是一个宽泛的领域词(如 AI, Web, Database, Systems, Security 等)`;
+
+const COMPILE_SYSTEM_PROMPT_WITH_IMAGES = `你是一个知识编译器。你的任务是将原始文本和图片编译为高质量的结构化 wiki 文章。
+
+输出要求:
+1. 文章开头第一行是标题(# 标题)
+2. 紧接一段 1-2 句的摘要
+3. 用清晰的 Markdown 格式组织正文,提取核心观点和关键信息
+4. 保留重要的代码示例、数据、引用
+5. **仔细分析每张图片并将视觉内容整合到文章中**:
+ - 科学图片(Western Blot、电泳、流式细胞术):描述条带位置、相对强度、分子量标记、实验结论
+ - 数据图表(柱状图、折线图、散点图):描述趋势、关键数据点、统计显著性
+ - 示意图/流程图:描述结构、步骤和关系
+ - 表格/数据:提取关键数值和对比
+6. 文章末尾输出元数据块(严格遵循格式):
+
+\`\`\`meta
+title: 文章标题
+tags: tag1, tag2, tag3
+category: 分类名
+\`\`\`
+
+注意:
+- 不要丢失关键信息,图片中的数据和文字中的数据同等重要
+- 保持客观,不添加原文没有的观点
+- 如果原文是英文,wiki 文章也用英文;中文同理
+- category 应该是一个宽泛的领域词(如 AI, Web, Database, Systems, Security 等)`;
+
+const COMPILE_USER_PROMPT = `请将以下原始文本编译为 wiki 文章:
+
+{text}`;
+
+const COMPILE_USER_PROMPT_WITH_IMAGES = `请将以下原始文本和附带的 {imageCount} 张图片编译为 wiki 文章。图片按顺序对应文本中的引用位置。
+
+{text}`;
+
+const SUMMARIZE_CHUNK_PROMPT = `请对以下文本片段生成详细摘要,保留所有关键信息、数据点和代码示例:
+
+{text}`;
+
+const SUMMARIZE_CHUNK_WITH_IMAGES_PROMPT = `请对以下文本片段和附带的图片生成详细摘要。保留所有关键信息、数据点和代码示例,并详细描述图片中的视觉内容:
+
+{text}`;
+
+const MERGE_SUMMARIES_PROMPT = `以下是一篇长文档各部分的摘要。请将它们合并为一篇完整的结构化 wiki 文章。
+
+要求和上面的编译器一样:标题 + 摘要 + 正文 + meta 块。
+
+各部分摘要:
+
+{summaries}`;
+
+const UPDATE_ARTICLE_PROMPT = `已有一篇 wiki 文章,现在有新的相关信息需要合并进去。请更新这篇文章,整合新内容,不要丢失已有信息。
+
+输出更新后的完整文章(格式同编译器要求:标题 + 摘要 + 正文 + meta 块)。
+
+===已有文章===
+{existing}
+
+===新内容===
+{new_content}`;
+
+/**
+ * 编译器接受外部传入的 ConversationClient,复用 Chat 的完整认证和模型配置
+ */
+interface ConversationClient {
+ createMessage(messages: any[], tools?: any, systemPrompt?: any, options?: any): Promise;
+}
+
+export class KnowledgeCompiler {
+ private store: KnowledgeStore;
+ private maxSinglePassTokens: number;
+ private client?: ConversationClient;
+
+ constructor(store: KnowledgeStore, opts?: { maxSinglePassTokens?: number; client?: ConversationClient }) {
+ this.store = store;
+ this.maxSinglePassTokens = opts?.maxSinglePassTokens || 8000;
+ this.client = opts?.client;
+ }
+
+ /**
+ * 调用 LLM(支持纯文本和多模态图片)
+ */
+ private async callLLM(
+ systemPrompt: string,
+ userPrompt: string,
+ images?: ExtractedImageRef[],
+ ): Promise {
+ if (!this.client) {
+ throw new Error('No LLM client provided. Pass a client via constructor options.');
+ }
+
+ let content: any;
+
+ if (images && images.length > 0) {
+ // 多模态:text + images 交织
+ const blocks: any[] = [{ type: 'text', text: `${systemPrompt}\n\n${userPrompt}` }];
+ for (const img of images) {
+ blocks.push({
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: img.mimeType,
+ data: img.base64,
+ },
+ });
+ }
+ content = blocks;
+ } else {
+ // 纯文本
+ content = `${systemPrompt}\n\n${userPrompt}`;
+ }
+
+ const response = await this.client.createMessage(
+ [{ role: 'user', content }],
+ [],
+ undefined,
+ { preferStreamingTransport: true },
+ );
+
+ const text = response.content
+ .filter((b: any) => b.type === 'text' && b.text)
+ .map((b: any) => b.text)
+ .join('');
+
+ if (!text) throw new Error('LLM returned empty response');
+ return text;
+ }
+
+ /**
+ * 编译单个 raw 文件为 wiki 文章
+ */
+ async compileOne(slug: string, opts?: { force?: boolean }): Promise {
+ const rawContent = await this.store.readRawFile(slug);
+ if (!rawContent) {
+ throw new Error(`Raw file not found: ${slug}`);
+ }
+
+ // 检查是否已有对应文章
+ const existingArticle = await this.store.findArticleBySlug(slug);
+ if (existingArticle && !opts?.force) {
+ // 已编译且不强制,跳过
+ return existingArticle;
+ }
+
+ // 解析 frontmatter 获取预设元数据
+ const fm = this.store.parseRawFrontmatter(rawContent);
+ const body = this.store.stripFrontmatter(rawContent);
+ const tokens = estimateTokens(body);
+
+ // 加载关联图片(如果有)
+ let images: ExtractedImageRef[] = [];
+ if (fm.images && fm.images > 0) {
+ images = await this.store.readImageFiles(slug);
+ }
+
+ let wikiContent: string;
+
+ if (tokens <= this.maxSinglePassTokens) {
+ // 短文档:整篇一次性编译
+ wikiContent = await this.compileSinglePass(body, images);
+ } else {
+ // 长文档:分块摘要 → 二次合并
+ wikiContent = await this.compileMultiPass(body, images);
+ }
+
+ // 解析 LLM 输出的元数据
+ const parsed = this.parseCompileOutput(wikiContent);
+
+ // 构建文章元数据
+ const article = existingArticle
+ ? { ...existingArticle, updatedAt: new Date().toISOString() }
+ : this.store.createArticleMeta({
+ slug,
+ title: parsed.title || slug,
+ tags: fm.tags || parsed.tags,
+ category: fm.category || parsed.category,
+ sourceFiles: [`${slug}.md`],
+ });
+
+ // 更新元数据(LLM 可能提取了更好的标签和分类)
+ if (parsed.tags.length > 0 && !fm.tags) article.tags = parsed.tags;
+ if (parsed.category && !fm.category) article.category = parsed.category;
+ if (parsed.title) article.title = parsed.title;
+ article.updatedAt = new Date().toISOString();
+
+ // 写入 wiki 文件和元数据
+ await this.store.writeWikiArticle(slug, parsed.cleanContent);
+ await this.store.upsertArticle(article);
+
+ return article;
+ }
+
+ /**
+ * 短文档编译:整篇一次性给 LLM(支持多模态)
+ */
+ private async compileSinglePass(text: string, images?: ExtractedImageRef[]): Promise {
+ const hasImages = images && images.length > 0;
+ const sysPrompt = hasImages ? COMPILE_SYSTEM_PROMPT_WITH_IMAGES : COMPILE_SYSTEM_PROMPT;
+ const userPrompt = hasImages
+ ? COMPILE_USER_PROMPT_WITH_IMAGES
+ .replace('{imageCount}', String(images!.length))
+ .replace('{text}', text)
+ : COMPILE_USER_PROMPT.replace('{text}', text);
+
+ return this.callLLM(sysPrompt, userPrompt, hasImages ? images : undefined);
+ }
+
+ /**
+ * 长文档编译:分块 → 摘要 → 合并(每块带关联图片)
+ */
+ private async compileMultiPass(text: string, images?: ExtractedImageRef[]): Promise {
+ const chunks = chunkText(text, 4000);
+ const summaries: string[] = [];
+
+ // 按 chunk 分配图片
+ const chunkImages = images && images.length > 0
+ ? distributeImagesToChunks(images, chunks.length)
+ : chunks.map(() => [] as ExtractedImageRef[]);
+
+ // 逐块摘要
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i];
+ const imgs = chunkImages[i];
+ const hasImages = imgs.length > 0;
+
+ const prompt = hasImages
+ ? SUMMARIZE_CHUNK_WITH_IMAGES_PROMPT.replace('{text}', chunk.text)
+ : SUMMARIZE_CHUNK_PROMPT.replace('{text}', chunk.text);
+
+ const summary = await this.callLLM(
+ '你是一个文本摘要器。生成详细摘要,保留关键信息。' +
+ (hasImages ? '请同时描述图片中的视觉内容。' : ''),
+ prompt,
+ hasImages ? imgs : undefined,
+ );
+ summaries.push(summary);
+ }
+
+ // 合并所有摘要为最终文章(纯文本,不再发图片)
+ const allSummaries = summaries
+ .map((s, i) => `### Part ${i + 1}\n${s}`)
+ .join('\n\n');
+
+ return this.callLLM(
+ COMPILE_SYSTEM_PROMPT,
+ MERGE_SUMMARIES_PROMPT.replace('{summaries}', allSummaries),
+ );
+ }
+
+ /**
+ * 更新已有文章(合并新内容,支持图片)
+ */
+ async updateArticle(articleId: string, newRawSlug: string): Promise {
+ const article = await this.store.findArticle(articleId);
+ if (!article) throw new Error(`Article not found: ${articleId}`);
+
+ const existingContent = await this.store.readWikiArticle(article.slug);
+ if (!existingContent) throw new Error(`Wiki file not found: ${article.slug}`);
+
+ const newRawContent = await this.store.readRawFile(newRawSlug);
+ if (!newRawContent) throw new Error(`Raw file not found: ${newRawSlug}`);
+
+ const newBody = this.store.stripFrontmatter(newRawContent);
+ const newFm = this.store.parseRawFrontmatter(newRawContent);
+
+ // 加载新内容的图片
+ let images: ExtractedImageRef[] = [];
+ if (newFm.images && newFm.images > 0) {
+ images = await this.store.readImageFiles(newRawSlug);
+ }
+
+ const sysPrompt = images.length > 0 ? COMPILE_SYSTEM_PROMPT_WITH_IMAGES : COMPILE_SYSTEM_PROMPT;
+
+ const updatedContent = await this.callLLM(
+ sysPrompt,
+ UPDATE_ARTICLE_PROMPT
+ .replace('{existing}', existingContent)
+ .replace('{new_content}', newBody),
+ images.length > 0 ? images : undefined,
+ );
+
+ const parsed = this.parseCompileOutput(updatedContent);
+
+ // 更新文章
+ article.updatedAt = new Date().toISOString();
+ if (!article.sourceFiles.includes(`${newRawSlug}.md`)) {
+ article.sourceFiles.push(`${newRawSlug}.md`);
+ }
+ if (parsed.tags.length > 0) article.tags = parsed.tags;
+ if (parsed.category) article.category = parsed.category;
+ if (parsed.title) article.title = parsed.title;
+
+ await this.store.writeWikiArticle(article.slug, parsed.cleanContent);
+ await this.store.upsertArticle(article);
+
+ return article;
+ }
+
+ /**
+ * 批量编译
+ */
+ async compile(opts?: CompileOptions): Promise {
+ const result: CompileResult = {
+ compiled: 0,
+ updated: 0,
+ failed: 0,
+ errors: [],
+ articles: [],
+ };
+
+ let slugs: string[];
+
+ if (opts?.slugs?.length) {
+ slugs = opts.slugs;
+ } else {
+ // 默认编译所有未编译的
+ slugs = opts?.force
+ ? await this.store.listRawSlugs()
+ : await this.store.listUncompiledSlugs();
+ }
+
+ if (slugs.length === 0) {
+ return result;
+ }
+
+ // 加载现有 meta
+ const meta = await this.store.loadMeta();
+
+ for (const slug of slugs) {
+ try {
+ const existing = await this.store.findArticleBySlug(slug);
+ const article = await this.compileOne(slug, { force: opts?.force });
+
+ if (article) {
+ result.articles.push(article);
+ if (existing && !opts?.force) {
+ result.updated++;
+ } else {
+ result.compiled++;
+ }
+ }
+ } catch (err: any) {
+ result.failed++;
+ result.errors.push({
+ slug,
+ error: err.message || String(err),
+ });
+ }
+ }
+
+ // 更新最后编译时间
+ meta.lastCompileAt = new Date().toISOString();
+ await this.store.saveMeta();
+
+ // 自动重建反向链接
+ await regenerateAllBacklinks(this.store);
+
+ return result;
+ }
+
+ /**
+ * 解析 LLM 编译输出,提取元数据和正文
+ */
+ private parseCompileOutput(output: string): {
+ title: string;
+ tags: string[];
+ category: string;
+ cleanContent: string;
+ } {
+ let title = '';
+ let tags: string[] = [];
+ let category = '';
+
+ // 提取 meta 块
+ const metaMatch = output.match(/```meta\n([\s\S]*?)\n```/);
+ if (metaMatch) {
+ const metaText = metaMatch[1];
+ const titleMatch = metaText.match(/title:\s*(.+)/);
+ if (titleMatch) title = titleMatch[1].trim();
+
+ const tagsMatch = metaText.match(/tags:\s*(.+)/);
+ if (tagsMatch) {
+ tags = tagsMatch[1].split(',').map(t => t.trim()).filter(Boolean);
+ }
+
+ const catMatch = metaText.match(/category:\s*(.+)/);
+ if (catMatch) category = catMatch[1].trim();
+ }
+
+ // 如果 meta 块没找到 title,从第一个 # 标题提取
+ if (!title) {
+ const h1Match = output.match(/^#\s+(.+)/m);
+ if (h1Match) title = h1Match[1].trim();
+ }
+
+ // 清理内容:去掉 meta 块
+ const cleanContent = output.replace(/\n*```meta\n[\s\S]*?\n```\n*/g, '').trim();
+
+ return { title, tags, category, cleanContent };
+ }
+}
diff --git a/src/knowledge/deferred-ingest.ts b/src/knowledge/deferred-ingest.ts
new file mode 100644
index 00000000..11039476
--- /dev/null
+++ b/src/knowledge/deferred-ingest.ts
@@ -0,0 +1,133 @@
+/**
+ * 延迟静默知识库摄入
+ * 用于聊天附件上传后,后台异步执行 ingest + compile 流水线
+ */
+
+import { getKnowledgeStore } from './store.js';
+import { KnowledgeCompiler } from './compiler.js';
+
+/** 可摄入知识库的文档扩展名 */
+const KNOWLEDGE_EXTS = new Set([
+ '.pdf', '.docx', '.xlsx', '.pptx',
+ '.md', '.txt', '.markdown',
+ '.html', '.htm',
+]);
+
+/** 编译延迟(ms)—— 攒批:多个附件上传后只触发一次编译 */
+const COMPILE_DELAY_MS = 30_000;
+
+/** 编译定时器(用于攒批) */
+let compileTimer: ReturnType | null = null;
+
+/** 待编译的 slug 队列 */
+const pendingSlugs: Set = new Set();
+
+/**
+ * 判断文件是否适合摄入知识库
+ */
+export function isKnowledgeEligible(filename: string): boolean {
+ const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase();
+ return KNOWLEDGE_EXTS.has(ext);
+}
+
+/**
+ * 从原始文件名提取 slug(去掉时间戳前缀和扩展名)
+ */
+function slugFromOriginalName(originalName: string): string {
+ const base = originalName.replace(/\.[^.]+$/, ''); // 去扩展名
+ return base
+ .toLowerCase()
+ .replace(/[^a-z0-9\u4e00-\u9fff\u3400-\u4dbf-]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+ .substring(0, 80) || `upload-${Date.now()}`;
+}
+
+/**
+ * 延迟静默摄入附件到知识库
+ * - 立即执行 ingest(写入 raw/)
+ * - 延迟 30s 攒批执行 compile(避免多附件上传时重复编译)
+ *
+ * @param tempFilePath 临时文件路径(已保存的附件)
+ * @param originalName 用户上传的原始文件名
+ */
+export function deferredKnowledgeIngest(tempFilePath: string, originalName: string): void {
+ // 异步执行,不阻塞调用方
+ (async () => {
+ try {
+ const store = getKnowledgeStore();
+ const slug = slugFromOriginalName(originalName);
+
+ const result = await store.ingestFile(tempFilePath, {
+ tags: ['attachment', 'auto-ingest'],
+ category: 'uploads',
+ slug,
+ });
+
+ if (result.success) {
+ console.log(`[Knowledge] Attachment ingested: ${originalName} → ${slug}`);
+ // 加入编译队列
+ pendingSlugs.add(slug);
+ scheduleCompile();
+ } else {
+ console.warn(`[Knowledge] Attachment ingest failed: ${originalName}`, result.error);
+ }
+ } catch (err) {
+ console.warn(`[Knowledge] Attachment ingest error: ${originalName}`, err);
+ }
+ })();
+}
+
+/**
+ * 攒批调度编译:每次调用重置 30s 定时器
+ */
+function scheduleCompile(): void {
+ if (compileTimer) {
+ clearTimeout(compileTimer);
+ }
+ compileTimer = setTimeout(async () => {
+ compileTimer = null;
+ const slugs = Array.from(pendingSlugs);
+ pendingSlugs.clear();
+
+ if (slugs.length === 0) return;
+
+ try {
+ const store = getKnowledgeStore();
+ // compile 需要 LLM client,这里尝试懒加载
+ // 如果没有可用的 client(未配置认证),跳过编译,等用户手动触发
+ let client;
+ try {
+ const { webAuth } = await import('../web/server/web-auth.js');
+ const { getRuntimeBackendCapabilities } = await import('../web/shared/runtime-capabilities.js');
+ const { createConversationClient } = await import('../web/server/runtime/factory.js');
+ const { getProviderForRuntimeBackend } = await import('../web/shared/model-catalog.js');
+
+ const creds = webAuth.getCredentials();
+ const runtimeBackend = webAuth.getRuntimeBackend();
+ const caps = getRuntimeBackendCapabilities(runtimeBackend);
+ const compileModel = caps.defaultTestModel || 'gpt-5.4';
+ const provider = getProviderForRuntimeBackend(runtimeBackend, compileModel);
+ client = createConversationClient({
+ provider,
+ model: compileModel,
+ apiKey: creds.apiKey,
+ authToken: creds.authToken,
+ baseUrl: creds.baseUrl,
+ timeout: 300000,
+ });
+ } catch {
+ console.log('[Knowledge] No LLM client available, skipping auto-compile. Use manual compile later.');
+ return;
+ }
+
+ const compiler = new KnowledgeCompiler(store, { client });
+ const result = await compiler.compile({ slugs });
+ console.log(
+ `[Knowledge] Auto-compile done: ${result.compiled} compiled, ${result.updated} updated, ${result.failed} failed`
+ );
+ } catch (err) {
+ console.warn('[Knowledge] Auto-compile error:', err);
+ }
+ }, COMPILE_DELAY_MS);
+}
diff --git a/src/knowledge/linker.ts b/src/knowledge/linker.ts
new file mode 100644
index 00000000..aa9877c9
--- /dev/null
+++ b/src/knowledge/linker.ts
@@ -0,0 +1,111 @@
+/**
+ * BacklinkLinker - 文章间反向链接检测
+ *
+ * 两种策略:
+ * 1. 关键词重叠(快速,无 LLM 调用)— 基于 title/tags 的 Jaccard 相似度
+ * 2. LLM 辅助(可选)— 让模型判断哪些文章相关
+ */
+
+import type { WikiArticle } from './types.js';
+import { KnowledgeStore } from './store.js';
+
+/**
+ * 计算两篇文章的关键词相似度
+ */
+function keywordSimilarity(a: WikiArticle, b: WikiArticle): number {
+ // 合并 title 词 + tags 作为关键词集合
+ const wordsA = extractKeywords(a);
+ const wordsB = extractKeywords(b);
+
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
+
+ // Jaccard 相似度
+ let intersection = 0;
+ for (const w of wordsA) {
+ if (wordsB.has(w)) intersection++;
+ }
+
+ const union = wordsA.size + wordsB.size - intersection;
+ return union > 0 ? intersection / union : 0;
+}
+
+/**
+ * 从文章中提取关键词集合
+ */
+function extractKeywords(article: WikiArticle): Set {
+ const words = new Set();
+
+ // title 拆词
+ const titleWords = article.title
+ .toLowerCase()
+ .split(/[\s\-_/\\:,.;!?()[\]{}<>'"]+/)
+ .filter(w => w.length > 2);
+ for (const w of titleWords) words.add(w);
+
+ // tags
+ for (const t of article.tags) {
+ words.add(t.toLowerCase());
+ }
+
+ // category
+ if (article.category) {
+ words.add(article.category.toLowerCase());
+ }
+
+ return words;
+}
+
+/**
+ * 基于关键词重叠检测反向链接
+ */
+export function detectBacklinks(
+ articles: WikiArticle[],
+ threshold: number = 0.15,
+): Map {
+ const links = new Map();
+
+ for (const a of articles) {
+ links.set(a.id, []);
+ }
+
+ for (let i = 0; i < articles.length; i++) {
+ for (let j = i + 1; j < articles.length; j++) {
+ const sim = keywordSimilarity(articles[i], articles[j]);
+ if (sim >= threshold) {
+ links.get(articles[i].id)!.push(articles[j].id);
+ links.get(articles[j].id)!.push(articles[i].id);
+ }
+ }
+ }
+
+ return links;
+}
+
+/**
+ * 重新生成所有文章的 backlinks 并更新 meta.json
+ */
+export async function regenerateAllBacklinks(
+ store: KnowledgeStore,
+ threshold: number = 0.15,
+): Promise<{ updated: number }> {
+ const meta = await store.loadMeta();
+ const links = detectBacklinks(meta.articles, threshold);
+
+ let updated = 0;
+ for (const article of meta.articles) {
+ const newBacklinks = links.get(article.id) || [];
+ const oldStr = JSON.stringify(article.backlinks.sort());
+ const newStr = JSON.stringify(newBacklinks.sort());
+
+ if (oldStr !== newStr) {
+ article.backlinks = newBacklinks;
+ updated++;
+ }
+ }
+
+ if (updated > 0) {
+ await store.saveMeta();
+ }
+
+ return { updated };
+}
diff --git a/src/knowledge/store.ts b/src/knowledge/store.ts
new file mode 100644
index 00000000..c748c38e
--- /dev/null
+++ b/src/knowledge/store.ts
@@ -0,0 +1,687 @@
+/**
+ * KnowledgeStore - 知识库文件系统管理
+ * 管理 raw/wiki 目录结构、meta.json 读写、URL/文件摄入
+ */
+
+import * as fs from 'fs/promises';
+import * as fsSync from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import * as crypto from 'crypto';
+import axios from 'axios';
+import TurndownService from 'turndown';
+import { gfm } from 'turndown-plugin-gfm';
+import type {
+ WikiArticle,
+ KnowledgeMeta,
+ IngestResult,
+ IngestOptions,
+ KnowledgeStats,
+ ExtractedImageRef,
+} from './types.js';
+
+/**
+ * 创建 Turndown 服务(HTML → Markdown)
+ * 复用 WebFetchTool 的配置模式
+ */
+function createTurndownService(): TurndownService {
+ const service = new TurndownService({
+ headingStyle: 'atx',
+ codeBlockStyle: 'fenced',
+ emDelimiter: '_',
+ strongDelimiter: '**',
+ linkStyle: 'inlined',
+ hr: '---',
+ bulletListMarker: '-',
+ fence: '```',
+ });
+ service.use(gfm);
+ service.addRule('removeScripts', {
+ filter: ['script', 'style', 'noscript'],
+ replacement: () => '',
+ });
+ return service;
+}
+
+const turndown = createTurndownService();
+
+/**
+ * 从 URL 生成 slug
+ */
+function slugFromUrl(url: string): string {
+ try {
+ const u = new URL(url);
+ const parts = u.pathname.split('/').filter(Boolean);
+ const base = parts.length > 0
+ ? parts[parts.length - 1]
+ : u.hostname.replace(/\./g, '-');
+ return sanitizeSlug(base);
+ } catch {
+ return sanitizeSlug(url);
+ }
+}
+
+/**
+ * 从文件路径生成 slug
+ */
+function slugFromFilePath(filePath: string): string {
+ const base = path.basename(filePath, path.extname(filePath));
+ return sanitizeSlug(base);
+}
+
+/**
+ * 清理 slug(只保留安全字符)
+ */
+function sanitizeSlug(input: string): string {
+ return input
+ .toLowerCase()
+ .replace(/[^a-z0-9\u4e00-\u9fff\u3400-\u4dbf-]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+ .substring(0, 80) || `doc-${Date.now()}`;
+}
+
+/**
+ * 生成唯一 ID
+ */
+function generateId(): string {
+ return crypto.randomUUID();
+}
+
+/**
+ * 获取知识库根目录
+ */
+function getKnowledgeDir(): string {
+ const configDir = process.env.AXON_CONFIG_DIR || path.join(os.homedir(), '.axon');
+ return path.join(configDir, 'knowledge');
+}
+
+export class KnowledgeStore {
+ private rootDir: string;
+ private rawDir: string;
+ private wikiDir: string;
+ private imagesDir: string;
+ private metaPath: string;
+ private meta: KnowledgeMeta | null = null;
+
+ constructor(rootDir?: string) {
+ this.rootDir = rootDir || getKnowledgeDir();
+ this.rawDir = path.join(this.rootDir, 'raw');
+ this.wikiDir = path.join(this.rootDir, 'wiki');
+ this.imagesDir = path.join(this.rootDir, 'images');
+ this.metaPath = path.join(this.rootDir, 'meta.json');
+ }
+
+ /**
+ * 确保目录结构存在
+ */
+ async ensureDirs(): Promise {
+ await fs.mkdir(this.rawDir, { recursive: true });
+ await fs.mkdir(this.wikiDir, { recursive: true });
+ await fs.mkdir(this.imagesDir, { recursive: true });
+ }
+
+ /**
+ * 获取 wiki 目录路径(供 MemorySyncEngine 使用)
+ */
+ getWikiDir(): string {
+ return this.wikiDir;
+ }
+
+ getRawDir(): string {
+ return this.rawDir;
+ }
+
+ getRootDir(): string {
+ return this.rootDir;
+ }
+
+ // ============ meta.json 管理 ============
+
+ /**
+ * 读取 meta.json
+ */
+ async loadMeta(): Promise {
+ if (this.meta) return this.meta;
+
+ try {
+ const content = await fs.readFile(this.metaPath, 'utf-8');
+ this.meta = JSON.parse(content) as KnowledgeMeta;
+ } catch {
+ this.meta = { articles: [], categories: [] };
+ }
+ return this.meta;
+ }
+
+ /**
+ * 写入 meta.json(原子写入:先写临时文件再 rename)
+ */
+ async saveMeta(): Promise {
+ if (!this.meta) return;
+
+ // 更新 categories 列表
+ const cats = new Set();
+ for (const a of this.meta.articles) {
+ if (a.category) cats.add(a.category);
+ }
+ this.meta.categories = [...cats].sort();
+
+ const tmpPath = this.metaPath + '.tmp';
+ await fs.writeFile(tmpPath, JSON.stringify(this.meta, null, 2), 'utf-8');
+ await fs.rename(tmpPath, this.metaPath);
+ }
+
+ /**
+ * 查找文章
+ */
+ async findArticle(id: string): Promise {
+ const meta = await this.loadMeta();
+ return meta.articles.find(a => a.id === id);
+ }
+
+ /**
+ * 通过 slug 查找文章
+ */
+ async findArticleBySlug(slug: string): Promise {
+ const meta = await this.loadMeta();
+ return meta.articles.find(a => a.slug === slug);
+ }
+
+ /**
+ * 添加或更新文章元数据
+ */
+ async upsertArticle(article: WikiArticle): Promise {
+ const meta = await this.loadMeta();
+ const idx = meta.articles.findIndex(a => a.id === article.id);
+ if (idx >= 0) {
+ meta.articles[idx] = article;
+ } else {
+ meta.articles.push(article);
+ }
+ await this.saveMeta();
+ }
+
+ /**
+ * 删除文章
+ */
+ async removeArticle(id: string): Promise {
+ const meta = await this.loadMeta();
+ meta.articles = meta.articles.filter(a => a.id !== id);
+ // 清理其他文章中的 backlink 引用
+ for (const a of meta.articles) {
+ a.backlinks = a.backlinks.filter(b => b !== id);
+ }
+ await this.saveMeta();
+ }
+
+ // ============ 摄入 ============
+
+ /**
+ * 摄入 URL — 抓取网页并保存为 raw markdown
+ */
+ async ingestUrl(url: string, opts?: IngestOptions): Promise {
+ await this.ensureDirs();
+ const slug = opts?.slug || slugFromUrl(url);
+ const rawPath = `${slug}.md`;
+ const absPath = path.join(this.rawDir, rawPath);
+
+ try {
+ const response = await axios.get(url, {
+ timeout: 30000,
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; Axon-KnowledgeBase/1.0)',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ },
+ maxRedirects: 5,
+ maxContentLength: 10 * 1024 * 1024,
+ });
+
+ const contentType: string = response.headers['content-type'] || '';
+ let text: string;
+
+ if (contentType.includes('text/html')) {
+ text = turndown.turndown(response.data);
+ } else if (contentType.includes('application/json')) {
+ text = '```json\n' + JSON.stringify(response.data, null, 2) + '\n```';
+ } else {
+ text = String(response.data);
+ }
+
+ // 添加来源元数据作为 frontmatter
+ const frontmatter = [
+ '---',
+ `source: ${url}`,
+ `ingested_at: ${new Date().toISOString()}`,
+ opts?.tags?.length ? `tags: [${opts.tags.join(', ')}]` : null,
+ opts?.category ? `category: ${opts.category}` : null,
+ '---',
+ '',
+ ].filter(Boolean).join('\n');
+
+ await fs.writeFile(absPath, frontmatter + text, 'utf-8');
+
+ return {
+ success: true,
+ rawPath,
+ slug,
+ textLength: text.length,
+ imageCount: 0,
+ };
+ } catch (err: any) {
+ return {
+ success: false,
+ rawPath,
+ slug,
+ textLength: 0,
+ imageCount: 0,
+ error: err.message || String(err),
+ };
+ }
+ }
+
+ /**
+ * 摄入本地文件 — 提取文本 + 图片并保存
+ */
+ async ingestFile(filePath: string, opts?: IngestOptions): Promise {
+ await this.ensureDirs();
+ const slug = opts?.slug || slugFromFilePath(filePath);
+ const rawPath = `${slug}.md`;
+ const absPath = path.join(this.rawDir, rawPath);
+ const ext = path.extname(filePath).toLowerCase();
+
+ try {
+ let text: string;
+ let imageCount = 0;
+ let imageDirRel: string | undefined;
+
+ if (ext === '.md' || ext === '.txt' || ext === '.markdown') {
+ text = await fs.readFile(filePath, 'utf-8');
+ } else if (ext === '.pdf') {
+ // 文本提取
+ const { execFile } = await import('child_process');
+ const { promisify } = await import('util');
+ const execFileAsync = promisify(execFile);
+ try {
+ const { stdout } = await execFileAsync('pdftotext', [filePath, '-'], { timeout: 30000 });
+ text = stdout;
+ } catch {
+ throw new Error('pdftotext not available. Install poppler-utils to ingest PDF files.');
+ }
+
+ // 图片提取:渲染 PDF 页面为 JPEG
+ try {
+ const { extractPdfPages } = await import('../media/pdf.js');
+ const result = await extractPdfPages(filePath, { firstPage: 1, lastPage: 20 });
+ if (result.success) {
+ const imgDir = await this.getImagesDir(slug);
+ imageDirRel = `images/${slug}`;
+ const jpgFiles = (await fs.readdir(result.data.file.outputDir))
+ .filter(f => f.endsWith('.jpg'))
+ .sort();
+
+ for (let i = 0; i < jpgFiles.length; i++) {
+ const srcPath = path.join(result.data.file.outputDir, jpgFiles[i]);
+ const destName = `page-${i + 1}.jpg`;
+ await fs.copyFile(srcPath, path.join(imgDir, destName));
+ imageCount++;
+ }
+
+ // 在文本中插入图片占位符
+ if (imageCount > 0) {
+ const lines = text.split('\n');
+ // PDF 按页插入占位符,粗略估算每页行数
+ const linesPerPage = Math.max(1, Math.ceil(lines.length / imageCount));
+ const enhancedLines: string[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ enhancedLines.push(lines[i]);
+ const pageIdx = Math.floor(i / linesPerPage);
+ if ((i + 1) % linesPerPage === 0 && pageIdx < imageCount) {
+ enhancedLines.push(`\n\n`);
+ }
+ }
+ text = enhancedLines.join('\n');
+ }
+
+ // 清理临时目录
+ await fs.rm(result.data.file.outputDir, { recursive: true, force: true }).catch(() => {});
+ }
+ } catch {
+ // 图片提取失败不阻断文本入库
+ }
+ } else if (ext === '.docx' || ext === '.xlsx' || ext === '.pptx') {
+ // 文本提取
+ const { documentToText } = await import('../media/office.js');
+ text = await documentToText(filePath);
+
+ // 图片提取:从 ZIP 提取原始嵌入图片
+ try {
+ const { extractDocumentVisuals, compressExtractedImages } = await import('../media/office-visual.js');
+ const visuals = await extractDocumentVisuals(filePath);
+ const allImages = [
+ ...visuals.slides.flatMap(s => s.images),
+ ...visuals.unassociatedImages,
+ ];
+
+ if (allImages.length > 0) {
+ const compressed = await compressExtractedImages(allImages);
+ if (compressed.length > 0) {
+ const imgDir = await this.getImagesDir(slug);
+ imageDirRel = `images/${slug}`;
+
+ // 按 slide 分组保存图片
+ for (let i = 0; i < compressed.length; i++) {
+ const img = compressed[i];
+ const imgExt = img.mimeType === 'image/png' ? '.png' : '.jpg';
+ const slideNum = img.slideIndex || 0;
+ const destName = slideNum > 0
+ ? `slide${slideNum}-img${i + 1}${imgExt}`
+ : `img${i + 1}${imgExt}`;
+ await fs.writeFile(
+ path.join(imgDir, destName),
+ Buffer.from(img.base64, 'base64'),
+ );
+ imageCount++;
+ }
+
+ // 在文本中按 slide 插入图片占位符
+ if (visuals.slides.length > 0) {
+ const slideTexts: string[] = [];
+ for (const slide of visuals.slides) {
+ slideTexts.push(`[Slide ${slide.index}] ${slide.text}`);
+ // 插入该 slide 关联的图片
+ const slideImgs = compressed.filter(img => img.slideIndex === slide.index);
+ for (const img of slideImgs) {
+ const imgExt = img.mimeType === 'image/png' ? '.png' : '.jpg';
+ slideTexts.push(`) : `img`}${imgExt})`);
+ }
+ }
+ // 用带图片占位的文本替换原文本(保留更多结构信息)
+ text = slideTexts.join('\n');
+ }
+ }
+ }
+ } catch {
+ // 图片提取失败不阻断文本入库
+ }
+ } else if (ext === '.html' || ext === '.htm') {
+ const html = await fs.readFile(filePath, 'utf-8');
+ text = turndown.turndown(html);
+ } else if (ext === '.json') {
+ const raw = await fs.readFile(filePath, 'utf-8');
+ text = '```json\n' + raw + '\n```';
+ } else {
+ // 默认当作纯文本
+ text = await fs.readFile(filePath, 'utf-8');
+ }
+
+ // 添加来源元数据
+ const frontmatter = [
+ '---',
+ `source: ${filePath}`,
+ `ingested_at: ${new Date().toISOString()}`,
+ opts?.tags?.length ? `tags: [${opts.tags.join(', ')}]` : null,
+ opts?.category ? `category: ${opts.category}` : null,
+ imageCount > 0 ? `images: ${imageCount}` : null,
+ imageDirRel ? `image_dir: ${imageDirRel}` : null,
+ '---',
+ '',
+ ].filter(Boolean).join('\n');
+
+ await fs.writeFile(absPath, frontmatter + text, 'utf-8');
+
+ return {
+ success: true,
+ rawPath,
+ slug,
+ textLength: text.length,
+ imageCount,
+ imageDirPath: imageDirRel,
+ };
+ } catch (err: any) {
+ return {
+ success: false,
+ rawPath,
+ slug,
+ textLength: 0,
+ imageCount: 0,
+ error: err.message || String(err),
+ };
+ }
+ }
+
+ // ============ Wiki 文件读写 ============
+
+ /**
+ * 读取 wiki 文章内容
+ */
+ async readWikiArticle(slug: string): Promise {
+ const filePath = path.join(this.wikiDir, `${slug}.md`);
+ try {
+ return await fs.readFile(filePath, 'utf-8');
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * 写入 wiki 文章
+ */
+ async writeWikiArticle(slug: string, content: string): Promise {
+ await this.ensureDirs();
+ const filePath = path.join(this.wikiDir, `${slug}.md`);
+ await fs.writeFile(filePath, content, 'utf-8');
+ }
+
+ /**
+ * 读取 raw 文件内容
+ */
+ async readRawFile(slug: string): Promise {
+ const filePath = path.join(this.rawDir, `${slug}.md`);
+ try {
+ return await fs.readFile(filePath, 'utf-8');
+ } catch {
+ return null;
+ }
+ }
+
+ // ============ 查询 ============
+
+ /**
+ * 列出所有 raw 文件 slug
+ */
+ async listRawSlugs(): Promise {
+ try {
+ const files = await fs.readdir(this.rawDir);
+ return files
+ .filter(f => f.endsWith('.md'))
+ .map(f => f.replace(/\.md$/, ''));
+ } catch {
+ return [];
+ }
+ }
+
+ /**
+ * 列出未编译的 raw slug(有 raw 但无对应 wiki 文章)
+ */
+ async listUncompiledSlugs(): Promise {
+ const meta = await this.loadMeta();
+ const rawSlugs = await this.listRawSlugs();
+ const compiledSlugs = new Set(
+ meta.articles.flatMap(a => a.sourceFiles.map(f => f.replace(/\.md$/, '')))
+ );
+ return rawSlugs.filter(s => !compiledSlugs.has(s));
+ }
+
+ /**
+ * 获取知识库统计
+ */
+ async getStats(): Promise {
+ const meta = await this.loadMeta();
+ const rawSlugs = await this.listRawSlugs();
+ const uncompiledSlugs = await this.listUncompiledSlugs();
+
+ return {
+ rawCount: rawSlugs.length,
+ wikiCount: meta.articles.length,
+ uncompiledCount: uncompiledSlugs.length,
+ categoryCount: meta.categories.length,
+ lastCompileAt: meta.lastCompileAt,
+ };
+ }
+
+ // ============ 图片管理 ============
+
+ /**
+ * 获取 slug 的图片目录(确保存在)
+ */
+ async getImagesDir(slug: string): Promise {
+ const dir = path.join(this.imagesDir, slug);
+ await fs.mkdir(dir, { recursive: true });
+ return dir;
+ }
+
+ /**
+ * 读取 slug 下所有图片,返回 ExtractedImageRef[]
+ * 供编译器在编译阶段传给多模态 LLM
+ */
+ async readImageFiles(slug: string): Promise {
+ const dir = path.join(this.imagesDir, slug);
+ try {
+ const files = await fs.readdir(dir);
+ const imageFiles = files
+ .filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
+ .sort();
+
+ const results: ExtractedImageRef[] = [];
+ for (const filename of imageFiles) {
+ const data = await fs.readFile(path.join(dir, filename));
+ const ext = path.extname(filename).toLowerCase();
+ const mimeType = ext === '.png' ? 'image/png' : 'image/jpeg';
+
+ // 从文件名推断页码/slide编号
+ const slideMatch = filename.match(/slide(\d+)/i);
+ const pageMatch = filename.match(/page-(\d+)/i);
+ const pageIndex = slideMatch ? parseInt(slideMatch[1]) : pageMatch ? parseInt(pageMatch[1]) : undefined;
+
+ // 生成标签
+ let label = filename;
+ if (slideMatch) label = `Slide ${slideMatch[1]} image`;
+ else if (pageMatch) label = `Page ${pageMatch[1]}`;
+
+ results.push({
+ filename,
+ base64: data.toString('base64'),
+ mimeType,
+ label,
+ pageIndex,
+ });
+ }
+ return results;
+ } catch {
+ return [];
+ }
+ }
+
+ /**
+ * 删除 slug 的图片目录
+ */
+ async removeImages(slug: string): Promise {
+ const dir = path.join(this.imagesDir, slug);
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
+ }
+
+ // ============ 辅助方法 ============
+
+ /**
+ * 生成新文章的元数据骨架
+ */
+ createArticleMeta(opts: {
+ slug: string;
+ title: string;
+ tags?: string[];
+ category?: string;
+ sourceFiles: string[];
+ }): WikiArticle {
+ const now = new Date().toISOString();
+ return {
+ id: generateId(),
+ title: opts.title,
+ slug: opts.slug,
+ tags: opts.tags || [],
+ category: opts.category || 'uncategorized',
+ sourceFiles: opts.sourceFiles,
+ backlinks: [],
+ createdAt: now,
+ updatedAt: now,
+ };
+ }
+
+ /**
+ * 从 raw 文件的 frontmatter 提取预设标签、分类和图片信息
+ */
+ parseRawFrontmatter(content: string): {
+ tags?: string[];
+ category?: string;
+ source?: string;
+ images?: number;
+ imageDir?: string;
+ } {
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
+ if (!match) return {};
+
+ const fm = match[1];
+ const result: {
+ tags?: string[];
+ category?: string;
+ source?: string;
+ images?: number;
+ imageDir?: string;
+ } = {};
+
+ const tagsMatch = fm.match(/tags:\s*\[(.*?)\]/);
+ if (tagsMatch) {
+ result.tags = tagsMatch[1].split(',').map(t => t.trim()).filter(Boolean);
+ }
+
+ const catMatch = fm.match(/category:\s*(.+)/);
+ if (catMatch) {
+ result.category = catMatch[1].trim();
+ }
+
+ const srcMatch = fm.match(/source:\s*(.+)/);
+ if (srcMatch) {
+ result.source = srcMatch[1].trim();
+ }
+
+ const imagesMatch = fm.match(/images:\s*(\d+)/);
+ if (imagesMatch) {
+ result.images = parseInt(imagesMatch[1]);
+ }
+
+ const imgDirMatch = fm.match(/image_dir:\s*(.+)/);
+ if (imgDirMatch) {
+ result.imageDir = imgDirMatch[1].trim();
+ }
+
+ return result;
+ }
+
+ /**
+ * 从 raw 文件内容中去掉 frontmatter,只返回正文
+ */
+ stripFrontmatter(content: string): string {
+ return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
+ }
+}
+
+/**
+ * 全局知识库单例
+ */
+let _defaultStore: KnowledgeStore | null = null;
+
+export function getKnowledgeStore(): KnowledgeStore {
+ if (!_defaultStore) {
+ _defaultStore = new KnowledgeStore();
+ }
+ return _defaultStore;
+}
diff --git a/src/knowledge/types.ts b/src/knowledge/types.ts
new file mode 100644
index 00000000..2ae23794
--- /dev/null
+++ b/src/knowledge/types.ts
@@ -0,0 +1,130 @@
+/**
+ * Knowledge Base 类型定义
+ * Karpathy LLM Knowledge Base 模式:raw → LLM compile → wiki → hybrid search
+ */
+
+/**
+ * Wiki 文章元数据
+ */
+export interface WikiArticle {
+ /** 唯一标识符 */
+ id: string;
+ /** 文章标题 */
+ title: string;
+ /** URL 友好的文件名(不含 .md) */
+ slug: string;
+ /** 标签列表 */
+ tags: string[];
+ /** 分类 */
+ category: string;
+ /** 来源原始文件路径(相对于 raw/) */
+ sourceFiles: string[];
+ /** 反向链接的文章 ID 列表 */
+ backlinks: string[];
+ /** 创建时间 (ISO 8601) */
+ createdAt: string;
+ /** 最后更新时间 (ISO 8601) */
+ updatedAt: string;
+}
+
+/**
+ * 知识库元数据索引(meta.json)
+ */
+export interface KnowledgeMeta {
+ /** 所有文章 */
+ articles: WikiArticle[];
+ /** 所有分类 */
+ categories: string[];
+ /** 最后编译时间 */
+ lastCompileAt?: string;
+}
+
+/**
+ * 摄入结果
+ */
+export interface IngestResult {
+ /** 是否成功 */
+ success: boolean;
+ /** 原始文件保存路径(相对于 raw/) */
+ rawPath: string;
+ /** slug 标识 */
+ slug: string;
+ /** 提取的文本长度 */
+ textLength: number;
+ /** 提取的图片数量 */
+ imageCount: number;
+ /** 图片目录路径(相对于 knowledge root,如 "images/slug") */
+ imageDirPath?: string;
+ /** 错误信息 */
+ error?: string;
+}
+
+/**
+ * 编译阶段使用的图片引用
+ */
+export interface ExtractedImageRef {
+ /** 磁盘上的文件名 (如 "slide3-img1.jpg") */
+ filename: string;
+ /** Base64 编码数据 */
+ base64: string;
+ /** MIME 类型 */
+ mimeType: string;
+ /** 上下文标签 (如 "Slide 3, Image 1") */
+ label: string;
+ /** 关联的页面/幻灯片编号 */
+ pageIndex?: number;
+}
+
+/**
+ * 编译结果
+ */
+export interface CompileResult {
+ /** 编译成功数 */
+ compiled: number;
+ /** 更新数(合并到已有文章) */
+ updated: number;
+ /** 失败数 */
+ failed: number;
+ /** 失败详情 */
+ errors: Array<{ slug: string; error: string }>;
+ /** 生成/更新的文章列表 */
+ articles: WikiArticle[];
+}
+
+/**
+ * 编译选项
+ */
+export interface CompileOptions {
+ /** 只编译指定的 slug(默认编译所有未编译的) */
+ slugs?: string[];
+ /** 强制重新编译已有的 wiki 文章 */
+ force?: boolean;
+}
+
+/**
+ * 摄入选项
+ */
+export interface IngestOptions {
+ /** 预设标签 */
+ tags?: string[];
+ /** 预设分类 */
+ category?: string;
+ /** 自定义 slug(默认自动从 URL/文件名生成) */
+ slug?: string;
+}
+
+/**
+ * 知识库统计
+ */
+export interface KnowledgeStats {
+ /** 原始文件数 */
+ rawCount: number;
+ /** wiki 文章数 */
+ wikiCount: number;
+ /** 未编译的原始文件数 */
+ uncompiledCount: number;
+ /** 分类数 */
+ categoryCount: number;
+ /** 最后编译时间 */
+ lastCompileAt?: string;
+}
diff --git a/src/media/index.ts b/src/media/index.ts
index 43584e8f..d4cbb7cc 100644
--- a/src/media/index.ts
+++ b/src/media/index.ts
@@ -71,6 +71,7 @@ export {
export {
extractDocumentVisuals,
compressExtractedImages,
+ pptxHasChartsOrDiagrams,
MAX_IMAGES_PER_DOCUMENT,
type VisualExtractionResult,
type ExtractedImage,
diff --git a/src/media/office-visual.ts b/src/media/office-visual.ts
index 2412694c..98892609 100644
--- a/src/media/office-visual.ts
+++ b/src/media/office-visual.ts
@@ -57,6 +57,31 @@ export const MAX_IMAGES_PER_DOCUMENT = 20;
/** 单张图片最大尺寸(字节),超过的跳过 */
const MAX_SINGLE_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
+/**
+ * 检测 PPTX 中是否包含图表(chart)或 SmartArt/Diagram
+ * 这些内容存储为 XML 定义,无法从 ppt/media/ 直接提取为图片
+ */
+export async function pptxHasChartsOrDiagrams(filePath: string): Promise {
+ try {
+ const buffer = fs.readFileSync(filePath);
+ const JSZip = (await import('jszip')).default;
+ const zip = await JSZip.loadAsync(buffer);
+ let found = false;
+ zip.forEach((relativePath: string) => {
+ if (
+ relativePath.startsWith('ppt/charts/') ||
+ relativePath.startsWith('ppt/diagrams/') ||
+ relativePath.startsWith('ppt/embeddings/')
+ ) {
+ found = true;
+ }
+ });
+ return found;
+ } catch {
+ return false;
+ }
+}
+
/**
* 从 Office 文档中提取嵌入图片和文本
*/
diff --git a/src/media/pdf.ts b/src/media/pdf.ts
index 860063ca..2fc9d9fd 100644
--- a/src/media/pdf.ts
+++ b/src/media/pdf.ts
@@ -192,7 +192,7 @@ export async function extractPdfPages(
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pdf-'));
// 构建 pdftoppm 参数
- const args: string[] = ['-jpeg', '-r', '100'];
+ const args: string[] = ['-jpeg', '-r', '200'];
if (pageRange) {
args.push('-f', String(pageRange.firstPage));
diff --git a/src/memory/long-term-store.ts b/src/memory/long-term-store.ts
index e95c4f9c..b6e796b7 100644
--- a/src/memory/long-term-store.ts
+++ b/src/memory/long-term-store.ts
@@ -909,6 +909,192 @@ export class LongTermStore {
).all(limit) as Array<{ id: string; text: string }>;
}
+ // ==========================================================================
+ // 维护:去重 + 清理 + VACUUM
+ // ==========================================================================
+
+ /**
+ * 语义去重:删除内容 hash 完全相同的重复 chunk(跨文件)
+ * 保留 updated_at 最新的那一条,删除其余副本
+ * @returns 删除的重复 chunk 数量
+ */
+ deduplicateChunks(): number {
+ // 找出所有 hash 出现 > 1 次的 chunk 组
+ const dupeGroups = this.db.prepare(`
+ SELECT hash, COUNT(*) as cnt
+ FROM chunks
+ GROUP BY hash
+ HAVING cnt > 1
+ `).all() as Array<{ hash: string; cnt: number }>;
+
+ if (dupeGroups.length === 0) return 0;
+
+ let totalRemoved = 0;
+
+ const transaction = this.db.transaction(() => {
+ for (const { hash } of dupeGroups) {
+ // 同一 hash 的所有 chunk,按 updated_at 降序(保留最新)
+ const chunks = this.db.prepare(
+ 'SELECT id FROM chunks WHERE hash = ? ORDER BY updated_at DESC'
+ ).all(hash) as Array<{ id: string }>;
+
+ // 跳过第一条(最新的),删除其余
+ const toDelete = chunks.slice(1);
+ for (const { id } of toDelete) {
+ // 删除向量索引
+ if (this.hasVecSearch) {
+ try { this.db.prepare('DELETE FROM chunks_vec WHERE chunk_id = ?').run(id); } catch { /* ignore */ }
+ }
+ // 删除 FTS 索引
+ if (this.hasFTS5) {
+ this.db.prepare('DELETE FROM chunks_fts WHERE id = ?').run(id);
+ }
+ // 删除 chunk
+ this.db.prepare('DELETE FROM chunks WHERE id = ?').run(id);
+ totalRemoved++;
+ }
+ }
+ });
+
+ transaction();
+ return totalRemoved;
+ }
+
+ /**
+ * 基于余弦相似度的语义去重(需要 embedding)
+ * 对同一 source 内的 chunk,如果余弦相似度 > threshold 则合并(保留较新的)
+ * @param threshold 相似度阈值(默认 0.85)
+ * @returns 删除的重复 chunk 数量
+ */
+ deduplicateSemantic(threshold: number = 0.85): number {
+ // 获取所有有 embedding 的 chunk
+ const chunks = this.db.prepare(`
+ SELECT id, source, embedding, updated_at
+ FROM chunks
+ WHERE embedding IS NOT NULL AND embedding != ''
+ ORDER BY source, updated_at DESC
+ `).all() as Array<{ id: string; source: string; embedding: string; updated_at: number }>;
+
+ if (chunks.length < 2) return 0;
+
+ // 按 source 分组
+ const bySource = new Map();
+ for (const chunk of chunks) {
+ const group = bySource.get(chunk.source) || [];
+ group.push(chunk);
+ bySource.set(chunk.source, group);
+ }
+
+ const toDeleteIds = new Set();
+
+ for (const [, group] of bySource) {
+ // 已按 updated_at 降序排列,新的在前
+ for (let i = 0; i < group.length; i++) {
+ if (toDeleteIds.has(group[i].id)) continue;
+
+ let vecI: number[];
+ try { vecI = JSON.parse(group[i].embedding); } catch { continue; }
+
+ for (let j = i + 1; j < group.length; j++) {
+ if (toDeleteIds.has(group[j].id)) continue;
+
+ let vecJ: number[];
+ try { vecJ = JSON.parse(group[j].embedding); } catch { continue; }
+
+ const sim = cosineSimilarity(vecI, vecJ);
+ if (sim >= threshold) {
+ // 删除较旧的(j > i,j 更旧)
+ toDeleteIds.add(group[j].id);
+ }
+ }
+ }
+ }
+
+ if (toDeleteIds.size === 0) return 0;
+
+ const transaction = this.db.transaction(() => {
+ for (const id of toDeleteIds) {
+ if (this.hasVecSearch) {
+ try { this.db.prepare('DELETE FROM chunks_vec WHERE chunk_id = ?').run(id); } catch { /* ignore */ }
+ }
+ if (this.hasFTS5) {
+ this.db.prepare('DELETE FROM chunks_fts WHERE id = ?').run(id);
+ }
+ this.db.prepare('DELETE FROM chunks WHERE id = ?').run(id);
+ }
+ });
+
+ transaction();
+ return toDeleteIds.size;
+ }
+
+ /**
+ * 清理过期 chunk(超过 maxAgeDays 天且无 embedding 或 score 很低)
+ * @param maxAgeDays 最大保留天数(默认 90)
+ * @returns 删除的过期 chunk 数量
+ */
+ cleanupStale(maxAgeDays: number = 90): number {
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
+
+ // 获取过期 chunk ids
+ const staleChunks = this.db.prepare(
+ 'SELECT id FROM chunks WHERE updated_at < ?'
+ ).all(cutoff) as Array<{ id: string }>;
+
+ if (staleChunks.length === 0) return 0;
+
+ const transaction = this.db.transaction(() => {
+ for (const { id } of staleChunks) {
+ if (this.hasVecSearch) {
+ try { this.db.prepare('DELETE FROM chunks_vec WHERE chunk_id = ?').run(id); } catch { /* ignore */ }
+ }
+ if (this.hasFTS5) {
+ this.db.prepare('DELETE FROM chunks_fts WHERE id = ?').run(id);
+ }
+ this.db.prepare('DELETE FROM chunks WHERE id = ?').run(id);
+ }
+ });
+
+ transaction();
+
+ // 清理 files 表中没有任何 chunk 的孤立文件记录
+ this.db.prepare(`
+ DELETE FROM files WHERE path NOT IN (SELECT DISTINCT path FROM chunks)
+ `).run();
+
+ return staleChunks.length;
+ }
+
+ /**
+ * 执行 SQLite VACUUM,回收已删除数据占用的磁盘空间
+ */
+ vacuum(): void {
+ this.db.exec('VACUUM');
+ }
+
+ /**
+ * 一键维护:去重 → 清理过期 → VACUUM
+ * @returns 维护统计
+ */
+ maintenance(opts?: { maxAgeDays?: number; semanticDedup?: boolean; semanticThreshold?: number }): {
+ hashDeduped: number;
+ semanticDeduped: number;
+ staleCleaned: number;
+ } {
+ const hashDeduped = this.deduplicateChunks();
+ const semanticDeduped = opts?.semanticDedup
+ ? this.deduplicateSemantic(opts.semanticThreshold ?? 0.85)
+ : 0;
+ const staleCleaned = this.cleanupStale(opts?.maxAgeDays ?? 90);
+
+ // 只在确实删除了数据时才 VACUUM(避免无谓的 I/O)
+ if (hashDeduped + semanticDeduped + staleCleaned > 0) {
+ this.vacuum();
+ }
+
+ return { hashDeduped, semanticDeduped, staleCleaned };
+ }
+
/**
* 关闭数据库连接
*/
diff --git a/src/memory/memory-search.ts b/src/memory/memory-search.ts
index 59799462..7c446b70 100644
--- a/src/memory/memory-search.ts
+++ b/src/memory/memory-search.ts
@@ -16,6 +16,7 @@ import { EmbeddingCache } from './embedding-cache.js';
import { mergeHybridResults } from './hybrid-search.js';
import { applyMMRToResults, type MMRConfig } from './mmr.js';
import { getCurrentCwd, isInCwdContext } from '../core/cwd-context.js';
+import { AutoMemoryScorer, type MemoryType } from './auto-memory-scorer.js';
/**
* 搜索选项
@@ -120,6 +121,7 @@ export class MemorySearchManager {
private embeddingProvider: EmbeddingProvider | null = null;
private embeddingCache: EmbeddingCache | null = null;
private embeddingConfig: EmbeddingConfig | null = null;
+ private syncCount: number = 0;
private constructor(opts: { projectDir: string; projectHash: string }) {
this.projectDir = opts.projectDir;
@@ -252,11 +254,18 @@ export class MemorySearchManager {
'tools-notes': notebookManager.getPath('tools-notes'),
};
+ // Knowledge Base wiki 目录
+ const knowledgeWikiDir = path.join(
+ process.env.AXON_CONFIG_DIR || path.join(os.homedir(), '.axon'),
+ 'knowledge', 'wiki'
+ );
+
const result = await this.syncEngine.syncAll({
memoryDir,
sessionsDir,
transcriptsDir,
notebookPaths,
+ knowledgeWikiDir,
});
if (process.env.AXON_DEBUG) {
@@ -279,6 +288,16 @@ export class MemorySearchManager {
});
}
+ // 定期维护:每 10 次 sync 执行一次去重 + 清理(不阻塞主流程)
+ this.syncCount++;
+ if (this.syncCount % 10 === 0) {
+ this.maintenance().catch(e => {
+ if (process.env.AXON_DEBUG) {
+ console.warn('[MemorySearch] Maintenance failed:', e);
+ }
+ });
+ }
+
this.dirty = false;
}
@@ -309,6 +328,10 @@ export class MemorySearchManager {
* 自动回忆(autoRecall)
* 根据查询从长期记忆中检索相关片段,格式化为可注入 system prompt 的文本
* 使用混合搜索(向量 + FTS5),无 embedding 时自动降级到纯 FTS5
+ *
+ * 置信度门控:搜索结果经过 AutoMemoryScorer 二次过滤,
+ * 低于 confidenceThreshold(默认 0.3)的结果被丢弃,防止低质量记忆污染上下文。
+ *
* @param query 查询文本(通常是用户的最新消息)
* @param maxResults 最大结果数(默认 5,避免占用太多 prompt 空间)
*/
@@ -331,21 +354,29 @@ export class MemorySearchManager {
const source = opts?.source === 'all' ? undefined : opts?.source;
results = this.store.search(query, {
source,
- maxResults,
+ maxResults: maxResults * 2, // 多取一些,因为置信度门控会过滤掉一部分
});
} else {
results = await this.hybridSearch(query, {
source: opts?.source,
- maxResults,
+ maxResults: maxResults * 2,
});
}
if (results.length === 0) return null;
// 过滤低分结果(混合搜索 + 时间衰减后 score < 0.1 的没有参考价值)
- const relevant = results.filter(r => r.score > 0.1);
+ let relevant = results.filter(r => r.score > 0.1);
+ if (relevant.length === 0) return null;
+
+ // 置信度门控:用 AutoMemoryScorer 对结果做二次打分过滤
+ const confidenceThreshold = 0.3;
+ relevant = this.applyConfidenceGating(relevant, confidenceThreshold);
if (relevant.length === 0) return null;
+ // 限制结果数
+ relevant = relevant.slice(0, maxResults);
+
// 格式化为简洁的回忆片段
const snippets = relevant.map((r, i) => {
const ageStr = this.formatAge(r.age);
@@ -355,6 +386,49 @@ export class MemorySearchManager {
return snippets.join('\n\n');
}
+ /**
+ * 置信度门控:用 AutoMemoryScorer 对搜索结果二次打分
+ * 综合 source 类型权重、搜索相关度、新鲜度,过滤低于阈值的结果
+ */
+ private applyConfidenceGating(
+ results: MemorySearchResult[],
+ threshold: number,
+ ): MemorySearchResult[] {
+ const scorer = new AutoMemoryScorer(threshold);
+
+ return results.filter(r => {
+ // 根据 source 推断 MemoryType
+ const memType = this.inferMemoryType(r.source, r.snippet);
+ const scoring = scorer.score({
+ id: r.id,
+ type: memType,
+ content: r.snippet,
+ relevance: r.score, // 搜索相关度作为 relevance 输入
+ createdAt: new Date(r.timestamp),
+ updatedAt: new Date(r.timestamp),
+ source: r.source,
+ });
+ return scoring.shouldSave;
+ });
+ }
+
+ /**
+ * 根据 source 和内容推断记忆类型
+ */
+ private inferMemoryType(source: MemorySource, snippet: string): MemoryType {
+ // notebook 来源的记忆权重较高
+ if (source === 'notebook') return 'design';
+ // knowledge base wiki 文章
+ if (source === 'knowledge') return 'docs';
+ // session 摘要
+ if (source === 'session') return 'docs';
+ // 根据内容关键词推断
+ const lower = snippet.toLowerCase();
+ if (/\b(bug|fix|error|issue|crash|fail)\b/i.test(lower)) return 'bugs';
+ if (/\b(function|class|import|export|const|let|var|def|return)\b/.test(lower)) return 'code';
+ return 'general';
+ }
+
/**
* 格式化时间差
*/
@@ -449,6 +523,40 @@ export class MemorySearchManager {
}
}
+ /**
+ * 记忆维护:去重 + 清理过期 + VACUUM
+ * 建议在 sync 完成后定期调用(如每天一次或每 N 次 sync 后一次)
+ * @returns 维护统计
+ */
+ async maintenance(opts?: {
+ maxAgeDays?: number;
+ semanticDedup?: boolean;
+ semanticThreshold?: number;
+ }): Promise<{ hashDeduped: number; semanticDeduped: number; staleCleaned: number }> {
+ // 确保 sync 完成后再维护
+ if (this.syncPromise) {
+ await this.syncPromise;
+ }
+
+ const result = this.store.maintenance({
+ maxAgeDays: opts?.maxAgeDays ?? 90,
+ semanticDedup: opts?.semanticDedup ?? !!this.embeddingProvider,
+ semanticThreshold: opts?.semanticThreshold ?? 0.85,
+ });
+
+ if (process.env.AXON_DEBUG) {
+ const total = result.hashDeduped + result.semanticDeduped + result.staleCleaned;
+ if (total > 0) {
+ console.log(
+ `[MemorySearch] Maintenance: ${result.hashDeduped} hash-deduped, ` +
+ `${result.semanticDeduped} semantic-deduped, ${result.staleCleaned} stale-cleaned`
+ );
+ }
+ }
+
+ return result;
+ }
+
/**
* 获取 embedding provider(供外部使用,如 sync 时批量生成 embedding)
*/
diff --git a/src/memory/memory-sync.ts b/src/memory/memory-sync.ts
index 49d86bfe..7f8e70a3 100644
--- a/src/memory/memory-sync.ts
+++ b/src/memory/memory-sync.ts
@@ -550,21 +550,84 @@ export class MemorySyncEngine {
/**
* 同步所有文件
*/
+ /**
+ * 同步 knowledge wiki 目录下的 .md 文件
+ */
+ async syncKnowledgeFiles(knowledgeWikiDir: string): Promise {
+ const result: SyncResult = {
+ added: 0,
+ updated: 0,
+ removed: 0,
+ unchanged: 0,
+ };
+
+ try {
+ const files = await listMarkdownFiles(knowledgeWikiDir);
+ const processedPaths = new Set();
+
+ for (const absPath of files) {
+ try {
+ const stats = await fs.lstat(absPath);
+ if (stats.isSymbolicLink()) continue;
+
+ const entry = await buildFileEntry(absPath, knowledgeWikiDir, 'knowledge');
+ processedPaths.add(entry.path);
+
+ const existingHash = this.store.getFileHash(entry.path);
+
+ if (!existingHash) {
+ const content = await fs.readFile(absPath, 'utf-8');
+ this.store.indexFile(entry, content);
+ result.added++;
+ } else if (existingHash !== entry.hash) {
+ const content = await fs.readFile(absPath, 'utf-8');
+ this.store.indexFile(entry, content);
+ result.updated++;
+ } else {
+ result.unchanged++;
+ }
+ } catch (error) {
+ console.warn(`[MemorySync] Failed to process knowledge file ${absPath}:`, error);
+ }
+ }
+
+ const indexedPaths = this.store.listFilePaths('knowledge');
+ for (const indexedPath of indexedPaths) {
+ if (!processedPaths.has(indexedPath)) {
+ this.store.removeFile(indexedPath);
+ result.removed++;
+ }
+ }
+ } catch (error) {
+ // 目录不存在时静默跳过
+ const code = error && typeof error === 'object' && 'code' in error
+ ? (error as NodeJS.ErrnoException).code : undefined;
+ if (code !== 'ENOENT') {
+ console.error('[MemorySync] Failed to sync knowledge files:', error);
+ }
+ }
+
+ return result;
+ }
+
async syncAll(opts?: {
memoryDir?: string;
sessionsDir?: string;
transcriptsDir?: string;
notebookPaths?: Partial>;
+ knowledgeWikiDir?: string;
}): Promise<{
memory: SyncResult;
sessions: SyncResult;
transcripts: SyncResult;
notebooks: SyncResult;
+ knowledge: SyncResult;
}> {
const memoryDir = opts?.memoryDir;
const sessionsDir = opts?.sessionsDir;
const transcriptsDir = opts?.transcriptsDir;
const notebookPaths = opts?.notebookPaths;
+ const knowledgeWikiDir = opts?.knowledgeWikiDir;
const memoryResult: SyncResult = memoryDir
? await this.syncMemoryFiles(memoryDir)
@@ -582,11 +645,16 @@ export class MemorySyncEngine {
? await this.syncNotebookFiles(notebookPaths)
: { added: 0, updated: 0, removed: 0, unchanged: 0 };
+ const knowledgeResult: SyncResult = knowledgeWikiDir
+ ? await this.syncKnowledgeFiles(knowledgeWikiDir)
+ : { added: 0, updated: 0, removed: 0, unchanged: 0 };
+
return {
memory: memoryResult,
sessions: sessionsResult,
transcripts: transcriptsResult,
notebooks: notebooksResult,
+ knowledge: knowledgeResult,
};
}
diff --git a/src/memory/notebook.ts b/src/memory/notebook.ts
index 345291e4..4a537af0 100644
--- a/src/memory/notebook.ts
+++ b/src/memory/notebook.ts
@@ -1014,8 +1014,16 @@ export class NotebookManager {
// System Prompt 集成
// --------------------------------------------------------------------------
- /** 生成用于注入 system prompt 的笔记本摘要 */
- getNotebookSummaryForPrompt(): string {
+ /**
+ * 生成用于注入 system prompt 的笔记本摘要
+ *
+ * project.md 按需加载:
+ * - 如果传入 queryContext,按 ## section 拆分 project.md,
+ * 只注入与 query 相关的 section(关键词匹配)+ 最近的 section
+ * - 不传 queryContext 时全量加载(兼容旧行为)
+ * - 最终注入的 project 内容不超过 PROJECT_ON_DEMAND_BUDGET tokens
+ */
+ getNotebookSummaryForPrompt(queryContext?: string): string {
const parts: string[] = [];
const profile = this.read('profile');
@@ -1030,7 +1038,12 @@ export class NotebookManager {
const project = this.read('project');
if (project.trim()) {
- parts.push(`\n${project.trim()}\n `);
+ const projectContent = queryContext
+ ? this.selectRelevantProjectSections(project, queryContext)
+ : project.trim();
+ if (projectContent) {
+ parts.push(`\n${projectContent}\n `);
+ }
}
const identity = this.read('identity');
@@ -1050,6 +1063,133 @@ export class NotebookManager {
return parts.join('\n\n');
}
+ /**
+ * 按需加载 project.md:按 ## 标题拆分为 section,
+ * 选择与 queryContext 最相关的 + 最近日期的 section,
+ * 总 token 不超过 PROJECT_ON_DEMAND_BUDGET
+ */
+ private selectRelevantProjectSections(content: string, query: string): string {
+ const PROJECT_ON_DEMAND_BUDGET = 4000; // 按需模式下预算减半
+
+ // 拆分为 sections(按 ## 标题切分)
+ const sections = this.splitIntoSections(content);
+
+ if (sections.length <= 1) {
+ // 没有多个 section,不需要过滤
+ return content.trim();
+ }
+
+ // 对每个 section 打分
+ const queryLower = query.toLowerCase();
+ const queryWords = queryLower
+ .split(/[\s,;,;。!?!?]+/)
+ .filter(w => w.length >= 2);
+
+ const scored = sections.map((section, index) => {
+ const textLower = section.text.toLowerCase();
+ let score = 0;
+
+ // 关键词匹配得分
+ for (const word of queryWords) {
+ if (textLower.includes(word)) {
+ score += 2;
+ }
+ }
+
+ // 日期越新得分越高(section 顺序通常是时间线)
+ // 给最后几个 section 加分(通常是最近的记录)
+ const recencyBonus = Math.max(0, (index - sections.length + 3)) * 1.5;
+ score += recencyBonus;
+
+ // 标题中含日期且日期较新的加分
+ const dateMatch = section.heading.match(/(\d{4}[-/]\d{2}[-/]\d{2})/);
+ if (dateMatch) {
+ const sectionDate = new Date(dateMatch[1]);
+ const daysSince = (Date.now() - sectionDate.getTime()) / (24 * 60 * 60 * 1000);
+ if (daysSince < 7) score += 3; // 一周内
+ else if (daysSince < 30) score += 1; // 一月内
+ }
+
+ return { section, score, tokens: estimateTokens(section.text) };
+ });
+
+ // 按分数降序排列
+ scored.sort((a, b) => b.score - a.score);
+
+ // 贪心选择,直到达到 token 预算
+ const selected: typeof scored = [];
+ let totalTokens = 0;
+
+ for (const item of scored) {
+ if (totalTokens + item.tokens > PROJECT_ON_DEMAND_BUDGET) {
+ // 如果还没选任何 section,至少选第一个(最相关的)
+ if (selected.length === 0) {
+ selected.push(item);
+ }
+ break;
+ }
+ selected.push(item);
+ totalTokens += item.tokens;
+ }
+
+ // 按原始顺序恢复排列
+ selected.sort((a, b) => {
+ return sections.indexOf(a.section) - sections.indexOf(b.section);
+ });
+
+ // 如果选择的 section 数量 < 总数,加一个提示
+ const omitted = sections.length - selected.length;
+ const result = selected.map(s => s.section.text).join('\n\n');
+
+ if (omitted > 0) {
+ return `${result}\n\n`;
+ }
+
+ return result;
+ }
+
+ /**
+ * 将 markdown 内容按 ## 标题切分为 sections
+ */
+ private splitIntoSections(content: string): Array<{ heading: string; text: string }> {
+ const lines = content.split('\n');
+ const sections: Array<{ heading: string; text: string; startLine: number }> = [];
+ let currentHeading = '';
+ let currentLines: string[] = [];
+ let currentStart = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ // 匹配 ## 标题(不匹配 # 顶级标题,那是文件标题)
+ if (/^##\s+/.test(line)) {
+ // 保存之前的 section
+ if (currentLines.length > 0) {
+ sections.push({
+ heading: currentHeading,
+ text: currentLines.join('\n').trim(),
+ startLine: currentStart,
+ });
+ }
+ currentHeading = line.replace(/^##\s+/, '').trim();
+ currentLines = [line];
+ currentStart = i;
+ } else {
+ currentLines.push(line);
+ }
+ }
+
+ // 最后一个 section
+ if (currentLines.length > 0) {
+ sections.push({
+ heading: currentHeading,
+ text: currentLines.join('\n').trim(),
+ startLine: currentStart,
+ });
+ }
+
+ return sections;
+ }
+
// --------------------------------------------------------------------------
// 辅助方法
// --------------------------------------------------------------------------
diff --git a/src/memory/types.ts b/src/memory/types.ts
index d56f0df3..243e4f96 100644
--- a/src/memory/types.ts
+++ b/src/memory/types.ts
@@ -3,7 +3,7 @@
*/
// 记忆来源类型
-export type MemorySource = 'memory' | 'session' | 'notebook';
+export type MemorySource = 'memory' | 'session' | 'notebook' | 'knowledge';
// 记忆搜索结果
export interface MemorySearchResult {
diff --git a/src/skills/builtin/opencli/SKILL.md b/src/skills/builtin/opencli/SKILL.md
new file mode 100644
index 00000000..d550f266
--- /dev/null
+++ b/src/skills/builtin/opencli/SKILL.md
@@ -0,0 +1,98 @@
+---
+description: 使用 opencli 从 78+ 网站获取结构化数据。当用户需要从知乎、B站、Twitter、HackerNews、Reddit、小红书等网站抓取内容时使用。
+user-invocable: true
+argument-hint: " [options]"
+---
+
+# OpenCLI — 网站结构化数据获取
+
+通过 Bash 工具调用 opencli CLI,从 78+ 网站获取结构化数据。
+
+## 前置条件
+
+检查是否已安装:
+```bash
+opencli --version
+```
+未安装则执行:
+```bash
+npm i -g @jackwener/opencli
+```
+
+## 基本用法
+
+```bash
+opencli [options] --format json
+```
+
+- `opencli --help` — 查看所有支持的站点
+- `opencli --help` — 查看站点的所有命令
+
+## 常用站点与命令
+
+### 资讯 / 社区
+```bash
+opencli hackernews top --limit 10 --format json
+opencli v2ex hot --format json
+opencli reddit hot --subreddit programming --format json
+opencli lobsters newest --format json
+opencli producthunt today --format json
+```
+
+### 中文平台
+```bash
+opencli zhihu hot --format json
+opencli bilibili trending --format json
+opencli xiaohongshu search "关键词" --format json
+opencli douban movie-top --format json
+opencli weibo hot --format json
+opencli jike trending --format json
+```
+
+### 技术 / 学术
+```bash
+opencli stackoverflow search "keyword" --format json
+opencli arxiv search "machine learning" --limit 5 --format json
+opencli devto top --format json
+```
+
+### 社交媒体
+```bash
+opencli twitter trending --format json
+opencli instagram search "keyword" --format json
+opencli linkedin jobs "software engineer" --format json
+```
+
+### 金融
+```bash
+opencli xueqiu hot --format json
+opencli yahoo-finance quote AAPL --format json
+```
+
+## 输出格式
+
+支持 `--format` 参数:`json`、`table`、`yaml`、`csv`、`md`
+
+优先使用 `--format json` 以获得结构化数据,便于后续处理。
+
+## 需要登录的站点
+
+部分站点(如 B站、小红书、微博)需要浏览器登录态。opencli 会复用本机浏览器的 cookie。如果提示未登录:
+1. 用 Browser 工具打开对应站点并登录
+2. 再用 opencli 获取数据
+
+## 使用原则
+
+- **优先用 WebFetch / WebSearch**,能满足需求就不用 opencli
+- 当需要**结构化数据**(如热榜列表、搜索结果、用户信息)时用 opencli
+- 当需要**登录态访问**时用 opencli(复用浏览器 cookie)
+- 当 WebFetch 返回的 HTML 难以解析时用 opencli(站点有现成适配器)
+
+## 执行 $ARGUMENTS
+
+用户的参数直接传递给 opencli:
+```bash
+opencli $ARGUMENTS --format json
+```
+
+如果用户只指定了站点没指定命令,先用 `opencli --help` 查看可用命令。
diff --git a/src/tools/create-agent.ts b/src/tools/create-agent.ts
new file mode 100644
index 00000000..5d0e3bc3
--- /dev/null
+++ b/src/tools/create-agent.ts
@@ -0,0 +1,418 @@
+/**
+ * CreateAgent - 运行时创建/管理自定义 Agent
+ *
+ * 往 ~/.axon/agents/ 写 .md 文件(frontmatter + system prompt),
+ * 写完后立即可被 Task 工具调用。
+ *
+ * 与 CreateTool 的关系:
+ * - CreateTool:创建工具(JS 文件,注册到 ToolRegistry,模型直接调用)
+ * - CreateAgent:创建 Agent 类型(Markdown 文件,通过 Task 工具以子代理方式调用)
+ */
+
+import { BaseTool } from './base.js';
+import type { ToolDefinition, ToolResult } from '../types/index.js';
+import * as fs from 'fs';
+import * as path from 'path';
+
+export interface CreateAgentInput {
+ /** 操作类型 */
+ action?: 'create' | 'delete' | 'list' | 'get';
+ /** Agent 名称(英文,用于 subagent_type 引用) */
+ name: string;
+ /** Agent 描述(告诉主 LLM 何时使用这个 agent) */
+ description?: string;
+ /** System prompt(agent 的行为指令,Markdown 格式) */
+ systemPrompt?: string;
+ /** 可用工具列表,逗号分隔。支持 Task(AgentType) 语法限制子 agent 类型。默认 "*"(全部) */
+ tools?: string;
+ /** 模型:sonnet / opus / haiku / inherit */
+ model?: string;
+ /** 是否继承父对话上下文 */
+ forkContext?: boolean;
+ /** 权限模式 */
+ permissionMode?: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions';
+ /** 最大轮次 */
+ maxTurns?: number;
+ /** agent 颜色标识 */
+ color?: string;
+ /** 记忆范围:user / project / local */
+ memory?: string;
+ /** 禁用的工具列表,逗号分隔 */
+ disallowedTools?: string;
+ /** 保存位置:user(~/.axon/agents/)或 project(.axon/agents/) */
+ scope?: 'user' | 'project';
+}
+
+const VALID_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'];
+const VALID_PERMISSION_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
+const VALID_COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray'];
+const VALID_MEMORY_VALUES = ['user', 'project', 'local'];
+
+export class CreateAgentTool extends BaseTool {
+ name = 'CreateAgent';
+ shouldDefer = true;
+ searchHint = 'create agent, new agent type, custom agent, define agent, build agent';
+ description = `Create, delete, list, or inspect custom agent types at runtime. Agents are saved as .md files and immediately available via the Task tool.
+
+Use this when:
+- You need a specialized agent for a specific domain or workflow
+- You want to define a reusable agent with constrained tools and focused instructions
+- You want to persist an agent configuration for future conversations
+
+Actions:
+- create: Write a new agent .md file (default)
+- delete: Remove an agent file
+- list: Show all custom agents (user + project)
+- get: Read an agent's full definition
+
+The created agent can be immediately invoked via the Task tool with subagent_type set to the agent's name.`;
+
+ getInputSchema(): ToolDefinition['inputSchema'] {
+ return {
+ type: 'object',
+ properties: {
+ action: {
+ type: 'string',
+ enum: ['create', 'delete', 'list', 'get'],
+ description: 'Action to perform. Defaults to "create".',
+ },
+ name: {
+ type: 'string',
+ description: 'Agent name (English). Used as subagent_type when invoking via Task tool. Required for create/delete/get.',
+ },
+ description: {
+ type: 'string',
+ description: 'When to use this agent. Shown to the main LLM for agent selection. Required for create.',
+ },
+ systemPrompt: {
+ type: 'string',
+ description: 'The agent\'s system prompt in Markdown. Defines its behavior, workflow steps, constraints, and goals. Required for create.',
+ },
+ tools: {
+ type: 'string',
+ description: 'Comma-separated tool list. Use "*" for all tools. Supports Task(AgentType) syntax. Default: "*".',
+ },
+ model: {
+ type: 'string',
+ enum: VALID_MODELS,
+ description: 'Model to use. Default: sonnet.',
+ },
+ forkContext: {
+ type: 'boolean',
+ description: 'If true, agent inherits parent conversation context. Forces model to "inherit". Default: false.',
+ },
+ permissionMode: {
+ type: 'string',
+ enum: VALID_PERMISSION_MODES,
+ description: 'Permission mode. Default: not set (uses system default).',
+ },
+ maxTurns: {
+ type: 'number',
+ description: 'Max conversation turns for the agent.',
+ },
+ color: {
+ type: 'string',
+ enum: VALID_COLORS,
+ description: 'Display color for the agent.',
+ },
+ memory: {
+ type: 'string',
+ enum: VALID_MEMORY_VALUES,
+ description: 'Memory scope for the agent.',
+ },
+ disallowedTools: {
+ type: 'string',
+ description: 'Comma-separated list of tools to disallow.',
+ },
+ scope: {
+ type: 'string',
+ enum: ['user', 'project'],
+ description: 'Where to save: "user" (~/.axon/agents/) or "project" (.axon/agents/). Default: "user".',
+ },
+ },
+ required: ['name'],
+ };
+ }
+
+ async execute(input: CreateAgentInput): Promise {
+ const action = input.action || 'create';
+
+ switch (action) {
+ case 'list':
+ return this.listAgents();
+ case 'delete':
+ return this.deleteAgent(input.name, input.scope);
+ case 'get':
+ return this.getAgent(input.name);
+ case 'create':
+ return this.createAgent(input);
+ default:
+ return this.error(`Unknown action: ${action}. Use 'create', 'delete', 'list', or 'get'.`);
+ }
+ }
+
+ private getUserAgentsDir(): string {
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
+ return path.join(homeDir, '.axon', 'agents');
+ }
+
+ private getProjectAgentsDir(): string {
+ return path.join(process.cwd(), '.axon', 'agents');
+ }
+
+ private getAgentsDir(scope: 'user' | 'project' = 'user'): string {
+ return scope === 'project' ? this.getProjectAgentsDir() : this.getUserAgentsDir();
+ }
+
+ private toFileName(name: string): string {
+ // PascalCase/camelCase → kebab-case
+ const kebab = name
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
+ .toLowerCase();
+ return `${kebab}.md`;
+ }
+
+ /**
+ * 列出所有自定义 agent
+ */
+ private listAgents(): ToolResult {
+ const userDir = this.getUserAgentsDir();
+ const projectDir = this.getProjectAgentsDir();
+ const entries: string[] = [];
+
+ for (const [label, dir] of [['user', userDir], ['project', projectDir]] as const) {
+ if (!fs.existsSync(dir)) continue;
+ try {
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
+ for (const file of files) {
+ const content = fs.readFileSync(path.join(dir, file), 'utf-8');
+ const { name, description } = this.extractMeta(content);
+ entries.push(` [${label}] ${name || file} — ${description || '(no description)'}`);
+ }
+ } catch {
+ // 读取失败静默跳过
+ }
+ }
+
+ if (entries.length === 0) {
+ return this.success(
+ `No custom agents found.\n\nUser agents: ${userDir}\nProject agents: ${projectDir}\n\nUse CreateAgent with action="create" to define new agent types.`
+ );
+ }
+
+ return this.success(
+ `Custom agents (${entries.length}):\n\n${entries.join('\n')}\n\nUser dir: ${userDir}\nProject dir: ${projectDir}`
+ );
+ }
+
+ /**
+ * 获取 agent 完整定义
+ */
+ private getAgent(name: string): ToolResult {
+ if (!name) return this.error('Agent name is required.');
+
+ const filePath = this.findAgentFile(name);
+ if (!filePath) {
+ return this.error(`Agent "${name}" not found in user or project agents directories.`);
+ }
+
+ const content = fs.readFileSync(filePath, 'utf-8');
+ return this.success(`File: ${filePath}\n\n${content}`);
+ }
+
+ /**
+ * 删除 agent
+ */
+ private deleteAgent(name: string, scope?: 'user' | 'project'): ToolResult {
+ if (!name) return this.error('Agent name is required.');
+
+ // 如果指定了 scope,只在该 scope 查找
+ if (scope) {
+ const dir = this.getAgentsDir(scope);
+ const filePath = path.join(dir, this.toFileName(name));
+ if (!fs.existsSync(filePath)) {
+ return this.error(`Agent file not found: ${filePath}`);
+ }
+ fs.unlinkSync(filePath);
+ return this.success(`Deleted agent "${name}".\nFile: ${filePath}`);
+ }
+
+ // 未指定 scope,按优先级查找
+ const filePath = this.findAgentFile(name);
+ if (!filePath) {
+ return this.error(`Agent "${name}" not found in user or project agents directories.`);
+ }
+
+ fs.unlinkSync(filePath);
+ return this.success(`Deleted agent "${name}".\nFile: ${filePath}`);
+ }
+
+ /**
+ * 创建 agent
+ */
+ private createAgent(input: CreateAgentInput): ToolResult {
+ const { name, description, systemPrompt, scope = 'user' } = input;
+
+ if (!name) return this.error('Agent name is required.');
+ if (!description) return this.error('Agent description (when-to-use) is required.');
+ if (!systemPrompt) return this.error('systemPrompt is required.');
+
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
+ return this.error(
+ `Invalid agent name "${name}". Must start with a letter, contain only letters, digits, _ or -.`
+ );
+ }
+
+ // 验证可选字段
+ if (input.model && !VALID_MODELS.includes(input.model)) {
+ return this.error(`Invalid model "${input.model}". Valid: ${VALID_MODELS.join(', ')}`);
+ }
+ if (input.color && !VALID_COLORS.includes(input.color)) {
+ return this.error(`Invalid color "${input.color}". Valid: ${VALID_COLORS.join(', ')}`);
+ }
+ if (input.memory && !VALID_MEMORY_VALUES.includes(input.memory)) {
+ return this.error(`Invalid memory "${input.memory}". Valid: ${VALID_MEMORY_VALUES.join(', ')}`);
+ }
+ if (input.permissionMode && !VALID_PERMISSION_MODES.includes(input.permissionMode)) {
+ return this.error(`Invalid permissionMode "${input.permissionMode}". Valid: ${VALID_PERMISSION_MODES.join(', ')}`);
+ }
+
+ // forkContext: true 强制 model: inherit
+ let model = input.model;
+ if (input.forkContext && model !== 'inherit') {
+ model = 'inherit';
+ }
+
+ // 构建 frontmatter
+ const mdContent = this.buildAgentFile(name, description, systemPrompt, {
+ tools: input.tools,
+ model,
+ forkContext: input.forkContext,
+ permissionMode: input.permissionMode,
+ maxTurns: input.maxTurns,
+ color: input.color,
+ memory: input.memory,
+ disallowedTools: input.disallowedTools,
+ });
+
+ // 写入文件
+ const dir = this.getAgentsDir(scope);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ const fileName = this.toFileName(name);
+ const filePath = path.join(dir, fileName);
+ const isUpdate = fs.existsSync(filePath);
+
+ fs.writeFileSync(filePath, mdContent, 'utf-8');
+
+ const actionWord = isUpdate ? 'Updated' : 'Created';
+ return this.success(
+ [
+ `${actionWord} agent "${name}".`,
+ `File: ${filePath}`,
+ `Scope: ${scope}`,
+ ``,
+ `The agent is now available. Invoke it via the Task tool:`,
+ ` Task({ subagent_type: "${name}", prompt: "...", description: "..." })`,
+ ].join('\n')
+ );
+ }
+
+ /**
+ * 查找 agent 文件(project 优先于 user)
+ */
+ private findAgentFile(name: string): string | null {
+ const fileName = this.toFileName(name);
+
+ // project scope 优先
+ const projectPath = path.join(this.getProjectAgentsDir(), fileName);
+ if (fs.existsSync(projectPath)) return projectPath;
+
+ const userPath = path.join(this.getUserAgentsDir(), fileName);
+ if (fs.existsSync(userPath)) return userPath;
+
+ return null;
+ }
+
+ /**
+ * 从 .md 内容中快速提取 name 和 description
+ */
+ private extractMeta(content: string): { name?: string; description?: string } {
+ const regex = /^---\s*\n([\s\S]*?)---\s*\n?/;
+ const match = content.match(regex);
+ if (!match) return {};
+
+ const frontmatter: Record = {};
+ for (const line of (match[1] || '').split('\n')) {
+ const colonIndex = line.indexOf(':');
+ if (colonIndex > 0) {
+ const key = line.slice(0, colonIndex).trim();
+ const value = line.slice(colonIndex + 1).trim().replace(/^["']|["']$/g, '');
+ if (key) frontmatter[key] = value;
+ }
+ }
+
+ return {
+ name: frontmatter.name,
+ description: frontmatter.description || frontmatter['when-to-use'],
+ };
+ }
+
+ /**
+ * 构建 agent .md 文件内容
+ */
+ private buildAgentFile(
+ name: string,
+ description: string,
+ systemPrompt: string,
+ options: {
+ tools?: string;
+ model?: string;
+ forkContext?: boolean;
+ permissionMode?: string;
+ maxTurns?: number;
+ color?: string;
+ memory?: string;
+ disallowedTools?: string;
+ },
+ ): string {
+ const lines: string[] = ['---'];
+ lines.push(`name: ${name}`);
+ lines.push(`description: ${description}`);
+
+ if (options.tools) {
+ lines.push(`tools: "${options.tools}"`);
+ }
+ if (options.model) {
+ lines.push(`model: ${options.model}`);
+ }
+ if (options.forkContext) {
+ lines.push(`forkContext: true`);
+ }
+ if (options.permissionMode) {
+ lines.push(`permissionMode: ${options.permissionMode}`);
+ }
+ if (options.maxTurns !== undefined) {
+ lines.push(`maxTurns: ${options.maxTurns}`);
+ }
+ if (options.color) {
+ lines.push(`color: ${options.color}`);
+ }
+ if (options.memory) {
+ lines.push(`memory: ${options.memory}`);
+ }
+ if (options.disallowedTools) {
+ lines.push(`disallowedTools: "${options.disallowedTools}"`);
+ }
+
+ lines.push('---');
+ lines.push('');
+ lines.push(systemPrompt);
+ lines.push('');
+
+ return lines.join('\n');
+ }
+}
diff --git a/src/tools/file.ts b/src/tools/file.ts
index 5392b8bf..4a8393d0 100644
--- a/src/tools/file.ts
+++ b/src/tools/file.ts
@@ -37,6 +37,7 @@ import {
clearDocumentCache,
extractDocumentVisuals,
compressExtractedImages,
+ pptxHasChartsOrDiagrams,
renderPresentationToImages,
MAX_RENDERED_PRESENTATION_PAGES,
} from '../media/index.js';
@@ -723,6 +724,100 @@ Usage:
const ext = path.extname(filePath).toLowerCase().slice(1);
const sizeKB = (stat.size / 1024).toFixed(2);
+ // PPTX 混合策略:直接提取原始图片(无损)+ 检测图表/SmartArt
+ // 有图表时用 LibreOffice 渲染补充,确保不丢内容
+ if (ext === 'pptx') {
+ try {
+ const visuals = await extractDocumentVisuals(filePath);
+ const allImages = [
+ ...visuals.slides.flatMap(s => s.images),
+ ...visuals.unassociatedImages,
+ ];
+
+ // 检测 PPTX 中是否包含图表/SmartArt(这些无法从 ZIP 直接提取为图片)
+ const hasCharts = await pptxHasChartsOrDiagrams(filePath);
+
+ if (allImages.length > 0) {
+ const compressed = await compressExtractedImages(allImages);
+
+ if (compressed.length > 0) {
+ const imageBlocks: Array<{
+ type: 'image';
+ source: {
+ type: 'base64';
+ media_type: 'image/jpeg' | 'image/png';
+ data: string;
+ };
+ }> = [];
+
+ for (const img of compressed) {
+ imageBlocks.push({
+ type: 'image' as const,
+ source: {
+ type: 'base64' as const,
+ media_type: img.mimeType,
+ data: img.base64,
+ },
+ });
+ }
+
+ // 如果有图表/SmartArt,用 LibreOffice 渲染补充
+ if (hasCharts) {
+ try {
+ const rendered = await renderPresentationToImages(filePath);
+ const jpgFiles = fs.readdirSync(rendered.file.outputDir)
+ .filter(f => f.endsWith('.jpg'))
+ .sort();
+ for (const jpgFile of jpgFiles) {
+ const jpgPath = path.join(rendered.file.outputDir, jpgFile);
+ const base64 = fs.readFileSync(jpgPath).toString('base64');
+ imageBlocks.push({
+ type: 'image' as const,
+ source: {
+ type: 'base64' as const,
+ media_type: 'image/jpeg' as 'image/jpeg' | 'image/png',
+ data: base64,
+ },
+ });
+ }
+ } catch {
+ // 渲染失败不影响已提取的原始图片
+ }
+ }
+
+ let textOutput = `[PPTX Document: ${filePath}] (${sizeKB} KB)\n`;
+ textOutput += `Images: ${compressed.length} embedded image(s) extracted (original quality)`;
+ if (hasCharts) {
+ textOutput += ` + slide renders (charts/diagrams detected)`;
+ }
+ textOutput += '\n\n';
+ textOutput += visuals.fullText;
+
+ return {
+ success: true,
+ output: textOutput,
+ newMessages: [{
+ role: 'user' as const,
+ content: imageBlocks as any,
+ }],
+ };
+ }
+ }
+
+ // 没有嵌入图片但有图表:用 LibreOffice 渲染
+ if (hasCharts) {
+ // fall through 到下面的 PPT/PPTX 渲染逻辑
+ } else if (visuals.fullText) {
+ // 纯文本 PPT(无图片无图表)
+ const header = `[PPTX Document: ${filePath}] (${sizeKB} KB)\n\n`;
+ return { success: true, output: header + visuals.fullText };
+ }
+ } catch {
+ // 直接提取失败,fallback 到 LibreOffice 渲染
+ }
+ }
+
+ // PPT(旧格式)或 PPTX fallback: LibreOffice 渲染整页为图片
if (ext === 'ppt' || ext === 'pptx') {
try {
const rendered = await renderPresentationToImages(filePath);
@@ -780,31 +875,26 @@ Usage:
};
}
} catch {
- // PPT 渲染失败时再回退到旧的 embedded image 提取逻辑
+ // LibreOffice 渲染也失败
}
}
- // 尝试视觉提取(图片 + 文本)
+ // 其他 Office 文档:尝试视觉提取
try {
const visuals = await extractDocumentVisuals(filePath);
-
- // 收集所有图片(slide 关联的 + 未关联的)
const allImages = [
...visuals.slides.flatMap(s => s.images),
...visuals.unassociatedImages,
];
if (allImages.length > 0) {
- // 压缩图片
const compressed = await compressExtractedImages(allImages);
if (compressed.length > 0) {
- // 构建文本输出
let textOutput = `[${ext.toUpperCase()} Document: ${filePath}] (${sizeKB} KB)\n`;
textOutput += `Images: ${compressed.length} embedded image(s) extracted\n\n`;
textOutput += visuals.fullText;
- // 构建 image blocks(与 PDF readPdfEnhanced 模式一致)
const imageBlocks: Array<{
type: 'image';
source: {
@@ -836,7 +926,6 @@ Usage:
}
}
- // 有文本但没图片:返回纯文本
if (visuals.fullText) {
const header = `[${ext.toUpperCase()} Document: ${filePath}] (${sizeKB} KB)\n\n`;
return { success: true, output: header + visuals.fullText };
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 61b7eddb..a57c7273 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -28,6 +28,7 @@ export * from './schedule.js';
export * from './self-evolve.js';
export * from './browser.js';
export * from './create-tool.js';
+export * from './create-agent.js';
export * from './custom-tool-loader.js';
export * from './eye.js';
export * from './ear.js';
@@ -36,6 +37,8 @@ export * from './network-agent.js';
export * from './lsp.js';
export * from './repl.js';
export * from './tool-search.js';
+export * from './knowledge-ingest.js';
+export * from './knowledge-compile.js';
// 蓝图工具不通过此处 re-export
// 蓝图模块直接 import 各自需要的工具文件 (如 ../tools/dispatch-worker.js)
@@ -62,6 +65,7 @@ import { SelfEvolveTool } from './self-evolve.js';
import { BrowserTool } from './browser.js';
import { MemorySearchTool } from './memory-search.js';
import { CreateToolTool } from './create-tool.js';
+import { CreateAgentTool } from './create-agent.js';
import { loadCustomTools } from './custom-tool-loader.js';
import { DatabaseTool } from './database.js';
import { EyeTool } from './eye.js';
@@ -72,6 +76,8 @@ import { NetworkTool } from './network-agent.js';
import { LSPTool } from './lsp.js';
import { ReplTool } from './repl.js';
import { ToolSearchTool } from './tool-search.js';
+import { KnowledgeIngestTool } from './knowledge-ingest.js';
+import { KnowledgeCompileTool } from './knowledge-compile.js';
// ============ 蓝图工具 imports (lazy) ============
import { GenerateBlueprintTool } from './generate-blueprint.js';
@@ -175,6 +181,9 @@ export function registerCoreTools(): void {
// 17. CreateTool 自定义 Skill 创建(写入 ~/.axon/skills/,利用 Skill 系统)
toolRegistry.register(new CreateToolTool());
+ // 18. CreateAgent 自定义 Agent 类型创建(写入 ~/.axon/agents/)
+ toolRegistry.register(new CreateAgentTool());
+
// 20. Database 开发工具
toolRegistry.register(new DatabaseTool());
@@ -196,6 +205,10 @@ export function registerCoreTools(): void {
// 26. REPL 交互式代码执行工具(Python / Node.js)
toolRegistry.register(new ReplTool());
+ // 27. Knowledge Base 知识库工具(Karpathy LLM Knowledge Base 模式)
+ toolRegistry.register(new KnowledgeIngestTool());
+ toolRegistry.register(new KnowledgeCompileTool());
+
// 27. 加载外挂自定义工具 (~/.axon/custom-tools/*.js)
loadCustomTools().catch(err => {
console.warn('[Tools] Failed to load custom tools:', err);
diff --git a/src/tools/knowledge-compile.ts b/src/tools/knowledge-compile.ts
new file mode 100644
index 00000000..290b93a5
--- /dev/null
+++ b/src/tools/knowledge-compile.ts
@@ -0,0 +1,102 @@
+/**
+ * KnowledgeCompile 工具
+ * Agent 可调用:将 raw/ 原始文件编译为 wiki 文章
+ */
+
+import { BaseTool } from './base.js';
+import type { ToolResult, ToolDefinition } from '../types/index.js';
+import { getKnowledgeStore } from '../knowledge/store.js';
+import { KnowledgeCompiler } from '../knowledge/compiler.js';
+import { createClientWithModel } from '../core/client.js';
+import { modelConfig } from '../models/config.js';
+
+export interface KnowledgeCompileInput {
+ /** 指定编译的 slug 列表(空则编译所有未编译的) */
+ slugs?: string[];
+ /** 强制重新编译已有文章 */
+ force?: boolean;
+}
+
+export class KnowledgeCompileTool extends BaseTool {
+ name = 'KnowledgeCompile';
+ shouldDefer = true;
+ searchHint = 'compile knowledge base, build wiki, generate wiki article from raw documents';
+
+ description = `Compile raw knowledge base files into structured wiki articles using LLM.
+
+Use this tool when:
+- After ingesting new documents/URLs, compile them into wiki articles
+- The user wants to rebuild or update the knowledge base
+- New raw files have been added and need processing
+
+Compilation strategy:
+- Short documents (≤8K tokens): entire text compiled in one LLM call
+- Long documents (>8K tokens): chunked summaries → merged into final article
+- Compiled wiki articles are automatically indexed for search via MemorySearch
+
+If no slugs specified, compiles all unprocessed raw files.`;
+
+ constructor() {
+ super({
+ baseTimeout: 300000, // 5 分钟超时(编译涉及多次 LLM 调用)
+ });
+ }
+
+ getInputSchema(): ToolDefinition['inputSchema'] {
+ return {
+ type: 'object',
+ properties: {
+ slugs: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Specific raw file slugs to compile (default: all uncompiled)',
+ },
+ force: {
+ type: 'boolean',
+ description: 'Force recompile even if wiki article already exists (default: false)',
+ },
+ },
+ };
+ }
+
+ async execute(input: KnowledgeCompileInput): Promise {
+ const store = getKnowledgeStore();
+ // CLI 模式:使用 haiku 模型做编译
+ const client = createClientWithModel(modelConfig.resolveAlias('haiku'));
+ const compiler = new KnowledgeCompiler(store, { client });
+
+ const result = await compiler.compile({
+ slugs: input.slugs,
+ force: input.force,
+ });
+
+ if (result.compiled === 0 && result.updated === 0 && result.failed === 0) {
+ return this.success('No raw files to compile. All documents are up to date.');
+ }
+
+ let output = `Compilation complete:\n`;
+ output += ` Compiled: ${result.compiled} new articles\n`;
+ if (result.updated > 0) output += ` Updated: ${result.updated} existing articles\n`;
+ if (result.failed > 0) output += ` Failed: ${result.failed}\n`;
+
+ if (result.articles.length > 0) {
+ output += `\nArticles:\n`;
+ for (const a of result.articles) {
+ output += ` - ${a.title} [${a.category}] (${a.tags.join(', ')})\n`;
+ }
+ }
+
+ if (result.errors.length > 0) {
+ output += `\nErrors:\n`;
+ for (const e of result.errors) {
+ output += ` - ${e.slug}: ${e.error}\n`;
+ }
+ }
+
+ output += `\nWiki articles are now searchable via MemorySearch with source='knowledge'.`;
+
+ return result.failed > 0 && result.compiled === 0
+ ? this.error(output)
+ : this.success(output);
+ }
+}
diff --git a/src/tools/knowledge-ingest.ts b/src/tools/knowledge-ingest.ts
new file mode 100644
index 00000000..688be7bd
--- /dev/null
+++ b/src/tools/knowledge-ingest.ts
@@ -0,0 +1,90 @@
+/**
+ * KnowledgeIngest 工具
+ * Agent 可调用:将 URL 或本地文件摄入到知识库 raw/
+ */
+
+import { BaseTool } from './base.js';
+import type { ToolResult, ToolDefinition } from '../types/index.js';
+import { getKnowledgeStore } from '../knowledge/store.js';
+
+export interface KnowledgeIngestInput {
+ /** URL 或本地文件路径 */
+ source: string;
+ /** 预设标签 */
+ tags?: string[];
+ /** 预设分类 */
+ category?: string;
+ /** 自定义 slug */
+ slug?: string;
+}
+
+export class KnowledgeIngestTool extends BaseTool {
+ name = 'KnowledgeIngest';
+ shouldDefer = true;
+ searchHint = 'save to knowledge base, clip URL, ingest document, add to wiki, collect article';
+
+ description = `Ingest a URL or local file into the knowledge base for later compilation into wiki articles.
+
+Use this tool when:
+- The user wants to save a web page, article, or document to the knowledge base
+- Building a personal knowledge base from various sources
+- Collecting research materials for later reference
+
+The source content is extracted (HTML→Markdown, PDF→text, Office→text) and saved to the raw/ directory.
+After ingesting, use KnowledgeCompile to compile raw files into searchable wiki articles.`;
+
+ getInputSchema(): ToolDefinition['inputSchema'] {
+ return {
+ type: 'object',
+ properties: {
+ source: {
+ type: 'string',
+ description: 'URL (https://...) or absolute local file path to ingest',
+ },
+ tags: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Optional tags to categorize the content',
+ },
+ category: {
+ type: 'string',
+ description: 'Optional category (e.g. AI, Web, Database, Systems)',
+ },
+ slug: {
+ type: 'string',
+ description: 'Optional custom slug for the raw file name',
+ },
+ },
+ required: ['source'],
+ };
+ }
+
+ async execute(input: KnowledgeIngestInput): Promise {
+ const store = getKnowledgeStore();
+ const opts = {
+ tags: input.tags,
+ category: input.category,
+ slug: input.slug,
+ };
+
+ const isUrl = /^https?:\/\//i.test(input.source);
+
+ const result = isUrl
+ ? await store.ingestUrl(input.source, opts)
+ : await store.ingestFile(input.source, opts);
+
+ if (!result.success) {
+ return this.error(`Failed to ingest: ${result.error}`);
+ }
+
+ return this.success(
+ `Ingested successfully:\n` +
+ ` Source: ${input.source}\n` +
+ ` Saved to: raw/${result.rawPath}\n` +
+ ` Text length: ${result.textLength} chars\n` +
+ ` Images extracted: ${result.imageCount}\n` +
+ ` Slug: ${result.slug}\n\n` +
+ `Use KnowledgeCompile to compile this into a wiki article.`
+ );
+ }
+}
diff --git a/src/tools/memory-search.ts b/src/tools/memory-search.ts
index bba302b0..002d3b07 100644
--- a/src/tools/memory-search.ts
+++ b/src/tools/memory-search.ts
@@ -26,7 +26,7 @@ function formatAge(ms: number): string {
*/
export interface MemorySearchInput {
query: string;
- source?: 'all' | 'memory' | 'session' | 'notebook';
+ source?: 'all' | 'memory' | 'session' | 'notebook' | 'knowledge';
maxResults?: number;
}
@@ -61,7 +61,7 @@ PROACTIVE USAGE: You should actively search memory when the user's question rela
},
source: {
type: 'string',
- enum: ['all', 'memory', 'session', 'notebook'],
+ enum: ['all', 'memory', 'session', 'notebook', 'knowledge'],
description: 'Filter by source type (default: all)',
},
maxResults: {
diff --git a/src/web/client/src/App.tsx b/src/web/client/src/App.tsx
index 62a6152d..47f96b90 100644
--- a/src/web/client/src/App.tsx
+++ b/src/web/client/src/App.tsx
@@ -15,6 +15,7 @@ import {
DebugPanel,
} from './components';
import { CrossSessionToast } from './components/CrossSessionToast';
+import { TopicShiftToast } from './components/TopicShiftToast';
import { UpdateBanner } from './components/UpdateBanner';
import { SlashCommandDialog } from './components/SlashCommandDialog';
import { RewindOption } from './components/RewindMenu';
@@ -86,6 +87,8 @@ interface AppProps {
registerMessaging?: (messaging: { send: (msg: any) => void; addMessageHandler: (handler: (msg: any) => void) => () => void }) => void;
onLoginClick?: () => void;
authRefreshKey?: number;
+ workMode?: 'chat' | 'ide';
+ onToggleWorkMode?: () => void;
}
function AppContent({
@@ -99,6 +102,8 @@ function AppContent({
registerMessaging,
onLoginClick,
authRefreshKey = 0,
+ workMode = 'chat',
+ onToggleWorkMode,
}: AppProps) {
const { t } = useLanguage();
const { state: projectState, openFolder } = useProject();
@@ -155,6 +160,13 @@ function AppContent({
authRefreshKey,
});
const [thinkingConfig, setThinkingConfig] = useState(() => normalizeWebThinkingConfig());
+ const prevBackendRef = useRef(runtimeBackend);
+ useEffect(() => {
+ if (runtimeBackend === 'axon-cloud' && prevBackendRef.current !== 'axon-cloud') {
+ setThinkingConfig(prev => ({ ...prev, level: 'low' }));
+ }
+ prevBackendRef.current = runtimeBackend;
+ }, [runtimeBackend]);
const availableModels = useRuntimeModelCatalog({
connected,
runtimeBackend,
@@ -257,6 +269,8 @@ function AppContent({
dismissCrossSessionNotification,
slashCommandResult,
setSlashCommandResult,
+ topicShiftDetected,
+ dismissTopicShift,
} = useMessageHandler({
addMessageHandler,
model,
@@ -872,6 +886,8 @@ function AppContent({
onLoginClick={onLoginClick}
onNewSession={sessionManager.handleNewSession}
hasMessages={messages.length > 0}
+ workMode={workMode}
+ onToggleWorkMode={onToggleWorkMode}
/>
@@ -979,6 +995,15 @@ function AppContent({
onDismiss={dismissCrossSessionNotification}
/>
)}
+ {topicShiftDetected && (
+ {
+ dismissTopicShift();
+ sessionManager.handleNewSession();
+ }}
+ onDismiss={dismissTopicShift}
+ />
+ )}
(() => {
+ try {
+ const saved = localStorage.getItem('axon-work-mode');
+ if (saved === 'chat' || saved === 'ide') return saved;
+ } catch {}
+ return 'chat';
+ });
+
+ const toggleWorkMode = useCallback(() => {
+ setWorkMode(prev => {
+ const next = prev === 'chat' ? 'ide' : 'chat';
+ try { localStorage.setItem('axon-work-mode', next); } catch {}
+ // 切回聊天模式时确保回到 chat 页面
+ if (next === 'chat') {
+ setCurrentPage('chat');
+ }
+ return next;
+ });
+ }, []);
+
// 来自 App 的会话数据(通过回调上报)
const [sessions, setSessions] = useState([]);
const [sessionStatusMap, setSessionStatusMap] = useState