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 @@ Axon - AI Coding Platform | CLI + Web UI + Multi-Agent + content="Axon is a full-featured AI coding platform with Web UI IDE, scheduled task daemon, Agent Teams multi-agent collaboration, Blueprint workflow engine, 45+ tools, plugin system, and i18n support."> @@ -30,7 +30,7 @@ + content="Full-featured AI coding platform: Web UI IDE, scheduled task daemon, Agent Teams collaboration, Blueprint workflow engine, 45+ tools, plugin system, and more."> @@ -39,7 +39,7 @@ + content="Full-featured AI coding platform: Web UI IDE, scheduled task daemon, Agent Teams collaboration, Blueprint workflow engine, 45+ tools, plugin system, and more."> @@ -269,7 +269,7 @@

See It in Action

Features -

17 Core Capabilities

+

20 Core Capabilities

From CLI to Web UI, from single-agent to multi-agent, from local to multi-cloud — going far beyond the original Axon

@@ -290,7 +290,7 @@

Blueprint Workflow Engine

🛠️
-

36+ Built-in Tools

+

45+ Built-in Tools

File operations, search & analysis, Shell execution, web fetching, task management, scheduled tasks, Notebook, Plan mode, Skill system, LSP integration — a comprehensive toolchain

+ +
+
📡
+

Data Extraction (OpenCLI)

+

Extract structured data from 78+ popular websites. HackerNews, Reddit, Twitter, arXiv, StackOverflow, Zhihu, Bilibili and more. Natural language driven with cookie-based auth support

+
+
+
💭
+

Extended Thinking

+

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

+
diff --git a/landing-page/zh/index.html b/landing-page/zh/index.html index e908634c..97008e25 100644 --- a/landing-page/zh/index.html +++ b/landing-page/zh/index.html @@ -17,7 +17,7 @@ Axon - AI编程助手 | CLI + Web UI + 多智能体协作 - + @@ -191,7 +191,7 @@

$ 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 多种安装方式

+ +
+
📡
+

数据采集 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 软件

SlackSlack 机器人 DiscordDiscord 机器人 WhatsAppWhatsApp 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 思考预算,适合最复杂的场景
  • +
+
+
+ +

如何开启:

+
    +
  1. 点击右上角 ⚙️ 打开设置面板
  2. +
  3. 找到 "思考模式" 选项
  4. +
  5. 选择一个档位即可生效
  6. +
+ +
建议:日常对话保持关闭或使用低档。遇到复杂推理问题时,临时切换到高档或超高档,问完再切回来。
+ +
注意:档位越高,token 消耗和响应时间越大。超高档单次思考最多消耗 50K token。云端部署(axon-cloud)默认关闭扩展思考,需在设置中手动开启。
+ + +

8.16 桌面应用自动更新

+ +

Axon 桌面端(Electron 版本)内置了自动更新功能,保持应用始终最新,无需手动下载安装包。

+ +

更新流程:

+
    +
  1. 启动桌面应用时,自动检测是否有新版本
  2. +
  3. 如果有新版本,后台静默下载更新包(不影响你正常使用)
  4. +
  5. 下载完成后,状态栏会出现提示
  6. +
  7. 点击 "重启更新",应用重启后即完成升级
  8. +
+ +
支持 Windows 和 macOS 平台。整个过程无需手动操作,你只需要在方便的时候点击重启即可。
+ +

8.17 Notebook & REPL — 交互式代码执行

+ +

Axon 不只能帮你写代码文件,还能直接编辑 Jupyter Notebook 和在交互式环境中运行代码。

+ +

Jupyter Notebook 编辑

+ +

AI 可以直接操作 .ipynb 文件中的单元格:替换、插入、删除。代码单元格被修改后,旧的运行结果会自动清除。

+ +
+
Notebook 操作示例
+
+ "帮我修改这个 notebook 的第 3 个单元格,把 pandas 的读取路径改成 data.csv"
+ "在第 2 个单元格后面插入一个新的代码单元格,内容是绘制柱状图" +
+
+ +

REPL 交互式执行

+ +

支持 Python 3Node.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![Page ${pageIdx + 1}](${imageDirRel}/page-${pageIdx + 1}.jpg)\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(`![Slide ${slide.index} image](${imageDirRel}/${img.zipPath ? path.basename(img.zipPath, path.extname(img.zipPath)) : `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>(new Map()); @@ -206,29 +228,32 @@ function RootContent() { return (
- setShowAuthDialog(true)} - onSettingsClick={() => setShowSettings(true)} - authRefreshKey={authRefreshKey} - // 项目 - onOpenFolder={handleOpenFolder} - onCreateApp={() => setShowCreateApp(true)} - // 会话 - sessions={sessions} - sessionStatusMap={sessionStatusMap} - currentSessionId={currentSessionId} - onSessionSelect={(id) => sessionActionsRef.current.selectSession(id)} - onNewSession={() => sessionActionsRef.current.newSession()} - onSessionDelete={(id) => sessionActionsRef.current.deleteSession(id)} - onSessionRename={(id, name) => sessionActionsRef.current.renameSession(id, name)} - // 会话搜索 - onOpenSessionSearch={openSessionSearch} - // Git 面板 - onOpenGitPanel={toggleGitPanel} - /> + {/* 聊天模式 + chat 页面时隐藏 TopNavBar */} + {!(workMode === 'chat' && currentPage === 'chat') && ( + setShowAuthDialog(true)} + onSettingsClick={() => setShowSettings(true)} + authRefreshKey={authRefreshKey} + // 项目 + onOpenFolder={handleOpenFolder} + onCreateApp={() => setShowCreateApp(true)} + // 会话 + sessions={sessions} + sessionStatusMap={sessionStatusMap} + currentSessionId={currentSessionId} + onSessionSelect={(id) => sessionActionsRef.current.selectSession(id)} + onNewSession={() => sessionActionsRef.current.newSession()} + onSessionDelete={(id) => sessionActionsRef.current.deleteSession(id)} + onSessionRename={(id, name) => sessionActionsRef.current.renameSession(id, name)} + // 会话搜索 + onOpenSessionSearch={openSessionSearch} + // Git 面板 + onOpenGitPanel={toggleGitPanel} + /> + )}
{/* 所有页面始终挂载,通过 display:none 隐藏非活跃页面,避免切换时丢失状态和 WebSocket 连接 */}
@@ -251,6 +276,8 @@ function RootContent() { registerMessaging={handleRegisterMessaging} onLoginClick={() => setShowAuthDialog(true)} authRefreshKey={authRefreshKey} + workMode={workMode} + onToggleWorkMode={toggleWorkMode} />
@@ -292,6 +319,11 @@ function RootContent() { />
+
+ + + +
- ctx +
{usage && ( - <> - - {percentage}% - - - {formatTokens(usage.usedTokens)}/{formatTokens(usage.maxTokens)} - - + + {percentage}% + )} - +
)}
); diff --git a/src/web/client/src/components/InputArea.tsx b/src/web/client/src/components/InputArea.tsx index 602ac2b0..908090ee 100644 --- a/src/web/client/src/components/InputArea.tsx +++ b/src/web/client/src/components/InputArea.tsx @@ -119,6 +119,10 @@ interface InputAreaProps { // 新建对话 onNewSession?: () => void; hasMessages?: boolean; + + // 工作模式切换 + workMode?: 'chat' | 'ide'; + onToggleWorkMode?: () => void; } export function InputArea({ @@ -179,6 +183,8 @@ export function InputArea({ onLoginClick, onNewSession, hasMessages = false, + workMode = 'chat', + onToggleWorkMode, }: InputAreaProps) { void runtimeProvider; void onToggleVoice; @@ -450,6 +456,27 @@ export function InputArea({
+ {onToggleWorkMode && ( + + )} + +
+
+ +
+ ); +} diff --git a/src/web/client/src/components/swarm/TopNavBar/index.tsx b/src/web/client/src/components/swarm/TopNavBar/index.tsx index 028a34a1..7d7c33b1 100644 --- a/src/web/client/src/components/swarm/TopNavBar/index.tsx +++ b/src/web/client/src/components/swarm/TopNavBar/index.tsx @@ -32,8 +32,8 @@ interface ProjectItem { } export interface TopNavBarProps { - currentPage: 'chat' | 'code' | 'swarm' | 'blueprint' | 'customize' | 'apps' | 'activity'; - onPageChange: (page: 'chat' | 'code' | 'swarm' | 'blueprint' | 'customize' | 'apps' | 'activity') => void; + currentPage: 'chat' | 'code' | 'swarm' | 'blueprint' | 'customize' | 'apps' | 'activity' | 'knowledge'; + onPageChange: (page: 'chat' | 'code' | 'swarm' | 'blueprint' | 'customize' | 'apps' | 'activity' | 'knowledge') => void; onSettingsClick?: () => void; /** 连接状态 */ connected?: boolean; @@ -110,6 +110,17 @@ const AppsIcon = () => ( ); +const KnowledgeIcon = () => ( + + + + + + + + +); + const ActivityIcon = () => ( @@ -245,6 +256,7 @@ export default function TopNavBar({ { key: 'swarm', label: t('nav.swarm'), Icon: SwarmIcon }, { key: 'customize', label: t('nav.customize'), Icon: ToolboxIcon }, { key: 'apps', label: t('nav.myApps'), Icon: AppsIcon }, + { key: 'knowledge', label: t('nav.knowledge'), Icon: KnowledgeIcon }, { key: 'activity', label: t('nav.activity'), Icon: ActivityIcon }, ]; diff --git a/src/web/client/src/hooks/useMessageHandler.ts b/src/web/client/src/hooks/useMessageHandler.ts index 64012931..3eb9682b 100644 --- a/src/web/client/src/hooks/useMessageHandler.ts +++ b/src/web/client/src/hooks/useMessageHandler.ts @@ -84,6 +84,8 @@ interface UseMessageHandlerReturn { dismissCrossSessionNotification: () => void; slashCommandResult: SlashCommandResult | null; setSlashCommandResult: React.Dispatch>; + topicShiftDetected: boolean; + dismissTopicShift: () => void; } export function useMessageHandler({ @@ -106,6 +108,11 @@ export function useMessageHandler({ const [isTranscriptMode, setIsTranscriptMode] = useState(false); const [crossSessionNotification, setCrossSessionNotification] = useState(null); const [slashCommandResult, setSlashCommandResult] = useState(null); + const [topicShiftDetected, setTopicShiftDetected] = useState(false); + + const dismissTopicShift = useCallback(() => { + setTopicShiftDetected(false); + }, []); const dismissCrossSessionNotification = useCallback(() => { setCrossSessionNotification(null); @@ -476,6 +483,23 @@ export function useMessageHandler({ break; } + case 'topic_shift': + setTopicShiftDetected(true); + // 清理消息末尾的 标记 + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last?.role === 'assistant') { + const updatedContent = last.content.map(c => + c.type === 'text' + ? { ...c, text: c.text.replace(/\s*\s*$/, '') } + : c + ); + return [...prev.slice(0, -1), { ...last, content: updatedContent }]; + } + return prev; + }); + break; + case 'status': // 插话保护:忽略来自旧消息/cancel 的 idle 状态, // 避免覆盖用户刚发新消息设置的 'thinking' 状态 @@ -1205,5 +1229,7 @@ export function useMessageHandler({ dismissCrossSessionNotification, slashCommandResult, setSlashCommandResult, + topicShiftDetected, + dismissTopicShift, }; } diff --git a/src/web/client/src/i18n/locales.ts b/src/web/client/src/i18n/locales.ts index c70ae763..e2765358 100644 --- a/src/web/client/src/i18n/locales.ts +++ b/src/web/client/src/i18n/locales.ts @@ -795,6 +795,7 @@ const en: Translations = { 'nav.blueprint': 'Blueprint', 'nav.swarm': 'Swarm', 'nav.code': 'Files', + 'nav.knowledge': 'Knowledge', 'nav.newSession': 'New Chat', 'nav.startNewChat': 'Start new chat', 'nav.settings': 'Settings', @@ -2700,6 +2701,7 @@ const zh: Translations = { 'nav.blueprint': '蓝图', 'nav.swarm': '蜂群', 'nav.code': '文件', + 'nav.knowledge': '知识库', 'nav.newSession': '新对话', 'nav.startNewChat': '开始新对话', 'nav.settings': '设置', diff --git a/src/web/client/src/i18n/locales/en/chat.ts b/src/web/client/src/i18n/locales/en/chat.ts index 835975db..b177c1f7 100644 --- a/src/web/client/src/i18n/locales/en/chat.ts +++ b/src/web/client/src/i18n/locales/en/chat.ts @@ -22,7 +22,7 @@ const chat = { 'input.conversationListening': 'Voice conversation — speak to me...', 'input.conversationStart': 'Start voice conversation', 'input.conversationStop': 'Stop voice conversation', - 'input.debugProbe': 'API Probe - View system prompt and messages', + 'input.debugProbe': 'Debug panel — view system prompt and messages', 'input.imageEditStrength': 'Edit strength', 'input.imageEditStrength.low': 'Faithful', 'input.imageEditStrength.medium': 'Balanced', @@ -38,25 +38,27 @@ const chat = { 'input.permDontAsk': "Don't Ask", 'input.permDelegate': 'Delegate', 'input.permissionMode': 'Permission mode', - 'input.pinLock': 'Lock input - prevent auto-hide', - 'input.pinLockShort': 'Lock', - 'input.pinUnlock': 'Unlock - allow auto-hide', - 'input.pinUnlockShort': 'Unlock', + 'input.pinLock': 'Pin input — prevent auto-hide', + 'input.pinLockShort': 'Pin', + 'input.pinUnlock': 'Unpin — allow auto-hide', + 'input.pinUnlockShort': 'Unpin', 'input.placeholder': 'Tell me what you want to do... (/ for commands)', 'input.placeholder.hint1': 'e.g. "Add user login with JWT authentication"', 'input.placeholder.hint2': 'e.g. "Something is broken, help me find the bug"', 'input.placeholder.hint3': 'e.g. "Review my recent changes before I commit"', 'input.placeholder.hint4': 'e.g. "Help me understand how this project works"', - 'input.probe': 'Probe', + 'input.probe': 'Debug', 'input.messageQueued': 'Message queued — will send after context compression', 'input.loginRequired': 'Please log in or configure an API Key to start chatting — click here to set up', 'input.send': 'Send', 'input.stop': 'Stop', + 'input.switchToIDE': 'Switch to IDE mode', + 'input.switchToChat': 'Switch to Chat mode', 'input.switchModel': 'Switch model', 'input.switchProvider': 'Switch API provider', - 'input.thinkingDisable': 'Disable thinking', + 'input.thinkingDisable': 'No thinking', 'input.thinkingEnable': 'Enable thinking', - 'input.thinkingLevel': 'Thinking level', + 'input.thinkingLevel': 'Thinking depth', 'input.thinkingLevel.high': 'High', 'input.thinkingLevel.low': 'Low', 'input.thinkingLevel.medium': 'Medium', diff --git a/src/web/client/src/i18n/locales/en/common.ts b/src/web/client/src/i18n/locales/en/common.ts index dd1b9c6d..65aea17d 100644 --- a/src/web/client/src/i18n/locales/en/common.ts +++ b/src/web/client/src/i18n/locales/en/common.ts @@ -20,6 +20,7 @@ const common = { 'compact.regressionPassed': 'Regression: Passed', 'compact.review': 'Review: Score {{score}}', 'context.compacting': 'Compacting...', + 'context.label': 'Context usage', 'context.savedTokens': 'Saved {{tokens}} tokens', 'apiUsage.remaining': 'Remaining quota', 'apiUsage.cacheHit': 'Cache hit', @@ -38,6 +39,10 @@ const common = { 'crossSession.questionWaiting': 'Question Waiting', 'crossSession.toolRequestPermission': 'Tool "{{tool}}" requires permission', 'crossSession.unknown': 'unknown', + 'topicShift.title': 'New topic detected', + 'topicShift.description': 'Your question seems unrelated to the current conversation. Starting a new session is recommended.', + 'topicShift.newSession': 'New session', + 'topicShift.continue': 'Continue', 'error.checkAuthFailed': 'Failed to check auth status', 'error.gitLogFailed': 'Failed to get git log', 'error.gitStatusFailed': 'Failed to get git status', diff --git a/src/web/client/src/i18n/locales/en/index.ts b/src/web/client/src/i18n/locales/en/index.ts index b782e904..c0389777 100644 --- a/src/web/client/src/i18n/locales/en/index.ts +++ b/src/web/client/src/i18n/locales/en/index.ts @@ -8,6 +8,7 @@ import git, { type GitKeys } from './git'; import nav, { type NavKeys } from './nav'; import settings, { type SettingsKeys } from './settings'; import swarm, { type SwarmKeys } from './swarm'; +import knowledge, { type KnowledgeKeys } from './knowledge'; const en = { ...apps, @@ -20,7 +21,8 @@ const en = { ...nav, ...settings, ...swarm, + ...knowledge, } as const; -export type WebLocaleKeys = AppsKeys | AuthKeys | ChatKeys | CliKeys | CodeKeys | CommonKeys | GitKeys | NavKeys | SettingsKeys | SwarmKeys; +export type WebLocaleKeys = AppsKeys | AuthKeys | ChatKeys | CliKeys | CodeKeys | CommonKeys | GitKeys | NavKeys | SettingsKeys | SwarmKeys | KnowledgeKeys; export default en; diff --git a/src/web/client/src/i18n/locales/en/knowledge.ts b/src/web/client/src/i18n/locales/en/knowledge.ts new file mode 100644 index 00000000..76911bc6 --- /dev/null +++ b/src/web/client/src/i18n/locales/en/knowledge.ts @@ -0,0 +1,61 @@ +const knowledge = { + 'knowledge.title': 'Knowledge Base', + 'knowledge.articles': 'Articles', + 'knowledge.rawSources': 'Raw Sources', + 'knowledge.search': 'Search', + 'knowledge.graph': 'Graph', + 'knowledge.clipUrl': 'Clip URL', + 'knowledge.addSource': 'Add Source', + 'knowledge.compileAll': 'Compile All', + 'knowledge.compiling': 'Compiling...', + 'knowledge.loading': 'Loading Knowledge Base...', + 'knowledge.searching': 'Searching...', + 'knowledge.noResults': 'No results found.', + 'knowledge.searchPlaceholder': 'Search knowledge base...', + // Articles + 'knowledge.noArticles.title': 'No Articles Yet', + 'knowledge.noArticles.desc': 'Clip URLs or ingest files, then compile them into wiki articles.', + 'knowledge.linkedArticles': 'Linked Articles', + 'knowledge.back': 'Back', + // Raw + 'knowledge.noRaw.title': 'No Raw Files', + 'knowledge.noRaw.desc': 'Use "Clip URL" to add sources to the knowledge base.', + 'knowledge.compilePending': 'Compile {{count}} pending', + 'knowledge.compiled': 'Compiled', + 'knowledge.pending': 'Pending', + // Stats + 'knowledge.statsArticles': '{{count}} articles', + 'knowledge.statsSources': '{{count}} sources', + 'knowledge.statsPending': '{{count}} pending', + // Clip Dialog + 'knowledge.clip.title': 'Add to Knowledge Base', + 'knowledge.clip.modeUrl': 'URL', + 'knowledge.clip.modeFile': 'Upload File', + 'knowledge.clip.file': 'File', + 'knowledge.clip.dropHint': 'Drop file here or click to select', + 'knowledge.clip.supportedFormats': 'PDF, Markdown, Word, Excel, HTML, TXT, JSON', + 'knowledge.clip.upload': 'Upload', + 'knowledge.clip.url': 'URL', + 'knowledge.clip.urlPlaceholder': 'https://...', + 'knowledge.clip.tags': 'Tags (comma separated)', + 'knowledge.clip.tagsPlaceholder': 'AI, transformers, attention', + 'knowledge.clip.category': 'Category', + 'knowledge.clip.categoryPlaceholder': 'AI, Web, Database, Systems...', + 'knowledge.clip.autoCompile': 'Auto-compile after clipping', + 'knowledge.clip.cancel': 'Cancel', + 'knowledge.clip.submit': 'Clip', + 'knowledge.clip.clipping': 'Clipping...', + 'knowledge.clip.failed': 'Clip failed: {{error}}', + // Graph + 'knowledge.noGraph.title': 'No Data', + 'knowledge.noGraph.desc': 'Add and compile articles to see the knowledge graph.', + // Delete + 'knowledge.delete': 'Delete', + 'knowledge.confirmDelete': 'Are you sure you want to delete "{{name}}"?', + 'knowledge.deleteFailed': 'Delete failed: {{error}}', + // Compile + 'knowledge.compileFailed': 'Compile failed: {{error}}', +} as const; + +export type KnowledgeKeys = keyof typeof knowledge; +export default knowledge; diff --git a/src/web/client/src/i18n/locales/en/nav.ts b/src/web/client/src/i18n/locales/en/nav.ts index 167a7937..635ad146 100644 --- a/src/web/client/src/i18n/locales/en/nav.ts +++ b/src/web/client/src/i18n/locales/en/nav.ts @@ -183,6 +183,7 @@ const nav = { 'customize.tunnel': 'Public Share', 'nav.apps': 'Activity', 'nav.myApps': 'Apps', + 'nav.knowledge': 'Knowledge', 'nav.activity': 'Activity', // Tunnel 'tunnel.title': 'Public Share', diff --git a/src/web/client/src/i18n/locales/zh/chat.ts b/src/web/client/src/i18n/locales/zh/chat.ts index 36e3e744..6b5376ab 100644 --- a/src/web/client/src/i18n/locales/zh/chat.ts +++ b/src/web/client/src/i18n/locales/zh/chat.ts @@ -22,7 +22,7 @@ const chat = { 'input.conversationListening': '语音对话中 — 请说话...', 'input.conversationStart': '开启语音对话', 'input.conversationStop': '关闭语音对话', - 'input.debugProbe': 'API 探针 - 查看系统提示词和消息体', + 'input.debugProbe': '调试面板 — 查看系统提示词和消息体', 'input.imageEditStrength': '编辑强度', 'input.imageEditStrength.low': '保真', 'input.imageEditStrength.medium': '均衡', @@ -38,25 +38,27 @@ const chat = { 'input.permDontAsk': '自动拒绝', 'input.permDelegate': '委托', 'input.permissionMode': '权限模式', - 'input.pinLock': '锁定输入框 - 防止自动隐藏', - 'input.pinLockShort': '锁定', - 'input.pinUnlock': '取消锁定 - 允许自动隐藏输入框', - 'input.pinUnlockShort': '解锁', + 'input.pinLock': '固定输入框 — 防止自动隐藏', + 'input.pinLockShort': '固定', + 'input.pinUnlock': '取消固定 — 允许自动隐藏输入框', + 'input.pinUnlockShort': '取消固定', 'input.placeholder': '告诉我你想做什么... (/ 显示命令)', 'input.placeholder.hint1': '例如: "加一个 JWT 用户登录功能"', 'input.placeholder.hint2': '例如: "有东西坏了,帮我找到问题"', 'input.placeholder.hint3': '例如: "看看我最近的改动有没有问题"', 'input.placeholder.hint4': '例如: "帮我理解这个项目是怎么工作的"', - 'input.probe': '探针', + 'input.probe': '调试', 'input.messageQueued': '消息已排队 — 压缩完成后自动发送', 'input.loginRequired': '请先登录或配置 API Key 后再发送消息 — 点击此处设置', 'input.send': '发送', 'input.stop': '停止', + 'input.switchToIDE': '切换到 IDE 模式', + 'input.switchToChat': '切换到聊天模式', 'input.switchModel': '切换模型', 'input.switchProvider': '切换 API 服务商', - 'input.thinkingDisable': '关闭思考', + 'input.thinkingDisable': '不思考', 'input.thinkingEnable': '开启思考', - 'input.thinkingLevel': '思考强度', + 'input.thinkingLevel': '思考深度', 'input.thinkingLevel.high': '高', 'input.thinkingLevel.low': '低', 'input.thinkingLevel.medium': '中', diff --git a/src/web/client/src/i18n/locales/zh/common.ts b/src/web/client/src/i18n/locales/zh/common.ts index 83521376..6ad3ae25 100644 --- a/src/web/client/src/i18n/locales/zh/common.ts +++ b/src/web/client/src/i18n/locales/zh/common.ts @@ -20,6 +20,7 @@ const common = { 'compact.regressionPassed': '回归测试: 通过', 'compact.review': '审查: 评分 {{score}}', 'context.compacting': '压缩中...', + 'context.label': '上下文用量', 'context.savedTokens': '已节省 {{tokens}} tokens', 'apiUsage.remaining': '剩余额度', 'apiUsage.cacheHit': '缓存命中', @@ -38,6 +39,10 @@ const common = { 'crossSession.questionWaiting': '等待回答问题', 'crossSession.toolRequestPermission': '工具 "{{tool}}" 需要授权', 'crossSession.unknown': '未知', + 'topicShift.title': '检测到新话题', + 'topicShift.description': '你的问题似乎和当前对话无关,建议开启新会话以获得更好的体验', + 'topicShift.newSession': '开启新会话', + 'topicShift.continue': '继续当前', 'error.checkAuthFailed': '检查认证状态失败', 'error.gitLogFailed': '获取 git 日志失败', 'error.gitStatusFailed': '获取 git 状态失败', diff --git a/src/web/client/src/i18n/locales/zh/index.ts b/src/web/client/src/i18n/locales/zh/index.ts index a15cf62a..41083220 100644 --- a/src/web/client/src/i18n/locales/zh/index.ts +++ b/src/web/client/src/i18n/locales/zh/index.ts @@ -10,6 +10,7 @@ import git from './git'; import nav from './nav'; import settings from './settings'; import swarm from './swarm'; +import knowledge from './knowledge'; const zh: Record = { ...apps, @@ -22,6 +23,7 @@ const zh: Record = { ...nav, ...settings, ...swarm, + ...knowledge, }; export default zh; diff --git a/src/web/client/src/i18n/locales/zh/knowledge.ts b/src/web/client/src/i18n/locales/zh/knowledge.ts new file mode 100644 index 00000000..d4478388 --- /dev/null +++ b/src/web/client/src/i18n/locales/zh/knowledge.ts @@ -0,0 +1,61 @@ +const knowledge = { + 'knowledge.title': '知识库', + 'knowledge.articles': '文章', + 'knowledge.rawSources': '原始源', + 'knowledge.search': '搜索', + 'knowledge.graph': '图谱', + 'knowledge.clipUrl': '收藏 URL', + 'knowledge.addSource': '添加源', + 'knowledge.compileAll': '全部编译', + 'knowledge.compiling': '编译中...', + 'knowledge.loading': '正在加载知识库...', + 'knowledge.searching': '搜索中...', + 'knowledge.noResults': '没有找到结果。', + 'knowledge.searchPlaceholder': '搜索知识库...', + // Articles + 'knowledge.noArticles.title': '暂无文章', + 'knowledge.noArticles.desc': '收藏 URL 或导入文件,然后编译为 wiki 文章。', + 'knowledge.linkedArticles': '关联文章', + 'knowledge.back': '返回', + // Raw + 'knowledge.noRaw.title': '暂无原始文件', + 'knowledge.noRaw.desc': '使用"收藏 URL"添加源到知识库。', + 'knowledge.compilePending': '编译 {{count}} 个待处理', + 'knowledge.compiled': '已编译', + 'knowledge.pending': '待编译', + // Stats + 'knowledge.statsArticles': '{{count}} 篇文章', + 'knowledge.statsSources': '{{count}} 个源', + 'knowledge.statsPending': '{{count}} 个待编译', + // Clip Dialog + 'knowledge.clip.title': '添加到知识库', + 'knowledge.clip.modeUrl': '网址', + 'knowledge.clip.modeFile': '上传文件', + 'knowledge.clip.file': '文件', + 'knowledge.clip.dropHint': '拖拽文件到此处,或点击选择', + 'knowledge.clip.supportedFormats': 'PDF、Markdown、Word、Excel、HTML、TXT、JSON', + 'knowledge.clip.upload': '上传', + 'knowledge.clip.url': 'URL', + 'knowledge.clip.urlPlaceholder': 'https://...', + 'knowledge.clip.tags': '标签(逗号分隔)', + 'knowledge.clip.tagsPlaceholder': 'AI, transformers, attention', + 'knowledge.clip.category': '分类', + 'knowledge.clip.categoryPlaceholder': 'AI, Web, Database, Systems...', + 'knowledge.clip.autoCompile': '收藏后自动编译', + 'knowledge.clip.cancel': '取消', + 'knowledge.clip.submit': '收藏', + 'knowledge.clip.clipping': '收藏中...', + 'knowledge.clip.failed': '收藏失败:{{error}}', + // Graph + 'knowledge.noGraph.title': '暂无数据', + 'knowledge.noGraph.desc': '添加并编译文章后可查看知识图谱。', + // Delete + 'knowledge.delete': '删除', + 'knowledge.confirmDelete': '确定要删除"{{name}}"吗?', + 'knowledge.deleteFailed': '删除失败:{{error}}', + // Compile + 'knowledge.compileFailed': '编译失败:{{error}}', +} as const; + +export type KnowledgeKeys = keyof typeof knowledge; +export default knowledge; diff --git a/src/web/client/src/i18n/locales/zh/nav.ts b/src/web/client/src/i18n/locales/zh/nav.ts index 087010a8..45b33188 100644 --- a/src/web/client/src/i18n/locales/zh/nav.ts +++ b/src/web/client/src/i18n/locales/zh/nav.ts @@ -183,6 +183,7 @@ const nav = { 'customize.tunnel': '公网分享', 'nav.apps': '活动', 'nav.myApps': '应用', + 'nav.knowledge': '知识库', 'nav.activity': '活动', // Tunnel 'tunnel.title': '公网分享', diff --git a/src/web/client/src/pages/KnowledgePage/KnowledgePage.css b/src/web/client/src/pages/KnowledgePage/KnowledgePage.css new file mode 100644 index 00000000..9f6ad091 --- /dev/null +++ b/src/web/client/src/pages/KnowledgePage/KnowledgePage.css @@ -0,0 +1,615 @@ +/* KnowledgePage - LLM Knowledge Base */ + +.knowledge-page { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: var(--bg-primary, #1a1a2e); + color: var(--text-primary, #e0e0e0); + overflow: hidden; +} + +/* ============ Header ============ */ + +.knowledge-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--border-color, #2a2a4a); + flex-shrink: 0; +} + +.knowledge-header h2 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.knowledge-header-actions { + display: flex; + gap: 8px; +} + +.knowledge-stats { + display: flex; + gap: 16px; + font-size: 12px; + color: var(--text-secondary, #888); +} + +.knowledge-stats span { + display: flex; + align-items: center; + gap: 4px; +} + +/* ============ Tabs ============ */ + +.knowledge-tabs { + display: flex; + border-bottom: 1px solid var(--border-color, #2a2a4a); + padding: 0 20px; + flex-shrink: 0; +} + +.knowledge-tab { + padding: 8px 16px; + font-size: 13px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary, #888); + cursor: pointer; + transition: all 0.2s; +} + +.knowledge-tab:hover { + color: var(--text-primary, #e0e0e0); +} + +.knowledge-tab.active { + color: var(--accent-color, #6c63ff); + border-bottom-color: var(--accent-color, #6c63ff); +} + +/* ============ Content ============ */ + +.knowledge-content { + flex: 1; + overflow-y: auto; + padding: 16px 20px; +} + +/* ============ Article List ============ */ + +.knowledge-category { + margin-bottom: 24px; +} + +.knowledge-category h3 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 8px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color, #2a2a4a); +} + +.knowledge-article-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} + +.knowledge-article-item:hover { + background: var(--bg-hover, rgba(255,255,255,0.05)); +} + +.knowledge-article-title { + font-size: 14px; + font-weight: 500; +} + +.knowledge-article-meta { + display: flex; + gap: 6px; + align-items: center; + font-size: 11px; + color: var(--text-secondary, #888); +} + +.knowledge-tag { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-tertiary, rgba(108,99,255,0.15)); + color: var(--accent-color, #6c63ff); + font-size: 11px; +} + +.knowledge-date { + color: var(--text-tertiary, #666); + font-size: 11px; +} + +/* ============ Article View ============ */ + +.knowledge-article-view { + max-width: 800px; +} + +.knowledge-article-view-header { + margin-bottom: 16px; +} + +.knowledge-article-view-header h1 { + font-size: 22px; + margin: 0 0 8px 0; +} + +.knowledge-article-view-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.knowledge-back-btn { + background: none; + border: 1px solid var(--border-color, #2a2a4a); + color: var(--text-secondary, #888); + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-bottom: 12px; +} + +.knowledge-back-btn:hover { + border-color: var(--text-primary, #e0e0e0); + color: var(--text-primary, #e0e0e0); +} + +.knowledge-article-body { + line-height: 1.7; + font-size: 14px; +} + +.knowledge-article-body h1 { font-size: 20px; margin: 20px 0 8px; } +.knowledge-article-body h2 { font-size: 17px; margin: 18px 0 8px; } +.knowledge-article-body h3 { font-size: 15px; margin: 14px 0 6px; } +.knowledge-article-body p { margin: 0 0 12px; } +.knowledge-article-body code { + background: var(--bg-tertiary, rgba(255,255,255,0.08)); + padding: 1px 4px; + border-radius: 3px; + font-size: 13px; +} +.knowledge-article-body pre { + background: var(--bg-tertiary, rgba(0,0,0,0.3)); + padding: 12px; + border-radius: 6px; + overflow-x: auto; + margin: 0 0 12px; +} +.knowledge-article-body pre code { + background: none; + padding: 0; +} +.knowledge-article-body ul, .knowledge-article-body ol { + margin: 0 0 12px; + padding-left: 24px; +} +.knowledge-article-body blockquote { + border-left: 3px solid var(--accent-color, #6c63ff); + margin: 0 0 12px; + padding: 4px 12px; + color: var(--text-secondary, #888); +} + +.knowledge-backlinks { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border-color, #2a2a4a); +} + +.knowledge-backlinks h4 { + font-size: 13px; + color: var(--text-secondary, #888); + margin: 0 0 8px; +} + +.knowledge-backlink-chip { + display: inline-block; + padding: 3px 10px; + margin: 2px 4px; + border-radius: 12px; + background: var(--bg-tertiary, rgba(108,99,255,0.1)); + color: var(--accent-color, #6c63ff); + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.knowledge-backlink-chip:hover { + background: rgba(108,99,255,0.25); +} + +/* ============ Raw Files List ============ */ + +.knowledge-raw-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; +} + +.knowledge-raw-item:hover { + background: var(--bg-hover, rgba(255,255,255,0.05)); +} + +.knowledge-raw-status { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; +} + +.knowledge-raw-status.compiled { + background: rgba(72,187,120,0.15); + color: #48bb78; +} + +.knowledge-raw-status.pending { + background: rgba(237,137,54,0.15); + color: #ed8936; +} + +/* ============ Search ============ */ + +.knowledge-search-input { + width: 100%; + padding: 10px 14px; + background: var(--bg-secondary, #16162a); + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 6px; + color: var(--text-primary, #e0e0e0); + font-size: 14px; + margin-bottom: 16px; + outline: none; +} + +.knowledge-search-input:focus { + border-color: var(--accent-color, #6c63ff); +} + +.knowledge-search-result { + padding: 10px 12px; + border-radius: 6px; + margin-bottom: 8px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.knowledge-search-result-path { + font-size: 11px; + color: var(--text-secondary, #888); + margin-bottom: 4px; +} + +.knowledge-search-result-snippet { + font-size: 13px; + line-height: 1.5; +} + +.knowledge-search-result-score { + font-size: 11px; + color: var(--accent-color, #6c63ff); +} + +/* ============ Clip Dialog ============ */ + +.knowledge-clip-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.knowledge-clip-dialog { + background: var(--bg-primary, #1a1a2e); + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 10px; + padding: 24px; + width: 480px; + max-width: 90vw; +} + +.knowledge-clip-dialog h3 { + margin: 0 0 16px; + font-size: 16px; +} + +.knowledge-clip-field { + margin-bottom: 12px; +} + +.knowledge-clip-field label { + display: block; + font-size: 12px; + color: var(--text-secondary, #888); + margin-bottom: 4px; +} + +.knowledge-clip-field input { + width: 100%; + padding: 8px 12px; + background: var(--bg-secondary, #16162a); + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 6px; + color: var(--text-primary, #e0e0e0); + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +.knowledge-clip-field input:focus { + border-color: var(--accent-color, #6c63ff); +} + +/* Mode tabs */ +.knowledge-clip-mode-tabs { + display: flex; + gap: 0; + margin-bottom: 16px; + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 6px; + overflow: hidden; +} + +.knowledge-clip-mode-tab { + flex: 1; + padding: 8px 0; + background: transparent; + border: none; + color: var(--text-secondary, #888); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.knowledge-clip-mode-tab.active { + background: var(--accent-color, #6c63ff); + color: white; +} + +.knowledge-clip-mode-tab:not(.active):hover { + background: var(--bg-secondary, #16162a); + color: var(--text-primary, #e0e0e0); +} + +/* Drop zone */ +.knowledge-clip-dropzone { + border: 2px dashed var(--border-color, #2a2a4a); + border-radius: 8px; + padding: 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + min-height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.knowledge-clip-dropzone:hover, +.knowledge-clip-dropzone.dragover { + border-color: var(--accent-color, #6c63ff); + background: rgba(108, 99, 255, 0.05); +} + +.knowledge-clip-dropzone.has-file { + border-style: solid; + border-color: var(--accent-color, #6c63ff); + padding: 12px 16px; +} + +.knowledge-clip-dropzone-hint { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-secondary, #888); + font-size: 13px; +} + +.knowledge-clip-dropzone-formats { + font-size: 11px; + color: var(--text-tertiary, #666); +} + +.knowledge-clip-file-info { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.knowledge-clip-file-name { + font-size: 13px; + color: var(--text-primary, #e0e0e0); + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.knowledge-clip-file-size { + font-size: 11px; + color: var(--text-secondary, #888); + flex-shrink: 0; +} + +.knowledge-clip-file-remove { + background: none; + border: none; + color: var(--text-secondary, #888); + font-size: 18px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + flex-shrink: 0; +} + +.knowledge-clip-file-remove:hover { + color: #e53e3e; +} + +.knowledge-clip-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* ============ Delete Button ============ */ + +.knowledge-delete-btn { + background: none; + border: 1px solid transparent; + color: var(--text-secondary, #888); + font-size: 16px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; + opacity: 0; + transition: all 0.15s; + flex-shrink: 0; +} + +.knowledge-article-item:hover .knowledge-delete-btn, +.knowledge-raw-item:hover .knowledge-delete-btn { + opacity: 1; +} + +.knowledge-delete-btn:hover { + color: #e53e3e; + border-color: #e53e3e; + background: rgba(229, 62, 62, 0.1); +} + +/* Search result type badge */ +.knowledge-search-result-type { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + margin-right: 8px; + text-transform: uppercase; +} + +.knowledge-search-result-type.wiki { + background: rgba(108, 99, 255, 0.2); + color: #6c63ff; +} + +.knowledge-search-result-type.raw { + background: rgba(237, 137, 54, 0.2); + color: #ed8936; +} + +/* ============ Buttons ============ */ + +.knowledge-btn { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border-color, #2a2a4a); + background: var(--bg-secondary, #16162a); + color: var(--text-primary, #e0e0e0); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.knowledge-btn:hover { + border-color: var(--accent-color, #6c63ff); +} + +.knowledge-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.knowledge-btn.primary { + background: var(--accent-color, #6c63ff); + border-color: var(--accent-color, #6c63ff); + color: white; +} + +.knowledge-btn.primary:hover { + opacity: 0.9; +} + +/* ============ Graph View (Phase 4) ============ */ + +.knowledge-graph-container { + width: 100%; + height: 100%; + min-height: 400px; +} + +/* ============ Empty States ============ */ + +.knowledge-empty { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary, #888); +} + +.knowledge-empty h3 { + font-size: 16px; + margin: 0 0 8px; + color: var(--text-primary, #e0e0e0); +} + +.knowledge-empty p { + font-size: 13px; + margin: 0 0 16px; +} + +/* ============ Loading ============ */ + +.knowledge-loading { + text-align: center; + padding: 40px; + color: var(--text-secondary, #888); + font-size: 13px; +} + +/* ============ Responsive ============ */ + +@media (max-width: 768px) { + .knowledge-header { + flex-direction: column; + gap: 8px; + } + .knowledge-clip-dialog { + width: 95vw; + } +} diff --git a/src/web/client/src/pages/KnowledgePage/index.tsx b/src/web/client/src/pages/KnowledgePage/index.tsx new file mode 100644 index 00000000..8ea7c2c0 --- /dev/null +++ b/src/web/client/src/pages/KnowledgePage/index.tsx @@ -0,0 +1,887 @@ +/** + * KnowledgePage — LLM Knowledge Base + * Karpathy 模式:raw → compile → wiki → search + * + * Tabs: Articles | Raw Sources | Search | Graph + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useLanguage } from '../../i18n'; +import './KnowledgePage.css'; + +// ============ Types ============ + +interface WikiArticle { + id: string; + title: string; + slug: string; + tags: string[]; + category: string; + sourceFiles: string[]; + backlinks: string[]; + createdAt: string; + updatedAt: string; +} + +interface RawFile { + slug: string; + compiled: boolean; +} + +interface SearchResult { + id: string; + slug: string; + title: string; + category: string; + tags: string[]; + score: number; + snippet: string; + type: 'wiki' | 'raw'; +} + +interface KnowledgeStats { + rawCount: number; + wikiCount: number; + uncompiledCount: number; + categoryCount: number; + lastCompileAt?: string; +} + +type Tab = 'articles' | 'raw' | 'search' | 'graph'; + +// ============ API helpers ============ + +async function apiFetch(path: string, opts?: RequestInit): Promise { + const res = await fetch(`/api/knowledge${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'API error'); + return data.data; +} + +// ============ ClipDialog ============ + +type ClipMode = 'url' | 'file'; + +function ClipDialog({ onClose, onClipped }: { onClose: () => void; onClipped: () => void }) { + const { t } = useLanguage(); + const [mode, setMode] = useState('url'); + const [url, setUrl] = useState(''); + const [file, setFile] = useState(null); + const [tags, setTags] = useState(''); + const [category, setCategory] = useState(''); + const [loading, setLoading] = useState(false); + const [autoCompile, setAutoCompile] = useState(false); + const [dragOver, setDragOver] = useState(false); + + const canSubmit = mode === 'url' ? !!url.trim() : !!file; + + const handleClip = async () => { + if (!canSubmit) return; + setLoading(true); + try { + if (mode === 'url') { + await apiFetch('/ingest', { + method: 'POST', + body: JSON.stringify({ + source: url.trim(), + tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [], + category: category.trim() || undefined, + }), + }); + } else if (file) { + // 读文件为 base64,通过 JSON 上传(避免 multipart 解析问题) + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result.split(',')[1]); // 去掉 data:xxx;base64, 前缀 + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + await apiFetch('/upload', { + method: 'POST', + body: JSON.stringify({ + filename: file.name, + data: base64, + tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [], + category: category.trim() || undefined, + }), + }); + } + + if (autoCompile) { + await apiFetch('/compile', { method: 'POST', body: JSON.stringify({}) }); + } + + onClipped(); + onClose(); + } catch (err: any) { + alert(t('knowledge.clip.failed', { error: err.message })); + } finally { + setLoading(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + setFile(droppedFile); + setMode('file'); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) setFile(selected); + }; + + const acceptedTypes = '.md,.txt,.pdf,.docx,.xlsx,.pptx,.html,.htm,.json,.csv,.xml,.yaml,.yml'; + + return ( +
+
e.stopPropagation()}> +

{t('knowledge.clip.title')}

+ + {/* Mode tabs */} +
+ + +
+ + {/* URL input */} + {mode === 'url' && ( +
+ + setUrl(e.target.value)} + placeholder={t('knowledge.clip.urlPlaceholder')} + autoFocus + onKeyDown={e => e.key === 'Enter' && handleClip()} + /> +
+ )} + + {/* File upload */} + {mode === 'file' && ( +
+ +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => document.getElementById('knowledge-file-input')?.click()} + > + {file ? ( +
+ {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + + +
+ ) : ( +
+ {t('knowledge.clip.dropHint')} + + {t('knowledge.clip.supportedFormats')} + +
+ )} + +
+
+ )} + +
+ + setTags(e.target.value)} + placeholder={t('knowledge.clip.tagsPlaceholder')} + /> +
+
+ + setCategory(e.target.value)} + placeholder={t('knowledge.clip.categoryPlaceholder')} + /> +
+
+ +
+
+ + +
+
+
+ ); +} + +// ============ ArticleList ============ + +function ArticleList({ + articles, + categories, + onSelect, + onDelete, +}: { + articles: WikiArticle[]; + categories: string[]; + onSelect: (article: WikiArticle) => void; + onDelete: (article: WikiArticle) => void; +}) { + const { t } = useLanguage(); + if (articles.length === 0) { + return ( +
+

{t('knowledge.noArticles.title')}

+

{t('knowledge.noArticles.desc')}

+
+ ); + } + + // Group by category + const grouped: Record = {}; + for (const a of articles) { + const cat = a.category || 'uncategorized'; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(a); + } + + const sortedCats = Object.keys(grouped).sort(); + + return ( +
+ {sortedCats.map(cat => ( +
+

{cat} ({grouped[cat].length})

+ {grouped[cat].map(article => ( +
onSelect(article)} + > +
+
{article.title}
+
+ {article.tags.slice(0, 4).map(t => ( + {t} + ))} + + {new Date(article.updatedAt).toLocaleDateString()} + +
+
+ +
+ ))} +
+ ))} +
+ ); +} + +// ============ ArticleView ============ + +function ArticleView({ + article, + allArticles, + onBack, + onNavigate, +}: { + article: WikiArticle; + allArticles: WikiArticle[]; + onBack: () => void; + onNavigate: (article: WikiArticle) => void; +}) { + const { t } = useLanguage(); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + apiFetch<{ article: WikiArticle; content: string }>(`/articles/${article.id}`) + .then(data => setContent(data.content)) + .catch(() => setContent('*Failed to load article*')) + .finally(() => setLoading(false)); + }, [article.id]); + + // Find backlinked articles + const backlinkedArticles = allArticles.filter(a => article.backlinks.includes(a.id)); + + return ( +
+ +
+

{article.title}

+
+ {article.tags.map(t => ( + {t} + ))} + + {article.category} · Updated {new Date(article.updatedAt).toLocaleDateString()} + +
+
+ {loading ? ( +
Loading...
+ ) : ( +
+ )} + {backlinkedArticles.length > 0 && ( +
+

{t('knowledge.linkedArticles')}

+ {backlinkedArticles.map(a => ( + onNavigate(a)} + > + {a.title} + + ))} +
+ )} +
+ ); +} + +// ============ RawFilesList ============ + +function RawFilesList({ files, onCompile, onDeleteRaw }: { files: RawFile[]; onCompile: (slugs?: string[]) => void; onDeleteRaw: (slug: string) => void }) { + const { t } = useLanguage(); + if (files.length === 0) { + return ( +
+

{t('knowledge.noRaw.title')}

+

{t('knowledge.noRaw.desc')}

+
+ ); + } + + const pending = files.filter(f => !f.compiled); + + return ( +
+ {pending.length > 0 && ( +
+ +
+ )} + {files.map(f => ( +
+ {f.slug}.md +
+ + {f.compiled ? t('knowledge.compiled') : t('knowledge.pending')} + + +
+
+ ))} +
+ ); +} + +// ============ SearchView ============ + +function SearchView() { + const { t } = useLanguage(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + + const doSearch = useCallback(async (q: string) => { + if (!q.trim()) { setResults([]); return; } + setLoading(true); + try { + const data = await apiFetch<{ query: string; results: SearchResult[] }>( + `/search?q=${encodeURIComponent(q)}&maxResults=20` + ); + setResults(data.results); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const timer = setTimeout(() => doSearch(query), 300); + return () => clearTimeout(timer); + }, [query, doSearch]); + + return ( +
+ setQuery(e.target.value)} + placeholder={t('knowledge.searchPlaceholder')} + autoFocus + /> + {loading &&
{t('knowledge.searching')}
} + {results.map(r => ( +
+
+ + {r.type === 'wiki' ? 'Wiki' : 'Raw'} + + {r.title} + {r.category && r.category !== 'raw' && ( + {r.category} + )} +
+
{r.snippet}
+
+ ))} + {!loading && query && results.length === 0 && ( +
+

{t('knowledge.noResults')}

+
+ )} +
+ ); +} + +// ============ GraphView (Phase 4) ============ + +function GraphView({ articles }: { articles: WikiArticle[] }) { + const { t } = useLanguage(); + + if (articles.length === 0) { + return ( +
+

{t('knowledge.noGraph.title')}

+

{t('knowledge.noGraph.desc')}

+
+ ); + } + + // Simple canvas-based force graph + return ; +} + +// ============ Simple Force Graph (no D3 dependency) ============ + +interface GraphNode { + id: string; + label: string; + category: string; + x: number; + y: number; + vx: number; + vy: number; +} + +interface GraphEdge { + source: string; + target: string; +} + +function SimpleForceGraph({ articles }: { articles: WikiArticle[] }) { + const canvasRef = useCallback((canvas: HTMLCanvasElement | null) => { + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.parentElement?.getBoundingClientRect(); + const W = rect?.width || 800; + const H = rect?.height || 500; + canvas.width = W; + canvas.height = H; + + // Build nodes and edges + const nodes: GraphNode[] = articles.map((a, i) => ({ + id: a.id, + label: a.title.length > 20 ? a.title.substring(0, 20) + '...' : a.title, + category: a.category, + x: W / 2 + (Math.random() - 0.5) * W * 0.6, + y: H / 2 + (Math.random() - 0.5) * H * 0.6, + vx: 0, + vy: 0, + })); + + const nodeMap = new Map(nodes.map(n => [n.id, n])); + + const edges: GraphEdge[] = []; + for (const a of articles) { + for (const bl of a.backlinks) { + if (nodeMap.has(bl)) { + edges.push({ source: a.id, target: bl }); + } + } + } + + // Category colors + const catColors = new Map(); + const palette = ['#6c63ff', '#48bb78', '#ed8936', '#e53e3e', '#38b2ac', '#9f7aea', '#f56565', '#4fd1c5']; + let colorIdx = 0; + for (const n of nodes) { + if (!catColors.has(n.category)) { + catColors.set(n.category, palette[colorIdx++ % palette.length]); + } + } + + // Simple force simulation + let animId: number; + const simulate = () => { + // Repulsion + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const dx = nodes[j].x - nodes[i].x; + const dy = nodes[j].y - nodes[i].y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const force = 2000 / (dist * dist); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + nodes[i].vx -= fx; + nodes[i].vy -= fy; + nodes[j].vx += fx; + nodes[j].vy += fy; + } + } + + // Attraction (edges) + for (const e of edges) { + const s = nodeMap.get(e.source)!; + const t = nodeMap.get(e.target)!; + const dx = t.x - s.x; + const dy = t.y - s.y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const force = (dist - 100) * 0.01; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + s.vx += fx; + s.vy += fy; + t.vx -= fx; + t.vy -= fy; + } + + // Center gravity + for (const n of nodes) { + n.vx += (W / 2 - n.x) * 0.001; + n.vy += (H / 2 - n.y) * 0.001; + } + + // Apply velocity + for (const n of nodes) { + n.vx *= 0.9; // damping + n.vy *= 0.9; + n.x += n.vx; + n.y += n.vy; + // Bounds + n.x = Math.max(30, Math.min(W - 30, n.x)); + n.y = Math.max(30, Math.min(H - 30, n.y)); + } + + // Draw + ctx.clearRect(0, 0, W, H); + + // Edges + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + for (const e of edges) { + const s = nodeMap.get(e.source)!; + const t = nodeMap.get(e.target)!; + ctx.beginPath(); + ctx.moveTo(s.x, s.y); + ctx.lineTo(t.x, t.y); + ctx.stroke(); + } + + // Nodes + for (const n of nodes) { + const color = catColors.get(n.category) || '#6c63ff'; + ctx.beginPath(); + ctx.arc(n.x, n.y, 6, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + ctx.fillStyle = '#e0e0e0'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(n.label, n.x, n.y - 10); + } + + animId = requestAnimationFrame(simulate); + }; + + simulate(); + + return () => cancelAnimationFrame(animId); + }, [articles]); + + return ( +
+ +
+ ); +} + +// ============ Simple Markdown → HTML ============ + +function markdownToHtml(md: string): string { + return md + // Code blocks + .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Headers + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold + .replace(/\*\*(.+?)\*\*/g, '$1') + // Italic + .replace(/\*(.+?)\*/g, '$1') + // Blockquote + .replace(/^> (.+)$/gm, '
$1
') + // Unordered list + .replace(/^- (.+)$/gm, '
  • $1
  • ') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Paragraphs (double newline) + .replace(/\n\n/g, '

    ') + // Single newline in non-block context + .replace(/\n/g, '
    ') + // Wrap + .replace(/^/, '

    ') + .replace(/$/, '

    '); +} + +// ============ Main Component ============ + +export default function KnowledgePage() { + const { t } = useLanguage(); + const [tab, setTab] = useState('articles'); + const [articles, setArticles] = useState([]); + const [categories, setCategories] = useState([]); + const [rawFiles, setRawFiles] = useState([]); + const [stats, setStats] = useState(null); + const [selectedArticle, setSelectedArticle] = useState(null); + const [showClip, setShowClip] = useState(false); + const [compiling, setCompiling] = useState(false); + const [loading, setLoading] = useState(true); + + const loadData = useCallback(async () => { + try { + const [articlesData, rawData, statsData] = await Promise.all([ + apiFetch<{ articles: WikiArticle[]; categories: string[] }>('/articles'), + apiFetch<{ files: RawFile[] }>('/raw'), + apiFetch('/stats'), + ]); + setArticles(articlesData.articles); + setCategories(articlesData.categories); + setRawFiles(rawData.files); + setStats(statsData); + } catch { + // Silently fail on initial load + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const handleCompile = async (slugs?: string[]) => { + setCompiling(true); + try { + await apiFetch('/compile', { + method: 'POST', + body: JSON.stringify({ slugs }), + }); + await loadData(); + } catch (err: any) { + alert(t('knowledge.compileFailed', { error: err.message })); + } finally { + setCompiling(false); + } + }; + + const handleDeleteArticle = async (article: WikiArticle) => { + if (!confirm(t('knowledge.confirmDelete', { name: article.title }))) return; + try { + await apiFetch(`/articles/${article.id}`, { method: 'DELETE' }); + await loadData(); + } catch (err: any) { + alert(t('knowledge.deleteFailed', { error: err.message })); + } + }; + + const handleDeleteRaw = async (slug: string) => { + if (!confirm(t('knowledge.confirmDelete', { name: slug }))) return; + try { + await apiFetch(`/raw/${slug}`, { method: 'DELETE' }); + await loadData(); + } catch (err: any) { + alert(t('knowledge.deleteFailed', { error: err.message })); + } + }; + + const handleArticleSelect = (article: WikiArticle) => { + setSelectedArticle(article); + }; + + const handleArticleBack = () => { + setSelectedArticle(null); + }; + + const tabLabels: Record = { + articles: t('knowledge.articles'), + raw: t('knowledge.rawSources'), + search: t('knowledge.search'), + graph: t('knowledge.graph'), + }; + + if (loading) { + return ( +
    +
    {t('knowledge.loading')}
    +
    + ); + } + + return ( +
    + {/* Header */} +
    +
    +

    {t('knowledge.title')}

    + {stats && ( +
    + {t('knowledge.statsArticles', { count: stats.wikiCount })} + {t('knowledge.statsSources', { count: stats.rawCount })} + {stats.uncompiledCount > 0 && ( + {t('knowledge.statsPending', { count: stats.uncompiledCount })} + )} +
    + )} +
    +
    + + +
    +
    + + {/* Tabs */} +
    + {(['articles', 'raw', 'search', 'graph'] as Tab[]).map(tabKey => ( + + ))} +
    + + {/* Content */} +
    + {tab === 'articles' && !selectedArticle && ( + + )} + {tab === 'articles' && selectedArticle && ( + setSelectedArticle(a)} + /> + )} + {tab === 'raw' && ( + + )} + {tab === 'search' && } + {tab === 'graph' && } +
    + + {/* Clip Dialog */} + {showClip && ( + setShowClip(false)} + onClipped={loadData} + /> + )} +
    + ); +} diff --git a/src/web/client/src/styles/index.css b/src/web/client/src/styles/index.css index 35cfba19..2917aa5b 100644 --- a/src/web/client/src/styles/index.css +++ b/src/web/client/src/styles/index.css @@ -1702,6 +1702,7 @@ body { } .attach-btn, +.work-mode-btn, .conversation-mode-btn, .more-btn { height: 34px; @@ -1718,12 +1719,14 @@ body { transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; } -.attach-btn { +.attach-btn, +.work-mode-btn { width: 34px; padding: 0; } .attach-btn:hover, +.work-mode-btn:hover, .conversation-mode-btn:hover, .more-btn:hover { background: rgba(255, 255, 255, 0.05); @@ -1731,6 +1734,7 @@ body { } .attach-btn.has-attachments, +.work-mode-btn.active, .conversation-mode-btn.conversation-active, .more-btn.active { background: rgba(255, 255, 255, 0.06); @@ -1932,8 +1936,8 @@ body { } .pin-toggle-btn.pinned { - background: rgba(99, 102, 241, 0.14); - color: #dbe1ff; + background: rgba(255, 255, 255, 0.06); + color: rgba(226, 232, 240, 0.9); } .command-tool-btn { @@ -1964,8 +1968,8 @@ body { } .send-btn { - width: 36px; - height: 36px; + width: 40px; + height: 40px; padding: 0; background: #6371f7; color: #ffffff; @@ -1977,12 +1981,14 @@ body { display: flex; align-items: center; justify-content: center; - transition: background 0.2s ease, opacity 0.2s ease; + transition: background 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease; flex-shrink: 0; + box-shadow: 0 2px 8px rgba(99, 113, 247, 0.3); } .send-btn:hover:not(:disabled) { background: #7683ff; + box-shadow: 0 4px 14px rgba(99, 113, 247, 0.45); } .send-btn:disabled { @@ -3624,13 +3630,15 @@ body { } .attach-btn, + .work-mode-btn, .conversation-mode-btn, .more-btn, .command-tool-btn { height: 32px; } - .attach-btn { + .attach-btn, + .work-mode-btn { width: 32px; padding: 0; } @@ -3717,6 +3725,7 @@ body { } .attach-btn, + .work-mode-btn, .conversation-mode-btn, .more-btn { width: 34px; diff --git a/src/web/server/channels/adapters/slack-bot.ts b/src/web/server/channels/adapters/slack-bot.ts index 81be3474..69fb2dcb 100644 --- a/src/web/server/channels/adapters/slack-bot.ts +++ b/src/web/server/channels/adapters/slack-bot.ts @@ -132,7 +132,7 @@ export class SlackBotAdapter implements ChannelAdapter { // 2. 上传文件 await fetch(uploadResult.upload_url, { method: 'POST', - body: imageData, + body: imageData as unknown as BodyInit, headers: { 'Content-Type': mimeType }, }); diff --git a/src/web/server/channels/adapters/whatsapp.ts b/src/web/server/channels/adapters/whatsapp.ts index 679940f7..750f9fed 100644 --- a/src/web/server/channels/adapters/whatsapp.ts +++ b/src/web/server/channels/adapters/whatsapp.ts @@ -104,7 +104,7 @@ export class WhatsAppAdapter implements ChannelAdapter { const uploadUrl = `${GRAPH_API_BASE}/${phoneNumberId}/media`; const formData = new FormData(); - const blob = new Blob([imageData], { type: mimeType }); + const blob = new Blob([imageData as unknown as BlobPart], { type: mimeType }); formData.append('file', blob, `image.${mimeType.split('/')[1] || 'png'}`); formData.append('type', mimeType); formData.append('messaging_product', 'whatsapp'); diff --git a/src/web/server/conversation.ts b/src/web/server/conversation.ts index b5acca7c..75a14ffe 100644 --- a/src/web/server/conversation.ts +++ b/src/web/server/conversation.ts @@ -2335,7 +2335,11 @@ export class ConversationManager { (block): block is ToolUseBlock => block.type === 'tool_use' ); - if (toolUseBlocks.length > 0 && stopReason === 'tool_use') { + if (toolUseBlocks.length > 0 && stopReason !== 'tool_use') { + console.warn(`[ConversationManager] Tool use blocks found but stopReason="${stopReason}" (expected "tool_use"). Executing tools anyway for compatibility with non-Claude models.`); + } + + if (toolUseBlocks.length > 0) { // 执行工具并收集结果 const toolResults: any[] = []; // 收集所有工具返回的 newMessages(对齐官网实现) @@ -4492,7 +4496,9 @@ Respond ONLY with valid JSON, no other text.`; try { const nbMgr = activateNotebookManager(state.session.cwd) || initNotebookManager(state.session.cwd); if (nbMgr) { - notebookSummary = nbMgr.getNotebookSummaryForPrompt() || undefined; + // 传入用户查询上下文,让 project.md 按需加载相关 section + const nbQuery = this.getLatestUserRecallQuery(state); + notebookSummary = nbMgr.getNotebookSummaryForPrompt(nbQuery || undefined) || undefined; } } catch { // 笔记本加载失败不影响主流程 @@ -4628,6 +4634,19 @@ Respond ONLY with valid JSON, no other text.`; extraParts.push(config.appendPrompt); } + // 话题切换检测指令(会话至少有 2 轮对话后才注入) + const userMsgCount = state.messages.filter(m => m.role === 'user').length; + if (userMsgCount >= 2) { + extraParts.push( + `# Topic shift detection\n` + + `If the user's latest message is clearly about a completely different topic/task from the ongoing conversation ` + + `(not a follow-up, clarification, or related tangent), append the exact HTML comment ` + + `at the very end of your response (after all visible content). ` + + `Only use this when the topic change is obvious and unambiguous. Do NOT use it for normal conversation flow, ` + + `follow-up questions, or when the user is simply changing approach to the same problem.` + ); + } + // 注入 WebUI 专属工具引导(ImageGen 等) const webuiToolGuidance = this.buildWebuiToolGuidance(); if (webuiToolGuidance) { diff --git a/src/web/server/routes/api.ts b/src/web/server/routes/api.ts index 9390392f..1fba5efa 100644 --- a/src/web/server/routes/api.ts +++ b/src/web/server/routes/api.ts @@ -18,6 +18,7 @@ import vectordbApiRouter from './vectordb-api.js'; import mcpCliApiRouter from './mcp-cli-api.js'; import tunnelApiRouter from './tunnel-api.js'; import appApiRouter from './app-api.js'; +import { createKnowledgeRouter } from './knowledge-api.js'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -67,6 +68,10 @@ export function setupApiRoutes(app: Express, conversationManager: ConversationMa app.use('/api/tunnel', tunnelApiRouter); app.use('/api/apps', appApiRouter); + // ============ Knowledge Base API ============ + // LLM Knowledge Base(Karpathy 模式:raw → compile → wiki → search) + app.use('/api/knowledge', createKnowledgeRouter()); + // 健康检查 app.get('/api/health', (req: Request, res: Response) => { res.json({ diff --git a/src/web/server/routes/knowledge-api.ts b/src/web/server/routes/knowledge-api.ts new file mode 100644 index 00000000..df8c9d78 --- /dev/null +++ b/src/web/server/routes/knowledge-api.ts @@ -0,0 +1,574 @@ +/** + * Knowledge Base API 路由 + * + * LLM Knowledge Base 管理: + * - 文章列表/详情 + * - 原始文件列表 + * - URL/文件摄入 + * - Wiki 编译 + * - 知识搜索 + * - 统计信息 + * - Bookmarklet 生成 + * - 批量摄入 + */ + +import { Router, Request, Response } from 'express'; +import { getKnowledgeStore } from '../../../knowledge/store.js'; +import { KnowledgeCompiler } from '../../../knowledge/compiler.js'; +import { webAuth } from '../web-auth.js'; +import { getRuntimeBackendCapabilities } from '../../shared/runtime-capabilities.js'; +import { createConversationClient } from '../runtime/factory.js'; +import { getProviderForRuntimeBackend } from '../../shared/model-catalog.js'; + +export function createKnowledgeRouter(): Router { + +const router = Router(); + +// ============ 文章 ============ + +/** + * GET /api/knowledge/articles + * 文章列表 + 元数据 + */ +router.get('/articles', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const meta = await store.loadMeta(); + const category = req.query.category as string | undefined; + const tag = req.query.tag as string | undefined; + + let articles = meta.articles; + if (category) { + articles = articles.filter(a => a.category === category); + } + if (tag) { + articles = articles.filter(a => a.tags.includes(tag)); + } + + // 按更新时间倒序 + articles.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + + res.json({ success: true, data: { articles, categories: meta.categories } }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/knowledge/articles/:id + * 单篇文章内容 + */ +router.get('/articles/:id', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const article = await store.findArticle(req.params.id); + if (!article) { + return res.status(404).json({ success: false, error: 'Article not found' }); + } + + const content = await store.readWikiArticle(article.slug); + res.json({ success: true, data: { article, content } }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * DELETE /api/knowledge/articles/:id + * 删除文章 + */ +router.delete('/articles/:id', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const article = await store.findArticle(req.params.id); + if (!article) { + return res.status(404).json({ success: false, error: 'Article not found' }); + } + + const fs = await import('fs/promises'); + const pathMod = await import('path'); + + // 删除 wiki 文件 + const wikiPath = pathMod.join(store.getWikiDir(), `${article.slug}.md`); + try { await fs.unlink(wikiPath); } catch {} + + // 同时删除关联的 raw 文件(否则会被识别为"未编译",重新编译又会复活) + for (const srcFile of article.sourceFiles) { + const rawPath = pathMod.join(store.getRawDir(), srcFile); + try { await fs.unlink(rawPath); } catch {} + } + + // 删除关联的图片目录 + await store.removeImages(article.slug); + + await store.removeArticle(req.params.id); + searchCache = null; + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ 原始文件 ============ + +/** + * GET /api/knowledge/raw + * 原始文件列表 + */ +router.get('/raw', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const slugs = await store.listRawSlugs(); + const uncompiled = await store.listUncompiledSlugs(); + const uncompiledSet = new Set(uncompiled); + + const files = slugs.map(slug => ({ + slug, + compiled: !uncompiledSet.has(slug), + })); + + res.json({ success: true, data: { files } }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/knowledge/raw/:slug + * 原始文件内容 + */ +router.get('/raw/:slug', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const content = await store.readRawFile(req.params.slug); + if (content === null) { + return res.status(404).json({ success: false, error: 'Raw file not found' }); + } + res.json({ success: true, data: { slug: req.params.slug, content } }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * DELETE /api/knowledge/raw/:slug + * 删除原始文件 + */ +router.delete('/raw/:slug', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const slug = req.params.slug; + const content = await store.readRawFile(slug); + if (content === null) { + return res.status(404).json({ success: false, error: 'Raw file not found' }); + } + + const fs = await import('fs/promises'); + const pathMod = await import('path'); + + // 删除 raw 文件 + const rawPath = pathMod.join(store.getRawDir(), `${slug}.md`); + await fs.unlink(rawPath); + + // 删除关联的图片目录 + await store.removeImages(slug); + + // 清理关联的 wiki 文章(sourceFiles 包含此 slug 的文章) + const meta = await store.loadMeta(); + const rawFile = `${slug}.md`; + const linkedArticles = meta.articles.filter(a => a.sourceFiles.includes(rawFile)); + for (const article of linkedArticles) { + // 从 sourceFiles 中移除 + article.sourceFiles = article.sourceFiles.filter(f => f !== rawFile); + // 如果没有其他源文件了,删除整篇 wiki 文章 + if (article.sourceFiles.length === 0) { + const wikiPath = pathMod.join(store.getWikiDir(), `${article.slug}.md`); + try { await fs.unlink(wikiPath); } catch {} + await store.removeArticle(article.id); + } else { + await store.upsertArticle(article); + } + } + + searchCache = null; + res.json({ success: true, data: { removedArticles: linkedArticles.filter(a => a.sourceFiles.length === 0).map(a => a.title) } }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ 摄入 ============ + +/** + * POST /api/knowledge/ingest + * 摄入 URL 或文件 + * Body: { source: string, tags?: string[], category?: string, slug?: string } + */ +router.post('/ingest', async (req: Request, res: Response) => { + try { + const { source, tags, category, slug } = req.body; + if (!source) { + return res.status(400).json({ success: false, error: 'source is required' }); + } + + const store = getKnowledgeStore(); + const isUrl = /^https?:\/\//i.test(source); + const result = isUrl + ? await store.ingestUrl(source, { tags, category, slug }) + : await store.ingestFile(source, { tags, category, slug }); + + if (!result.success) { + return res.status(500).json({ success: false, error: result.error }); + } + + searchCache = null; + res.json({ success: true, data: result }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * POST /api/knowledge/ingest-batch + * 批量摄入 + * Body: { sources: Array<{ source: string, tags?: string[], category?: string }> } + */ +router.post('/ingest-batch', async (req: Request, res: Response) => { + try { + const { sources } = req.body; + if (!Array.isArray(sources) || sources.length === 0) { + return res.status(400).json({ success: false, error: 'sources array is required' }); + } + + const store = getKnowledgeStore(); + const results = []; + + for (const item of sources) { + const isUrl = /^https?:\/\//i.test(item.source); + const result = isUrl + ? await store.ingestUrl(item.source, { tags: item.tags, category: item.category }) + : await store.ingestFile(item.source, { tags: item.tags, category: item.category }); + results.push(result); + } + + const succeeded = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + + res.json({ + success: true, + data: { total: sources.length, succeeded, failed, results }, + }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ 编译 ============ + +/** + * POST /api/knowledge/compile + * 触发编译 + * Body: { slugs?: string[], force?: boolean } + */ +router.post('/compile', async (req: Request, res: Response) => { + try { + const { slugs, force } = req.body || {}; + const store = getKnowledgeStore(); + // 用 createConversationClient 创建 runtime-aware client,走和 Chat 完全一样的路径 + const creds = webAuth.getCredentials(); + const runtimeBackend = webAuth.getRuntimeBackend(); + const caps = getRuntimeBackendCapabilities(runtimeBackend); + const compileModel = caps.defaultTestModel || 'gpt-5.4'; + const provider = getProviderForRuntimeBackend(runtimeBackend, compileModel); + const client = createConversationClient({ + provider, + model: compileModel, + apiKey: creds.apiKey, + authToken: creds.authToken, + baseUrl: creds.baseUrl, + timeout: 300000, + }); + const compiler = new KnowledgeCompiler(store, { client }); + + const result = await compiler.compile({ slugs, force }); + searchCache = null; + res.json({ success: true, data: result }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ 搜索 ============ + +/** + * GET /api/knowledge/search?q=xxx&maxResults=10 + * 知识库搜索 + */ +// 搜索索引缓存(避免每次搜索都读全量文件) +let searchCache: { contents: Map; builtAt: number } | null = null; +const SEARCH_CACHE_TTL = 30_000; // 30 秒缓存 + +async function getSearchIndex(store: ReturnType): Promise> { + const now = Date.now(); + if (searchCache && (now - searchCache.builtAt) < SEARCH_CACHE_TTL) { + return searchCache.contents; + } + + const contents = new Map(); + const meta = await store.loadMeta(); + + // 预加载所有 wiki 内容 + for (const article of meta.articles) { + const content = await store.readWikiArticle(article.slug); + if (content) contents.set(`wiki:${article.slug}`, content); + } + + // 预加载未编译的 raw 内容 + const rawSlugs = await store.listRawSlugs(); + const compiledSlugs = new Set(meta.articles.flatMap(a => a.sourceFiles.map(f => f.replace(/\.md$/, '')))); + for (const slug of rawSlugs) { + if (compiledSlugs.has(slug)) continue; + const content = await store.readRawFile(slug); + if (content) contents.set(`raw:${slug}`, content); + } + + searchCache = { contents, builtAt: now }; + return contents; +} + +router.get('/search', async (req: Request, res: Response) => { + try { + const q = req.query.q as string; + if (!q) { + return res.status(400).json({ success: false, error: 'q parameter is required' }); + } + + const maxResults = parseInt(req.query.maxResults as string) || 20; + const store = getKnowledgeStore(); + const meta = await store.loadMeta(); + const contentIndex = await getSearchIndex(store); + const queryLower = q.toLowerCase(); + const queryTerms = queryLower.split(/\s+/).filter(Boolean); + + interface LocalSearchResult { + id: string; + slug: string; + title: string; + category: string; + tags: string[]; + score: number; + snippet: string; + type: 'wiki' | 'raw'; + } + + const results: LocalSearchResult[] = []; + + // 搜索 wiki 文章(元数据 + 内容) + for (const article of meta.articles) { + let score = 0; + const titleLower = article.title.toLowerCase(); + const tagsLower = article.tags.map(t => t.toLowerCase()); + const catLower = (article.category || '').toLowerCase(); + + // 标题匹配(权重高) + for (const term of queryTerms) { + if (titleLower.includes(term)) score += 3; + if (tagsLower.some(t => t.includes(term))) score += 2; + if (catLower.includes(term)) score += 1; + } + + // 内容匹配 + const content = contentIndex.get(`wiki:${article.slug}`) || null; + let snippet = ''; + if (content) { + const contentLower = content.toLowerCase(); + for (const term of queryTerms) { + const idx = contentLower.indexOf(term); + if (idx !== -1) { + score += 1; + if (!snippet) { + const start = Math.max(0, idx - 60); + const end = Math.min(content.length, idx + term.length + 60); + snippet = (start > 0 ? '...' : '') + content.substring(start, end).trim() + (end < content.length ? '...' : ''); + } + } + } + } + + if (score > 0) { + results.push({ + id: article.id, + slug: article.slug, + title: article.title, + category: article.category, + tags: article.tags, + score, + snippet: snippet || article.title, + type: 'wiki', + }); + } + } + + // 也搜索未编译的 raw 文件(从缓存中取) + for (const [key, content] of contentIndex) { + if (!key.startsWith('raw:')) continue; + const slug = key.substring(4); + + let score = 0; + const slugLower = slug.toLowerCase(); + const contentLower = content.toLowerCase(); + let snippet = ''; + + for (const term of queryTerms) { + if (slugLower.includes(term)) score += 2; + const idx = contentLower.indexOf(term); + if (idx !== -1) { + score += 1; + if (!snippet) { + const plainContent = store.stripFrontmatter(content); + const plainLower = plainContent.toLowerCase(); + const plainIdx = plainLower.indexOf(term); + if (plainIdx !== -1) { + const start = Math.max(0, plainIdx - 60); + const end = Math.min(plainContent.length, plainIdx + term.length + 60); + snippet = (start > 0 ? '...' : '') + plainContent.substring(start, end).trim() + (end < plainContent.length ? '...' : ''); + } + } + } + } + + if (score > 0) { + results.push({ + id: `raw:${slug}`, + slug, + title: slug, + category: 'raw', + tags: [], + score, + snippet: snippet || slug, + type: 'raw', + }); + } + } + + // 按分数排序 + results.sort((a, b) => b.score - a.score); + + res.json({ success: true, data: { query: q, results: results.slice(0, maxResults) } }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ 统计 ============ + +/** + * GET /api/knowledge/stats + * 知识库统计 + */ +router.get('/stats', async (req: Request, res: Response) => { + try { + const store = getKnowledgeStore(); + const stats = await store.getStats(); + res.json({ success: true, data: stats }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ Backlinks ============ + +/** + * POST /api/knowledge/regenerate-backlinks + * 重新生成所有反向链接 + */ +router.post('/regenerate-backlinks', async (req: Request, res: Response) => { + try { + const { regenerateAllBacklinks } = await import('../../../knowledge/linker.js'); + const store = getKnowledgeStore(); + const result = await regenerateAllBacklinks(store); + res.json({ success: true, data: result }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ 文件上传摄入 ============ + +/** + * POST /api/knowledge/upload + * 上传文件摄入(JSON base64 编码) + * Body: { filename: string, data: string (base64), tags?: string[], category?: string, slug?: string } + */ +router.post('/upload', async (req: Request, res: Response) => { + try { + const { filename, data, tags, category, slug } = req.body; + if (!filename || !data) { + return res.status(400).json({ success: false, error: 'filename and data (base64) are required' }); + } + + // base64 解码写入临时文件 + const fs = await import('fs/promises'); + const pathMod = await import('path'); + const os = await import('os'); + const tmpDir = await fs.mkdtemp(pathMod.join(os.tmpdir(), 'axon-upload-')); + const tmpFile = pathMod.join(tmpDir, filename); + const buffer = Buffer.from(data, 'base64'); + await fs.writeFile(tmpFile, buffer); + + const store = getKnowledgeStore(); + const result = await store.ingestFile(tmpFile, { + tags: Array.isArray(tags) ? tags : [], + category: category || undefined, + slug: slug || undefined, + }); + + // 清理临时文件 + try { await fs.unlink(tmpFile); await fs.rmdir(tmpDir); } catch {} + + if (!result.success) { + return res.status(500).json({ success: false, error: result.error }); + } + + searchCache = null; + res.json({ success: true, data: result }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ============ Bookmarklet ============ + +/** + * GET /api/knowledge/bookmarklet + * 返回浏览器书签小工具 JS + */ +router.get('/bookmarklet', (req: Request, res: Response) => { + const host = req.headers.host || 'localhost:3456'; + const protocol = req.protocol || 'http'; + const baseUrl = `${protocol}://${host}`; + + const js = `javascript:void((function(){ + var url=location.href; + var title=document.title; + fetch('${baseUrl}/api/knowledge/ingest',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({source:url,tags:[],category:''}) + }).then(function(r){return r.json()}).then(function(d){ + if(d.success){alert('Clipped to Axon KB: '+d.data.slug)} + else{alert('Clip failed: '+d.error)} + }).catch(function(e){alert('Clip error: '+e)}); + })())`; + + res.json({ + success: true, + data: { + bookmarklet: js.replace(/\n\s*/g, ''), + instructions: 'Drag this link to your bookmarks bar, then click it on any page to clip it to Axon Knowledge Base.', + }, + }); +}); + +return router; +} // end createKnowledgeRouter diff --git a/src/web/server/runtime/codex-client.ts b/src/web/server/runtime/codex-client.ts index 38c6d9f8..f54560cd 100644 --- a/src/web/server/runtime/codex-client.ts +++ b/src/web/server/runtime/codex-client.ts @@ -1476,7 +1476,7 @@ export class CodexConversationClient implements ConversationClient { ? 'none' : options?.reasoningEffort || (options?.enableThinking ? 'medium' : undefined); - if (reasoningEffort) { + if (reasoningEffort && reasoningEffort !== 'none') { body.reasoning = { effort: reasoningEffort, summary: 'auto', diff --git a/src/web/server/websocket.ts b/src/web/server/websocket.ts index 22940f88..b3925c1e 100644 --- a/src/web/server/websocket.ts +++ b/src/web/server/websocket.ts @@ -27,6 +27,8 @@ import type { AutonomousWorkerExecutor } from '../../blueprint/autonomous-worker import { getSwarmLogDB, type WorkerLog, type WorkerStream } from './database/swarm-logs.js'; // v4.9: 导入共享 E2E Agent 注册表 import { registerE2EAgent, unregisterE2EAgent, getE2EAgent } from '../../blueprint/e2e-agent-registry.js'; +// 知识库延迟摄入 +import { isKnowledgeEligible, deferredKnowledgeIngest } from '../../knowledge/deferred-ingest.js'; // 终端管理器 import { TerminalManager } from './terminal-manager.js'; import { resolveSessionAlias, syncClientSessionAlias } from './session-alias.js'; @@ -2727,6 +2729,9 @@ async function executeChatStreaming( const cmux = getCmuxBridge(); cmux.onThinking(); + // 话题切换检测:累积模型回复文本,完成后检查 标记 + let topicShiftAccumulatedText = ''; + try { // 调用对话管理器,传入流式回调(媒体附件包含 mimeType 和类型) // 所有回调使用 getActiveWs() 动态获取 WebSocket,确保刷新后消息仍能送达 @@ -2753,6 +2758,7 @@ async function executeChatStreaming( }, onTextDelta: (text: string) => { + topicShiftAccumulatedText += text; sendMessage(getActiveWs(), { type: 'text_delta', payload: { messageId, text, sessionId: chatSessionId }, @@ -2837,6 +2843,15 @@ async function executeChatStreaming( type: 'status', payload: { status: 'idle', sessionId: chatSessionId }, }); + + // 话题切换检测:检查模型回复末尾是否包含标记 + if (topicShiftAccumulatedText.includes('')) { + sendMessage(getActiveWs(), { + type: 'topic_shift', + payload: { sessionId: chatSessionId, messageId }, + }); + } + cmux.onComplete(); }, @@ -6113,6 +6128,11 @@ async function processFileAttachment(file: FileAttachment): Promise { console.log('[WebSocket] File attachment saved to temp file: ' + tempFilePath); + // 文档类附件延迟静默摄入知识库(ingest + 攒批 compile) + if (isKnowledgeEligible(name)) { + deferredKnowledgeIngest(tempFilePath, name); + } + // 根据 MIME 类型或扩展名给出提示 const ext = path.extname(name).toLowerCase(); let hint = ''; diff --git a/src/web/shared/thinking-config.ts b/src/web/shared/thinking-config.ts index 520cea83..2eaa6fc0 100644 --- a/src/web/shared/thinking-config.ts +++ b/src/web/shared/thinking-config.ts @@ -120,6 +120,14 @@ export function getResolvedWebThinkingConfig( }; } + // axon-cloud 订阅默认思考深度为 low,除非用户显式设置了其他级别 + if (runtimeBackend === 'axon-cloud' && config?.level == null) { + return { + ...normalized, + level: 'low', + }; + } + const supportedLevels = getSupportedWebThinkingLevels(runtimeBackend, model); const resolvedLevel = supportedLevels.includes(normalized.level) ? normalized.level diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index 5eeae4c5..c1ded9c7 100644 --- a/src/web/shared/types.ts +++ b/src/web/shared/types.ts @@ -430,6 +430,8 @@ export type ServerMessage = // v3.8: 任务跳过响应 | { type: 'task:skip_success'; payload: { blueprintId: string; taskId: string; success: true; timestamp: string } } | { type: 'task:skip_failed'; payload: { blueprintId: string; taskId: string; success: false; error: string; timestamp: string } } + // 话题切换检测 + | { type: 'topic_shift'; payload: { sessionId?: string; messageId?: string } } // v4.5: 用户插嘴响应 | { type: 'task:interject_success'; payload: { blueprintId: string; taskId: string; success: true; message: string; timestamp: string } } | { type: 'task:interject_failed'; payload: { blueprintId: string; taskId: string; success: false; error: string; timestamp: string } }