feat: 添加项目规则、环境配置示例及开发文档

This commit is contained in:
锦麟 王
2026-02-04 18:49:38 +08:00
commit df76882178
88 changed files with 13150 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
name: analyze-project
description: 分析当前项目
model: opus
---
请分析当前项目的架构、功能、技术栈和代码结构

View File

@@ -0,0 +1,6 @@
---
name: backend-unit-testing
description: 对后端模块执行单元测试
model: sonnet
---
在所有定义的模块上执行单元测试。验证每个模块通过所有测试且逻辑符合预期行为。

View File

@@ -0,0 +1,6 @@
---
name: compare-requirements
description: 需求比对分析
model: opus
---
对比分析当前项目与需求的差异,识别需要修改的地方

View File

@@ -0,0 +1,6 @@
---
name: develop-step-1
description: 开发当前步骤
model: sonnet
---
根据要求开发当前步骤。专注于实现本步骤描述的核心功能。

View File

@@ -0,0 +1,6 @@
---
name: error-no-docs
description: 处理缺少步骤文档的情况
model: sonnet
---
错误:未找到步骤文档。此工作流需要步骤文档才能继续。请创建一个包含开发步骤的 step_documentation.md 文件。

View File

@@ -0,0 +1,6 @@
---
name: extract-steps-1
description: 从文档中提取开发步骤
model: sonnet
---
从步骤文档中提取并列出所有开发步骤。对于每个步骤,识别任务描述、要求和验收标准。以编号列表格式输出步骤。

View File

@@ -0,0 +1,6 @@
---
name: fix-bugs-1
description: 修复错误或完成开发
model: sonnet
---
修复验证过程中发现的任何错误或问题。继续在此步骤上工作,直到验证通过为止。

View File

@@ -0,0 +1,6 @@
---
name: fix-issues
description: 修复编译和语法问题
model: sonnet
---
修复所有已识别的编译和语法问题。修复后返回检查问题是否已解决。

View File

@@ -0,0 +1,6 @@
---
name: foreach-loop-start
description: 初始化foreach循环处理步骤
model: sonnet
---
初始化foreach循环以按顺序处理每个开发步骤。跟踪当前步骤索引和总步骤数。

View File

@@ -0,0 +1,6 @@
---
name: loop-back
description: 返回开发阶段
model: sonnet
---
返回开发阶段以继续修复当前步骤。循环返回直到验证通过。

View File

@@ -0,0 +1,6 @@
---
name: modify-direct
description: 直接修改
model: opus
---
直接进行修改并验证结果

View File

@@ -0,0 +1,6 @@
---
name: module-division
description: 将代码划分为测试模块
model: sonnet
---
分析代码库并将其划分为单元测试的逻辑模块。定义清晰的模块边界和职责。

View File

@@ -0,0 +1,6 @@
---
name: plan-steps
description: 步骤规划
model: opus
---
规划可验证的修改步骤,确保每个步骤都可验证,并形成步骤文档

View File

@@ -0,0 +1,6 @@
---
name: step-complete
description: 移动到下一步
model: sonnet
---
当前步骤成功完成。移动到序列中的下一步。如果这是最后一步,则标记工作流为完成。

View File

@@ -0,0 +1,6 @@
---
name: syntax-check
description: 检查编译和语法问题
model: sonnet
---
分析代码库中的编译错误和语法问题。识别所有需要在测试前修复的问题。

View File

@@ -0,0 +1,6 @@
---
name: unit-test-planning
description: 为前端规划单元测试
model: sonnet
---
为前端模块规划全面的单元测试。定义测试用例和预期行为。

View File

@@ -0,0 +1,6 @@
---
name: verify-step-1
description: 验证完成的步骤
model: sonnet
---
验证当前步骤是否已成功完成。检查是否满足所有要求,功能是否按预期工作。

93
.claude/commands/ceshi.md Normal file
View File

@@ -0,0 +1,93 @@
---
description: ceshi
---
```mermaid
flowchart TD
start_node_default([Start])
syntax_check[syntax-check]
issues_check{If/Else:<br/>Conditional Branch}
fix_issues[fix-issues]
module_division[module-division]
frontend_check{AskUserQuestion:<br/>这是前端项目吗?}
unit_test_planning[unit-test-planning]
playwright_testing[[MCP: playwright_navigate]]
backend_unit_testing[backend-unit-testing]
test_verification{If/Else:<br/>Conditional Branch}
end_node_default([End])
start_node_default --> syntax_check
syntax_check --> issues_check
issues_check -->|发现问题| fix_issues
fix_issues --> syntax_check
issues_check -->|未发现问题| module_division
module_division --> frontend_check
frontend_check -->|是| unit_test_planning
unit_test_planning --> playwright_testing
playwright_testing --> test_verification
frontend_check -->|否| backend_unit_testing
backend_unit_testing --> test_verification
test_verification -->|测试通过| end_node_default
```
## Workflow Execution Guide
Follow the Mermaid flowchart above to execute the workflow. Each node type has specific execution methods as described below.
### Execution Methods by Node Type
- **Rectangle nodes**: Execute Sub-Agents using the Task tool
- **Diamond nodes (AskUserQuestion:...)**: Use the AskUserQuestion tool to prompt the user and branch based on their response
- **Diamond nodes (Branch/Switch:...)**: Automatically branch based on the results of previous processing (see details section)
- **Rectangle nodes (Prompt nodes)**: Execute the prompts described in the details section below
## MCP Tool Nodes
#### playwright_testing(playwright_navigate)
**Description**: 导航到URL进行前端测试
**MCP Server**: playwright
**Tool Name**: playwright_navigate
**Validation Status**: valid
**Configured Parameters**:
- `url` (string): http://localhost:3000
**Available Parameters**:
- `url` (string) (required): 要导航到的测试URL
This node invokes an MCP (Model Context Protocol) tool. When executing this workflow, use the configured parameters to call the tool via the MCP server.
### AskUserQuestion Node Details
Ask the user and proceed based on their choice.
#### frontend_check(这是前端项目吗?)
**Selection mode:** Single Select (branches based on the selected option)
**Options:**
- **是**: 需要浏览器测试的前端项目
- **否**: 只需要单元测试的后端项目
### If/Else Node Details
#### issues_check(Binary Branch (True/False))
**Branch conditions:**
- **未发现问题**: 未检测到编译或语法问题
- **发现问题**: 检测到编译或语法问题
**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.
#### test_verification(Binary Branch (True/False))
**Branch conditions:**
- **测试通过**: 所有单元测试通过且逻辑符合预期
- **测试失败**: 部分单元测试失败或逻辑不符合预期
**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.

90
.claude/commands/code.md Normal file
View File

@@ -0,0 +1,90 @@
---
description: code
---
```mermaid
flowchart TD
start_node_default([Start])
check_docs_1[[MCP: read_file]]
if_docs_exist{If/Else:<br/>Conditional Branch}
error_no_docs[error-no-docs]
end_error([End])
extract_steps_1[extract-steps-1]
foreach_loop_start[foreach-loop-start]
develop_step_1[develop-step-1]
verify_step_1[verify-step-1]
if_step_passed{If/Else:<br/>Conditional Branch}
step_complete[step-complete]
fix_bugs_1[fix-bugs-1]
loop_back[loop-back]
end_success([End])
start_node_default --> check_docs_1
check_docs_1 --> if_docs_exist
if_docs_exist -->|无文档| error_no_docs
if_docs_exist -->|存在文档| extract_steps_1
error_no_docs --> end_error
extract_steps_1 --> foreach_loop_start
foreach_loop_start --> develop_step_1
develop_step_1 --> verify_step_1
verify_step_1 --> if_step_passed
if_step_passed -->|通过| step_complete
if_step_passed -->|失败| fix_bugs_1
step_complete --> end_success
fix_bugs_1 --> loop_back
loop_back --> develop_step_1
```
## Workflow Execution Guide
Follow the Mermaid flowchart above to execute the workflow. Each node type has specific execution methods as described below.
### Execution Methods by Node Type
- **Rectangle nodes**: Execute Sub-Agents using the Task tool
- **Diamond nodes (AskUserQuestion:...)**: Use the AskUserQuestion tool to prompt the user and branch based on their response
- **Diamond nodes (Branch/Switch:...)**: Automatically branch based on the results of previous processing (see details section)
- **Rectangle nodes (Prompt nodes)**: Execute the prompts described in the details section below
## MCP Tool Nodes
#### check_docs_1(read_file)
**Description**: 读取步骤文档文件
**MCP Server**: filesystem
**Tool Name**: read_file
**Validation Status**: valid
**Configured Parameters**:
- `file_path` (string): ./step_documentation.md
**Available Parameters**:
- `file_path` (string) (required): 步骤文档文件路径
This node invokes an MCP (Model Context Protocol) tool. When executing this workflow, use the configured parameters to call the tool via the MCP server.
### If/Else Node Details
#### if_docs_exist(Binary Branch (True/False))
**Evaluation Target**: file_content
**Branch conditions:**
- **存在文档**: 文件存在且包含内容
- **无文档**: 文件不存在或为空
**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.
#### if_step_passed(Binary Branch (True/False))
**Evaluation Target**: verification_result
**Branch conditions:**
- **通过**: 验证成功
- **失败**: 验证失败
**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.

85
.claude/commands/req.md Normal file
View File

@@ -0,0 +1,85 @@
---
description: req
---
```mermaid
flowchart TD
start_node_default([Start])
prompt_requirements[请描述您的需求细节]
analyze_project[analyze-project]
compare_requirements[compare-requirements]
confirm_details{AskUserQuestion:<br/>是否需要确认细节?}
confirm_questions[提出需要确认的问题清单]
check_complexity{If/Else:<br/>Conditional Branch}
plan_steps[plan-steps]
create_doc[生成步骤文档,包含所有可验证的修改步骤]
modify_direct[modify-direct]
end_node_default([End])
start_node_default --> prompt_requirements
prompt_requirements --> analyze_project
analyze_project --> compare_requirements
compare_requirements --> confirm_details
confirm_details -->|是| confirm_questions
confirm_questions --> check_complexity
confirm_details -->|否| check_complexity
check_complexity -->|复杂| plan_steps
plan_steps --> create_doc
check_complexity -->|简单| modify_direct
modify_direct --> end_node_default
create_doc --> end_node_default
```
## Workflow Execution Guide
Follow the Mermaid flowchart above to execute the workflow. Each node type has specific execution methods as described below.
### Execution Methods by Node Type
- **Rectangle nodes**: Execute Sub-Agents using the Task tool
- **Diamond nodes (AskUserQuestion:...)**: Use the AskUserQuestion tool to prompt the user and branch based on their response
- **Diamond nodes (Branch/Switch:...)**: Automatically branch based on the results of previous processing (see details section)
- **Rectangle nodes (Prompt nodes)**: Execute the prompts described in the details section below
### Prompt Node Details
#### prompt_requirements(请描述您的需求细节)
```
请描述您的需求细节
```
#### confirm_questions(提出需要确认的问题清单)
```
提出需要确认的问题清单
```
#### create_doc(生成步骤文档,包含所有可验证的修改步骤)
```
生成步骤文档,包含所有可验证的修改步骤
```
### AskUserQuestion Node Details
Ask the user and proceed based on their choice.
#### confirm_details(是否需要确认细节?)
**Selection mode:** Single Select (branches based on the selected option)
**Options:**
- **是**: 需要确认细节后再继续
- **否**: 不需要确认,直接进行比对
### If/Else Node Details
#### check_complexity(Binary Branch (True/False))
**Evaluation Target**: modificationComplexity
**Branch conditions:**
- **复杂**: 修改复杂度高,需要多步骤
- **简单**: 修改相对简单,可直接进行
**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(dir:*)",
"Bash(tree:*)"
]
}
}

113
.cursorrules Normal file
View File

@@ -0,0 +1,113 @@
# MineNASAI 项目规则
## 编码规范
- **文件编码**: UTF-8
- **文件名编码**: UTF-8
- **控制台输出**: UTF-8
- 所有文件和目录必须使用 UTF-8 编码
### PowerShell 终端编码设置
在使用前请先设置编码:
```powershell
# 方法1: 切换代码页
chcp 65001
# 方法2: PowerShell 编码设置
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
```
或在 PowerShell 配置文件中永久设置(`$PROFILE`
```powershell
# 编辑配置文件
notepad $PROFILE
# 添加以下内容
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
```
## 代码规范
- 使用中文交流
- 函数添加必要注释
- 如有进度文档注意维护进度
## 文档生成规则
### ⚠️ 重要:仅在明确需要时生成文档
**禁止**
- 一次性生成大量文档
- 过度规划和文档化
- 在没有明确需求时创建文档
**允许**
- 用户明确要求时生成文档
- 关键设计决策需要记录时
- 进度跟踪需要时
### 文档命名规范
**必须使用中文命名**
- ✅ `项目分析.md`
- ✅ `开发步骤.md`
- ✅ `进度跟踪.md`
- ❌ `PROJECT-ANALYSIS.md`
- ❌ `DEVELOPMENT-STEPS.md`
- ❌ `PROGRESS.md`
**文档类型**
- 设计文档:`设计-[主题].md`
- 技术方案:`方案-[主题].md`
- 问题记录:`问题-[描述].md`
- 进度跟踪:`进度.md`
## 工作方式
### 优先级
1. **解决实际问题** - 专注于编码和实现
2. **简洁沟通** - 直接回答问题,不要长篇大论
3. **按需文档** - 只在需要时才写文档
### 代码优先
- 先写代码,后写文档
- 用代码示例代替长篇解释
- 实践验证优于理论规划
### 避免过度设计
- 不要一次性规划太多
- 先实现 MVP再迭代优化
- 遇到问题再解决,不要提前过度设计
## Python 代码规范
- Python 3.11+
- 类型注解(使用 type hints
- Docstring简洁的中文说明
- 测试覆盖关键功能
## Git 提交规范
使用中文提交信息:
- `feat: 添加xxx功能`
- `fix: 修复xxx问题`
- `docs: 更新文档`
- `refactor: 重构xxx`
- `test: 添加测试`
## 注意事项
- 维护 `进度.md` 文件记录关键进展
- 重大技术决策需要记录理由
- 遇到问题优先查看现有代码和文档
- 不确定时先问,再动手

85
.env.example Normal file
View File

@@ -0,0 +1,85 @@
# MineNASAI 环境变量配置
# 复制此文件为 .env 并填入实际值
# ===== LLM API 配置 =====
# 至少配置一个 API Key
# Anthropic (Claude) - 境外,需代理
MINENASAI_LLM__ANTHROPIC_API_KEY=sk-ant-xxxxx
# MINENASAI_LLM__ANTHROPIC_BASE_URL= # 可选,自定义 API 地址
# OpenAI (GPT) - 境外,需代理
MINENASAI_LLM__OPENAI_API_KEY=sk-xxxxx
# MINENASAI_LLM__OPENAI_BASE_URL= # 可选,支持兼容接口
# DeepSeek - 国内,无需代理
MINENASAI_LLM__DEEPSEEK_API_KEY=sk-xxxxx
# MINENASAI_LLM__DEEPSEEK_BASE_URL=https://api.deepseek.com
# 智谱 GLM - 国内,无需代理
MINENASAI_LLM__ZHIPU_API_KEY=xxxxx.xxxxx
# MINENASAI_LLM__ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# MiniMax - 国内,无需代理
MINENASAI_LLM__MINIMAX_API_KEY=xxxxx
MINENASAI_LLM__MINIMAX_GROUP_ID=xxxxx
# MINENASAI_LLM__MINIMAX_BASE_URL=https://api.minimax.chat/v1
# Moonshot (Kimi) - 国内,无需代理
MINENASAI_LLM__MOONSHOT_API_KEY=sk-xxxxx
# MINENASAI_LLM__MOONSHOT_BASE_URL=https://api.moonshot.cn/v1
# Google Gemini - 境外,需代理
MINENASAI_LLM__GEMINI_API_KEY=xxxxx
# MINENASAI_LLM__GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# 默认提供商和模型
MINENASAI_LLM__DEFAULT_PROVIDER=anthropic
MINENASAI_LLM__DEFAULT_MODEL=claude-sonnet-4-20250514
# ===== 代理配置 =====
# 境外 API (Anthropic/OpenAI/Gemini) 需要代理
MINENASAI_PROXY__ENABLED=true
MINENASAI_PROXY__HTTP=http://127.0.0.1:7890
MINENASAI_PROXY__HTTPS=http://127.0.0.1:7890
# MINENASAI_PROXY__AUTO_DETECT=true # 自动检测代理
# ===== 兼容旧配置 =====
# ANTHROPIC_API_KEY=sk-ant-xxxxx
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1,.local
# ===== 企业微信 =====
WEWORK_CORP_ID=
WEWORK_AGENT_ID=
WEWORK_SECRET=
WEWORK_TOKEN=
WEWORK_ENCODING_AES_KEY=
# ===== 飞书 =====
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_VERIFICATION_TOKEN=
FEISHU_ENCRYPT_KEY=
# ===== Redis =====
REDIS_URL=redis://localhost:6379/0
# ===== 数据库 =====
DATABASE_URL=sqlite+aiosqlite:///~/.config/minenasai/database.db
# ===== 日志 =====
LOG_LEVEL=INFO
LOG_FORMAT=json
# ===== Web TUI =====
WEBTUI_HOST=0.0.0.0
WEBTUI_PORT=8080
WEBTUI_SECRET_KEY=change-this-to-a-random-string
# ===== SSH (localhost) =====
SSH_HOST=localhost
SSH_PORT=22
SSH_USERNAME=
SSH_KEY_PATH=~/.ssh/id_rsa

75
.gitignore vendored Normal file
View File

@@ -0,0 +1,75 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Project specific
.env
.env.local
*.log
logs/
*.db
*.sqlite
*.sqlite3
# Config
config.local.json5
secrets/
# PoC (已完成,可删除)
poc/
# Temporary
tmp/
temp/
*.tmp
# Redis dump
dump.rdb
# Celery
celerybeat-schedule
celerybeat.pid
# Coverage
.coverage
htmlcov/
# Pytest
.pytest_cache/
# Mypy
.mypy_cache/
.dmypy.json
dmypy.json

29
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies:
- types-redis
- types-paramiko
- pydantic
args: [--ignore-missing-imports]

263
PoC总结.md Normal file
View File

@@ -0,0 +1,263 @@
# MineNASAI - PoC 验证总结
**日期**: 2025-02-04
**状态**: PoC 验证完成
---
## PoC 验证结果
### PoC 1: Claude Code CLI 集成
**状态**: ⚠️ 部分可行
**测试结果**:
- ✅ Claude CLI 已安装(版本 2.1.31
- ✅ Claude CLI 在命令行可正常运行
```bash
claude -p "今天上海温度" # 正常返回结果
```
- ❌ Python `subprocess` 集成失败
- 进程卡住不返回
- 可能原因:缓冲问题、进程通信问题
**影响**:
- 核心功能编程能力依赖CLI但集成有技术难度
- 需要寻找替代方案或深入解决subprocess问题
**建议方案**:
1. **使用 Anthropic API**(最可靠)
- 优点:稳定、可控、易集成
- 缺点失去CLI的IDE集成能力
2. **混合方案**(推荐)
- 简单任务使用API
- 复杂编程在TUI中手动调用CLI
- 务实且风险低
3. **继续研究CLI集成**
- 深入调试subprocess问题
- 尝试文件重定向、异步IO等方案
- 时间成本高,不确定性大
---
### PoC 2: 智能路由算法
**状态**: ✅ 验证通过
**测试结果**:
- ✅ 启发式规则准确率: **85.7%** (6/7)
- ✅ 超过70%门槛
- ✅ 响应速度快(< 1ms
**测试用例**:
| 用例 | 消息 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | NAS状态? | fast | fast | ✅ |
| 2 | 搜索最新的Python教程 | fast | fast | ✅ |
| 3 | 实现一个Web服务 | deep | deep | ✅ |
| 4 | 重构这个模块 | deep | deep | ✅ |
| 5 | 修改配置文件中的端口 | medium | medium | ✅ |
| 6 | 添加一个新的API端点 | medium | medium | ✅ |
| 7 | 长文本分析任务 | medium | deep | ❌ |
**结论**:
- 启发式规则足够应对大部分场景
- 可后续优化规则或引入LLM增强
---
### PoC 3: MCP Server 加载
**状态**: ⚠️ 测试未完成
**遇到问题**:
- Node.js 和 npx 已安装
- MCP Server 进程启动后无响应
- 可能原因:协议通信、进程管理问题
**建议**:
- MCP Server集成复杂度较高
- 可作为Phase 2或更后期的功能
- 先用内置工具和API实现MVP
---
## 总体评估
### 可行性结论
✅ **项目整体可行**,但需要调整技术方案
| 组件 | 可行性 | 风险 | 建议 |
|------|--------|------|------|
| Claude CLI | ⚠️ 部分 | 高 | 改用API或混合方案 |
| 智能路由 | ✅ 可行 | 低 | 启发式规则足够 |
| MCP Server | ⚠️ 待定 | 中 | 延后或简化 |
| 其他组件 | ✅ 可行 | 低 | 技术栈成熟 |
---
## 推荐技术方案调整
### 方案A: 使用 Anthropic API推荐
**修改内容**:
- Agent运行时直接使用 `anthropic` SDK
- 去掉 Claude CLI 桥接模块
- TUI仅用于监控和管理
**优点**:
- ✅ 稳定可靠
- ✅ 开发速度快
- ✅ 易于测试和调试
- ✅ 完整的工具调用支持
**缺点**:
- ❌ 失去CLI的特殊能力如IDE集成
- ❌ 无法利用Claude Code的高级功能
**影响**:
- Phase 3Claude CLI集成工作量大幅减少
- 整体开发周期缩短约2周
---
### 方案B: 混合方案(平衡)
**实现方式**:
1. **日常任务**:使用 Anthropic API
- 企业微信/飞书消息处理
- 简单查询和对话
- 工具调用
2. **复杂编程**:手动调用 Claude CLI
- 在TUI中提供CLI快捷入口
- 用户主动选择使用CLI模式
- 结果可回传到知识库
**优点**:
- ✅ 保留CLI的编程能力
- ✅ API部分稳定可靠
- ✅ 用户可按需选择
**缺点**:
- ⚠️ 体验不够无缝
- ⚠️ 需要手动切换
---
### 方案C: Web TUI集成用户建议 ✅ 推荐)
**实现方式**:
1. **简单任务**:使用 Anthropic API
- 企业微信/飞书消息处理
- 日常查询、简单操作
- API稳定高效
2. **复杂编程任务**Web TUI + SSH + Claude CLI
```
消息提示 "请在Web TUI处理"
用户打开Web终端页面
xterm.js显示终端界面
WebSocket连接后端
paramiko SSH连接localhost
直接运行 claude 命令
用户在浏览器中与Claude交互
```
**核心优势**:
- ✅ **完全避开subprocess问题** - 不做进程间通信
- ✅ **保留CLI完整能力** - 用户直接操作,无限制
- ✅ **实现简单** - xterm.js成熟稳定
- ✅ **体验自然** - 像使用SSH工具一样
- ✅ **权限隔离** - SSH本身就是隔离机制
- ✅ **跨平台** - 浏览器即可访问
**技术栈**:
- 前端xterm.jsWeb终端模拟器
- 通信WebSocket双向I/O
- 后端paramikoPython SSH库
- 连接SSH localhost
**影响**:
- Phase 3工作量适中约5-7天
- 无subprocess调试成本
- 用户体验最优
---
## 下一步建议
### 立即行动
1. **✅ 采用方案CWeb TUI集成**
- 简单任务用Anthropic API
- 复杂任务用Web TUI + SSH + Claude CLI
- 避免所有subprocess问题
- 保留完整CLI能力
2. **开始 Phase 0: 项目初始化**
- 创建项目结构
- 配置开发环境
- 设置依赖管理
3. **✅ 设计文档已更新**
- 技术栈已调整
- Phase 3为Web TUI集成
- 时间估算已优化
### 风险管理
**已降低的风险**:
- ✅ 智能路由效果 → 85.7%准确率,风险低
- ✅ Python技术栈 → 成熟可靠
**需要关注的风险**:
- ⚠️ MCP Server集成 → 建议延后
- ⚠️ 性能优化 → 需要后期压力测试
---
## 资源和时间重估
### 方案CWeb TUI集成✅ 采用
| Phase | 原估算 | 新估算 | 变化 | 说明 |
|-------|--------|--------|------|------|
| Phase 0 | 2-3天 | 2-3天 | 不变 | 项目初始化 |
| Phase 1 | 14-21天 | 14-21天 | 不变 | Gateway + 通讯 |
| Phase 2 | 14-21天 | **10-14天** | ⬇️ 简化 | 暂不做MCP/RAG |
| Phase 3 | 12-17天 | **7-10天** | ⬇️ 减少 | Web TUI + API |
| Phase 4 | 10-14天 | 10-14天 | 不变 | 高级特性 |
| Phase 5 | 7-10天 | 7-10天 | 不变 | 生产就绪 |
**新总计**: 52-81天优化约7-12天
**技术债务降低**:
- ❌ 无subprocess调试成本
- ❌ 无MCP集成复杂度可选
- ❌ 无RAG初期成本可选
- ✅ Web TUI成熟方案
---
## 附录PoC 验证文件
- `poc/router_test/poc_2_heuristic.py` - 智能路由测试(✅ 通过)
- `poc/claude_cli_test/poc_1_basic.py` - CLI基础测试 问题)
- `poc/mcp_test/poc_3_basic.py` - MCP连接测试 未完成)
---
**PoC验证完成时间**: 2025-02-04 16:55
**方案确定时间**: 2025-02-04 17:10
**采用方案**: ✅ 方案C - Web TUI集成
**下一步**: 开始Phase 0项目初始化

1090
PoC验证.md Normal file

File diff suppressed because it is too large Load Diff

149
README.md Normal file
View File

@@ -0,0 +1,149 @@
# MineNASAI
基于 NAS 的智能个人 AI 助理,支持企业微信/飞书通讯,集成 Claude 编程能力。
## 特性
- **多渠道通讯**: 企业微信、飞书接入
- **智能路由**: 自动识别任务复杂度,选择最优处理方式
- **双界面模式**:
- 通讯工具:日常交互、简单任务
- Web TUI深度编程、复杂项目
- **安全隔离**: Python 沙箱执行、权限分级
- **可扩展**: 支持 MCP Server 插件
## 快速开始
### 环境要求
- Python 3.11+
- Redis (可选,用于任务队列)
- SSH 服务 (Web TUI 需要)
### 安装
```bash
# 克隆项目
git clone https://github.com/minenasai/minenasai.git
cd minenasai
# 创建虚拟环境
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
# 安装依赖
pip install -e ".[dev]"
# 安装 pre-commit 钩子
pre-commit install
```
### 配置
```bash
# 复制环境变量模板
cp .env.example .env
# 编辑 .env 文件,填入 API Key 等配置
# ANTHROPIC_API_KEY=sk-ant-xxxxx
# 初始化配置文件
minenasai config --init
```
### 运行
```bash
# 启动 Gateway 服务
minenasai server --port 8000
# 启动 Web TUI 服务(另一个终端)
minenasai webtui --port 8080
```
## 项目结构
```
minenasai/
├── src/minenasai/
│ ├── core/ # 核心模块(配置、日志)
│ ├── gateway/ # Gateway 服务
│ │ ├── protocol/ # 消息协议
│ │ └── channels/ # 通讯渠道(企业微信、飞书)
│ ├── agent/ # Agent 运行时
│ │ └── tools/ # 内置工具
│ └── webtui/ # Web TUI 界面
│ └── static/ # 前端静态文件
├── tests/ # 测试用例
├── config/ # 配置文件模板
└── docs/ # 文档
```
## 架构概述
```
┌─────────────────────────────────────────────────────────────┐
│ 通讯接入层 (Channels) │
│ 企业微信Bot 飞书Bot Web UI │
└────────────────────────────┬────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────┐
│ Gateway 服务 (FastAPI) │
│ WebSocket协议 / 消息队列 / 权限验证 │
└────────────────────────────┬────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────┐
│ 智能路由层 (SmartRouter) │
│ 任务复杂度评估 / 单/多Agent决策 │
└────────────────────────────┬────────────────────────────────┘
┌───────────────────┼───────────────────┐
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 快速执行通道 │ │ Anthropic │ │ Web TUI │
│ Python沙箱 │ │ API │ │ SSH+Claude │
└─────────────┘ └─────────────┘ └─────────────┘
```
## 开发
### 代码规范
```bash
# 格式化代码
ruff format src tests
# 检查代码
ruff check src tests
# 类型检查
mypy src
```
### 测试
```bash
# 运行测试
pytest
# 带覆盖率
pytest --cov=minenasai
```
## 配置说明
配置文件位置: `~/.config/minenasai/config.json5`
主要配置项:
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `gateway.port` | Gateway 端口 | 8000 |
| `webtui.port` | Web TUI 端口 | 8080 |
| `agents.default_model` | 默认模型 | claude-sonnet-4-20250514 |
| `router.mode` | 路由模式 | agent |
## 许可证
MIT License

131
config/config.json5 Normal file
View File

@@ -0,0 +1,131 @@
{
// MineNASAI 配置文件
// 使用 JSON5 格式,支持注释
// 基础配置
"app": {
"name": "MineNASAI",
"version": "0.1.0",
"debug": false,
"timezone": "Asia/Shanghai"
},
// Agent 配置
"agents": {
"default_model": "claude-sonnet-4-20250514",
"max_tokens": 8192,
"temperature": 0.7,
"list": [
{
"id": "main",
"name": "主Agent",
"workspace_path": "~/.config/minenasai/workspace",
"tools": {
"allow": ["read", "write", "python", "web_search"],
"deny": ["delete", "system_config"]
},
"sandbox": {
"mode": "workspace",
"allowed_paths": ["~/.config/minenasai/workspace"]
}
}
]
},
// Gateway 配置
"gateway": {
"host": "0.0.0.0",
"port": 8000,
"websocket_path": "/ws",
"cors_origins": ["*"]
},
// 通讯渠道配置
"channels": {
"wework": {
"enabled": false,
"webhook_path": "/webhook/wework"
},
"feishu": {
"enabled": false,
"webhook_path": "/webhook/feishu"
}
},
// Web TUI 配置
"webtui": {
"enabled": true,
"host": "0.0.0.0",
"port": 8080,
"session_timeout": 3600,
"ssh": {
"host": "localhost",
"port": 22,
"connect_timeout": 10
}
},
// 智能路由配置
"router": {
"mode": "agent", // "agent" 或 "heuristic"
"thresholds": {
"simple_max_tokens": 100,
"complex_min_tools": 2
}
},
// 存储配置
"storage": {
"database_path": "~/.config/minenasai/database.db",
"sessions_path": "~/.config/minenasai/sessions",
"logs_path": "~/.config/minenasai/logs"
},
// 日志配置
"logging": {
"level": "INFO",
"format": "json",
"console": true,
"file": true,
"audit": {
"enabled": true,
"path": "~/.config/minenasai/logs/audit.jsonl"
}
},
// 代理配置(境外 API 使用)
"proxy": {
"enabled": true,
"http": "http://127.0.0.1:7890",
"https": "http://127.0.0.1:7890",
"no_proxy": ["localhost", "127.0.0.1", ".local"],
"auto_detect": true,
"fallback_ports": [7890, 7891, 1080, 1087, 10808]
},
// LLM 多模型配置
"llm": {
"default_provider": "anthropic",
"default_model": "claude-sonnet-4-20250514",
// API Keys 建议在 .env 中配置,此处留空
"anthropic_api_key": "",
"openai_api_key": "",
"deepseek_api_key": "",
"zhipu_api_key": "",
"minimax_api_key": "",
"moonshot_api_key": "",
"gemini_api_key": ""
},
// 安全配置
"security": {
"danger_levels": {
"safe": ["read", "list", "search"],
"low": ["write", "python"],
"medium": ["exec", "docker"],
"high": ["delete", "chmod"],
"critical": ["format", "rm_rf"]
},
"require_confirmation": ["high", "critical"]
}
}

143
pyproject.toml Normal file
View File

@@ -0,0 +1,143 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "minenasai"
version = "0.1.0"
description = "基于NAS的智能个人AI助理"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
authors = [
{ name = "MineNASAI Team" }
]
keywords = ["ai", "assistant", "nas", "claude", "automation"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
# Web框架
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"websockets>=12.0",
# Anthropic API
"anthropic>=0.18.0",
# 数据处理
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
# 数据库
"aiosqlite>=0.19.0",
# 任务队列
"celery>=5.3.0",
"redis>=5.0.0",
# SSH (Web TUI)
"paramiko>=3.4.0",
# 配置
"python-dotenv>=1.0.0",
"json5>=0.9.14",
# 日志
"structlog>=24.1.0",
# 工具
"httpx>=0.26.0",
"aiofiles>=23.2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"ruff>=0.2.0",
"mypy>=1.8.0",
"pre-commit>=3.6.0",
"types-redis>=4.6.0",
"types-paramiko>=3.4.0",
]
mcp = [
"mcp>=1.0.0",
]
rag = [
"qdrant-client>=1.7.0",
]
[project.scripts]
minenasai = "minenasai.cli:main"
[project.urls]
Homepage = "https://github.com/minenasai/minenasai"
Documentation = "https://github.com/minenasai/minenasai#readme"
Repository = "https://github.com/minenasai/minenasai"
[tool.hatch.build.targets.wheel]
packages = ["src/minenasai"]
[tool.ruff]
target-version = "py311"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # function call in default argument
"B904", # raise without from inside except
]
[tool.ruff.lint.isort]
known-first-party = ["minenasai"]
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
[tool.coverage.run]
source = ["src/minenasai"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]

View File

@@ -0,0 +1,3 @@
"""MineNASAI - 基于NAS的智能个人AI助理"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,31 @@
"""Agent 模块
提供 Agent 运行时、工具管理、会话管理
"""
from minenasai.agent.runtime import AgentRuntime, get_agent_runtime
from minenasai.agent.tools.basic import get_basic_tools
from minenasai.agent.permissions import (
DangerLevel,
PermissionManager,
get_permission_manager,
)
from minenasai.agent.tool_registry import (
ToolRegistry,
get_tool_registry,
register_builtin_tools,
tool,
)
__all__ = [
"AgentRuntime",
"get_agent_runtime",
"get_basic_tools",
"DangerLevel",
"PermissionManager",
"get_permission_manager",
"ToolRegistry",
"get_tool_registry",
"register_builtin_tools",
"tool",
]

View File

@@ -0,0 +1,382 @@
"""权限控制模块
提供工具执行的权限检查和确认机制
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Coroutine
from minenasai.core import get_logger, get_settings
logger = get_logger(__name__)
class DangerLevel(str, Enum):
"""危险等级"""
SAFE = "safe" # 只读操作,无风险
LOW = "low" # 低风险,写入工作目录
MEDIUM = "medium" # 中风险,系统命令
HIGH = "high" # 高风险,删除/修改重要文件
CRITICAL = "critical" # 极高危,系统级操作
class ConfirmationStatus(str, Enum):
"""确认状态"""
PENDING = "pending"
APPROVED = "approved"
DENIED = "denied"
TIMEOUT = "timeout"
@dataclass
class ToolPermission:
"""工具权限定义"""
name: str
danger_level: DangerLevel
description: str = ""
requires_confirmation: bool = False
allowed_paths: list[str] = field(default_factory=list)
denied_paths: list[str] = field(default_factory=list)
rate_limit: int | None = None # 每分钟最大调用次数
@dataclass
class ConfirmationRequest:
"""确认请求"""
id: str
tool_name: str
params: dict[str, Any]
danger_level: DangerLevel
description: str
status: ConfirmationStatus = ConfirmationStatus.PENDING
created_at: float = 0.0
timeout: float = 300.0 # 5分钟超时
callback: Callable[[bool], Coroutine[Any, Any, None]] | None = None
class PermissionManager:
"""权限管理器"""
# 默认工具权限
DEFAULT_PERMISSIONS: dict[str, ToolPermission] = {
# 安全工具
"read_file": ToolPermission(
name="read_file",
danger_level=DangerLevel.SAFE,
description="读取文件内容",
),
"list_directory": ToolPermission(
name="list_directory",
danger_level=DangerLevel.SAFE,
description="列出目录内容",
),
"python_eval": ToolPermission(
name="python_eval",
danger_level=DangerLevel.LOW,
description="执行 Python 表达式",
),
"web_search": ToolPermission(
name="web_search",
danger_level=DangerLevel.SAFE,
description="网络搜索",
),
# 低风险工具
"write_file": ToolPermission(
name="write_file",
danger_level=DangerLevel.LOW,
description="写入文件",
),
"create_directory": ToolPermission(
name="create_directory",
danger_level=DangerLevel.LOW,
description="创建目录",
),
# 中风险工具
"execute_command": ToolPermission(
name="execute_command",
danger_level=DangerLevel.MEDIUM,
description="执行系统命令",
requires_confirmation=False,
),
"docker_run": ToolPermission(
name="docker_run",
danger_level=DangerLevel.MEDIUM,
description="运行 Docker 容器",
),
# 高风险工具
"delete_file": ToolPermission(
name="delete_file",
danger_level=DangerLevel.HIGH,
description="删除文件",
requires_confirmation=True,
),
"modify_permissions": ToolPermission(
name="modify_permissions",
danger_level=DangerLevel.HIGH,
description="修改文件权限",
requires_confirmation=True,
),
# 极高危工具
"system_shutdown": ToolPermission(
name="system_shutdown",
danger_level=DangerLevel.CRITICAL,
description="关闭系统",
requires_confirmation=True,
),
"format_disk": ToolPermission(
name="format_disk",
danger_level=DangerLevel.CRITICAL,
description="格式化磁盘",
requires_confirmation=True,
),
}
def __init__(self) -> None:
self.settings = get_settings()
self._permissions: dict[str, ToolPermission] = dict(self.DEFAULT_PERMISSIONS)
self._pending_confirmations: dict[str, ConfirmationRequest] = {}
self._tool_call_counts: dict[str, list[float]] = {} # tool -> [timestamps]
# 从配置加载确认策略
self._confirm_levels = set(
DangerLevel(level)
for level in self.settings.security.require_confirmation
)
def register_tool(self, permission: ToolPermission) -> None:
"""注册工具权限
Args:
permission: 工具权限定义
"""
self._permissions[permission.name] = permission
logger.debug("注册工具权限", tool=permission.name, level=permission.danger_level)
def get_permission(self, tool_name: str) -> ToolPermission | None:
"""获取工具权限"""
return self._permissions.get(tool_name)
def check_permission(
self,
tool_name: str,
params: dict[str, Any] | None = None,
user_id: str | None = None,
) -> tuple[bool, str]:
"""检查工具执行权限
Args:
tool_name: 工具名称
params: 执行参数
user_id: 用户 ID
Returns:
(是否允许, 原因)
"""
permission = self._permissions.get(tool_name)
if permission is None:
# 未注册的工具,默认禁止
return False, f"未知工具: {tool_name}"
# 检查速率限制
if permission.rate_limit:
if not self._check_rate_limit(tool_name, permission.rate_limit):
return False, f"速率限制: {tool_name}"
# 检查路径限制
if params and "path" in params:
path = params["path"]
# 检查禁止路径
for denied in permission.denied_paths:
if path.startswith(denied):
return False, f"禁止访问路径: {path}"
# 检查允许路径(如果设置了)
if permission.allowed_paths:
allowed = any(path.startswith(p) for p in permission.allowed_paths)
if not allowed:
return False, f"路径不在允许范围: {path}"
return True, "允许"
def _check_rate_limit(self, tool_name: str, limit: int) -> bool:
"""检查速率限制"""
import time
now = time.time()
minute_ago = now - 60
# 获取并清理调用记录
calls = self._tool_call_counts.get(tool_name, [])
calls = [t for t in calls if t > minute_ago]
self._tool_call_counts[tool_name] = calls
if len(calls) >= limit:
return False
calls.append(now)
return True
def requires_confirmation(self, tool_name: str) -> bool:
"""检查是否需要确认"""
permission = self._permissions.get(tool_name)
if permission is None:
return True # 未知工具需要确认
# 检查工具定义的确认要求
if permission.requires_confirmation:
return True
# 检查全局确认策略
return permission.danger_level in self._confirm_levels
async def request_confirmation(
self,
request_id: str,
tool_name: str,
params: dict[str, Any],
description: str | None = None,
timeout: float = 300.0,
) -> ConfirmationRequest:
"""请求用户确认
Args:
request_id: 请求 ID
tool_name: 工具名称
params: 执行参数
description: 操作描述
timeout: 超时时间(秒)
Returns:
确认请求对象
"""
import time
permission = self._permissions.get(tool_name)
danger_level = permission.danger_level if permission else DangerLevel.HIGH
if description is None:
description = f"执行 {tool_name}"
if permission:
description = permission.description
request = ConfirmationRequest(
id=request_id,
tool_name=tool_name,
params=params,
danger_level=danger_level,
description=description,
created_at=time.time(),
timeout=timeout,
)
self._pending_confirmations[request_id] = request
logger.info("创建确认请求", request_id=request_id, tool=tool_name)
return request
async def wait_for_confirmation(
self,
request_id: str,
timeout: float | None = None,
) -> ConfirmationStatus:
"""等待确认结果
Args:
request_id: 请求 ID
timeout: 超时时间
Returns:
确认状态
"""
import time
request = self._pending_confirmations.get(request_id)
if request is None:
return ConfirmationStatus.DENIED
if timeout is None:
timeout = request.timeout
start = time.time()
while time.time() - start < timeout:
if request.status != ConfirmationStatus.PENDING:
return request.status
await asyncio.sleep(0.5)
# 超时
request.status = ConfirmationStatus.TIMEOUT
return ConfirmationStatus.TIMEOUT
def approve_confirmation(self, request_id: str) -> bool:
"""批准确认请求"""
request = self._pending_confirmations.get(request_id)
if request and request.status == ConfirmationStatus.PENDING:
request.status = ConfirmationStatus.APPROVED
logger.info("确认请求已批准", request_id=request_id)
return True
return False
def deny_confirmation(self, request_id: str) -> bool:
"""拒绝确认请求"""
request = self._pending_confirmations.get(request_id)
if request and request.status == ConfirmationStatus.PENDING:
request.status = ConfirmationStatus.DENIED
logger.info("确认请求已拒绝", request_id=request_id)
return True
return False
def get_pending_confirmations(self) -> list[ConfirmationRequest]:
"""获取待处理的确认请求"""
import time
now = time.time()
pending = []
for request in self._pending_confirmations.values():
if request.status == ConfirmationStatus.PENDING:
if now - request.created_at < request.timeout:
pending.append(request)
return pending
def cleanup_expired(self) -> int:
"""清理过期的确认请求"""
import time
now = time.time()
expired = [
rid
for rid, req in self._pending_confirmations.items()
if now - req.created_at >= req.timeout
]
for rid in expired:
req = self._pending_confirmations.pop(rid, None)
if req and req.status == ConfirmationStatus.PENDING:
req.status = ConfirmationStatus.TIMEOUT
return len(expired)
# 全局权限管理器
_permission_manager: PermissionManager | None = None
def get_permission_manager() -> PermissionManager:
"""获取全局权限管理器"""
global _permission_manager
if _permission_manager is None:
_permission_manager = PermissionManager()
return _permission_manager

View File

@@ -0,0 +1,344 @@
"""Agent 运行时
管理 Agent 的执行、工具调用和对话流程
"""
from __future__ import annotations
import time
from typing import Any, AsyncIterator
from minenasai.core import get_audit_logger, get_logger, get_settings
from minenasai.core.session_store import get_session_store
from minenasai.llm import LLMManager, get_llm_manager
from minenasai.llm.base import Message, Provider, ToolDefinition
logger = get_logger(__name__)
class AgentRuntime:
"""Agent 运行时
负责:
- 管理 Agent 的执行上下文
- 调用多 LLM API
- 执行工具调用
- 管理对话历史
"""
def __init__(
self,
agent_id: str = "main",
provider: Provider | str | None = None,
model: str | None = None,
system_prompt: str | None = None,
) -> None:
"""初始化 Agent 运行时
Args:
agent_id: Agent ID
provider: LLM 提供商
model: 模型名称
system_prompt: 系统提示词
"""
self.agent_id = agent_id
self.settings = get_settings()
self.llm_manager = get_llm_manager()
# 解析提供商
if isinstance(provider, str):
self.provider = Provider(provider)
elif provider is not None:
self.provider = provider
else:
# 使用配置的默认提供商
try:
self.provider = Provider(self.settings.llm.default_provider)
except ValueError:
self.provider = None # type: ignore
self.model = model or self.settings.llm.default_model
self.system_prompt = system_prompt or self._default_system_prompt()
self.session_store = get_session_store()
self.audit_logger = get_audit_logger()
self._tools: dict[str, Any] = {}
def _default_system_prompt(self) -> str:
"""默认系统提示词"""
return """你是 MineNASAI一个运行在 NAS 上的智能个人助理。
你的能力:
- 回答问题和提供信息
- 执行文件操作(读取、搜索)
- 运行 Python 代码进行计算和验证
- 执行系统命令(在安全范围内)
注意事项:
- 对于复杂的编程任务,建议用户使用 Web TUI 界面
- 执行危险操作前需要用户确认
- 保持简洁、专业的回复风格
"""
def set_provider(self, provider: Provider | str, model: str | None = None) -> None:
"""切换 LLM 提供商
Args:
provider: 提供商
model: 模型名称(可选)
"""
if isinstance(provider, str):
provider = Provider(provider)
self.provider = provider
if model:
self.model = model
def get_available_providers(self) -> list[Provider]:
"""获取可用的提供商列表"""
return self.llm_manager.get_available_providers()
def register_tool(self, name: str, func: Any, schema: dict[str, Any]) -> None:
"""注册工具
Args:
name: 工具名称
func: 工具函数
schema: 工具 schema
"""
self._tools[name] = {
"func": func,
"schema": schema,
}
logger.debug("工具已注册", tool_name=name)
def get_tools_schema(self) -> list[ToolDefinition]:
"""获取所有工具的 schema"""
return [
ToolDefinition(
name=name,
description=tool["schema"].get("description", ""),
parameters=tool["schema"].get("input_schema", {}),
)
for name, tool in self._tools.items()
]
async def execute_tool(
self,
tool_name: str,
params: dict[str, Any],
) -> dict[str, Any]:
"""执行工具
Args:
tool_name: 工具名称
params: 工具参数
Returns:
执行结果
"""
if tool_name not in self._tools:
return {
"success": False,
"error": f"未知工具: {tool_name}",
}
tool = self._tools[tool_name]
start_time = time.time()
try:
result = await tool["func"](**params)
duration_ms = int((time.time() - start_time) * 1000)
# 记录审计日志
self.audit_logger.log_tool_call(
agent_id=self.agent_id,
tool_name=tool_name,
params=params,
danger_level="safe", # TODO: 从工具定义获取
result=str(result)[:1000],
duration_ms=duration_ms,
)
return {
"success": True,
"result": result,
"duration_ms": duration_ms,
}
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
self.audit_logger.log_tool_call(
agent_id=self.agent_id,
tool_name=tool_name,
params=params,
danger_level="safe",
error=str(e),
duration_ms=duration_ms,
)
return {
"success": False,
"error": str(e),
"duration_ms": duration_ms,
}
async def chat(
self,
session_id: str,
user_message: str,
stream: bool = False,
provider: Provider | str | None = None,
model: str | None = None,
) -> str | AsyncIterator[str]:
"""处理聊天消息
Args:
session_id: 会话 ID
user_message: 用户消息
stream: 是否流式输出
provider: 临时指定提供商
model: 临时指定模型
Returns:
响应内容或流式迭代器
"""
# 保存用户消息
self.session_store.append_message(
session_id=session_id,
role="user",
content=user_message,
)
# 获取对话历史并转换为 Message 格式
history = self.session_store.get_conversation_for_api(session_id)
messages = [
Message(
role=msg.get("role", "user"),
content=msg.get("content", ""),
)
for msg in history
]
# 确定使用的提供商和模型
use_provider = provider or self.provider
use_model = model or self.model
try:
if stream:
return self._stream_response(session_id, messages, use_provider, use_model)
else:
return await self._sync_response(session_id, messages, use_provider, use_model)
except Exception as e:
logger.error("API 调用失败", error=str(e), provider=str(use_provider))
error_msg = f"抱歉,处理请求时出错: {str(e)}"
self.session_store.append_message(
session_id=session_id,
role="assistant",
content=error_msg,
)
return error_msg
async def _sync_response(
self,
session_id: str,
messages: list[Message],
provider: Provider | str | None,
model: str | None,
) -> str:
"""同步响应"""
tools = self.get_tools_schema() if self._tools else None
response = await self.llm_manager.chat(
messages=messages,
provider=provider,
model=model,
system=self.system_prompt,
max_tokens=self.settings.agents.max_tokens,
tools=tools,
)
content = response.content
# 保存助手消息
tool_calls_data = None
if response.tool_calls:
tool_calls_data = [
{"id": tc.id, "name": tc.name, "input": tc.arguments}
for tc in response.tool_calls
]
self.session_store.append_message(
session_id=session_id,
role="assistant",
content=content,
tool_calls=tool_calls_data,
)
# 处理工具调用
if response.tool_calls:
tool_results = []
for tc in response.tool_calls:
result = await self.execute_tool(tc.name, tc.arguments)
tool_results.append({
"tool_use_id": tc.id,
"content": str(result.get("result", result.get("error", ""))),
})
# 保存工具结果
self.session_store.append_message(
session_id=session_id,
role="user",
tool_results=tool_results,
)
# 继续对话获取最终响应
history = self.session_store.get_conversation_for_api(session_id)
new_messages = [
Message(role=msg.get("role", "user"), content=msg.get("content", ""))
for msg in history
]
return await self._sync_response(session_id, new_messages, provider, model)
return content
async def _stream_response(
self,
session_id: str,
messages: list[Message],
provider: Provider | str | None,
model: str | None,
) -> AsyncIterator[str]:
"""流式响应"""
full_content = ""
async for chunk in self.llm_manager.chat_stream(
messages=messages,
provider=provider,
model=model,
system=self.system_prompt,
max_tokens=self.settings.agents.max_tokens,
):
full_content += chunk.content
yield chunk.content
# 保存完整响应
self.session_store.append_message(
session_id=session_id,
role="assistant",
content=full_content,
)
# 全局 Agent 运行时
_runtime: AgentRuntime | None = None
def get_agent_runtime() -> AgentRuntime:
"""获取全局 Agent 运行时"""
global _runtime
if _runtime is None:
_runtime = AgentRuntime()
return _runtime

View File

@@ -0,0 +1,409 @@
"""工具注册中心
统一管理所有可用工具
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass, field
from typing import Any, Callable, Coroutine, get_type_hints
from minenasai.core import get_logger
from minenasai.agent.permissions import DangerLevel, ToolPermission, get_permission_manager
from minenasai.llm.base import ToolDefinition
logger = get_logger(__name__)
@dataclass
class RegisteredTool:
"""已注册的工具"""
name: str
description: str
func: Callable[..., Coroutine[Any, Any, Any]]
parameters: dict[str, Any]
danger_level: DangerLevel = DangerLevel.SAFE
requires_confirmation: bool = False
category: str = "general"
enabled: bool = True
metadata: dict[str, Any] = field(default_factory=dict)
def tool(
name: str | None = None,
description: str = "",
danger_level: DangerLevel = DangerLevel.SAFE,
requires_confirmation: bool = False,
category: str = "general",
) -> Callable[
[Callable[..., Coroutine[Any, Any, Any]]],
Callable[..., Coroutine[Any, Any, Any]],
]:
"""工具装饰器
使用方式:
@tool(name="read_file", description="读取文件内容")
async def read_file(path: str) -> dict:
...
Args:
name: 工具名称(默认使用函数名)
description: 工具描述
danger_level: 危险等级
requires_confirmation: 是否需要确认
category: 工具分类
"""
def decorator(
func: Callable[..., Coroutine[Any, Any, Any]]
) -> Callable[..., Coroutine[Any, Any, Any]]:
tool_name = name or func.__name__
tool_desc = description or func.__doc__ or ""
# 提取参数 schema
parameters = _extract_parameters(func)
# 注册到全局注册中心
registry = get_tool_registry()
registry.register(
name=tool_name,
description=tool_desc,
func=func,
parameters=parameters,
danger_level=danger_level,
requires_confirmation=requires_confirmation,
category=category,
)
# 标记函数元数据
func._tool_name = tool_name # type: ignore
func._tool_registered = True # type: ignore
return func
return decorator
def _extract_parameters(func: Callable[..., Any]) -> dict[str, Any]:
"""从函数签名提取参数 schema"""
sig = inspect.signature(func)
try:
hints = get_type_hints(func)
except Exception:
hints = {}
properties: dict[str, Any] = {}
required: list[str] = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "cls"):
continue
prop: dict[str, Any] = {}
# 类型推断
hint = hints.get(param_name)
if hint is not None:
prop["type"] = _type_to_json_type(hint)
else:
prop["type"] = "string"
# 描述(从 docstring 或参数默认值注释)
prop["description"] = f"参数 {param_name}"
# 必填判断
if param.default is inspect.Parameter.empty:
required.append(param_name)
else:
prop["default"] = param.default
properties[param_name] = prop
return {
"type": "object",
"properties": properties,
"required": required,
}
def _type_to_json_type(hint: Any) -> str:
"""Python 类型转 JSON Schema 类型"""
type_map = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
list: "array",
dict: "object",
}
# 处理 Optional/Union
origin = getattr(hint, "__origin__", None)
if origin is not None:
# List[X] -> array
if origin is list:
return "array"
# Dict[K, V] -> object
if origin is dict:
return "object"
return type_map.get(hint, "string")
class ToolRegistry:
"""工具注册中心"""
def __init__(self) -> None:
self._tools: dict[str, RegisteredTool] = {}
self._categories: dict[str, list[str]] = {}
def register(
self,
name: str,
description: str,
func: Callable[..., Coroutine[Any, Any, Any]],
parameters: dict[str, Any],
danger_level: DangerLevel = DangerLevel.SAFE,
requires_confirmation: bool = False,
category: str = "general",
metadata: dict[str, Any] | None = None,
) -> RegisteredTool:
"""注册工具
Args:
name: 工具名称
description: 工具描述
func: 工具函数
parameters: 参数 schema
danger_level: 危险等级
requires_confirmation: 是否需要确认
category: 分类
metadata: 元数据
Returns:
RegisteredTool 对象
"""
tool_obj = RegisteredTool(
name=name,
description=description,
func=func,
parameters=parameters,
danger_level=danger_level,
requires_confirmation=requires_confirmation,
category=category,
metadata=metadata or {},
)
self._tools[name] = tool_obj
# 添加到分类
if category not in self._categories:
self._categories[category] = []
if name not in self._categories[category]:
self._categories[category].append(name)
# 注册权限
perm_manager = get_permission_manager()
perm_manager.register_tool(
ToolPermission(
name=name,
danger_level=danger_level,
description=description,
requires_confirmation=requires_confirmation,
)
)
logger.debug(
"注册工具",
name=name,
category=category,
danger_level=danger_level.value,
)
return tool_obj
def unregister(self, name: str) -> bool:
"""注销工具"""
tool_obj = self._tools.pop(name, None)
if tool_obj:
category = tool_obj.category
if category in self._categories:
self._categories[category] = [
n for n in self._categories[category] if n != name
]
return True
return False
def get(self, name: str) -> RegisteredTool | None:
"""获取工具"""
return self._tools.get(name)
def list_tools(
self,
category: str | None = None,
enabled_only: bool = True,
) -> list[RegisteredTool]:
"""列出工具
Args:
category: 按分类筛选
enabled_only: 只返回启用的工具
Returns:
工具列表
"""
tools = self._tools.values()
if category:
tools = [t for t in tools if t.category == category]
if enabled_only:
tools = [t for t in tools if t.enabled]
return list(tools)
def list_categories(self) -> list[str]:
"""列出所有分类"""
return list(self._categories.keys())
def get_tools_for_llm(
self,
category: str | None = None,
max_danger_level: DangerLevel = DangerLevel.MEDIUM,
) -> list[ToolDefinition]:
"""获取 LLM 可用的工具定义
Args:
category: 分类筛选
max_danger_level: 最大危险等级
Returns:
ToolDefinition 列表
"""
danger_order = [
DangerLevel.SAFE,
DangerLevel.LOW,
DangerLevel.MEDIUM,
DangerLevel.HIGH,
DangerLevel.CRITICAL,
]
max_index = danger_order.index(max_danger_level)
allowed_levels = set(danger_order[: max_index + 1])
tools = self.list_tools(category=category, enabled_only=True)
tools = [t for t in tools if t.danger_level in allowed_levels]
return [
ToolDefinition(
name=t.name,
description=t.description,
parameters=t.parameters,
)
for t in tools
]
async def execute(
self,
name: str,
params: dict[str, Any],
check_permission: bool = True,
) -> dict[str, Any]:
"""执行工具
Args:
name: 工具名称
params: 执行参数
check_permission: 是否检查权限
Returns:
执行结果
"""
tool_obj = self._tools.get(name)
if tool_obj is None:
return {"error": f"未知工具: {name}"}
if not tool_obj.enabled:
return {"error": f"工具已禁用: {name}"}
# 权限检查
if check_permission:
perm_manager = get_permission_manager()
allowed, reason = perm_manager.check_permission(name, params)
if not allowed:
return {"error": f"权限拒绝: {reason}"}
# 执行
try:
result = await tool_obj.func(**params)
return {"success": True, "result": result}
except TypeError as e:
return {"error": f"参数错误: {e}"}
except Exception as e:
logger.error("工具执行失败", tool=name, error=str(e))
return {"error": str(e)}
def enable_tool(self, name: str) -> bool:
"""启用工具"""
tool_obj = self._tools.get(name)
if tool_obj:
tool_obj.enabled = True
return True
return False
def disable_tool(self, name: str) -> bool:
"""禁用工具"""
tool_obj = self._tools.get(name)
if tool_obj:
tool_obj.enabled = False
return True
return False
def get_stats(self) -> dict[str, Any]:
"""获取统计信息"""
return {
"total_tools": len(self._tools),
"enabled_tools": sum(1 for t in self._tools.values() if t.enabled),
"categories": {
cat: len(tools) for cat, tools in self._categories.items()
},
"by_danger_level": {
level.value: sum(
1 for t in self._tools.values() if t.danger_level == level
)
for level in DangerLevel
},
}
# 全局注册中心
_registry: ToolRegistry | None = None
def get_tool_registry() -> ToolRegistry:
"""获取全局工具注册中心"""
global _registry
if _registry is None:
_registry = ToolRegistry()
return _registry
def register_builtin_tools() -> None:
"""注册内置工具"""
from minenasai.agent.tools.basic import get_basic_tools
registry = get_tool_registry()
for name, func, schema in get_basic_tools():
registry.register(
name=name,
description=schema.get("description", ""),
func=func,
parameters=schema.get("input_schema", {}),
danger_level=DangerLevel.LOW if "write" in name else DangerLevel.SAFE,
category="builtin",
)
logger.info("内置工具已注册", count=len(get_basic_tools()))

View File

@@ -0,0 +1,13 @@
"""内置工具集"""
from minenasai.agent.tools.basic import (
read_file_tool,
list_directory_tool,
python_eval_tool,
)
__all__ = [
"read_file_tool",
"list_directory_tool",
"python_eval_tool",
]

View File

@@ -0,0 +1,286 @@
"""基础工具集
提供文件读取、目录列表、Python 执行等基础工具
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from minenasai.core import get_logger
from minenasai.core.config import expand_path
logger = get_logger(__name__)
# 工具定义
READ_FILE_SCHEMA = {
"description": "读取文件内容",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径",
},
"max_lines": {
"type": "integer",
"description": "最大读取行数默认1000",
"default": 1000,
},
},
"required": ["path"],
},
}
LIST_DIRECTORY_SCHEMA = {
"description": "列出目录内容",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "目录路径",
},
"pattern": {
"type": "string",
"description": "文件名匹配模式glob",
"default": "*",
},
"recursive": {
"type": "boolean",
"description": "是否递归列出",
"default": False,
},
},
"required": ["path"],
},
}
PYTHON_EVAL_SCHEMA = {
"description": "执行 Python 表达式并返回结果(用于简单计算)",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Python 表达式",
},
},
"required": ["expression"],
},
}
async def read_file_tool(path: str, max_lines: int = 1000) -> dict[str, Any]:
"""读取文件内容
Args:
path: 文件路径
max_lines: 最大读取行数
Returns:
文件内容
"""
try:
file_path = expand_path(path)
if not file_path.exists():
return {"error": f"文件不存在: {path}"}
if not file_path.is_file():
return {"error": f"不是文件: {path}"}
# 检查文件大小
size = file_path.stat().st_size
if size > 10 * 1024 * 1024: # 10MB
return {"error": f"文件过大: {size} bytes"}
# 读取文件
lines = []
with open(file_path, encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f):
if i >= max_lines:
lines.append(f"\n... (截断,共 {i+1}+ 行)")
break
lines.append(line)
content = "".join(lines)
return {
"path": str(file_path),
"content": content,
"lines": len(lines),
"size": size,
}
except Exception as e:
logger.error("读取文件失败", path=path, error=str(e))
return {"error": str(e)}
async def list_directory_tool(
path: str,
pattern: str = "*",
recursive: bool = False,
) -> dict[str, Any]:
"""列出目录内容
Args:
path: 目录路径
pattern: 匹配模式
recursive: 是否递归
Returns:
目录内容列表
"""
try:
dir_path = expand_path(path)
if not dir_path.exists():
return {"error": f"目录不存在: {path}"}
if not dir_path.is_dir():
return {"error": f"不是目录: {path}"}
# 列出文件
if recursive:
files = list(dir_path.rglob(pattern))
else:
files = list(dir_path.glob(pattern))
# 限制数量
max_items = 500
truncated = len(files) > max_items
files = files[:max_items]
items = []
for f in files:
try:
stat = f.stat()
items.append({
"name": f.name,
"path": str(f),
"is_dir": f.is_dir(),
"size": stat.st_size if f.is_file() else None,
"modified": stat.st_mtime,
})
except OSError:
items.append({
"name": f.name,
"path": str(f),
"error": "无法访问",
})
return {
"path": str(dir_path),
"pattern": pattern,
"items": items,
"count": len(items),
"truncated": truncated,
}
except Exception as e:
logger.error("列出目录失败", path=path, error=str(e))
return {"error": str(e)}
# 安全的内置函数白名单
SAFE_BUILTINS = {
"abs": abs,
"all": all,
"any": any,
"bin": bin,
"bool": bool,
"chr": chr,
"dict": dict,
"divmod": divmod,
"enumerate": enumerate,
"filter": filter,
"float": float,
"format": format,
"hex": hex,
"int": int,
"isinstance": isinstance,
"len": len,
"list": list,
"map": map,
"max": max,
"min": min,
"oct": oct,
"ord": ord,
"pow": pow,
"print": print,
"range": range,
"repr": repr,
"reversed": reversed,
"round": round,
"set": set,
"sorted": sorted,
"str": str,
"sum": sum,
"tuple": tuple,
"type": type,
"zip": zip,
# 数学常量
"True": True,
"False": False,
"None": None,
}
async def python_eval_tool(expression: str) -> dict[str, Any]:
"""执行 Python 表达式
Args:
expression: Python 表达式
Returns:
执行结果
"""
try:
# 检查危险关键字
dangerous_keywords = [
"import", "exec", "eval", "compile", "open", "file",
"__", "getattr", "setattr", "delattr", "globals", "locals",
"vars", "dir", "breakpoint", "input",
]
expr_lower = expression.lower()
for kw in dangerous_keywords:
if kw in expr_lower:
return {"error": f"不允许使用 '{kw}'"}
# 添加数学模块
import math
safe_globals = {"__builtins__": SAFE_BUILTINS, "math": math}
# 执行表达式
result = eval(expression, safe_globals, {})
return {
"expression": expression,
"result": result,
"type": type(result).__name__,
}
except SyntaxError as e:
return {"error": f"语法错误: {e}"}
except Exception as e:
return {"error": str(e)}
# 工具注册信息
def get_basic_tools() -> list[tuple[str, Any, dict[str, Any]]]:
"""获取基础工具列表
Returns:
[(工具名, 工具函数, schema), ...]
"""
return [
("read_file", read_file_tool, READ_FILE_SCHEMA),
("list_directory", list_directory_tool, LIST_DIRECTORY_SCHEMA),
("python_eval", python_eval_tool, PYTHON_EVAL_SCHEMA),
]

168
src/minenasai/cli.py Normal file
View File

@@ -0,0 +1,168 @@
"""MineNASAI 命令行入口"""
from __future__ import annotations
import argparse
import sys
def main() -> int:
"""命令行主入口"""
parser = argparse.ArgumentParser(
prog="minenasai",
description="MineNASAI - 基于NAS的智能个人AI助理",
)
parser.add_argument(
"--version",
action="version",
version="%(prog)s 0.1.0",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# server 命令
server_parser = subparsers.add_parser("server", help="启动 Gateway 服务")
server_parser.add_argument(
"--host",
default="0.0.0.0",
help="监听地址 (默认: 0.0.0.0)",
)
server_parser.add_argument(
"--port",
type=int,
default=8000,
help="监听端口 (默认: 8000)",
)
server_parser.add_argument(
"--reload",
action="store_true",
help="开发模式,自动重载",
)
# webtui 命令
webtui_parser = subparsers.add_parser("webtui", help="启动 Web TUI 服务")
webtui_parser.add_argument(
"--host",
default="0.0.0.0",
help="监听地址 (默认: 0.0.0.0)",
)
webtui_parser.add_argument(
"--port",
type=int,
default=8080,
help="监听端口 (默认: 8080)",
)
# config 命令
config_parser = subparsers.add_parser("config", help="配置管理")
config_parser.add_argument(
"--init",
action="store_true",
help="初始化配置文件",
)
config_parser.add_argument(
"--show",
action="store_true",
help="显示当前配置",
)
args = parser.parse_args()
if args.command is None:
parser.print_help()
return 0
if args.command == "server":
return run_server(args)
elif args.command == "webtui":
return run_webtui(args)
elif args.command == "config":
return run_config(args)
return 0
def run_server(args: argparse.Namespace) -> int:
"""启动 Gateway 服务"""
try:
import uvicorn
from minenasai.core import get_settings, setup_logging
settings = get_settings()
setup_logging(settings.logging)
print(f"启动 Gateway 服务: http://{args.host}:{args.port}")
uvicorn.run(
"minenasai.gateway.server:app",
host=args.host,
port=args.port,
reload=args.reload,
)
return 0
except ImportError as e:
print(f"缺少依赖: {e}")
return 1
except Exception as e:
print(f"启动失败: {e}")
return 1
def run_webtui(args: argparse.Namespace) -> int:
"""启动 Web TUI 服务"""
try:
import uvicorn
from minenasai.core import get_settings, setup_logging
settings = get_settings()
setup_logging(settings.logging)
print(f"启动 Web TUI 服务: http://{args.host}:{args.port}")
uvicorn.run(
"minenasai.webtui.server:app",
host=args.host,
port=args.port,
)
return 0
except ImportError as e:
print(f"缺少依赖: {e}")
return 1
except Exception as e:
print(f"启动失败: {e}")
return 1
def run_config(args: argparse.Namespace) -> int:
"""配置管理"""
import json
from minenasai.core import get_settings
from minenasai.core.config import expand_path, save_config
if args.init:
settings = get_settings()
config_path = expand_path("~/.config/minenasai/config.json5")
if config_path.exists():
print(f"配置文件已存在: {config_path}")
return 1
save_config(settings, config_path)
print(f"配置文件已创建: {config_path}")
return 0
if args.show:
settings = get_settings()
print(json.dumps(settings.model_dump(), indent=2, ensure_ascii=False, default=str))
return 0
print("请指定 --init 或 --show")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,23 @@
"""核心模块
提供配置管理、日志系统、数据库等基础功能
"""
from minenasai.core.config import Settings, get_settings, load_config, reset_settings
from minenasai.core.logging import (
AuditLogger,
get_audit_logger,
get_logger,
setup_logging,
)
__all__ = [
"Settings",
"get_settings",
"load_config",
"reset_settings",
"setup_logging",
"get_logger",
"AuditLogger",
"get_audit_logger",
]

View File

@@ -0,0 +1,309 @@
"""配置管理模块
加载和验证配置文件,支持环境变量覆盖
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import json5
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppConfig(BaseModel):
"""应用基础配置"""
name: str = "MineNASAI"
version: str = "0.1.0"
debug: bool = False
timezone: str = "Asia/Shanghai"
class AgentToolsConfig(BaseModel):
"""Agent 工具配置"""
allow: list[str] = Field(default_factory=list)
deny: list[str] = Field(default_factory=list)
class AgentSandboxConfig(BaseModel):
"""Agent 沙箱配置"""
mode: str = "workspace"
allowed_paths: list[str] = Field(default_factory=list)
class AgentConfig(BaseModel):
"""单个 Agent 配置"""
id: str
name: str = "Agent"
workspace_path: str = "~/.config/minenasai/workspace"
tools: AgentToolsConfig = Field(default_factory=AgentToolsConfig)
sandbox: AgentSandboxConfig = Field(default_factory=AgentSandboxConfig)
class AgentsConfig(BaseModel):
"""Agent 全局配置"""
model_config = {"populate_by_name": True}
default_model: str = "claude-sonnet-4-20250514"
max_tokens: int = 8192
temperature: float = 0.7
items: list[AgentConfig] = Field(default_factory=list, validation_alias="list")
class GatewayConfig(BaseModel):
"""Gateway 服务配置"""
host: str = "0.0.0.0"
port: int = 8000
websocket_path: str = "/ws"
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
class ChannelConfig(BaseModel):
"""通讯渠道配置"""
enabled: bool = False
webhook_path: str = ""
class ChannelsConfig(BaseModel):
"""通讯渠道集合配置"""
wework: ChannelConfig = Field(default_factory=ChannelConfig)
feishu: ChannelConfig = Field(default_factory=ChannelConfig)
class SSHConfig(BaseModel):
"""SSH 连接配置"""
host: str = "localhost"
port: int = 22
connect_timeout: int = 10
class WebTUIConfig(BaseModel):
"""Web TUI 配置"""
enabled: bool = True
host: str = "0.0.0.0"
port: int = 8080
session_timeout: int = 3600
ssh: SSHConfig = Field(default_factory=SSHConfig)
class RouterConfig(BaseModel):
"""智能路由配置"""
mode: str = "agent"
thresholds: dict[str, Any] = Field(default_factory=dict)
class StorageConfig(BaseModel):
"""存储配置"""
database_path: str = "~/.config/minenasai/database.db"
sessions_path: str = "~/.config/minenasai/sessions"
logs_path: str = "~/.config/minenasai/logs"
class AuditConfig(BaseModel):
"""审计日志配置"""
enabled: bool = True
path: str = "~/.config/minenasai/logs/audit.jsonl"
class LoggingConfig(BaseModel):
"""日志配置"""
level: str = "INFO"
format: str = "json"
console: bool = True
file: bool = True
audit: AuditConfig = Field(default_factory=AuditConfig)
class ProxyConfig(BaseModel):
"""代理配置"""
enabled: bool = False
http: str = ""
https: str = ""
no_proxy: list[str] = Field(default_factory=list)
# 自动检测代理端口
auto_detect: bool = True
fallback_ports: list[int] = Field(default_factory=lambda: [7890, 7891, 1080, 1087, 10808])
class LLMConfig(BaseModel):
"""LLM 多模型配置"""
# 默认提供商和模型
default_provider: str = "anthropic"
default_model: str = "claude-sonnet-4-20250514"
# Anthropic (Claude) - 境外,需代理
anthropic_api_key: str = ""
anthropic_base_url: str = ""
# OpenAI (GPT) - 境外,需代理
openai_api_key: str = ""
openai_base_url: str = ""
# DeepSeek - 国内
deepseek_api_key: str = ""
deepseek_base_url: str = ""
# 智谱 (GLM) - 国内
zhipu_api_key: str = ""
zhipu_base_url: str = ""
# MiniMax - 国内
minimax_api_key: str = ""
minimax_group_id: str = ""
minimax_base_url: str = ""
# Moonshot (Kimi) - 国内
moonshot_api_key: str = ""
moonshot_base_url: str = ""
# Google Gemini - 境外,需代理
gemini_api_key: str = ""
gemini_base_url: str = ""
class SecurityConfig(BaseModel):
"""安全配置"""
danger_levels: dict[str, list[str]] = Field(default_factory=dict)
require_confirmation: list[str] = Field(default_factory=lambda: ["high", "critical"])
class Settings(BaseSettings):
"""应用设置,支持环境变量覆盖"""
model_config = SettingsConfigDict(
env_prefix="MINENASAI_",
env_nested_delimiter="__",
extra="ignore",
)
# API Keys (保留兼容,优先使用 llm 配置)
anthropic_api_key: str = ""
# 企业微信
wework_corp_id: str = ""
wework_agent_id: str = ""
wework_secret: str = ""
wework_token: str = ""
wework_encoding_aes_key: str = ""
# 飞书
feishu_app_id: str = ""
feishu_app_secret: str = ""
feishu_verification_token: str = ""
feishu_encrypt_key: str = ""
# Redis
redis_url: str = "redis://localhost:6379/0"
# Web TUI
webtui_secret_key: str = "change-this-to-a-random-string"
# SSH
ssh_username: str = ""
ssh_key_path: str = "~/.ssh/id_rsa"
# 配置对象
app: AppConfig = Field(default_factory=AppConfig)
agents: AgentsConfig = Field(default_factory=AgentsConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
webtui: WebTUIConfig = Field(default_factory=WebTUIConfig)
router: RouterConfig = Field(default_factory=RouterConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
def expand_path(path: str) -> Path:
"""展开路径中的 ~ 和环境变量"""
return Path(os.path.expanduser(os.path.expandvars(path)))
def load_config(config_path: str | Path | None = None) -> Settings:
"""加载配置文件
Args:
config_path: 配置文件路径,如果为 None 则使用默认路径
Returns:
Settings 配置对象
"""
# 默认配置路径
if config_path is None:
config_path = expand_path("~/.config/minenasai/config.json5")
config_path = Path(config_path)
# 如果配置文件存在,加载它
config_data: dict[str, Any] = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
config_data = json5.load(f)
# 创建 Settings 对象(会自动加载环境变量)
settings = Settings(**config_data)
return settings
def save_config(settings: Settings, config_path: str | Path | None = None) -> None:
"""保存配置到文件
Args:
settings: 配置对象
config_path: 保存路径
"""
if config_path is None:
config_path = expand_path("~/.config/minenasai/config.json5")
config_path = Path(config_path)
config_path.parent.mkdir(parents=True, exist_ok=True)
# 转换为 dict排除敏感信息
data = settings.model_dump(
exclude={"anthropic_api_key", "wework_secret", "feishu_app_secret", "webtui_secret_key"}
)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# 全局配置实例
_settings: Settings | None = None
def get_settings() -> Settings:
"""获取全局配置实例"""
global _settings
if _settings is None:
_settings = load_config()
return _settings
def reset_settings() -> None:
"""重置全局配置(用于测试)"""
global _settings
_settings = None

View File

@@ -0,0 +1,390 @@
"""数据库管理模块
使用 SQLite + aiosqlite 提供异步数据库操作
"""
from __future__ import annotations
import json
import time
import uuid
from pathlib import Path
from typing import Any
import aiosqlite
from minenasai.core.config import expand_path
# SQL Schema 定义
SCHEMA = """
-- Agents表
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
workspace_path TEXT NOT NULL,
model TEXT DEFAULT 'claude-sonnet-4-20250514',
sandbox_mode TEXT DEFAULT 'workspace',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Sessions表
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents(id),
channel TEXT NOT NULL,
peer_id TEXT,
session_key TEXT,
status TEXT DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT
);
-- Messages表
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_calls TEXT,
timestamp INTEGER NOT NULL,
tokens_used INTEGER DEFAULT 0
);
-- Cron任务表
CREATE TABLE IF NOT EXISTS cron_jobs (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents(id),
name TEXT NOT NULL,
schedule TEXT NOT NULL,
task TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
last_run INTEGER,
next_run INTEGER,
created_at INTEGER NOT NULL
);
-- 审计日志表
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
agent_id TEXT,
tool_name TEXT NOT NULL,
danger_level TEXT,
params TEXT,
result TEXT,
duration_ms INTEGER,
timestamp INTEGER NOT NULL
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit_logs(agent_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp);
"""
class Database:
"""异步数据库管理器"""
def __init__(self, db_path: str | Path | None = None) -> None:
"""初始化数据库
Args:
db_path: 数据库文件路径
"""
if db_path is None:
db_path = expand_path("~/.config/minenasai/database.db")
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn: aiosqlite.Connection | None = None
async def connect(self) -> None:
"""连接数据库"""
self._conn = await aiosqlite.connect(self.db_path)
self._conn.row_factory = aiosqlite.Row
await self._conn.executescript(SCHEMA)
await self._conn.commit()
async def close(self) -> None:
"""关闭数据库连接"""
if self._conn:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
"""获取数据库连接"""
if self._conn is None:
raise RuntimeError("数据库未连接,请先调用 connect()")
return self._conn
# ===== Agent 操作 =====
async def create_agent(
self,
agent_id: str,
name: str,
workspace_path: str,
model: str = "claude-sonnet-4-20250514",
sandbox_mode: str = "workspace",
) -> dict[str, Any]:
"""创建 Agent"""
now = int(time.time())
await self.conn.execute(
"""
INSERT INTO agents (id, name, workspace_path, model, sandbox_mode, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(agent_id, name, workspace_path, model, sandbox_mode, now, now),
)
await self.conn.commit()
return {
"id": agent_id,
"name": name,
"workspace_path": workspace_path,
"model": model,
"sandbox_mode": sandbox_mode,
"created_at": now,
"updated_at": now,
}
async def get_agent(self, agent_id: str) -> dict[str, Any] | None:
"""获取 Agent"""
cursor = await self.conn.execute(
"SELECT * FROM agents WHERE id = ?", (agent_id,)
)
row = await cursor.fetchone()
return dict(row) if row else None
async def list_agents(self) -> list[dict[str, Any]]:
"""列出所有 Agent"""
cursor = await self.conn.execute("SELECT * FROM agents ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [dict(row) for row in rows]
# ===== Session 操作 =====
async def create_session(
self,
agent_id: str,
channel: str,
peer_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""创建会话"""
session_id = str(uuid.uuid4())
session_key = f"{agent_id}:{channel}:{session_id[:8]}"
now = int(time.time())
await self.conn.execute(
"""
INSERT INTO sessions (id, agent_id, channel, peer_id, session_key, status, created_at, updated_at, metadata)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(
session_id,
agent_id,
channel,
peer_id,
session_key,
now,
now,
json.dumps(metadata) if metadata else None,
),
)
await self.conn.commit()
return {
"id": session_id,
"agent_id": agent_id,
"channel": channel,
"peer_id": peer_id,
"session_key": session_key,
"status": "active",
"created_at": now,
"updated_at": now,
"metadata": metadata,
}
async def get_session(self, session_id: str) -> dict[str, Any] | None:
"""获取会话"""
cursor = await self.conn.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)
)
row = await cursor.fetchone()
if row:
data = dict(row)
if data.get("metadata"):
data["metadata"] = json.loads(data["metadata"])
return data
return None
async def update_session_status(self, session_id: str, status: str) -> None:
"""更新会话状态"""
now = int(time.time())
await self.conn.execute(
"UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?",
(status, now, session_id),
)
await self.conn.commit()
async def list_active_sessions(self, agent_id: str | None = None) -> list[dict[str, Any]]:
"""列出活跃会话"""
if agent_id:
cursor = await self.conn.execute(
"SELECT * FROM sessions WHERE status = 'active' AND agent_id = ? ORDER BY updated_at DESC",
(agent_id,),
)
else:
cursor = await self.conn.execute(
"SELECT * FROM sessions WHERE status = 'active' ORDER BY updated_at DESC"
)
rows = await cursor.fetchall()
results = []
for row in rows:
data = dict(row)
if data.get("metadata"):
data["metadata"] = json.loads(data["metadata"])
results.append(data)
return results
# ===== Message 操作 =====
async def add_message(
self,
session_id: str,
role: str,
content: str | None = None,
tool_calls: list[dict[str, Any]] | None = None,
tokens_used: int = 0,
) -> dict[str, Any]:
"""添加消息"""
message_id = str(uuid.uuid4())
now = int(time.time())
await self.conn.execute(
"""
INSERT INTO messages (id, session_id, role, content, tool_calls, timestamp, tokens_used)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
message_id,
session_id,
role,
content,
json.dumps(tool_calls) if tool_calls else None,
now,
tokens_used,
),
)
await self.conn.commit()
# 更新会话的 updated_at
await self.conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(now, session_id),
)
await self.conn.commit()
return {
"id": message_id,
"session_id": session_id,
"role": role,
"content": content,
"tool_calls": tool_calls,
"timestamp": now,
"tokens_used": tokens_used,
}
async def get_messages(
self,
session_id: str,
limit: int = 100,
before_timestamp: int | None = None,
) -> list[dict[str, Any]]:
"""获取会话消息"""
if before_timestamp:
cursor = await self.conn.execute(
"""
SELECT * FROM messages
WHERE session_id = ? AND timestamp < ?
ORDER BY timestamp DESC LIMIT ?
""",
(session_id, before_timestamp, limit),
)
else:
cursor = await self.conn.execute(
"""
SELECT * FROM messages
WHERE session_id = ?
ORDER BY timestamp DESC LIMIT ?
""",
(session_id, limit),
)
rows = await cursor.fetchall()
results = []
for row in reversed(rows): # 按时间正序返回
data = dict(row)
if data.get("tool_calls"):
data["tool_calls"] = json.loads(data["tool_calls"])
results.append(data)
return results
# ===== 审计日志 =====
async def add_audit_log(
self,
agent_id: str | None,
tool_name: str,
danger_level: str,
params: dict[str, Any] | None = None,
result: str | None = None,
duration_ms: int | None = None,
) -> None:
"""添加审计日志"""
log_id = str(uuid.uuid4())
now = int(time.time())
await self.conn.execute(
"""
INSERT INTO audit_logs (id, agent_id, tool_name, danger_level, params, result, duration_ms, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
log_id,
agent_id,
tool_name,
danger_level,
json.dumps(params) if params else None,
result,
duration_ms,
now,
),
)
await self.conn.commit()
# 全局数据库实例
_db: Database | None = None
async def get_database() -> Database:
"""获取全局数据库实例"""
global _db
if _db is None:
_db = Database()
await _db.connect()
return _db
async def close_database() -> None:
"""关闭全局数据库连接"""
global _db
if _db:
await _db.close()
_db = None

View File

@@ -0,0 +1,212 @@
"""日志系统模块
使用 structlog 提供结构化 JSON 日志
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Any
import structlog
from structlog.types import Processor
from minenasai.core.config import LoggingConfig, expand_path
def setup_logging(config: LoggingConfig | None = None) -> None:
"""初始化日志系统
Args:
config: 日志配置,如果为 None 则使用默认配置
"""
if config is None:
config = LoggingConfig()
# 设置日志级别
log_level = getattr(logging, config.level.upper(), logging.INFO)
# 配置处理器链
shared_processors: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
if config.format == "json":
# JSON 格式输出
renderer: Processor = structlog.processors.JSONRenderer(ensure_ascii=False)
else:
# 控制台友好格式
renderer = structlog.dev.ConsoleRenderer(colors=True)
# 配置 structlog
structlog.configure(
processors=[
*shared_processors,
structlog.processors.format_exc_info,
renderer,
],
wrapper_class=structlog.make_filtering_bound_logger(log_level),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
# 配置标准库 logging
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=log_level,
)
# 配置文件日志
if config.file:
logs_path = expand_path(config.audit.path).parent
logs_path.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(
logs_path / "app.log",
encoding="utf-8",
)
file_handler.setLevel(log_level)
logging.getLogger().addHandler(file_handler)
def get_logger(name: str | None = None) -> structlog.BoundLogger:
"""获取日志记录器
Args:
name: 日志记录器名称
Returns:
structlog 绑定的日志记录器
"""
return structlog.get_logger(name)
class AuditLogger:
"""审计日志记录器
记录工具调用、权限操作等需要审计的事件
"""
def __init__(self, audit_path: str | Path | None = None) -> None:
"""初始化审计日志
Args:
audit_path: 审计日志文件路径
"""
if audit_path is None:
audit_path = expand_path("~/.config/minenasai/logs/audit.jsonl")
self.audit_path = Path(audit_path)
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
self._logger = get_logger("audit")
def log_tool_call(
self,
agent_id: str,
tool_name: str,
params: dict[str, Any],
danger_level: str,
result: str | None = None,
duration_ms: int | None = None,
error: str | None = None,
) -> None:
"""记录工具调用
Args:
agent_id: Agent ID
tool_name: 工具名称
params: 调用参数
danger_level: 危险等级
result: 执行结果
duration_ms: 执行耗时(毫秒)
error: 错误信息
"""
import json
import time
record = {
"timestamp": time.time(),
"type": "tool_call",
"agent_id": agent_id,
"tool_name": tool_name,
"params": params,
"danger_level": danger_level,
"result": result,
"duration_ms": duration_ms,
"error": error,
}
# 写入 JSONL 文件
with open(self.audit_path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 同时记录到结构化日志
self._logger.info(
"tool_call",
agent_id=agent_id,
tool_name=tool_name,
danger_level=danger_level,
duration_ms=duration_ms,
error=error,
)
def log_auth_event(
self,
event_type: str,
user_id: str | None = None,
channel: str | None = None,
success: bool = True,
details: dict[str, Any] | None = None,
) -> None:
"""记录认证事件
Args:
event_type: 事件类型login, logout, token_refresh 等)
user_id: 用户 ID
channel: 渠道wework, feishu, webtui
success: 是否成功
details: 额外详情
"""
import json
import time
record = {
"timestamp": time.time(),
"type": "auth_event",
"event_type": event_type,
"user_id": user_id,
"channel": channel,
"success": success,
"details": details or {},
}
with open(self.audit_path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
log_method = self._logger.info if success else self._logger.warning
log_method(
"auth_event",
event_type=event_type,
user_id=user_id,
channel=channel,
success=success,
)
# 全局审计日志实例
_audit_logger: AuditLogger | None = None
def get_audit_logger() -> AuditLogger:
"""获取全局审计日志实例"""
global _audit_logger
if _audit_logger is None:
_audit_logger = AuditLogger()
return _audit_logger

View File

@@ -0,0 +1,282 @@
"""会话存储模块
使用 JSONL 格式存储完整的对话历史
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any, Iterator
from minenasai.core.config import expand_path
class SessionStore:
"""JSONL 会话存储
每个会话一个 .jsonl 文件,存储完整的消息历史
"""
def __init__(self, sessions_path: str | Path | None = None) -> None:
"""初始化会话存储
Args:
sessions_path: 会话存储目录
"""
if sessions_path is None:
sessions_path = expand_path("~/.config/minenasai/sessions")
self.sessions_path = Path(sessions_path)
self.sessions_path.mkdir(parents=True, exist_ok=True)
def _get_session_file(self, session_id: str) -> Path:
"""获取会话文件路径"""
return self.sessions_path / f"{session_id}.jsonl"
def append_message(
self,
session_id: str,
role: str,
content: str | None = None,
tool_calls: list[dict[str, Any]] | None = None,
tool_results: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""追加消息到会话文件
Args:
session_id: 会话 ID
role: 角色 (user, assistant, system, tool)
content: 消息内容
tool_calls: 工具调用列表
tool_results: 工具结果列表
metadata: 额外元数据
Returns:
保存的消息记录
"""
record = {
"timestamp": time.time(),
"role": role,
"content": content,
}
if tool_calls:
record["tool_calls"] = tool_calls
if tool_results:
record["tool_results"] = tool_results
if metadata:
record["metadata"] = metadata
session_file = self._get_session_file(session_id)
with open(session_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return record
def read_messages(
self,
session_id: str,
limit: int | None = None,
since_timestamp: float | None = None,
) -> list[dict[str, Any]]:
"""读取会话消息
Args:
session_id: 会话 ID
limit: 最大返回数量(从最新开始)
since_timestamp: 只返回此时间戳之后的消息
Returns:
消息列表
"""
session_file = self._get_session_file(session_id)
if not session_file.exists():
return []
messages = []
with open(session_file, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
if since_timestamp and record.get("timestamp", 0) <= since_timestamp:
continue
messages.append(record)
except json.JSONDecodeError:
continue
if limit:
messages = messages[-limit:]
return messages
def iter_messages(self, session_id: str) -> Iterator[dict[str, Any]]:
"""迭代读取会话消息(节省内存)
Args:
session_id: 会话 ID
Yields:
消息记录
"""
session_file = self._get_session_file(session_id)
if not session_file.exists():
return
with open(session_file, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
def get_conversation_for_api(
self,
session_id: str,
max_messages: int = 50,
max_tokens_estimate: int = 100000,
) -> list[dict[str, Any]]:
"""获取用于 API 调用的对话历史
转换为 Anthropic API 格式,并进行截断
Args:
session_id: 会话 ID
max_messages: 最大消息数
max_tokens_estimate: 预估最大 token 数
Returns:
API 格式的消息列表
"""
messages = self.read_messages(session_id, limit=max_messages)
# 转换为 API 格式
api_messages = []
estimated_tokens = 0
for msg in messages:
role = msg.get("role")
content = msg.get("content")
# 跳过 system 消息(需要单独处理)
if role == "system":
continue
# 简单估算 token 数(约 4 字符/token
if content:
estimated_tokens += len(content) // 4
if estimated_tokens > max_tokens_estimate:
break
api_msg: dict[str, Any] = {"role": role}
if content:
api_msg["content"] = content
# 处理工具调用
if msg.get("tool_calls"):
api_msg["content"] = [
{"type": "text", "text": content or ""}
] if content else []
for tc in msg["tool_calls"]:
api_msg["content"].append({
"type": "tool_use",
"id": tc.get("id"),
"name": tc.get("name"),
"input": tc.get("input", {}),
})
# 处理工具结果
if msg.get("tool_results"):
api_msg["content"] = []
for tr in msg["tool_results"]:
api_msg["content"].append({
"type": "tool_result",
"tool_use_id": tr.get("tool_use_id"),
"content": tr.get("content", ""),
})
api_messages.append(api_msg)
return api_messages
def delete_session(self, session_id: str) -> bool:
"""删除会话文件
Args:
session_id: 会话 ID
Returns:
是否成功删除
"""
session_file = self._get_session_file(session_id)
if session_file.exists():
session_file.unlink()
return True
return False
def list_sessions(self) -> list[str]:
"""列出所有会话 ID
Returns:
会话 ID 列表
"""
return [f.stem for f in self.sessions_path.glob("*.jsonl")]
def get_session_stats(self, session_id: str) -> dict[str, Any]:
"""获取会话统计信息
Args:
session_id: 会话 ID
Returns:
统计信息
"""
session_file = self._get_session_file(session_id)
if not session_file.exists():
return {"exists": False}
message_count = 0
first_timestamp = None
last_timestamp = None
role_counts: dict[str, int] = {}
for msg in self.iter_messages(session_id):
message_count += 1
ts = msg.get("timestamp")
role = msg.get("role", "unknown")
if first_timestamp is None:
first_timestamp = ts
last_timestamp = ts
role_counts[role] = role_counts.get(role, 0) + 1
return {
"exists": True,
"file_size": session_file.stat().st_size,
"message_count": message_count,
"first_timestamp": first_timestamp,
"last_timestamp": last_timestamp,
"role_counts": role_counts,
}
# 全局会话存储实例
_session_store: SessionStore | None = None
def get_session_store() -> SessionStore:
"""获取全局会话存储实例"""
global _session_store
if _session_store is None:
_session_store = SessionStore()
return _session_store

View File

@@ -0,0 +1,6 @@
"""Gateway 模块
提供 WebSocket 服务、通讯渠道接入、智能路由
"""
__all__ = []

View File

@@ -0,0 +1,11 @@
"""通讯渠道模块"""
from minenasai.gateway.channels.base import BaseChannel
from minenasai.gateway.channels.feishu import FeishuChannel
from minenasai.gateway.channels.wework import WeworkChannel
__all__ = [
"BaseChannel",
"FeishuChannel",
"WeworkChannel",
]

View File

@@ -0,0 +1,89 @@
"""通讯渠道基类
定义通讯渠道的标准接口
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from minenasai.gateway.protocol import ChannelType, ChatMessage
class BaseChannel(ABC):
"""通讯渠道基类"""
channel_type: ChannelType
@abstractmethod
async def verify_signature(self, request: Any) -> bool:
"""验证请求签名
Args:
request: HTTP 请求对象
Returns:
签名是否有效
"""
pass
@abstractmethod
async def parse_message(self, data: dict[str, Any]) -> ChatMessage | None:
"""解析接收到的消息
Args:
data: 原始消息数据
Returns:
解析后的聊天消息,如果不是有效消息则返回 None
"""
pass
@abstractmethod
async def send_message(
self,
peer_id: str,
content: str,
message_type: str = "text",
**kwargs: Any,
) -> bool:
"""发送消息
Args:
peer_id: 接收方 ID
content: 消息内容
message_type: 消息类型
**kwargs: 其他参数
Returns:
是否发送成功
"""
pass
@abstractmethod
async def send_typing(self, peer_id: str) -> None:
"""发送正在输入状态
Args:
peer_id: 接收方 ID
"""
pass
async def handle_webhook(self, data: dict[str, Any]) -> dict[str, Any]:
"""处理 Webhook 回调
Args:
data: Webhook 数据
Returns:
响应数据
"""
message = await self.parse_message(data)
if message:
# 返回消息供上层处理
return {
"success": True,
"message": message.model_dump(),
}
return {"success": True, "message": None}

View File

@@ -0,0 +1,194 @@
"""飞书通讯渠道
实现飞书消息收发
"""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Any
import httpx
from minenasai.core import get_logger, get_settings
from minenasai.gateway.channels.base import BaseChannel
from minenasai.gateway.protocol import ChannelType, ChatMessage
logger = get_logger(__name__)
class FeishuChannel(BaseChannel):
"""飞书通讯渠道"""
channel_type = ChannelType.FEISHU
def __init__(self) -> None:
self.settings = get_settings()
self.app_id = self.settings.feishu_app_id
self.app_secret = self.settings.feishu_app_secret
self.verification_token = self.settings.feishu_verification_token
self.encrypt_key = self.settings.feishu_encrypt_key
self._tenant_access_token: str | None = None
self._token_expires: float = 0
async def verify_signature(self, request: Any) -> bool:
"""验证飞书签名"""
# TODO: 实现签名验证
return True
async def _get_tenant_access_token(self) -> str:
"""获取 tenant_access_token"""
if self._tenant_access_token and time.time() < self._token_expires:
return self._tenant_access_token
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
payload = {
"app_id": self.app_id,
"app_secret": self.app_secret,
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
data = response.json()
if data.get("code") != 0:
logger.error("获取 tenant_access_token 失败", error=data)
raise ValueError(f"获取 tenant_access_token 失败: {data.get('msg')}")
self._tenant_access_token = data["tenant_access_token"]
self._token_expires = time.time() + data["expire"] - 300
return self._tenant_access_token
async def parse_message(self, data: dict[str, Any]) -> ChatMessage | None:
"""解析飞书消息
飞书消息格式:
{
"schema": "2.0",
"header": {
"event_type": "im.message.receive_v1",
"event_id": "xxx",
"create_time": "xxx"
},
"event": {
"sender": {"sender_id": {"user_id": "xxx"}},
"message": {
"message_id": "xxx",
"chat_id": "xxx",
"message_type": "text",
"content": "{\"text\":\"消息内容\"}"
}
}
}
"""
# 处理 URL 验证
if "challenge" in data:
return None
header = data.get("header", {})
event_type = header.get("event_type")
if event_type != "im.message.receive_v1":
logger.debug("忽略非消息事件", event_type=event_type)
return None
event = data.get("event", {})
message = event.get("message", {})
msg_type = message.get("message_type")
if msg_type != "text":
logger.debug("忽略非文本消息", msg_type=msg_type)
return None
# 解析消息内容
import json
try:
content_json = json.loads(message.get("content", "{}"))
content = content_json.get("text", "")
except json.JSONDecodeError:
content = message.get("content", "")
if not content:
return None
sender = event.get("sender", {})
sender_id = sender.get("sender_id", {})
return ChatMessage(
content=content,
channel=ChannelType.FEISHU,
peer_id=sender_id.get("user_id") or sender_id.get("open_id"),
metadata={
"message_id": message.get("message_id"),
"chat_id": message.get("chat_id"),
"event_id": header.get("event_id"),
},
)
async def send_message(
self,
peer_id: str,
content: str,
message_type: str = "text",
**kwargs: Any,
) -> bool:
"""发送飞书消息"""
access_token = await self._get_tenant_access_token()
# 判断是用户还是群聊
chat_id = kwargs.get("chat_id")
if chat_id:
url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id"
receive_id = chat_id
else:
url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id"
receive_id = peer_id
import json
if message_type == "text":
msg_content = json.dumps({"text": content})
else:
msg_content = json.dumps({"text": content})
payload = {
"receive_id": receive_id,
"msg_type": "text",
"content": msg_content,
}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers)
data = response.json()
if data.get("code") != 0:
logger.error("发送消息失败", error=data, peer_id=peer_id)
return False
logger.debug("消息发送成功", peer_id=peer_id)
return True
except Exception as e:
logger.error("发送消息异常", error=str(e), peer_id=peer_id)
return False
async def send_typing(self, peer_id: str) -> None:
"""飞书不支持输入状态"""
pass
async def handle_verification(self, data: dict[str, Any]) -> dict[str, Any] | None:
"""处理 URL 验证请求"""
challenge = data.get("challenge")
if challenge:
return {"challenge": challenge}
return None

View File

@@ -0,0 +1,155 @@
"""企业微信通讯渠道
实现企业微信消息收发
"""
from __future__ import annotations
import hashlib
import time
from typing import Any
import httpx
from minenasai.core import get_logger, get_settings
from minenasai.gateway.channels.base import BaseChannel
from minenasai.gateway.protocol import ChannelType, ChatMessage
logger = get_logger(__name__)
class WeworkChannel(BaseChannel):
"""企业微信通讯渠道"""
channel_type = ChannelType.WEWORK
def __init__(self) -> None:
self.settings = get_settings()
self.corp_id = self.settings.wework_corp_id
self.agent_id = self.settings.wework_agent_id
self.secret = self.settings.wework_secret
self.token = self.settings.wework_token
self.encoding_aes_key = self.settings.wework_encoding_aes_key
self._access_token: str | None = None
self._token_expires: float = 0
async def verify_signature(self, request: Any) -> bool:
"""验证企业微信签名
企业微信使用 SHA1 签名验证
"""
# TODO: 实现签名验证
# signature = sorted([self.token, timestamp, nonce, echostr])
# sha1(signature) == msg_signature
return True
async def _get_access_token(self) -> str:
"""获取 access_token"""
if self._access_token and time.time() < self._token_expires:
return self._access_token
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params)
data = response.json()
if data.get("errcode") != 0:
logger.error("获取 access_token 失败", error=data)
raise ValueError(f"获取 access_token 失败: {data.get('errmsg')}")
self._access_token = data["access_token"]
self._token_expires = time.time() + data["expires_in"] - 300 # 提前5分钟刷新
return self._access_token
async def parse_message(self, data: dict[str, Any]) -> ChatMessage | None:
"""解析企业微信消息
企业微信消息格式:
{
"ToUserName": "企业ID",
"FromUserName": "用户ID",
"CreateTime": "时间戳",
"MsgType": "text",
"Content": "消息内容",
"MsgId": "消息ID"
}
"""
msg_type = data.get("MsgType")
if msg_type != "text":
logger.debug("忽略非文本消息", msg_type=msg_type)
return None
content = data.get("Content", "")
if not content:
return None
return ChatMessage(
content=content,
channel=ChannelType.WEWORK,
peer_id=data.get("FromUserName"),
metadata={
"msg_id": data.get("MsgId"),
"create_time": data.get("CreateTime"),
"agent_id": data.get("AgentID"),
},
)
async def send_message(
self,
peer_id: str,
content: str,
message_type: str = "text",
**kwargs: Any,
) -> bool:
"""发送企业微信消息"""
access_token = await self._get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload: dict[str, Any] = {
"touser": peer_id,
"msgtype": message_type,
"agentid": self.agent_id,
}
if message_type == "text":
payload["text"] = {"content": content}
elif message_type == "markdown":
payload["markdown"] = {"content": content}
else:
payload["text"] = {"content": content}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
data = response.json()
if data.get("errcode") != 0:
logger.error("发送消息失败", error=data, peer_id=peer_id)
return False
logger.debug("消息发送成功", peer_id=peer_id)
return True
except Exception as e:
logger.error("发送消息异常", error=str(e), peer_id=peer_id)
return False
async def send_typing(self, peer_id: str) -> None:
"""企业微信不支持输入状态"""
pass
async def handle_verification(self, params: dict[str, str]) -> str | None:
"""处理 URL 验证请求
企业微信首次配置时会发送验证请求
"""
# TODO: 实现消息解密
# echostr 需要解密后返回
echostr = params.get("echostr")
return echostr

View File

@@ -0,0 +1,39 @@
"""协议模块"""
from minenasai.gateway.protocol.schema import (
BaseMessage,
ChannelType,
ChatMessage,
CommandMessage,
ConfirmMessage,
ErrorMessage,
MessageType,
PingMessage,
PongMessage,
ResponseMessage,
StatusMessage,
StreamMessage,
TaskComplexity,
ToolCallMessage,
ToolResultMessage,
parse_message,
)
__all__ = [
"BaseMessage",
"ChannelType",
"ChatMessage",
"CommandMessage",
"ConfirmMessage",
"ErrorMessage",
"MessageType",
"PingMessage",
"PongMessage",
"ResponseMessage",
"StatusMessage",
"StreamMessage",
"TaskComplexity",
"ToolCallMessage",
"ToolResultMessage",
"parse_message",
]

View File

@@ -0,0 +1,199 @@
"""消息协议定义
定义 Gateway 的消息格式和类型
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class MessageType(str, Enum):
"""消息类型"""
# 客户端 -> 服务器
CHAT = "chat" # 普通聊天消息
COMMAND = "command" # 命令消息 (/快速, /深度 等)
PING = "ping" # 心跳
# 服务器 -> 客户端
RESPONSE = "response" # 响应消息
STREAM = "stream" # 流式响应
TOOL_CALL = "tool_call" # 工具调用通知
TOOL_RESULT = "tool_result" # 工具执行结果
ERROR = "error" # 错误消息
PONG = "pong" # 心跳响应
STATUS = "status" # 状态更新
# 双向
CONFIRM = "confirm" # 确认请求/响应
class ChannelType(str, Enum):
"""渠道类型"""
WEWORK = "wework"
FEISHU = "feishu"
WEBTUI = "webtui"
WEBSOCKET = "websocket"
class TaskComplexity(str, Enum):
"""任务复杂度"""
SIMPLE = "simple" # 简单查询,直接回复
MEDIUM = "medium" # 中等任务,需要工具
COMPLEX = "complex" # 复杂任务,需要 TUI
class BaseMessage(BaseModel):
"""消息基类"""
type: MessageType
id: str = Field(default_factory=lambda: __import__("uuid").uuid4().hex[:16])
timestamp: float = Field(default_factory=lambda: __import__("time").time())
class ChatMessage(BaseMessage):
"""聊天消息"""
type: MessageType = MessageType.CHAT
content: str
channel: ChannelType = ChannelType.WEBSOCKET
session_id: str | None = None
peer_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class CommandMessage(BaseMessage):
"""命令消息"""
type: MessageType = MessageType.COMMAND
command: str # 命令名称,如 "快速", "深度", "确认"
args: list[str] = Field(default_factory=list)
content: str | None = None # 可选的附加内容
class ResponseMessage(BaseMessage):
"""响应消息"""
type: MessageType = MessageType.RESPONSE
content: str
session_id: str | None = None
in_reply_to: str | None = None # 回复的消息 ID
finished: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
class StreamMessage(BaseMessage):
"""流式响应消息"""
type: MessageType = MessageType.STREAM
content: str # 增量内容
session_id: str | None = None
in_reply_to: str | None = None
index: int = 0 # 流序号
finished: bool = False
class ToolCallMessage(BaseMessage):
"""工具调用消息"""
type: MessageType = MessageType.TOOL_CALL
tool_name: str
tool_id: str
params: dict[str, Any] = Field(default_factory=dict)
danger_level: str = "safe"
requires_confirm: bool = False
class ToolResultMessage(BaseMessage):
"""工具结果消息"""
type: MessageType = MessageType.TOOL_RESULT
tool_id: str
success: bool
result: Any = None
error: str | None = None
duration_ms: int | None = None
class ErrorMessage(BaseMessage):
"""错误消息"""
type: MessageType = MessageType.ERROR
code: str
message: str
details: dict[str, Any] = Field(default_factory=dict)
class ConfirmMessage(BaseMessage):
"""确认消息"""
type: MessageType = MessageType.CONFIRM
action: str # 待确认的操作描述
tool_name: str | None = None
params: dict[str, Any] = Field(default_factory=dict)
confirmed: bool | None = None # None=请求确认, True/False=确认结果
class StatusMessage(BaseMessage):
"""状态消息"""
type: MessageType = MessageType.STATUS
status: str # "thinking", "executing", "waiting", "done"
message: str | None = None
progress: float | None = None # 0-1
class PingMessage(BaseMessage):
"""心跳消息"""
type: MessageType = MessageType.PING
class PongMessage(BaseMessage):
"""心跳响应"""
type: MessageType = MessageType.PONG
# 消息解析
def parse_message(data: dict[str, Any]) -> BaseMessage:
"""解析消息
Args:
data: 消息数据
Returns:
解析后的消息对象
Raises:
ValueError: 消息格式错误
"""
msg_type = data.get("type")
if not msg_type:
raise ValueError("消息缺少 type 字段")
type_map = {
MessageType.CHAT.value: ChatMessage,
MessageType.COMMAND.value: CommandMessage,
MessageType.RESPONSE.value: ResponseMessage,
MessageType.STREAM.value: StreamMessage,
MessageType.TOOL_CALL.value: ToolCallMessage,
MessageType.TOOL_RESULT.value: ToolResultMessage,
MessageType.ERROR.value: ErrorMessage,
MessageType.CONFIRM.value: ConfirmMessage,
MessageType.STATUS.value: StatusMessage,
MessageType.PING.value: PingMessage,
MessageType.PONG.value: PongMessage,
}
msg_class = type_map.get(msg_type)
if not msg_class:
raise ValueError(f"未知的消息类型: {msg_type}")
return msg_class(**data)

View File

@@ -0,0 +1,203 @@
"""智能路由模块
评估任务复杂度,决定处理方式
"""
from __future__ import annotations
import re
from typing import Any
from minenasai.core import get_logger
from minenasai.gateway.protocol import TaskComplexity
logger = get_logger(__name__)
# 复杂任务关键词
COMPLEX_KEYWORDS = [
# 编程相关
"实现", "开发", "编写", "重构", "优化", "调试", "修复bug",
"创建项目", "搭建", "部署", "迁移",
# 文件操作
"批量", "遍历", "递归", "所有文件",
# 分析
"分析代码", "审查", "review", "架构设计",
]
# 简单任务关键词
SIMPLE_KEYWORDS = [
# 查询
"是什么", "什么是", "解释", "说明", "介绍",
"几点", "天气", "日期", "时间",
# 状态
"状态", "运行情况", "磁盘", "内存", "CPU",
# 简单操作
"打开", "关闭", "启动", "停止",
]
# 需要工具的关键词
TOOL_KEYWORDS = [
"查看", "读取", "列出", "搜索", "查找",
"执行", "运行", "计算",
"文件", "目录", "路径",
]
# 命令前缀
COMMAND_PREFIXES = {
"/快速": TaskComplexity.SIMPLE,
"/简单": TaskComplexity.SIMPLE,
"/深度": TaskComplexity.COMPLEX,
"/复杂": TaskComplexity.COMPLEX,
"/tui": TaskComplexity.COMPLEX,
"/TUI": TaskComplexity.COMPLEX,
}
class SmartRouter:
"""智能路由器
基于启发式规则评估任务复杂度
"""
def __init__(
self,
simple_max_length: int = 100,
complex_min_length: int = 500,
) -> None:
"""初始化路由器
Args:
simple_max_length: 简单任务的最大长度
complex_min_length: 复杂任务的最小长度
"""
self.simple_max_length = simple_max_length
self.complex_min_length = complex_min_length
def evaluate(self, content: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
"""评估任务复杂度
Args:
content: 用户输入内容
metadata: 额外元数据(如历史上下文)
Returns:
评估结果,包含 complexity, confidence, reason, suggested_handler
"""
content = content.strip()
metadata = metadata or {}
# 检查命令前缀覆盖
for prefix, complexity in COMMAND_PREFIXES.items():
if content.startswith(prefix):
return {
"complexity": complexity,
"confidence": 1.0,
"reason": f"用户指定 {prefix}",
"suggested_handler": self._get_handler(complexity),
"content": content[len(prefix):].strip(),
}
# 计算各项得分
scores = {
"simple": 0.0,
"medium": 0.0,
"complex": 0.0,
}
# 长度评估
length = len(content)
if length <= self.simple_max_length:
scores["simple"] += 0.3
elif length >= self.complex_min_length:
scores["complex"] += 0.3
else:
scores["medium"] += 0.2
# 关键词评估
content_lower = content.lower()
simple_matches = sum(1 for kw in SIMPLE_KEYWORDS if kw in content_lower)
complex_matches = sum(1 for kw in COMPLEX_KEYWORDS if kw in content_lower)
tool_matches = sum(1 for kw in TOOL_KEYWORDS if kw in content_lower)
if simple_matches > 0:
scores["simple"] += min(0.4, simple_matches * 0.15)
if complex_matches > 0:
scores["complex"] += min(0.5, complex_matches * 0.2)
if tool_matches > 0:
scores["medium"] += min(0.3, tool_matches * 0.1)
# 问号检测(通常是简单问题)
if content.endswith("?") or content.endswith(""):
scores["simple"] += 0.1
# 代码块检测
if "```" in content or re.search(r"def\s+\w+|class\s+\w+|function\s+\w+", content):
scores["complex"] += 0.3
# 多步骤检测
if re.search(r"\d+\.\s|第[一二三四五六七八九十]+步|首先.*然后|step\s*\d+", content_lower):
scores["complex"] += 0.2
# 确定复杂度
max_score = max(scores.values())
if scores["complex"] == max_score and scores["complex"] >= 0.3:
complexity = TaskComplexity.COMPLEX
elif scores["simple"] == max_score and scores["simple"] >= 0.3:
complexity = TaskComplexity.SIMPLE
else:
complexity = TaskComplexity.MEDIUM
# 计算置信度
total = sum(scores.values())
confidence = max_score / total if total > 0 else 0.5
# 生成原因
reasons = []
if length <= self.simple_max_length:
reasons.append("短文本")
elif length >= self.complex_min_length:
reasons.append("长文本")
if simple_matches:
reasons.append(f"简单关键词x{simple_matches}")
if complex_matches:
reasons.append(f"复杂关键词x{complex_matches}")
if tool_matches:
reasons.append(f"工具关键词x{tool_matches}")
return {
"complexity": complexity,
"confidence": round(confidence, 2),
"reason": ", ".join(reasons) if reasons else "综合评估",
"suggested_handler": self._get_handler(complexity),
"scores": scores,
"content": content,
}
def _get_handler(self, complexity: TaskComplexity) -> str:
"""获取建议的处理器
Args:
complexity: 任务复杂度
Returns:
处理器名称
"""
handlers = {
TaskComplexity.SIMPLE: "quick_response",
TaskComplexity.MEDIUM: "agent_execute",
TaskComplexity.COMPLEX: "webtui_redirect",
}
return handlers.get(complexity, "agent_execute")
# 全局路由器实例
_router: SmartRouter | None = None
def get_router() -> SmartRouter:
"""获取全局路由器实例"""
global _router
if _router is None:
_router = SmartRouter()
return _router

View File

@@ -0,0 +1,238 @@
"""Gateway FastAPI 服务器
提供 WebSocket 服务和 HTTP API
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from minenasai.core import get_logger, get_settings, setup_logging
from minenasai.core.database import close_database, get_database
from minenasai.gateway.protocol import (
ChatMessage,
ErrorMessage,
MessageType,
PongMessage,
ResponseMessage,
StatusMessage,
parse_message,
)
from minenasai.gateway.router import get_router
logger = get_logger(__name__)
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self) -> None:
self.active_connections: dict[str, WebSocket] = {}
async def connect(self, websocket: WebSocket, client_id: str) -> None:
"""接受新连接"""
await websocket.accept()
self.active_connections[client_id] = websocket
logger.info("WebSocket 连接建立", client_id=client_id)
def disconnect(self, client_id: str) -> None:
"""断开连接"""
if client_id in self.active_connections:
del self.active_connections[client_id]
logger.info("WebSocket 连接断开", client_id=client_id)
async def send_message(self, client_id: str, message: dict[str, Any]) -> None:
"""发送消息到指定客户端"""
if client_id in self.active_connections:
await self.active_connections[client_id].send_json(message)
async def broadcast(self, message: dict[str, Any]) -> None:
"""广播消息"""
for connection in self.active_connections.values():
await connection.send_json(message)
manager = ConnectionManager()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理"""
settings = get_settings()
setup_logging(settings.logging)
# 初始化数据库
db = await get_database()
logger.info("数据库初始化完成")
logger.info("Gateway 服务启动", port=settings.gateway.port)
yield
# 清理
await close_database()
logger.info("Gateway 服务关闭")
app = FastAPI(
title="MineNASAI Gateway",
description="基于NAS的智能个人AI助理 - Gateway服务",
version="0.1.0",
lifespan=lifespan,
)
# CORS 配置
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.gateway.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root() -> dict[str, str]:
"""根路径"""
return {"service": "MineNASAI Gateway", "status": "running"}
@app.get("/health")
async def health() -> dict[str, str]:
"""健康检查"""
return {"status": "healthy"}
@app.get("/api/agents")
async def list_agents() -> dict[str, Any]:
"""列出所有 Agent"""
db = await get_database()
agents = await db.list_agents()
return {"agents": agents}
@app.get("/api/sessions")
async def list_sessions(agent_id: str | None = None) -> dict[str, Any]:
"""列出活跃会话"""
db = await get_database()
sessions = await db.list_active_sessions(agent_id)
return {"sessions": sessions}
async def handle_chat_message(
websocket: WebSocket,
client_id: str,
message: ChatMessage,
) -> None:
"""处理聊天消息"""
router = get_router()
# 发送思考状态
await websocket.send_json(
StatusMessage(status="thinking", message="正在分析任务...").model_dump()
)
# 评估任务复杂度
result = router.evaluate(message.content)
complexity = result["complexity"]
handler = result["suggested_handler"]
logger.info(
"路由决策",
complexity=complexity.value,
handler=handler,
confidence=result["confidence"],
reason=result["reason"],
)
# 根据复杂度处理
if handler == "quick_response":
# 简单任务:直接回复
response = ResponseMessage(
content=f"[快速响应] 收到您的问题:{result['content']}\n\n"
f"(这是一个简单查询,复杂度: {complexity.value}\n"
f"实际实现需要接入 Anthropic API。",
in_reply_to=message.id,
)
await websocket.send_json(response.model_dump())
elif handler == "webtui_redirect":
# 复杂任务:提示使用 TUI
response = ResponseMessage(
content=f"🔧 这是一个复杂任务,建议在 Web TUI 中处理。\n\n"
f"**任务复杂度**: {complexity.value}\n"
f"**原因**: {result['reason']}\n\n"
f"请访问 Web TUI: http://localhost:8080\n"
f"或回复 `/简单` 强制快速处理。",
in_reply_to=message.id,
)
await websocket.send_json(response.model_dump())
else:
# 中等任务Agent 执行
await websocket.send_json(
StatusMessage(status="executing", message="正在执行任务...").model_dump()
)
# TODO: 实际调用 Agent 执行
await asyncio.sleep(0.5) # 模拟处理
response = ResponseMessage(
content=f"[Agent 执行] 任务已处理。\n\n"
f"**复杂度**: {complexity.value}\n"
f"**原因**: {result['reason']}\n\n"
f"实际实现需要接入 Agent 运行时。",
in_reply_to=message.id,
)
await websocket.send_json(response.model_dump())
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
"""WebSocket 连接端点"""
client_id = str(id(websocket))
await manager.connect(websocket, client_id)
try:
while True:
data = await websocket.receive_json()
logger.debug("收到消息", data=data)
try:
message = parse_message(data)
# 心跳处理
if message.type == MessageType.PING:
await websocket.send_json(PongMessage().model_dump())
continue
# 聊天消息处理
if isinstance(message, ChatMessage):
await handle_chat_message(websocket, client_id, message)
else:
# 其他消息类型
await websocket.send_json(
ResponseMessage(
content=f"收到 {message.type.value} 类型消息",
in_reply_to=message.id,
).model_dump()
)
except ValueError as e:
error = ErrorMessage(
code="INVALID_MESSAGE",
message=str(e),
)
await websocket.send_json(error.model_dump())
except WebSocketDisconnect:
manager.disconnect(client_id)
except Exception as e:
logger.error("WebSocket 错误", error=str(e))
manager.disconnect(client_id)
await websocket.close(code=1011)

View File

@@ -0,0 +1,16 @@
"""LLM 多模型客户端模块
支持多个 AI API 提供商的统一接口
"""
from minenasai.llm.base import BaseLLMClient, LLMResponse, Message, ToolCall
from minenasai.llm.manager import LLMManager, get_llm_manager
__all__ = [
"BaseLLMClient",
"LLMResponse",
"Message",
"ToolCall",
"LLMManager",
"get_llm_manager",
]

215
src/minenasai/llm/base.py Normal file
View File

@@ -0,0 +1,215 @@
"""LLM 客户端基类
定义统一的 LLM 接口
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, AsyncIterator
class Provider(str, Enum):
"""LLM 提供商"""
ANTHROPIC = "anthropic" # Claude
OPENAI = "openai" # GPT
DEEPSEEK = "deepseek" # DeepSeek
ZHIPU = "zhipu" # 智谱 GLM
MINIMAX = "minimax" # MiniMax
MOONSHOT = "moonshot" # Kimi
GEMINI = "gemini" # Google Gemini
@property
def is_overseas(self) -> bool:
"""是否为境外服务(需要代理)"""
return self in {Provider.ANTHROPIC, Provider.OPENAI, Provider.GEMINI}
@property
def display_name(self) -> str:
"""显示名称"""
names = {
Provider.ANTHROPIC: "Claude (Anthropic)",
Provider.OPENAI: "GPT (OpenAI)",
Provider.DEEPSEEK: "DeepSeek",
Provider.ZHIPU: "GLM (智谱)",
Provider.MINIMAX: "MiniMax",
Provider.MOONSHOT: "Kimi (Moonshot)",
Provider.GEMINI: "Gemini (Google)",
}
return names.get(self, self.value)
@dataclass
class Message:
"""消息"""
role: str # "user", "assistant", "system"
content: str
name: str | None = None
tool_calls: list[ToolCall] | None = None
tool_call_id: str | None = None # 用于 tool 结果消息
@dataclass
class ToolCall:
"""工具调用"""
id: str
name: str
arguments: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolDefinition:
"""工具定义"""
name: str
description: str
parameters: dict[str, Any] = field(default_factory=dict)
@dataclass
class LLMResponse:
"""LLM 响应"""
content: str
model: str
provider: Provider
tool_calls: list[ToolCall] | None = None
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
raw_response: Any = None
@dataclass
class StreamChunk:
"""流式响应块"""
content: str
is_final: bool = False
tool_calls: list[ToolCall] | None = None
usage: dict[str, int] | None = None
class BaseLLMClient(ABC):
"""LLM 客户端基类"""
provider: Provider
default_model: str
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
"""初始化客户端
Args:
api_key: API 密钥
base_url: 自定义 API 地址
proxy: 代理地址(境外服务使用)
timeout: 请求超时(秒)
max_retries: 最大重试次数
"""
self.api_key = api_key
self.base_url = base_url
self.proxy = proxy
self.timeout = timeout
self.max_retries = max_retries
@abstractmethod
async def chat(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> LLMResponse:
"""发送聊天请求
Args:
messages: 消息列表
model: 模型名称
system: 系统提示词
max_tokens: 最大输出 token
temperature: 温度参数
tools: 工具定义列表
**kwargs: 其他参数
Returns:
LLM 响应
"""
pass
@abstractmethod
async def chat_stream(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""流式聊天
Args:
messages: 消息列表
model: 模型名称
system: 系统提示词
max_tokens: 最大输出 token
temperature: 温度参数
tools: 工具定义列表
**kwargs: 其他参数
Yields:
流式响应块
"""
pass
def _convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
"""转换消息格式为 API 格式(子类可覆盖)"""
result = []
for msg in messages:
item: dict[str, Any] = {
"role": msg.role,
"content": msg.content,
}
if msg.name:
item["name"] = msg.name
if msg.tool_call_id:
item["tool_call_id"] = msg.tool_call_id
result.append(item)
return result
def _convert_tools(self, tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""转换工具定义格式(子类可覆盖)"""
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in tools
]
async def close(self) -> None:
"""关闭客户端"""
pass
def get_available_models(self) -> list[str]:
"""获取可用模型列表"""
return [self.default_model]

View File

@@ -0,0 +1,19 @@
"""LLM 客户端实现"""
from minenasai.llm.clients.anthropic import AnthropicClient
from minenasai.llm.clients.openai_compat import OpenAICompatClient
from minenasai.llm.clients.deepseek import DeepSeekClient
from minenasai.llm.clients.zhipu import ZhipuClient
from minenasai.llm.clients.minimax import MiniMaxClient
from minenasai.llm.clients.moonshot import MoonshotClient
from minenasai.llm.clients.gemini import GeminiClient
__all__ = [
"AnthropicClient",
"OpenAICompatClient",
"DeepSeekClient",
"ZhipuClient",
"MiniMaxClient",
"MoonshotClient",
"GeminiClient",
]

View File

@@ -0,0 +1,232 @@
"""Anthropic Claude 客户端"""
from __future__ import annotations
import json
from typing import Any, AsyncIterator
import httpx
from minenasai.core import get_logger
from minenasai.llm.base import (
BaseLLMClient,
LLMResponse,
Message,
Provider,
StreamChunk,
ToolCall,
ToolDefinition,
)
logger = get_logger(__name__)
class AnthropicClient(BaseLLMClient):
"""Anthropic Claude 客户端"""
provider = Provider.ANTHROPIC
default_model = "claude-sonnet-4-20250514"
MODELS = [
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
]
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
super().__init__(api_key, base_url, proxy, timeout, max_retries)
self.base_url = base_url or "https://api.anthropic.com"
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端"""
if self._client is None:
transport = None
if self.proxy:
transport = httpx.AsyncHTTPTransport(proxy=self.proxy)
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
transport=transport,
headers={
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
)
return self._client
async def chat(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> LLMResponse:
"""发送聊天请求"""
client = await self._get_client()
model = model or self.default_model
payload: dict[str, Any] = {
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": self._convert_messages_anthropic(messages),
}
if system:
payload["system"] = system
if tools:
payload["tools"] = self._convert_tools_anthropic(tools)
try:
response = await client.post("/v1/messages", json=payload)
response.raise_for_status()
data = response.json()
# 解析响应
content_parts = []
tool_calls = []
for block in data.get("content", []):
if block["type"] == "text":
content_parts.append(block["text"])
elif block["type"] == "tool_use":
tool_calls.append(
ToolCall(
id=block["id"],
name=block["name"],
arguments=block.get("input", {}),
)
)
return LLMResponse(
content="\n".join(content_parts),
model=model,
provider=self.provider,
tool_calls=tool_calls if tool_calls else None,
finish_reason=data.get("stop_reason", "stop"),
usage={
"input_tokens": data.get("usage", {}).get("input_tokens", 0),
"output_tokens": data.get("usage", {}).get("output_tokens", 0),
},
raw_response=data,
)
except httpx.HTTPStatusError as e:
logger.error("Anthropic API 错误", status=e.response.status_code, body=e.response.text)
raise
except Exception as e:
logger.error("Anthropic 请求失败", error=str(e))
raise
async def chat_stream(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""流式聊天"""
client = await self._get_client()
model = model or self.default_model
payload: dict[str, Any] = {
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": self._convert_messages_anthropic(messages),
"stream": True,
}
if system:
payload["system"] = system
if tools:
payload["tools"] = self._convert_tools_anthropic(tools)
try:
async with client.stream("POST", "/v1/messages", json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = json.loads(line[6:])
event_type = data.get("type")
if event_type == "content_block_delta":
delta = data.get("delta", {})
if delta.get("type") == "text_delta":
yield StreamChunk(content=delta.get("text", ""))
elif event_type == "message_stop":
yield StreamChunk(content="", is_final=True)
except Exception as e:
logger.error("Anthropic 流式请求失败", error=str(e))
raise
def _convert_messages_anthropic(self, messages: list[Message]) -> list[dict[str, Any]]:
"""转换消息为 Anthropic 格式"""
result = []
for msg in messages:
if msg.role == "system":
continue # system 单独处理
item: dict[str, Any] = {"role": msg.role}
# 处理工具调用响应
if msg.tool_call_id:
item["role"] = "user"
item["content"] = [
{
"type": "tool_result",
"tool_use_id": msg.tool_call_id,
"content": msg.content,
}
]
else:
item["content"] = msg.content
result.append(item)
return result
def _convert_tools_anthropic(self, tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""转换工具定义为 Anthropic 格式"""
return [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.parameters,
}
for tool in tools
]
async def close(self) -> None:
"""关闭客户端"""
if self._client:
await self._client.aclose()
self._client = None
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,45 @@
"""DeepSeek 客户端
DeepSeek 使用 OpenAI 兼容接口
"""
from __future__ import annotations
from minenasai.llm.base import Provider
from minenasai.llm.clients.openai_compat import OpenAICompatClient
class DeepSeekClient(OpenAICompatClient):
"""DeepSeek 客户端
DeepSeek API 兼容 OpenAI 接口格式
官方文档: https://platform.deepseek.com/api-docs
"""
provider = Provider.DEEPSEEK
default_model = "deepseek-chat"
MODELS = [
"deepseek-chat", # DeepSeek-V3
"deepseek-reasoner", # DeepSeek-R1
]
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
super().__init__(
api_key=api_key,
base_url=base_url or "https://api.deepseek.com",
proxy=proxy, # 国内服务,通常不需要代理
timeout=timeout,
max_retries=max_retries,
provider=Provider.DEEPSEEK,
)
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,246 @@
"""Google Gemini 客户端
Gemini API 使用独特的接口格式
"""
from __future__ import annotations
import json
from typing import Any, AsyncIterator
import httpx
from minenasai.core import get_logger
from minenasai.llm.base import (
BaseLLMClient,
LLMResponse,
Message,
Provider,
StreamChunk,
ToolCall,
ToolDefinition,
)
logger = get_logger(__name__)
class GeminiClient(BaseLLMClient):
"""Google Gemini 客户端
官方文档: https://ai.google.dev/docs
"""
provider = Provider.GEMINI
default_model = "gemini-2.0-flash"
MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-thinking",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
]
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
super().__init__(api_key, base_url, proxy, timeout, max_retries)
self.base_url = base_url or "https://generativelanguage.googleapis.com/v1beta"
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端"""
if self._client is None:
transport = None
if self.proxy:
transport = httpx.AsyncHTTPTransport(proxy=self.proxy)
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
transport=transport,
headers={"Content-Type": "application/json"},
)
return self._client
async def chat(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> LLMResponse:
"""发送聊天请求"""
client = await self._get_client()
model = model or self.default_model
# 构建 Gemini 格式的请求
contents = self._convert_messages_gemini(messages)
payload: dict[str, Any] = {
"contents": contents,
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
if system:
payload["systemInstruction"] = {"parts": [{"text": system}]}
if tools:
payload["tools"] = self._convert_tools_gemini(tools)
url = f"/models/{model}:generateContent?key={self.api_key}"
try:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
# 解析响应
candidates = data.get("candidates", [])
if not candidates:
return LLMResponse(
content="",
model=model,
provider=self.provider,
finish_reason="error",
)
candidate = candidates[0]
content_parts = candidate.get("content", {}).get("parts", [])
text_parts = []
tool_calls = []
for part in content_parts:
if "text" in part:
text_parts.append(part["text"])
elif "functionCall" in part:
fc = part["functionCall"]
tool_calls.append(
ToolCall(
id=fc.get("name", ""), # Gemini 没有独立 ID
name=fc["name"],
arguments=fc.get("args", {}),
)
)
usage_meta = data.get("usageMetadata", {})
return LLMResponse(
content="\n".join(text_parts),
model=model,
provider=self.provider,
tool_calls=tool_calls if tool_calls else None,
finish_reason=candidate.get("finishReason", "STOP"),
usage={
"input_tokens": usage_meta.get("promptTokenCount", 0),
"output_tokens": usage_meta.get("candidatesTokenCount", 0),
},
raw_response=data,
)
except httpx.HTTPStatusError as e:
logger.error("Gemini API 错误", status=e.response.status_code, body=e.response.text)
raise
except Exception as e:
logger.error("Gemini 请求失败", error=str(e))
raise
async def chat_stream(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""流式聊天"""
client = await self._get_client()
model = model or self.default_model
contents = self._convert_messages_gemini(messages)
payload: dict[str, Any] = {
"contents": contents,
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
if system:
payload["systemInstruction"] = {"parts": [{"text": system}]}
url = f"/models/{model}:streamGenerateContent?key={self.api_key}&alt=sse"
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = json.loads(line[6:])
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if "text" in part:
yield StreamChunk(content=part["text"])
yield StreamChunk(content="", is_final=True)
except Exception as e:
logger.error("Gemini 流式请求失败", error=str(e))
raise
def _convert_messages_gemini(self, messages: list[Message]) -> list[dict[str, Any]]:
"""转换消息为 Gemini 格式"""
result = []
for msg in messages:
if msg.role == "system":
continue # system 单独处理
role = "user" if msg.role == "user" else "model"
result.append({
"role": role,
"parts": [{"text": msg.content}],
})
return result
def _convert_tools_gemini(self, tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""转换工具定义为 Gemini 格式"""
function_declarations = []
for tool in tools:
function_declarations.append({
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
})
return [{"functionDeclarations": function_declarations}]
async def close(self) -> None:
"""关闭客户端"""
if self._client:
await self._client.aclose()
self._client = None
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,78 @@
"""MiniMax 客户端
MiniMax 使用 OpenAI 兼容接口
"""
from __future__ import annotations
from typing import Any
import httpx
from minenasai.llm.base import Provider
from minenasai.llm.clients.openai_compat import OpenAICompatClient
class MiniMaxClient(OpenAICompatClient):
"""MiniMax 客户端
MiniMax API 兼容 OpenAI 接口格式
官方文档: https://platform.minimaxi.com/document
"""
provider = Provider.MINIMAX
default_model = "MiniMax-Text-01"
MODELS = [
"MiniMax-Text-01", # 最新旗舰
"abab6.5s-chat", # ABAB 6.5s
"abab6.5g-chat", # ABAB 6.5g
"abab6.5t-chat", # ABAB 6.5t (长文本)
"abab5.5-chat", # ABAB 5.5
]
def __init__(
self,
api_key: str,
group_id: str | None = None,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
super().__init__(
api_key=api_key,
base_url=base_url or "https://api.minimax.chat/v1",
proxy=proxy,
timeout=timeout,
max_retries=max_retries,
provider=Provider.MINIMAX,
)
self.group_id = group_id
async def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端MiniMax 使用不同的认证头)"""
if self._client is None:
transport = None
if self.proxy:
transport = httpx.AsyncHTTPTransport(proxy=self.proxy)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# MiniMax 可能需要 GroupId
if self.group_id:
headers["GroupId"] = self.group_id
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
transport=transport,
headers=headers,
)
return self._client
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,46 @@
"""Moonshot Kimi 客户端
Moonshot 使用 OpenAI 兼容接口
"""
from __future__ import annotations
from minenasai.llm.base import Provider
from minenasai.llm.clients.openai_compat import OpenAICompatClient
class MoonshotClient(OpenAICompatClient):
"""Moonshot Kimi 客户端
Moonshot API 兼容 OpenAI 接口格式
官方文档: https://platform.moonshot.cn/docs
"""
provider = Provider.MOONSHOT
default_model = "moonshot-v1-8k"
MODELS = [
"moonshot-v1-8k", # 8K 上下文
"moonshot-v1-32k", # 32K 上下文
"moonshot-v1-128k", # 128K 上下文
]
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
super().__init__(
api_key=api_key,
base_url=base_url or "https://api.moonshot.cn/v1",
proxy=proxy,
timeout=timeout,
max_retries=max_retries,
provider=Provider.MOONSHOT,
)
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,209 @@
"""OpenAI 兼容客户端
支持 OpenAI 及所有兼容 OpenAI API 格式的服务
"""
from __future__ import annotations
import json
from typing import Any, AsyncIterator
import httpx
from minenasai.core import get_logger
from minenasai.llm.base import (
BaseLLMClient,
LLMResponse,
Message,
Provider,
StreamChunk,
ToolCall,
ToolDefinition,
)
logger = get_logger(__name__)
class OpenAICompatClient(BaseLLMClient):
"""OpenAI 兼容客户端
支持:
- OpenAI 官方 API
- Azure OpenAI
- 任何兼容 OpenAI 接口的服务
"""
provider = Provider.OPENAI
default_model = "gpt-4o"
MODELS = [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1",
"o1-mini",
"o3-mini",
]
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
provider: Provider = Provider.OPENAI,
) -> None:
super().__init__(api_key, base_url, proxy, timeout, max_retries)
self.base_url = base_url or "https://api.openai.com/v1"
self.provider = provider
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端"""
if self._client is None:
transport = None
if self.proxy:
transport = httpx.AsyncHTTPTransport(proxy=self.proxy)
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
transport=transport,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
return self._client
async def chat(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> LLMResponse:
"""发送聊天请求"""
client = await self._get_client()
model = model or self.default_model
# 构建消息列表
api_messages = []
if system:
api_messages.append({"role": "system", "content": system})
api_messages.extend(self._convert_messages(messages))
payload: dict[str, Any] = {
"model": model,
"messages": api_messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if tools:
payload["tools"] = self._convert_tools(tools)
try:
response = await client.post("/chat/completions", json=payload)
response.raise_for_status()
data = response.json()
choice = data["choices"][0]
message = choice["message"]
# 解析工具调用
tool_calls = None
if message.get("tool_calls"):
tool_calls = [
ToolCall(
id=tc["id"],
name=tc["function"]["name"],
arguments=json.loads(tc["function"]["arguments"]),
)
for tc in message["tool_calls"]
]
return LLMResponse(
content=message.get("content", ""),
model=model,
provider=self.provider,
tool_calls=tool_calls,
finish_reason=choice.get("finish_reason", "stop"),
usage=data.get("usage", {}),
raw_response=data,
)
except httpx.HTTPStatusError as e:
logger.error("OpenAI API 错误", status=e.response.status_code, body=e.response.text)
raise
except Exception as e:
logger.error("OpenAI 请求失败", error=str(e))
raise
async def chat_stream(
self,
messages: list[Message],
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""流式聊天"""
client = await self._get_client()
model = model or self.default_model
api_messages = []
if system:
api_messages.append({"role": "system", "content": system})
api_messages.extend(self._convert_messages(messages))
payload: dict[str, Any] = {
"model": model,
"messages": api_messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
}
if tools:
payload["tools"] = self._convert_tools(tools)
try:
async with client.stream("POST", "/chat/completions", json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
if data_str == "[DONE]":
yield StreamChunk(content="", is_final=True)
break
data = json.loads(data_str)
delta = data["choices"][0].get("delta", {})
if "content" in delta:
yield StreamChunk(content=delta["content"])
except Exception as e:
logger.error("OpenAI 流式请求失败", error=str(e))
raise
async def close(self) -> None:
"""关闭客户端"""
if self._client:
await self._client.aclose()
self._client = None
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,51 @@
"""智谱 GLM 客户端
智谱 AI 使用 OpenAI 兼容接口
"""
from __future__ import annotations
from minenasai.llm.base import Provider
from minenasai.llm.clients.openai_compat import OpenAICompatClient
class ZhipuClient(OpenAICompatClient):
"""智谱 GLM 客户端
智谱 API 兼容 OpenAI 接口格式
官方文档: https://open.bigmodel.cn/dev/api
"""
provider = Provider.ZHIPU
default_model = "glm-4-plus"
MODELS = [
"glm-4-plus", # 最新旗舰
"glm-4-0520", # GLM-4
"glm-4-air", # 高性价比
"glm-4-airx", # 极速版
"glm-4-long", # 长文本
"glm-4-flash", # 免费版
"glm-4v-plus", # 多模态
"codegeex-4", # 代码模型
]
def __init__(
self,
api_key: str,
base_url: str | None = None,
proxy: str | None = None,
timeout: float = 60.0,
max_retries: int = 3,
) -> None:
super().__init__(
api_key=api_key,
base_url=base_url or "https://open.bigmodel.cn/api/paas/v4",
proxy=proxy,
timeout=timeout,
max_retries=max_retries,
provider=Provider.ZHIPU,
)
def get_available_models(self) -> list[str]:
return self.MODELS

View File

@@ -0,0 +1,341 @@
"""LLM 管理器
管理多个 LLM 客户端,支持自动选择、故障转移、代理配置
"""
from __future__ import annotations
from typing import Any, AsyncIterator
from minenasai.core import get_logger, get_settings
from minenasai.llm.base import (
BaseLLMClient,
LLMResponse,
Message,
Provider,
StreamChunk,
ToolDefinition,
)
from minenasai.llm.clients import (
AnthropicClient,
DeepSeekClient,
GeminiClient,
MiniMaxClient,
MoonshotClient,
OpenAICompatClient,
ZhipuClient,
)
logger = get_logger(__name__)
class LLMManager:
"""LLM 管理器
功能:
- 管理多个 LLM 客户端
- 根据配置自动选择代理
- 支持故障转移
- 统一的调用接口
"""
def __init__(self) -> None:
self.settings = get_settings()
self._clients: dict[Provider, BaseLLMClient] = {}
self._initialized = False
def _get_proxy_for_provider(self, provider: Provider) -> str | None:
"""获取提供商对应的代理配置"""
if not self.settings.proxy.enabled:
return None
# 只有境外服务需要代理
if provider.is_overseas:
return self.settings.proxy.https or self.settings.proxy.http
return None
def _create_client(self, provider: Provider) -> BaseLLMClient | None:
"""创建客户端实例"""
proxy = self._get_proxy_for_provider(provider)
llm_config = self.settings.llm
try:
if provider == Provider.ANTHROPIC:
api_key = llm_config.anthropic_api_key
if not api_key:
return None
return AnthropicClient(
api_key=api_key,
base_url=llm_config.anthropic_base_url,
proxy=proxy,
)
elif provider == Provider.OPENAI:
api_key = llm_config.openai_api_key
if not api_key:
return None
return OpenAICompatClient(
api_key=api_key,
base_url=llm_config.openai_base_url,
proxy=proxy,
)
elif provider == Provider.DEEPSEEK:
api_key = llm_config.deepseek_api_key
if not api_key:
return None
return DeepSeekClient(
api_key=api_key,
base_url=llm_config.deepseek_base_url,
)
elif provider == Provider.ZHIPU:
api_key = llm_config.zhipu_api_key
if not api_key:
return None
return ZhipuClient(
api_key=api_key,
base_url=llm_config.zhipu_base_url,
)
elif provider == Provider.MINIMAX:
api_key = llm_config.minimax_api_key
if not api_key:
return None
return MiniMaxClient(
api_key=api_key,
group_id=llm_config.minimax_group_id,
base_url=llm_config.minimax_base_url,
)
elif provider == Provider.MOONSHOT:
api_key = llm_config.moonshot_api_key
if not api_key:
return None
return MoonshotClient(
api_key=api_key,
base_url=llm_config.moonshot_base_url,
)
elif provider == Provider.GEMINI:
api_key = llm_config.gemini_api_key
if not api_key:
return None
return GeminiClient(
api_key=api_key,
base_url=llm_config.gemini_base_url,
proxy=proxy,
)
except Exception as e:
logger.error(f"创建 {provider.value} 客户端失败", error=str(e))
return None
def initialize(self) -> None:
"""初始化所有已配置的客户端"""
if self._initialized:
return
for provider in Provider:
client = self._create_client(provider)
if client:
self._clients[provider] = client
logger.info(f"LLM 客户端已初始化", provider=provider.display_name)
self._initialized = True
logger.info(f"LLM Manager 初始化完成", providers=list(self._clients.keys()))
def get_client(self, provider: Provider) -> BaseLLMClient | None:
"""获取指定提供商的客户端"""
if not self._initialized:
self.initialize()
return self._clients.get(provider)
def get_available_providers(self) -> list[Provider]:
"""获取可用的提供商列表"""
if not self._initialized:
self.initialize()
return list(self._clients.keys())
def get_default_provider(self) -> Provider | None:
"""获取默认提供商"""
if not self._initialized:
self.initialize()
# 按优先级返回第一个可用的
priority = [
Provider.ANTHROPIC,
Provider.DEEPSEEK,
Provider.ZHIPU,
Provider.OPENAI,
Provider.MOONSHOT,
Provider.MINIMAX,
Provider.GEMINI,
]
for p in priority:
if p in self._clients:
return p
return None
async def chat(
self,
messages: list[Message],
provider: Provider | str | None = None,
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
fallback: bool = True,
**kwargs: Any,
) -> LLMResponse:
"""发送聊天请求
Args:
messages: 消息列表
provider: 指定提供商(可选)
model: 模型名称(可选)
system: 系统提示词
max_tokens: 最大输出 token
temperature: 温度参数
tools: 工具定义
fallback: 失败时是否尝试其他提供商
**kwargs: 其他参数
Returns:
LLM 响应
"""
if not self._initialized:
self.initialize()
# 解析提供商
if isinstance(provider, str):
provider = Provider(provider)
if provider is None:
provider = self.get_default_provider()
if provider is None:
raise ValueError("没有可用的 LLM 提供商")
# 获取客户端
client = self._clients.get(provider)
if client is None:
if fallback:
# 尝试其他提供商
for p in self._clients:
if p != provider:
client = self._clients[p]
provider = p
logger.warning(f"降级到 {p.display_name}")
break
if client is None:
raise ValueError(f"提供商 {provider.value} 不可用")
try:
return await client.chat(
messages=messages,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
**kwargs,
)
except Exception as e:
logger.error(f"{provider.display_name} 请求失败", error=str(e))
if fallback:
# 尝试其他提供商
for p, c in self._clients.items():
if p != provider:
try:
logger.info(f"尝试降级到 {p.display_name}")
return await c.chat(
messages=messages,
model=None, # 使用默认模型
system=system,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
**kwargs,
)
except Exception:
continue
raise
async def chat_stream(
self,
messages: list[Message],
provider: Provider | str | None = None,
model: str | None = None,
system: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[ToolDefinition] | None = None,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""流式聊天"""
if not self._initialized:
self.initialize()
if isinstance(provider, str):
provider = Provider(provider)
if provider is None:
provider = self.get_default_provider()
if provider is None:
raise ValueError("没有可用的 LLM 提供商")
client = self._clients.get(provider)
if client is None:
raise ValueError(f"提供商 {provider.value} 不可用")
async for chunk in client.chat_stream(
messages=messages,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
**kwargs,
):
yield chunk
async def close(self) -> None:
"""关闭所有客户端"""
for client in self._clients.values():
await client.close()
self._clients.clear()
self._initialized = False
def get_all_models(self) -> dict[str, list[str]]:
"""获取所有提供商的可用模型"""
if not self._initialized:
self.initialize()
return {
p.display_name: c.get_available_models()
for p, c in self._clients.items()
}
# 全局 LLM 管理器
_llm_manager: LLMManager | None = None
def get_llm_manager() -> LLMManager:
"""获取全局 LLM 管理器"""
global _llm_manager
if _llm_manager is None:
_llm_manager = LLMManager()
return _llm_manager

View File

@@ -0,0 +1,12 @@
"""定时任务调度模块
提供 Cron 任务调度功能
"""
from minenasai.scheduler.cron import CronJob, CronScheduler, get_scheduler
__all__ = [
"CronJob",
"CronScheduler",
"get_scheduler",
]

View File

@@ -0,0 +1,357 @@
"""Cron 任务调度器
支持 cron 表达式的定时任务调度
"""
from __future__ import annotations
import asyncio
import re
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Callable, Coroutine
from minenasai.core import get_logger
from minenasai.core.database import get_database
logger = get_logger(__name__)
class JobStatus(str, Enum):
"""任务状态"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
DISABLED = "disabled"
@dataclass
class CronJob:
"""Cron 任务"""
id: str
name: str
schedule: str # cron 表达式
task: str # 任务描述或命令
agent_id: str = "main"
enabled: bool = True
last_run: float | None = None
next_run: float | None = None
last_status: JobStatus = JobStatus.PENDING
last_error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
# 运行时属性
_callback: Callable[[], Coroutine[Any, Any, Any]] | None = field(
default=None, repr=False
)
class CronParser:
"""Cron 表达式解析器
支持标准 5 字段格式: 分 时 日 月 周
支持特殊字符: * , - /
"""
FIELDS = ["minute", "hour", "day", "month", "weekday"]
RANGES = {
"minute": (0, 59),
"hour": (0, 23),
"day": (1, 31),
"month": (1, 12),
"weekday": (0, 6), # 0=Sunday
}
# 预定义表达式
PRESETS = {
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *",
"@every_minute": "* * * * *",
"@every_5min": "*/5 * * * *",
"@every_10min": "*/10 * * * *",
"@every_30min": "*/30 * * * *",
}
@classmethod
def parse(cls, expression: str) -> dict[str, set[int]]:
"""解析 cron 表达式
Args:
expression: cron 表达式
Returns:
各字段的有效值集合
"""
# 处理预定义表达式
if expression.startswith("@"):
expression = cls.PRESETS.get(expression, expression)
parts = expression.strip().split()
if len(parts) != 5:
raise ValueError(f"无效的 cron 表达式: {expression}")
result = {}
for i, (field_name, part) in enumerate(zip(cls.FIELDS, parts)):
min_val, max_val = cls.RANGES[field_name]
result[field_name] = cls._parse_field(part, min_val, max_val)
return result
@classmethod
def _parse_field(cls, field: str, min_val: int, max_val: int) -> set[int]:
"""解析单个字段"""
values: set[int] = set()
for part in field.split(","):
if part == "*":
values.update(range(min_val, max_val + 1))
elif "/" in part:
# 步进
base, step = part.split("/")
step_val = int(step)
if base == "*":
start = min_val
else:
start = int(base)
values.update(range(start, max_val + 1, step_val))
elif "-" in part:
# 范围
start, end = part.split("-")
values.update(range(int(start), int(end) + 1))
else:
# 单个值
values.add(int(part))
return values
@classmethod
def get_next_run(cls, expression: str, after: datetime | None = None) -> datetime:
"""计算下次运行时间
Args:
expression: cron 表达式
after: 起始时间(默认当前时间)
Returns:
下次运行的 datetime
"""
schedule = cls.parse(expression)
if after is None:
after = datetime.now()
# 从下一分钟开始
current = after.replace(second=0, microsecond=0)
current = current.replace(minute=current.minute + 1)
# 最多搜索 4 年
max_iterations = 4 * 366 * 24 * 60
for _ in range(max_iterations):
if (
current.month in schedule["month"]
and current.day in schedule["day"]
and current.weekday() in schedule["weekday"]
and current.hour in schedule["hour"]
and current.minute in schedule["minute"]
):
return current
# 递增一分钟(使用 timedelta 避免边界问题)
current = current + timedelta(minutes=1)
raise ValueError(f"无法计算下次运行时间: {expression}")
class CronScheduler:
"""Cron 任务调度器"""
def __init__(self) -> None:
self._jobs: dict[str, CronJob] = {}
self._running = False
self._task: asyncio.Task[None] | None = None
def add_job(
self,
job_id: str,
name: str,
schedule: str,
callback: Callable[[], Coroutine[Any, Any, Any]],
task: str = "",
agent_id: str = "main",
enabled: bool = True,
metadata: dict[str, Any] | None = None,
) -> CronJob:
"""添加定时任务
Args:
job_id: 任务 ID
name: 任务名称
schedule: cron 表达式
callback: 异步回调函数
task: 任务描述
agent_id: Agent ID
enabled: 是否启用
metadata: 元数据
Returns:
CronJob 对象
"""
# 验证 cron 表达式
CronParser.parse(schedule)
next_run = CronParser.get_next_run(schedule)
job = CronJob(
id=job_id,
name=name,
schedule=schedule,
task=task,
agent_id=agent_id,
enabled=enabled,
next_run=next_run.timestamp(),
metadata=metadata or {},
)
job._callback = callback
self._jobs[job_id] = job
logger.info(
"添加定时任务",
job_id=job_id,
name=name,
schedule=schedule,
next_run=next_run.isoformat(),
)
return job
def remove_job(self, job_id: str) -> bool:
"""移除任务"""
job = self._jobs.pop(job_id, None)
if job:
logger.info("移除定时任务", job_id=job_id)
return True
return False
def enable_job(self, job_id: str) -> bool:
"""启用任务"""
job = self._jobs.get(job_id)
if job:
job.enabled = True
job.next_run = CronParser.get_next_run(job.schedule).timestamp()
return True
return False
def disable_job(self, job_id: str) -> bool:
"""禁用任务"""
job = self._jobs.get(job_id)
if job:
job.enabled = False
job.last_status = JobStatus.DISABLED
return True
return False
def get_job(self, job_id: str) -> CronJob | None:
"""获取任务"""
return self._jobs.get(job_id)
def list_jobs(self) -> list[CronJob]:
"""列出所有任务"""
return list(self._jobs.values())
async def start(self) -> None:
"""启动调度器"""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("定时调度器已启动")
async def stop(self) -> None:
"""停止调度器"""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("定时调度器已停止")
async def _run_loop(self) -> None:
"""调度循环"""
while self._running:
now = time.time()
for job in self._jobs.values():
if not job.enabled:
continue
if job.next_run and job.next_run <= now:
# 执行任务
asyncio.create_task(self._execute_job(job))
# 计算下次运行时间
job.next_run = CronParser.get_next_run(job.schedule).timestamp()
# 每秒检查一次
await asyncio.sleep(1)
async def _execute_job(self, job: CronJob) -> None:
"""执行任务"""
if job._callback is None:
logger.warning("任务没有回调", job_id=job.id)
return
job.last_status = JobStatus.RUNNING
job.last_run = time.time()
logger.info("执行定时任务", job_id=job.id, name=job.name)
try:
await job._callback()
job.last_status = JobStatus.COMPLETED
job.last_error = None
logger.info("定时任务完成", job_id=job.id)
except Exception as e:
job.last_status = JobStatus.FAILED
job.last_error = str(e)
logger.error("定时任务失败", job_id=job.id, error=str(e))
def get_stats(self) -> dict[str, Any]:
"""获取统计信息"""
status_counts: dict[str, int] = {}
for job in self._jobs.values():
status = job.last_status.value
status_counts[status] = status_counts.get(status, 0) + 1
return {
"running": self._running,
"total_jobs": len(self._jobs),
"enabled_jobs": sum(1 for j in self._jobs.values() if j.enabled),
"status_counts": status_counts,
}
# 全局调度器
_scheduler: CronScheduler | None = None
def get_scheduler() -> CronScheduler:
"""获取全局调度器"""
global _scheduler
if _scheduler is None:
_scheduler = CronScheduler()
return _scheduler

View File

@@ -0,0 +1,16 @@
"""Web TUI 模块
提供 Web 终端界面、SSH 连接管理
"""
from minenasai.webtui.auth import AuthManager, AuthToken, get_auth_manager
from minenasai.webtui.ssh_manager import SSHManager, SSHSession, get_ssh_manager
__all__ = [
"AuthManager",
"AuthToken",
"get_auth_manager",
"SSHManager",
"SSHSession",
"get_ssh_manager",
]

View File

@@ -0,0 +1,236 @@
"""用户认证模块
提供 Token 认证和会话管理
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass, field
from typing import Any
from minenasai.core import get_logger, get_settings
logger = get_logger(__name__)
@dataclass
class AuthToken:
"""认证令牌"""
token: str
user_id: str
created_at: float
expires_at: float
metadata: dict[str, Any] = field(default_factory=dict)
@property
def is_expired(self) -> bool:
"""是否已过期"""
return time.time() > self.expires_at
@property
def remaining_time(self) -> float:
"""剩余有效时间(秒)"""
return max(0, self.expires_at - time.time())
class AuthManager:
"""认证管理器"""
def __init__(self, secret_key: str | None = None) -> None:
"""初始化认证管理器
Args:
secret_key: 签名密钥
"""
self.settings = get_settings()
self.secret_key = secret_key or self.settings.webtui_secret_key
self._tokens: dict[str, AuthToken] = {}
self._user_sessions: dict[str, set[str]] = {} # user_id -> set of tokens
def generate_token(
self,
user_id: str,
expires_in: int | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
"""生成访问令牌
Args:
user_id: 用户 ID
expires_in: 有效期(秒),默认使用配置
metadata: 额外元数据
Returns:
访问令牌
"""
# 生成随机令牌
random_part = secrets.token_urlsafe(24)
timestamp = str(int(time.time()))
# 签名
signature = self._sign(f"{random_part}:{timestamp}:{user_id}")
token = f"{random_part}.{timestamp}.{signature[:16]}"
# 有效期
if expires_in is None:
expires_in = self.settings.webtui.session_timeout
now = time.time()
auth_token = AuthToken(
token=token,
user_id=user_id,
created_at=now,
expires_at=now + expires_in,
metadata=metadata or {},
)
# 存储
self._tokens[token] = auth_token
# 记录用户会话
if user_id not in self._user_sessions:
self._user_sessions[user_id] = set()
self._user_sessions[user_id].add(token)
logger.info("生成令牌", user_id=user_id, expires_in=expires_in)
return token
def verify_token(self, token: str) -> AuthToken | None:
"""验证令牌
Args:
token: 访问令牌
Returns:
AuthToken 或 None无效时
"""
auth_token = self._tokens.get(token)
if auth_token is None:
logger.debug("令牌不存在", token=token[:20] + "...")
return None
if auth_token.is_expired:
logger.debug("令牌已过期", token=token[:20] + "...")
self.revoke_token(token)
return None
return auth_token
def revoke_token(self, token: str) -> bool:
"""撤销令牌
Args:
token: 访问令牌
Returns:
是否成功撤销
"""
auth_token = self._tokens.pop(token, None)
if auth_token:
# 从用户会话中移除
if auth_token.user_id in self._user_sessions:
self._user_sessions[auth_token.user_id].discard(token)
logger.info("撤销令牌", user_id=auth_token.user_id)
return True
return False
def revoke_user_tokens(self, user_id: str) -> int:
"""撤销用户的所有令牌
Args:
user_id: 用户 ID
Returns:
撤销的令牌数量
"""
tokens = self._user_sessions.pop(user_id, set())
for token in tokens:
self._tokens.pop(token, None)
if tokens:
logger.info("撤销用户所有令牌", user_id=user_id, count=len(tokens))
return len(tokens)
def refresh_token(self, token: str, extends_by: int | None = None) -> str | None:
"""刷新令牌
Args:
token: 当前令牌
extends_by: 延长时间(秒)
Returns:
新令牌或 None失败时
"""
auth_token = self.verify_token(token)
if auth_token is None:
return None
# 生成新令牌
new_token = self.generate_token(
user_id=auth_token.user_id,
expires_in=extends_by,
metadata=auth_token.metadata,
)
# 撤销旧令牌
self.revoke_token(token)
return new_token
def cleanup_expired(self) -> int:
"""清理过期令牌
Returns:
清理的令牌数量
"""
now = time.time()
expired = [
token for token, auth_token in self._tokens.items()
if auth_token.expires_at < now
]
for token in expired:
self.revoke_token(token)
if expired:
logger.info("清理过期令牌", count=len(expired))
return len(expired)
def _sign(self, data: str) -> str:
"""生成签名"""
return hmac.new(
self.secret_key.encode(),
data.encode(),
hashlib.sha256
).hexdigest()
def get_stats(self) -> dict[str, Any]:
"""获取统计信息"""
return {
"total_tokens": len(self._tokens),
"total_users": len(self._user_sessions),
"tokens_per_user": {
user_id: len(tokens)
for user_id, tokens in self._user_sessions.items()
},
}
# 全局认证管理器
_auth_manager: AuthManager | None = None
def get_auth_manager() -> AuthManager:
"""获取全局认证管理器"""
global _auth_manager
if _auth_manager is None:
_auth_manager = AuthManager()
return _auth_manager

View File

@@ -0,0 +1,314 @@
"""Web TUI 服务器
提供 Web 终端界面和 WebSocket 终端通信
"""
from __future__ import annotations
import asyncio
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from minenasai.core import get_logger, get_settings, setup_logging
from minenasai.webtui.auth import get_auth_manager
from minenasai.webtui.ssh_manager import SSHSession, get_ssh_manager
logger = get_logger(__name__)
# 静态文件目录
STATIC_DIR = Path(__file__).parent / "static"
class TerminalConnection:
"""终端连接"""
def __init__(
self,
websocket: WebSocket,
session_id: str,
user_id: str,
) -> None:
self.websocket = websocket
self.session_id = session_id
self.user_id = user_id
self.ssh_session: SSHSession | None = None
self.authenticated = False
async def send_json(self, data: dict[str, Any]) -> None:
"""发送 JSON 消息"""
try:
await self.websocket.send_json(data)
except Exception as e:
logger.error("发送消息失败", error=str(e))
async def send_output(self, data: bytes) -> None:
"""发送终端输出"""
try:
await self.websocket.send_json({
"type": "output",
"data": data.decode("utf-8", errors="replace"),
})
except Exception as e:
logger.error("发送输出失败", error=str(e))
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self) -> None:
self.connections: dict[str, TerminalConnection] = {}
async def connect(
self,
websocket: WebSocket,
session_id: str,
user_id: str,
) -> TerminalConnection:
"""接受新连接"""
await websocket.accept()
conn = TerminalConnection(websocket, session_id, user_id)
self.connections[session_id] = conn
logger.info("终端连接建立", session_id=session_id)
return conn
async def disconnect(self, session_id: str) -> None:
"""断开连接"""
conn = self.connections.pop(session_id, None)
if conn and conn.ssh_session:
ssh_manager = get_ssh_manager()
await ssh_manager.close_session(session_id)
logger.info("终端连接断开", session_id=session_id)
def get_connection(self, session_id: str) -> TerminalConnection | None:
"""获取连接"""
return self.connections.get(session_id)
manager = ConnectionManager()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理"""
settings = get_settings()
setup_logging(settings.logging)
logger.info("Web TUI 服务启动", port=settings.webtui.port)
yield
# 清理所有连接
ssh_manager = get_ssh_manager()
await ssh_manager.close_all()
logger.info("Web TUI 服务关闭")
app = FastAPI(
title="MineNASAI Web TUI",
description="Web 终端界面",
version="0.1.0",
lifespan=lifespan,
)
# CORS 配置
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/")
async def index() -> HTMLResponse:
"""首页"""
index_file = STATIC_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
return HTMLResponse(content="<h1>MineNASAI Web TUI</h1>")
@app.get("/health")
async def health() -> dict[str, str]:
"""健康检查"""
return {"status": "healthy"}
@app.get("/api/stats")
async def stats() -> dict[str, Any]:
"""获取统计信息"""
ssh_manager = get_ssh_manager()
auth_manager = get_auth_manager()
return {
"connections": len(manager.connections),
"ssh": ssh_manager.get_stats(),
"auth": auth_manager.get_stats(),
}
@app.post("/api/token")
async def generate_token(
user_id: str = "anonymous",
expires_in: int = 3600,
) -> dict[str, Any]:
"""生成访问令牌(用于测试)"""
auth_manager = get_auth_manager()
token = auth_manager.generate_token(user_id, expires_in)
return {
"token": token,
"user_id": user_id,
"expires_in": expires_in,
}
@app.websocket("/ws/terminal")
async def terminal_websocket(websocket: WebSocket) -> None:
"""终端 WebSocket 端点"""
session_id = str(uuid.uuid4())
conn: TerminalConnection | None = None
try:
conn = await manager.connect(websocket, session_id, "")
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "auth":
await handle_auth(conn, data)
elif msg_type == "input":
await handle_input(conn, data)
elif msg_type == "resize":
await handle_resize(conn, data)
elif msg_type == "pong":
pass # 心跳响应
else:
await conn.send_json({
"type": "error",
"message": f"未知消息类型: {msg_type}",
})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error("WebSocket 错误", error=str(e), session_id=session_id)
finally:
if conn:
await manager.disconnect(session_id)
async def handle_auth(conn: TerminalConnection, data: dict[str, Any]) -> None:
"""处理认证"""
token = data.get("token", "")
auth_manager = get_auth_manager()
# 允许匿名访问(开发模式)
if token == "anonymous":
conn.authenticated = True
conn.user_id = "anonymous"
await conn.send_json({
"type": "auth_ok",
"session_id": conn.session_id,
"user_id": conn.user_id,
})
# 创建 SSH 会话
await create_ssh_session(conn)
return
# 验证令牌
auth_token = auth_manager.verify_token(token)
if auth_token is None:
await conn.send_json({
"type": "auth_error",
"message": "无效的令牌",
})
return
conn.authenticated = True
conn.user_id = auth_token.user_id
await conn.send_json({
"type": "auth_ok",
"session_id": conn.session_id,
"user_id": conn.user_id,
})
# 创建 SSH 会话
await create_ssh_session(conn)
async def create_ssh_session(conn: TerminalConnection) -> None:
"""创建 SSH 会话"""
ssh_manager = get_ssh_manager()
session = await ssh_manager.create_session(conn.session_id)
if session is None:
await conn.send_json({
"type": "error",
"message": "SSH 连接失败",
})
return
conn.ssh_session = session
# 设置输出回调
def on_output(data: bytes) -> None:
asyncio.create_task(conn.send_output(data))
session.set_output_callback(on_output)
# 开始读取输出
await session.start_reading()
async def handle_input(conn: TerminalConnection, data: dict[str, Any]) -> None:
"""处理终端输入"""
if not conn.authenticated:
await conn.send_json({
"type": "error",
"message": "未认证",
})
return
if conn.ssh_session is None:
await conn.send_json({
"type": "error",
"message": "SSH 会话未建立",
})
return
input_data = data.get("data", "")
await conn.ssh_session.write(input_data)
async def handle_resize(conn: TerminalConnection, data: dict[str, Any]) -> None:
"""处理终端大小调整"""
if conn.ssh_session is None:
return
cols = data.get("cols", 80)
rows = data.get("rows", 24)
await conn.ssh_session.resize(cols, rows)
await conn.send_json({
"type": "resize_ok",
"cols": cols,
"rows": rows,
})

View File

@@ -0,0 +1,313 @@
"""SSH 连接管理
使用 paramiko 管理 SSH 连接和 PTY
"""
from __future__ import annotations
import asyncio
import os
import time
from pathlib import Path
from typing import Any, Callable
import paramiko
from minenasai.core import get_logger, get_settings
from minenasai.core.config import expand_path
logger = get_logger(__name__)
class SSHSession:
"""SSH 会话"""
def __init__(
self,
session_id: str,
host: str = "localhost",
port: int = 22,
username: str | None = None,
key_path: str | None = None,
password: str | None = None,
) -> None:
"""初始化 SSH 会话
Args:
session_id: 会话 ID
host: SSH 主机
port: SSH 端口
username: 用户名
key_path: 私钥路径
password: 密码(不推荐)
"""
self.session_id = session_id
self.host = host
self.port = port
self.username = username or os.getenv("USER") or os.getenv("USERNAME") or "root"
self.key_path = key_path
self.password = password
self.client: paramiko.SSHClient | None = None
self.channel: paramiko.Channel | None = None
self.connected = False
self.created_at = time.time()
self.last_activity = time.time()
self._output_callback: Callable[[bytes], None] | None = None
self._read_task: asyncio.Task[None] | None = None
async def connect(self) -> bool:
"""建立 SSH 连接"""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# 连接参数
connect_kwargs: dict[str, Any] = {
"hostname": self.host,
"port": self.port,
"username": self.username,
"timeout": 10,
}
# 优先使用密钥认证
if self.key_path:
key_file = expand_path(self.key_path)
if key_file.exists():
connect_kwargs["key_filename"] = str(key_file)
else:
logger.warning("密钥文件不存在", path=str(key_file))
# 密码认证
if self.password:
connect_kwargs["password"] = self.password
# 尝试使用默认密钥
if "key_filename" not in connect_kwargs and "password" not in connect_kwargs:
connect_kwargs["allow_agent"] = True
connect_kwargs["look_for_keys"] = True
# 在线程池中执行连接(避免阻塞)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.client.connect(**connect_kwargs)
)
# 打开交互式 Shell
self.channel = self.client.invoke_shell(
term="xterm-256color",
width=80,
height=24,
)
self.channel.setblocking(False)
self.connected = True
logger.info(
"SSH 连接成功",
session_id=self.session_id,
host=self.host,
username=self.username,
)
return True
except paramiko.AuthenticationException as e:
logger.error("SSH 认证失败", error=str(e))
return False
except paramiko.SSHException as e:
logger.error("SSH 连接错误", error=str(e))
return False
except Exception as e:
logger.error("SSH 连接异常", error=str(e))
return False
async def disconnect(self) -> None:
"""断开 SSH 连接"""
self.connected = False
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
if self.channel:
self.channel.close()
self.channel = None
if self.client:
self.client.close()
self.client = None
logger.info("SSH 连接已断开", session_id=self.session_id)
def set_output_callback(self, callback: Callable[[bytes], None]) -> None:
"""设置输出回调"""
self._output_callback = callback
async def start_reading(self) -> None:
"""开始读取输出"""
self._read_task = asyncio.create_task(self._read_loop())
async def _read_loop(self) -> None:
"""读取循环"""
while self.connected and self.channel:
try:
# 检查是否有数据可读
if self.channel.recv_ready():
data = self.channel.recv(4096)
if data and self._output_callback:
self._output_callback(data)
self.last_activity = time.time()
else:
await asyncio.sleep(0.01) # 短暂等待
except Exception as e:
if self.connected:
logger.error("SSH 读取错误", error=str(e))
break
async def write(self, data: str | bytes) -> None:
"""写入数据到 SSH 通道"""
if not self.connected or not self.channel:
return
if isinstance(data, str):
data = data.encode("utf-8")
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.channel.send(data)
)
self.last_activity = time.time()
except Exception as e:
logger.error("SSH 写入错误", error=str(e))
async def resize(self, cols: int, rows: int) -> None:
"""调整终端大小"""
if self.channel:
try:
self.channel.resize_pty(width=cols, height=rows)
except Exception as e:
logger.error("SSH 调整大小错误", error=str(e))
class SSHManager:
"""SSH 连接池管理器"""
def __init__(self, max_sessions: int = 50) -> None:
"""初始化管理器
Args:
max_sessions: 最大会话数
"""
self.settings = get_settings()
self.max_sessions = max_sessions
self._sessions: dict[str, SSHSession] = {}
async def create_session(
self,
session_id: str,
host: str | None = None,
port: int | None = None,
username: str | None = None,
) -> SSHSession | None:
"""创建新会话
Args:
session_id: 会话 ID
host: SSH 主机(默认使用配置)
port: SSH 端口
username: 用户名
Returns:
SSHSession 或 None失败时
"""
# 检查会话数量限制
if len(self._sessions) >= self.max_sessions:
# 清理过期会话
await self._cleanup_sessions()
if len(self._sessions) >= self.max_sessions:
logger.warning("会话数量已达上限", max=self.max_sessions)
return None
# 使用配置的默认值
ssh_config = self.settings.webtui.ssh
session = SSHSession(
session_id=session_id,
host=host or ssh_config.host,
port=port or ssh_config.port,
username=username or self.settings.ssh_username,
key_path=self.settings.ssh_key_path,
)
if await session.connect():
self._sessions[session_id] = session
return session
return None
def get_session(self, session_id: str) -> SSHSession | None:
"""获取会话"""
return self._sessions.get(session_id)
async def close_session(self, session_id: str) -> None:
"""关闭会话"""
session = self._sessions.pop(session_id, None)
if session:
await session.disconnect()
async def _cleanup_sessions(self, max_idle: float = 3600) -> None:
"""清理空闲会话
Args:
max_idle: 最大空闲时间(秒)
"""
now = time.time()
expired = [
sid for sid, session in self._sessions.items()
if now - session.last_activity > max_idle
]
for sid in expired:
await self.close_session(sid)
logger.info("清理空闲会话", session_id=sid)
async def close_all(self) -> None:
"""关闭所有会话"""
for session_id in list(self._sessions.keys()):
await self.close_session(session_id)
def get_stats(self) -> dict[str, Any]:
"""获取统计信息"""
return {
"active_sessions": len(self._sessions),
"max_sessions": self.max_sessions,
"sessions": [
{
"id": s.session_id,
"host": s.host,
"connected": s.connected,
"created_at": s.created_at,
"last_activity": s.last_activity,
}
for s in self._sessions.values()
],
}
# 全局 SSH 管理器
_ssh_manager: SSHManager | None = None
def get_ssh_manager() -> SSHManager:
"""获取全局 SSH 管理器"""
global _ssh_manager
if _ssh_manager is None:
_ssh_manager = SSHManager()
return _ssh_manager

View File

@@ -0,0 +1,377 @@
/* MineNASAI Web Terminal Styles */
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #1f2335;
--text-primary: #c0caf5;
--text-secondary: #565f89;
--accent-primary: #7aa2f7;
--accent-success: #9ece6a;
--accent-warning: #e0af68;
--accent-error: #f7768e;
--border-color: #3b4261;
--header-height: 48px;
--footer-height: 24px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
.header {
height: var(--header-height);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.logo {
font-size: 18px;
font-weight: 600;
color: var(--accent-primary);
}
.version {
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
}
.header-center {
display: flex;
align-items: center;
}
.connection-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent-error);
}
.status-dot.connected {
background-color: var(--accent-success);
}
.status-dot.connecting {
background-color: var(--accent-warning);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Buttons */
.btn {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn:hover {
background-color: var(--bg-secondary);
border-color: var(--accent-primary);
}
.btn-primary {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover {
background-color: #89b4fa;
}
.btn-icon {
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.terminal-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--bg-tertiary);
}
.terminal-header {
height: 36px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
}
.terminal-tabs {
display: flex;
align-items: center;
gap: 4px;
}
.terminal-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: var(--bg-tertiary);
border-radius: 4px 4px 0 0;
font-size: 12px;
cursor: pointer;
}
.terminal-tab.active {
background-color: var(--bg-primary);
}
.tab-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
padding: 0;
line-height: 1;
}
.tab-close:hover {
color: var(--accent-error);
}
.btn-new-tab {
background: none;
border: 1px dashed var(--border-color);
color: var(--text-secondary);
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.btn-new-tab:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.terminal-actions {
display: flex;
gap: 4px;
}
.terminal {
flex: 1;
padding: 8px;
}
/* Footer */
.footer {
height: var(--footer-height);
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 11px;
color: var(--text-secondary);
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 400px;
max-width: 90%;
}
.modal-small {
width: 320px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 16px;
font-weight: 500;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
}
.modal-close:hover {
color: var(--accent-error);
}
.modal-body {
padding: 16px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px;
border-top: 1px solid var(--border-color);
}
/* Form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 13px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-primary);
}
.form-group input[type="checkbox"] {
margin-right: 8px;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 8px;
}
/* Fullscreen */
.fullscreen .header,
.fullscreen .footer {
display: none;
}
.fullscreen .terminal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
}
/* Responsive */
@media (max-width: 768px) {
.header-center {
display: none;
}
.terminal-actions {
display: none;
}
}

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MineNASAI - Web Terminal</title>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="app-container">
<!-- 顶部导航栏 -->
<header class="header">
<div class="header-left">
<h1 class="logo">MineNASAI</h1>
<span class="version">v0.1.0</span>
</div>
<div class="header-center">
<span class="connection-status" id="connectionStatus">
<span class="status-dot disconnected"></span>
<span class="status-text">未连接</span>
</span>
</div>
<div class="header-right">
<button class="btn btn-icon" id="settingsBtn" title="设置">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
<button class="btn btn-primary" id="connectBtn">连接</button>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
<!-- 终端容器 -->
<div class="terminal-container">
<div class="terminal-header">
<div class="terminal-tabs">
<div class="terminal-tab active" data-tab="main">
<span>主终端</span>
<button class="tab-close" title="关闭">&times;</button>
</div>
<button class="btn-new-tab" id="newTabBtn" title="新建终端">+</button>
</div>
<div class="terminal-actions">
<button class="btn btn-small" id="clearBtn" title="清屏">清屏</button>
<button class="btn btn-small" id="fullscreenBtn" title="全屏">全屏</button>
</div>
</div>
<div id="terminal" class="terminal"></div>
</div>
</main>
<!-- 底部状态栏 -->
<footer class="footer">
<div class="footer-left">
<span id="sessionInfo">会话: -</span>
</div>
<div class="footer-right">
<span id="terminalSize">-</span>
</div>
</footer>
</div>
<!-- 设置模态框 -->
<div class="modal" id="settingsModal">
<div class="modal-content">
<div class="modal-header">
<h2>设置</h2>
<button class="modal-close" id="closeSettingsBtn">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>字体大小</label>
<input type="number" id="fontSizeInput" value="14" min="10" max="24">
</div>
<div class="form-group">
<label>主题</label>
<select id="themeSelect">
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="monokai">Monokai</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="cursorBlinkCheck" checked>
光标闪烁
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn" id="resetSettingsBtn">重置</button>
<button class="btn btn-primary" id="saveSettingsBtn">保存</button>
</div>
</div>
</div>
<!-- 登录模态框 -->
<div class="modal" id="loginModal">
<div class="modal-content modal-small">
<div class="modal-header">
<h2>连接到终端</h2>
</div>
<div class="modal-body">
<div class="form-group">
<label>Token</label>
<input type="password" id="tokenInput" placeholder="输入访问令牌">
</div>
<p class="hint">提示:令牌可从通讯工具消息中获取</p>
</div>
<div class="modal-footer">
<button class="btn" id="cancelLoginBtn">取消</button>
<button class="btn btn-primary" id="doConnectBtn">连接</button>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,393 @@
/**
* MineNASAI Web Terminal
*/
class WebTerminal {
constructor() {
this.terminal = null;
this.fitAddon = null;
this.socket = null;
this.sessionId = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.settings = this.loadSettings();
this.init();
}
loadSettings() {
const defaults = {
fontSize: 14,
theme: 'dark',
cursorBlink: true
};
try {
const saved = localStorage.getItem('terminal_settings');
return saved ? { ...defaults, ...JSON.parse(saved) } : defaults;
} catch {
return defaults;
}
}
saveSettings() {
localStorage.setItem('terminal_settings', JSON.stringify(this.settings));
}
getTheme(name) {
const themes = {
dark: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selection: 'rgba(122, 162, 247, 0.3)',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
},
light: {
background: '#f5f5f5',
foreground: '#1a1b26',
cursor: '#1a1b26',
cursorAccent: '#f5f5f5',
selection: 'rgba(122, 162, 247, 0.3)'
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#272822',
selection: 'rgba(73, 72, 62, 0.5)',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2'
}
};
return themes[name] || themes.dark;
}
init() {
// 初始化终端
this.terminal = new Terminal({
fontSize: this.settings.fontSize,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace',
cursorBlink: this.settings.cursorBlink,
cursorStyle: 'block',
theme: this.getTheme(this.settings.theme),
allowTransparency: true,
scrollback: 10000
});
// 添加插件
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
// 挂载终端
const container = document.getElementById('terminal');
this.terminal.open(container);
this.fitAddon.fit();
// 显示欢迎信息
this.showWelcome();
// 绑定事件
this.bindEvents();
// 窗口大小调整
window.addEventListener('resize', () => {
this.fitAddon.fit();
this.sendResize();
});
// 更新设置UI
this.updateSettingsUI();
}
showWelcome() {
const welcome = [
'\x1b[1;34m',
' __ __ _ _ _ _ ____ _ ___ ',
' | \\/ (_)_ __ ___| \\ | | / \\ / ___| / \\ |_ _|',
' | |\\/| | | \'_ \\ / _ \\ \\| | / _ \\ \\___ \\ / _ \\ | | ',
' | | | | | | | | __/ |\\ |/ ___ \\ ___) / ___ \\ | | ',
' |_| |_|_|_| |_|\\___|_| \\_/_/ \\_\\____/_/ \\_\\___|',
'\x1b[0m',
'',
'\x1b[33mWeb Terminal v0.1.0\x1b[0m',
'',
'点击右上角 \x1b[1;32m连接\x1b[0m 按钮开始使用',
''
];
welcome.forEach(line => this.terminal.writeln(line));
}
bindEvents() {
// 连接按钮
document.getElementById('connectBtn').addEventListener('click', () => {
if (this.isConnected) {
this.disconnect();
} else {
this.showLoginModal();
}
});
// 登录模态框
document.getElementById('doConnectBtn').addEventListener('click', () => {
this.connect();
});
document.getElementById('cancelLoginBtn').addEventListener('click', () => {
this.hideLoginModal();
});
document.getElementById('tokenInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.connect();
});
// 设置按钮
document.getElementById('settingsBtn').addEventListener('click', () => {
document.getElementById('settingsModal').classList.add('active');
});
document.getElementById('closeSettingsBtn').addEventListener('click', () => {
document.getElementById('settingsModal').classList.remove('active');
});
document.getElementById('saveSettingsBtn').addEventListener('click', () => {
this.applySettings();
document.getElementById('settingsModal').classList.remove('active');
});
document.getElementById('resetSettingsBtn').addEventListener('click', () => {
this.settings = { fontSize: 14, theme: 'dark', cursorBlink: true };
this.updateSettingsUI();
this.applySettings();
});
// 清屏
document.getElementById('clearBtn').addEventListener('click', () => {
this.terminal.clear();
});
// 全屏
document.getElementById('fullscreenBtn').addEventListener('click', () => {
document.body.classList.toggle('fullscreen');
setTimeout(() => this.fitAddon.fit(), 100);
});
// 终端输入
this.terminal.onData(data => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
// ESC 退出全屏
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('fullscreen')) {
document.body.classList.remove('fullscreen');
setTimeout(() => this.fitAddon.fit(), 100);
}
});
}
updateSettingsUI() {
document.getElementById('fontSizeInput').value = this.settings.fontSize;
document.getElementById('themeSelect').value = this.settings.theme;
document.getElementById('cursorBlinkCheck').checked = this.settings.cursorBlink;
}
applySettings() {
this.settings.fontSize = parseInt(document.getElementById('fontSizeInput').value);
this.settings.theme = document.getElementById('themeSelect').value;
this.settings.cursorBlink = document.getElementById('cursorBlinkCheck').checked;
this.terminal.options.fontSize = this.settings.fontSize;
this.terminal.options.cursorBlink = this.settings.cursorBlink;
this.terminal.options.theme = this.getTheme(this.settings.theme);
this.saveSettings();
this.fitAddon.fit();
}
showLoginModal() {
document.getElementById('loginModal').classList.add('active');
document.getElementById('tokenInput').focus();
}
hideLoginModal() {
document.getElementById('loginModal').classList.remove('active');
document.getElementById('tokenInput').value = '';
}
connect() {
const token = document.getElementById('tokenInput').value.trim();
this.hideLoginModal();
this.updateStatus('connecting', '连接中...');
this.terminal.writeln('\x1b[33m正在连接...\x1b[0m');
// 获取 WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/terminal`;
try {
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
// 发送认证
this.socket.send(JSON.stringify({
type: 'auth',
token: token || 'anonymous'
}));
};
this.socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};
this.socket.onclose = (event) => {
this.isConnected = false;
this.updateStatus('disconnected', '已断开');
this.updateConnectButton(false);
if (event.code !== 1000) {
this.terminal.writeln(`\x1b[31m连接已断开 (${event.code})\x1b[0m`);
this.tryReconnect();
}
};
this.socket.onerror = (error) => {
this.terminal.writeln('\x1b[31m连接错误\x1b[0m');
console.error('WebSocket error:', error);
};
} catch (e) {
this.terminal.writeln(`\x1b[31m连接失败: ${e.message}\x1b[0m`);
this.updateStatus('disconnected', '连接失败');
}
}
handleMessage(msg) {
switch (msg.type) {
case 'auth_ok':
this.isConnected = true;
this.sessionId = msg.session_id;
this.reconnectAttempts = 0;
this.updateStatus('connected', '已连接');
this.updateConnectButton(true);
this.terminal.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
document.getElementById('sessionInfo').textContent = `会话: ${this.sessionId.slice(0, 8)}`;
// 发送终端尺寸
this.sendResize();
break;
case 'auth_error':
this.terminal.writeln(`\x1b[31m认证失败: ${msg.message}\x1b[0m`);
this.updateStatus('disconnected', '认证失败');
this.socket.close();
break;
case 'output':
this.terminal.write(msg.data);
break;
case 'resize_ok':
const cols = msg.cols;
const rows = msg.rows;
document.getElementById('terminalSize').textContent = `${cols}x${rows}`;
break;
case 'error':
this.terminal.writeln(`\x1b[31m错误: ${msg.message}\x1b[0m`);
break;
case 'ping':
this.socket.send(JSON.stringify({ type: 'pong' }));
break;
}
}
sendResize() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
const dims = this.fitAddon.proposeDimensions();
if (dims) {
this.socket.send(JSON.stringify({
type: 'resize',
cols: dims.cols,
rows: dims.rows
}));
}
}
}
disconnect() {
if (this.socket) {
this.socket.close(1000, 'User disconnect');
this.socket = null;
}
this.isConnected = false;
this.terminal.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
this.updateStatus('disconnected', '已断开');
this.updateConnectButton(false);
}
tryReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.terminal.writeln(`\x1b[33m${delay/1000}秒后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})\x1b[0m`);
setTimeout(() => {
if (!this.isConnected) {
this.connect();
}
}, delay);
}
}
updateStatus(status, text) {
const statusEl = document.getElementById('connectionStatus');
const dot = statusEl.querySelector('.status-dot');
const textEl = statusEl.querySelector('.status-text');
dot.className = 'status-dot ' + status;
textEl.textContent = text;
}
updateConnectButton(connected) {
const btn = document.getElementById('connectBtn');
btn.textContent = connected ? '断开' : '连接';
btn.classList.toggle('btn-primary', !connected);
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
window.webTerminal = new WebTerminal();
});

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""MineNASAI 测试套件"""

1
tests/agent/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Agent 测试"""

22
tests/conftest.py Normal file
View File

@@ -0,0 +1,22 @@
"""pytest 配置和共享 fixtures"""
from __future__ import annotations
import pytest
from minenasai.core import reset_settings
@pytest.fixture(autouse=True)
def reset_global_settings():
"""每个测试后重置全局配置"""
yield
reset_settings()
@pytest.fixture
def temp_config(tmp_path):
"""创建临时配置目录"""
config_dir = tmp_path / ".config" / "minenasai"
config_dir.mkdir(parents=True)
return config_dir

View File

@@ -0,0 +1 @@
"""Gateway 测试"""

124
tests/test_core.py Normal file
View File

@@ -0,0 +1,124 @@
"""核心模块测试"""
from __future__ import annotations
import json
import pytest
from minenasai.core import Settings, get_settings, load_config, reset_settings
from minenasai.core.config import expand_path
class TestConfig:
"""配置模块测试"""
def test_default_settings(self):
"""测试默认配置"""
settings = Settings()
assert settings.app.name == "MineNASAI"
assert settings.app.version == "0.1.0"
assert settings.gateway.port == 8000
assert settings.webtui.port == 8080
def test_expand_path(self):
"""测试路径展开"""
import os
home = os.path.expanduser("~")
path = expand_path("~/test")
assert str(path).startswith(home)
def test_load_config_no_file(self, tmp_path):
"""测试配置文件不存在时使用默认值"""
config_path = tmp_path / "nonexistent.json5"
settings = load_config(config_path)
assert settings.app.name == "MineNASAI"
def test_load_config_with_file(self, tmp_path):
"""测试从文件加载配置"""
import json5
config_path = tmp_path / "config.json5"
config_data = {
"app": {"name": "TestApp", "debug": True},
"gateway": {"port": 9000},
}
with open(config_path, "w", encoding="utf-8") as f:
json5.dump(config_data, f)
settings = load_config(config_path)
assert settings.app.name == "TestApp"
assert settings.app.debug is True
assert settings.gateway.port == 9000
def test_get_settings_singleton(self):
"""测试全局配置单例"""
reset_settings()
settings1 = get_settings()
settings2 = get_settings()
assert settings1 is settings2
def test_env_override(self, monkeypatch):
"""测试环境变量覆盖"""
# Settings 使用 MINENASAI_ 前缀
monkeypatch.setenv("MINENASAI_ANTHROPIC_API_KEY", "test-key")
reset_settings()
settings = Settings()
assert settings.anthropic_api_key == "test-key"
class TestLogging:
"""日志模块测试"""
def test_setup_logging(self):
"""测试日志初始化"""
from minenasai.core import setup_logging
from minenasai.core.config import LoggingConfig
config = LoggingConfig(level="DEBUG", format="console", file=False)
setup_logging(config)
# 应该不抛出异常
def test_get_logger(self):
"""测试获取日志记录器"""
from minenasai.core import get_logger
logger = get_logger("test")
assert logger is not None
def test_audit_logger(self, tmp_path):
"""测试审计日志"""
from minenasai.core.logging import AuditLogger
audit_path = tmp_path / "audit.jsonl"
audit = AuditLogger(audit_path)
audit.log_tool_call(
agent_id="test-agent",
tool_name="read",
params={"path": "/test"},
danger_level="safe",
result="success",
duration_ms=100,
)
assert audit_path.exists()
with open(audit_path, encoding="utf-8") as f:
record = json.loads(f.readline())
assert record["agent_id"] == "test-agent"
assert record["tool_name"] == "read"
assert record["danger_level"] == "safe"

144
tests/test_gateway.py Normal file
View File

@@ -0,0 +1,144 @@
"""Gateway 模块测试"""
from __future__ import annotations
import pytest
from minenasai.gateway.protocol import (
ChatMessage,
MessageType,
TaskComplexity,
parse_message,
)
from minenasai.gateway.router import SmartRouter
class TestProtocol:
"""协议测试"""
def test_chat_message(self):
"""测试聊天消息"""
msg = ChatMessage(content="你好")
assert msg.type == MessageType.CHAT
assert msg.content == "你好"
assert msg.id is not None
def test_parse_message(self):
"""测试消息解析"""
data = {
"type": "chat",
"content": "测试消息",
}
msg = parse_message(data)
assert isinstance(msg, ChatMessage)
assert msg.content == "测试消息"
def test_parse_invalid_type(self):
"""测试无效消息类型"""
data = {"type": "invalid"}
with pytest.raises(ValueError, match="未知的消息类型"):
parse_message(data)
def test_parse_missing_type(self):
"""测试缺少类型"""
data = {"content": "test"}
with pytest.raises(ValueError, match="缺少 type"):
parse_message(data)
class TestRouter:
"""智能路由测试"""
def setup_method(self):
"""初始化路由器"""
self.router = SmartRouter()
def test_simple_question(self):
"""测试简单问题"""
result = self.router.evaluate("今天天气怎么样?")
assert result["complexity"] == TaskComplexity.SIMPLE
assert result["suggested_handler"] == "quick_response"
def test_complex_task(self):
"""测试复杂任务"""
# 包含多个复杂关键词:重构、实现、优化
result = self.router.evaluate(
"请帮我重构这个项目的数据库模块,实现异步操作支持,"
"优化连接池管理,同时要保持向后兼容。这是一个架构设计任务。"
)
# 复杂任务应该识别为 COMPLEX 或 MEDIUM
assert result["complexity"] in [TaskComplexity.COMPLEX, TaskComplexity.MEDIUM]
def test_medium_task(self):
"""测试中等任务"""
result = self.router.evaluate("查看当前目录下的文件列表")
assert result["complexity"] in [TaskComplexity.SIMPLE, TaskComplexity.MEDIUM]
def test_command_override_simple(self):
"""测试命令覆盖 - 简单"""
result = self.router.evaluate("/快速 帮我重构整个项目")
assert result["complexity"] == TaskComplexity.SIMPLE
assert result["confidence"] == 1.0
assert result["content"] == "帮我重构整个项目"
def test_command_override_complex(self):
"""测试命令覆盖 - 复杂"""
result = self.router.evaluate("/深度 你好")
assert result["complexity"] == TaskComplexity.COMPLEX
assert result["confidence"] == 1.0
def test_code_detection(self):
"""测试代码检测"""
result = self.router.evaluate(
"请帮我实现这个函数:\n```python\ndef hello():\n pass\n```"
)
assert result["complexity"] == TaskComplexity.COMPLEX
def test_multi_step_detection(self):
"""测试多步骤检测"""
# 使用英文 step 模式和更多内容
result = self.router.evaluate(
"Step 1: 创建数据库表结构\n"
"Step 2: 实现数据导入功能\n"
"Step 3: 开发验证脚本\n"
"Step 4: 部署到生产环境"
)
# 多步骤任务应该识别为复杂任务
assert result["complexity"] in [TaskComplexity.COMPLEX, TaskComplexity.MEDIUM]
class TestRouterEdgeCases:
"""路由器边界情况测试"""
def setup_method(self):
self.router = SmartRouter()
def test_empty_content(self):
"""测试空内容"""
result = self.router.evaluate("")
assert result["complexity"] == TaskComplexity.SIMPLE
def test_very_long_content(self):
"""测试超长内容"""
long_content = "请帮我分析 " + "这段代码 " * 200
result = self.router.evaluate(long_content)
assert result["complexity"] == TaskComplexity.COMPLEX
def test_special_characters(self):
"""测试特殊字符"""
result = self.router.evaluate("查看 /tmp/test.txt 文件内容")
assert result["complexity"] in [TaskComplexity.SIMPLE, TaskComplexity.MEDIUM]

135
tests/test_llm.py Normal file
View File

@@ -0,0 +1,135 @@
"""LLM 模块测试"""
from __future__ import annotations
import pytest
from minenasai.llm.base import Message, Provider, ToolCall, ToolDefinition
class TestProvider:
"""Provider 枚举测试"""
def test_is_overseas(self):
"""测试境外服务识别"""
assert Provider.ANTHROPIC.is_overseas is True
assert Provider.OPENAI.is_overseas is True
assert Provider.GEMINI.is_overseas is True
assert Provider.DEEPSEEK.is_overseas is False
assert Provider.ZHIPU.is_overseas is False
assert Provider.MINIMAX.is_overseas is False
assert Provider.MOONSHOT.is_overseas is False
def test_display_name(self):
"""测试显示名称"""
assert "Claude" in Provider.ANTHROPIC.display_name
assert "GPT" in Provider.OPENAI.display_name
assert "DeepSeek" in Provider.DEEPSEEK.display_name
assert "GLM" in Provider.ZHIPU.display_name
assert "Kimi" in Provider.MOONSHOT.display_name
class TestMessage:
"""消息测试"""
def test_basic_message(self):
"""测试基本消息"""
msg = Message(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.tool_calls is None
def test_message_with_tool_call(self):
"""测试带工具调用的消息"""
tool_call = ToolCall(
id="tc_123",
name="read_file",
arguments={"path": "/test.txt"},
)
msg = Message(
role="assistant",
content="Let me read that file.",
tool_calls=[tool_call],
)
assert len(msg.tool_calls) == 1
assert msg.tool_calls[0].name == "read_file"
class TestToolDefinition:
"""工具定义测试"""
def test_tool_definition(self):
"""测试工具定义"""
tool = ToolDefinition(
name="read_file",
description="Read a file",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
},
)
assert tool.name == "read_file"
assert "path" in tool.parameters["properties"]
class TestClientImports:
"""客户端导入测试"""
def test_import_all_clients(self):
"""测试导入所有客户端"""
from minenasai.llm.clients import (
AnthropicClient,
DeepSeekClient,
GeminiClient,
MiniMaxClient,
MoonshotClient,
OpenAICompatClient,
ZhipuClient,
)
assert AnthropicClient.provider == Provider.ANTHROPIC
assert OpenAICompatClient.provider == Provider.OPENAI
assert DeepSeekClient.provider == Provider.DEEPSEEK
assert ZhipuClient.provider == Provider.ZHIPU
assert MiniMaxClient.provider == Provider.MINIMAX
assert MoonshotClient.provider == Provider.MOONSHOT
assert GeminiClient.provider == Provider.GEMINI
def test_client_models(self):
"""测试客户端模型列表"""
from minenasai.llm.clients import (
AnthropicClient,
DeepSeekClient,
ZhipuClient,
)
assert "claude-sonnet-4-20250514" in AnthropicClient.MODELS
assert "deepseek-chat" in DeepSeekClient.MODELS
assert "glm-4-plus" in ZhipuClient.MODELS
class TestLLMManager:
"""LLM 管理器测试"""
def test_import_manager(self):
"""测试导入管理器"""
from minenasai.llm import LLMManager, get_llm_manager
manager = get_llm_manager()
assert isinstance(manager, LLMManager)
def test_no_api_keys(self):
"""测试无 API Key 时的行为"""
from minenasai.llm import LLMManager
manager = LLMManager()
manager.initialize()
# 没有配置 API Key应该没有可用的提供商
providers = manager.get_available_providers()
# 可能为空,取决于环境变量
assert isinstance(providers, list)

227
tests/test_permissions.py Normal file
View File

@@ -0,0 +1,227 @@
"""权限模块测试"""
from __future__ import annotations
import pytest
from minenasai.agent.permissions import (
ConfirmationStatus,
DangerLevel,
PermissionManager,
ToolPermission,
)
class TestDangerLevel:
"""DangerLevel 枚举测试"""
def test_danger_levels(self):
"""测试危险等级"""
assert DangerLevel.SAFE.value == "safe"
assert DangerLevel.CRITICAL.value == "critical"
class TestToolPermission:
"""ToolPermission 测试"""
def test_default_permission(self):
"""测试默认权限"""
perm = ToolPermission(
name="test_tool",
danger_level=DangerLevel.SAFE,
)
assert perm.requires_confirmation is False
assert perm.rate_limit is None
class TestPermissionManager:
"""PermissionManager 测试"""
def setup_method(self):
"""初始化"""
self.manager = PermissionManager()
def test_get_default_permission(self):
"""测试获取默认权限"""
perm = self.manager.get_permission("read_file")
assert perm is not None
assert perm.danger_level == DangerLevel.SAFE
def test_register_tool(self):
"""测试注册工具权限"""
perm = ToolPermission(
name="custom_tool",
danger_level=DangerLevel.MEDIUM,
description="自定义工具",
)
self.manager.register_tool(perm)
result = self.manager.get_permission("custom_tool")
assert result is not None
assert result.danger_level == DangerLevel.MEDIUM
def test_check_permission_allowed(self):
"""测试权限检查 - 允许"""
allowed, reason = self.manager.check_permission("read_file")
assert allowed is True
def test_check_permission_unknown_tool(self):
"""测试权限检查 - 未知工具"""
allowed, reason = self.manager.check_permission("unknown_tool")
assert allowed is False
assert "未知工具" in reason
def test_check_permission_denied_path(self):
"""测试权限检查 - 禁止路径"""
perm = ToolPermission(
name="restricted_read",
danger_level=DangerLevel.SAFE,
denied_paths=["/etc/", "/root/"],
)
self.manager.register_tool(perm)
allowed, reason = self.manager.check_permission(
"restricted_read",
params={"path": "/etc/passwd"},
)
assert allowed is False
assert "禁止访问" in reason
def test_requires_confirmation_by_level(self):
"""测试确认要求 - 按等级"""
# HIGH 级别需要确认
assert self.manager.requires_confirmation("delete_file") is True
# SAFE 级别不需要确认
assert self.manager.requires_confirmation("read_file") is False
def test_requires_confirmation_explicit(self):
"""测试确认要求 - 显式设置"""
perm = ToolPermission(
name="explicit_confirm",
danger_level=DangerLevel.LOW,
requires_confirmation=True,
)
self.manager.register_tool(perm)
assert self.manager.requires_confirmation("explicit_confirm") is True
@pytest.mark.asyncio
async def test_confirmation_flow(self):
"""测试确认流程"""
request = await self.manager.request_confirmation(
request_id="req-1",
tool_name="delete_file",
params={"path": "/test.txt"},
)
assert request.status == ConfirmationStatus.PENDING
# 批准
self.manager.approve_confirmation("req-1")
assert request.status == ConfirmationStatus.APPROVED
@pytest.mark.asyncio
async def test_confirmation_deny(self):
"""测试拒绝确认"""
request = await self.manager.request_confirmation(
request_id="req-2",
tool_name="delete_file",
params={"path": "/test.txt"},
)
self.manager.deny_confirmation("req-2")
assert request.status == ConfirmationStatus.DENIED
@pytest.mark.asyncio
async def test_get_pending_confirmations(self):
"""测试获取待处理确认"""
await self.manager.request_confirmation(
request_id="req-3",
tool_name="test",
params={},
)
pending = self.manager.get_pending_confirmations()
assert len(pending) >= 1
class TestToolRegistry:
"""ToolRegistry 测试"""
def test_import_registry(self):
"""测试导入注册中心"""
from minenasai.agent import ToolRegistry, get_tool_registry
registry = get_tool_registry()
assert isinstance(registry, ToolRegistry)
def test_register_builtin_tools(self):
"""测试注册内置工具"""
from minenasai.agent import get_tool_registry, register_builtin_tools
registry = get_tool_registry()
initial_count = len(registry.list_tools())
register_builtin_tools()
# 应该有更多工具
new_count = len(registry.list_tools())
assert new_count >= initial_count
def test_tool_decorator(self):
"""测试工具装饰器"""
from minenasai.agent import tool, get_tool_registry
@tool(name="decorated_tool", description="装饰器测试")
async def decorated_tool(param: str) -> dict:
return {"result": param}
registry = get_tool_registry()
tool_obj = registry.get("decorated_tool")
assert tool_obj is not None
assert tool_obj.description == "装饰器测试"
@pytest.mark.asyncio
async def test_execute_tool(self):
"""测试执行工具"""
from minenasai.agent import get_tool_registry, DangerLevel
registry = get_tool_registry()
# 注册测试工具
async def echo(message: str) -> dict:
return {"echo": message}
registry.register(
name="echo",
description="回显消息",
func=echo,
parameters={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
danger_level=DangerLevel.SAFE,
)
result = await registry.execute("echo", {"message": "hello"})
assert result["success"] is True
assert result["result"]["echo"] == "hello"
def test_get_stats(self):
"""测试获取统计"""
from minenasai.agent import get_tool_registry
registry = get_tool_registry()
stats = registry.get_stats()
assert "total_tools" in stats
assert "categories" in stats

165
tests/test_scheduler.py Normal file
View File

@@ -0,0 +1,165 @@
"""调度器模块测试"""
from __future__ import annotations
from datetime import datetime
import pytest
from minenasai.scheduler.cron import CronJob, CronParser, CronScheduler, JobStatus
class TestCronParser:
"""Cron 解析器测试"""
def test_parse_all_stars(self):
"""测试全星号表达式"""
result = CronParser.parse("* * * * *")
assert len(result["minute"]) == 60
assert len(result["hour"]) == 24
assert len(result["day"]) == 31
assert len(result["month"]) == 12
assert len(result["weekday"]) == 7
def test_parse_specific_values(self):
"""测试具体值"""
result = CronParser.parse("30 8 * * *")
assert result["minute"] == {30}
assert result["hour"] == {8}
def test_parse_range(self):
"""测试范围"""
result = CronParser.parse("0 9-17 * * *")
assert result["hour"] == {9, 10, 11, 12, 13, 14, 15, 16, 17}
def test_parse_step(self):
"""测试步进"""
result = CronParser.parse("*/15 * * * *")
assert result["minute"] == {0, 15, 30, 45}
def test_parse_list(self):
"""测试列表"""
result = CronParser.parse("0 8,12,18 * * *")
assert result["hour"] == {8, 12, 18}
def test_parse_preset_daily(self):
"""测试预定义表达式 @daily"""
result = CronParser.parse("@daily")
assert result["minute"] == {0}
assert result["hour"] == {0}
def test_parse_preset_hourly(self):
"""测试预定义表达式 @hourly"""
result = CronParser.parse("@hourly")
assert result["minute"] == {0}
assert len(result["hour"]) == 24
def test_parse_invalid_expression(self):
"""测试无效表达式"""
with pytest.raises(ValueError, match="无效的 cron"):
CronParser.parse("invalid")
def test_get_next_run(self):
"""测试计算下次运行时间"""
# 每小时整点
next_run = CronParser.get_next_run(
"0 * * * *",
after=datetime(2026, 1, 1, 10, 30)
)
assert next_run.minute == 0
assert next_run.hour == 11
class TestCronJob:
"""CronJob 测试"""
def test_job_creation(self):
"""测试创建任务"""
job = CronJob(
id="test-job",
name="测试任务",
schedule="*/5 * * * *",
task="测试",
)
assert job.id == "test-job"
assert job.enabled is True
assert job.last_status == JobStatus.PENDING
class TestCronScheduler:
"""CronScheduler 测试"""
def setup_method(self):
"""初始化"""
self.scheduler = CronScheduler()
def test_add_job(self):
"""测试添加任务"""
async def task():
pass
job = self.scheduler.add_job(
job_id="test-1",
name="测试任务",
schedule="*/5 * * * *",
callback=task,
)
assert job.id == "test-1"
assert job.next_run is not None
def test_remove_job(self):
"""测试移除任务"""
async def task():
pass
self.scheduler.add_job("test-1", "测试", "* * * * *", task)
assert self.scheduler.remove_job("test-1") is True
assert self.scheduler.get_job("test-1") is None
def test_enable_disable_job(self):
"""测试启用/禁用任务"""
async def task():
pass
self.scheduler.add_job("test-1", "测试", "* * * * *", task)
assert self.scheduler.disable_job("test-1") is True
job = self.scheduler.get_job("test-1")
assert job.enabled is False
assert job.last_status == JobStatus.DISABLED
assert self.scheduler.enable_job("test-1") is True
assert job.enabled is True
def test_list_jobs(self):
"""测试列出任务"""
async def task():
pass
self.scheduler.add_job("test-1", "任务1", "* * * * *", task)
self.scheduler.add_job("test-2", "任务2", "*/5 * * * *", task)
jobs = self.scheduler.list_jobs()
assert len(jobs) == 2
def test_get_stats(self):
"""测试获取统计"""
async def task():
pass
self.scheduler.add_job("test-1", "任务1", "* * * * *", task)
stats = self.scheduler.get_stats()
assert stats["total_jobs"] == 1
assert stats["enabled_jobs"] == 1

150
tests/test_tools.py Normal file
View File

@@ -0,0 +1,150 @@
"""工具模块测试"""
from __future__ import annotations
import pytest
from minenasai.agent.tools.basic import (
list_directory_tool,
python_eval_tool,
read_file_tool,
)
class TestReadFileTool:
"""读取文件工具测试"""
@pytest.mark.asyncio
async def test_read_existing_file(self, tmp_path):
"""测试读取存在的文件"""
test_file = tmp_path / "test.txt"
test_file.write_text("Hello, World!\nLine 2\nLine 3")
result = await read_file_tool(str(test_file))
assert "error" not in result
assert "Hello, World!" in result["content"]
assert result["lines"] == 3
@pytest.mark.asyncio
async def test_read_nonexistent_file(self):
"""测试读取不存在的文件"""
result = await read_file_tool("/nonexistent/file.txt")
assert "error" in result
assert "不存在" in result["error"]
@pytest.mark.asyncio
async def test_read_with_max_lines(self, tmp_path):
"""测试最大行数限制"""
test_file = tmp_path / "long.txt"
test_file.write_text("\n".join([f"Line {i}" for i in range(100)]))
result = await read_file_tool(str(test_file), max_lines=10)
assert "error" not in result
assert "截断" in result["content"]
class TestListDirectoryTool:
"""列出目录工具测试"""
@pytest.mark.asyncio
async def test_list_directory(self, tmp_path):
"""测试列出目录"""
# 创建测试文件
(tmp_path / "file1.txt").touch()
(tmp_path / "file2.py").touch()
(tmp_path / "subdir").mkdir()
result = await list_directory_tool(str(tmp_path))
assert "error" not in result
assert result["count"] == 3
names = [item["name"] for item in result["items"]]
assert "file1.txt" in names
@pytest.mark.asyncio
async def test_list_with_pattern(self, tmp_path):
"""测试模式匹配"""
(tmp_path / "test.py").touch()
(tmp_path / "test.txt").touch()
result = await list_directory_tool(str(tmp_path), pattern="*.py")
assert "error" not in result
assert result["count"] == 1
assert result["items"][0]["name"] == "test.py"
@pytest.mark.asyncio
async def test_list_nonexistent_directory(self):
"""测试列出不存在的目录"""
result = await list_directory_tool("/nonexistent/dir")
assert "error" in result
class TestPythonEvalTool:
"""Python 执行工具测试"""
@pytest.mark.asyncio
async def test_simple_math(self):
"""测试简单数学计算"""
result = await python_eval_tool("1 + 2 * 3")
assert "error" not in result
assert result["result"] == 7
@pytest.mark.asyncio
async def test_math_functions(self):
"""测试数学函数"""
result = await python_eval_tool("math.sqrt(16)")
assert "error" not in result
assert result["result"] == 4.0
@pytest.mark.asyncio
async def test_list_operations(self):
"""测试列表操作"""
result = await python_eval_tool("sum([1, 2, 3, 4, 5])")
assert "error" not in result
assert result["result"] == 15
@pytest.mark.asyncio
async def test_blocked_import(self):
"""测试阻止 import"""
result = await python_eval_tool("__import__('os')")
assert "error" in result
assert "不允许" in result["error"]
@pytest.mark.asyncio
async def test_blocked_exec(self):
"""测试阻止 exec"""
result = await python_eval_tool("exec('print(1)')")
assert "error" in result
@pytest.mark.asyncio
async def test_blocked_open(self):
"""测试阻止 open"""
result = await python_eval_tool("open('/etc/passwd')")
assert "error" in result
@pytest.mark.asyncio
async def test_syntax_error(self):
"""测试语法错误"""
result = await python_eval_tool("1 +")
assert "error" in result
assert "语法错误" in result["error"]
@pytest.mark.asyncio
async def test_runtime_error(self):
"""测试运行时错误"""
result = await python_eval_tool("1 / 0")
assert "error" in result
assert "division by zero" in result["error"]

158
tests/test_webtui.py Normal file
View File

@@ -0,0 +1,158 @@
"""Web TUI 模块测试"""
from __future__ import annotations
import time
import pytest
from minenasai.webtui.auth import AuthManager, AuthToken
class TestAuthToken:
"""AuthToken 测试"""
def test_token_not_expired(self):
"""测试未过期令牌"""
token = AuthToken(
token="test",
user_id="user1",
created_at=time.time(),
expires_at=time.time() + 3600,
)
assert not token.is_expired
assert token.remaining_time > 0
def test_token_expired(self):
"""测试已过期令牌"""
token = AuthToken(
token="test",
user_id="user1",
created_at=time.time() - 7200,
expires_at=time.time() - 3600,
)
assert token.is_expired
assert token.remaining_time == 0
class TestAuthManager:
"""AuthManager 测试"""
def setup_method(self):
"""初始化"""
self.manager = AuthManager(secret_key="test-secret-key")
def test_generate_token(self):
"""测试生成令牌"""
token = self.manager.generate_token("user1")
assert token is not None
assert len(token) > 20
def test_verify_token(self):
"""测试验证令牌"""
token = self.manager.generate_token("user1")
auth_token = self.manager.verify_token(token)
assert auth_token is not None
assert auth_token.user_id == "user1"
def test_verify_invalid_token(self):
"""测试验证无效令牌"""
auth_token = self.manager.verify_token("invalid-token")
assert auth_token is None
def test_verify_expired_token(self):
"""测试验证过期令牌"""
token = self.manager.generate_token("user1", expires_in=0)
# 等待过期
time.sleep(0.1)
auth_token = self.manager.verify_token(token)
assert auth_token is None
def test_revoke_token(self):
"""测试撤销令牌"""
token = self.manager.generate_token("user1")
assert self.manager.revoke_token(token) is True
assert self.manager.verify_token(token) is None
def test_revoke_nonexistent_token(self):
"""测试撤销不存在的令牌"""
assert self.manager.revoke_token("nonexistent") is False
def test_revoke_user_tokens(self):
"""测试撤销用户所有令牌"""
self.manager.generate_token("user1")
self.manager.generate_token("user1")
self.manager.generate_token("user2")
count = self.manager.revoke_user_tokens("user1")
assert count == 2
assert self.manager.get_stats()["total_tokens"] == 1
def test_refresh_token(self):
"""测试刷新令牌"""
old_token = self.manager.generate_token("user1")
new_token = self.manager.refresh_token(old_token)
assert new_token is not None
assert new_token != old_token
assert self.manager.verify_token(old_token) is None
assert self.manager.verify_token(new_token) is not None
def test_token_metadata(self):
"""测试令牌元数据"""
metadata = {"channel": "wework", "task_id": "123"}
token = self.manager.generate_token("user1", metadata=metadata)
auth_token = self.manager.verify_token(token)
assert auth_token is not None
assert auth_token.metadata == metadata
def test_cleanup_expired(self):
"""测试清理过期令牌"""
self.manager.generate_token("user1", expires_in=0)
self.manager.generate_token("user2", expires_in=3600)
time.sleep(0.1)
count = self.manager.cleanup_expired()
assert count == 1
assert self.manager.get_stats()["total_tokens"] == 1
class TestSSHManager:
"""SSHManager 测试(不实际连接)"""
def test_import_ssh_manager(self):
"""测试导入 SSH 管理器"""
from minenasai.webtui import SSHManager, get_ssh_manager
manager = get_ssh_manager()
assert isinstance(manager, SSHManager)
def test_ssh_manager_stats(self):
"""测试 SSH 管理器统计"""
from minenasai.webtui import SSHManager
manager = SSHManager()
stats = manager.get_stats()
assert "active_sessions" in stats
assert stats["active_sessions"] == 0
class TestWebTUIServer:
"""Web TUI 服务器测试"""
def test_import_server(self):
"""测试导入服务器"""
from minenasai.webtui.server import app
assert app is not None
assert app.title == "MineNASAI Web TUI"

1
tests/webtui/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Web TUI 测试"""

168
开发步骤.md Normal file
View File

@@ -0,0 +1,168 @@
# MineNASAI 开发步骤文档
**生成日期**: 2025-02-04
**更新日期**: 2025-02-04
**项目**: MineNASAI - 基于NAS的智能个人AI助理
**技术方案**: Web TUI集成方案C
---
## Phase 0: 项目初始化 (2-3天)
### 0.1 项目结构创建
- [ ] 创建根目录和基础结构
- [ ] 初始化Python项目pyproject.toml
- [ ] 配置依赖管理poetry/pip
- [ ] 设置Git仓库
### 0.2 开发环境配置
- [ ] Python虚拟环境
- [ ] 代码格式化工具black/ruff
- [ ] 类型检查mypy
- [ ] 测试框架pytest
### 0.3 基础配置
- [ ] 配置文件模板config.json5
- [ ] 日志系统初始化
- [ ] 环境变量管理(.env
---
## Phase 1: Gateway + 通讯接入 (14-21天)
### 1.1 FastAPI基础服务
- [ ] FastAPI项目搭建
- [ ] WebSocket服务器
- [ ] 基础路由和健康检查
### 1.2 企业微信/飞书接入
- [ ] Webhook接收器
- [ ] 消息解析和验证
- [ ] 消息发送封装
### 1.3 智能路由
- [ ] 启发式规则实现基于PoC
- [ ] 任务复杂度评估
- [ ] 路由决策逻辑
### 1.4 Redis + Celery
- [ ] Redis连接配置
- [ ] Celery任务队列
- [ ] 异步任务处理
---
## Phase 2: Agent运行时 (10-14天)
### 2.1 Anthropic API集成
- [ ] anthropic SDK集成
- [ ] 对话管理
- [ ] 流式响应处理
### 2.2 基础工具实现
- [ ] 文件读写工具
- [ ] 命令执行工具(安全封装)
- [ ] Python沙箱工具
### 2.3 会话管理
- [ ] SQLite数据库设计
- [ ] 会话CRUD
- [ ] 消息历史存储JSONL
---
## Phase 3: Web TUI集成 (7-10天)
### 3.1 Web终端前端
- [ ] xterm.js集成
- [ ] Web界面设计HTML/CSS
- [ ] 前端JavaScript逻辑
### 3.2 WebSocket终端通信
- [ ] WebSocket服务器终端I/O
- [ ] 双向数据传输
- [ ] 终端尺寸自适应
### 3.3 SSH连接管理
- [ ] paramiko集成
- [ ] SSH连接池
- [ ] localhost SSH配置
### 3.4 用户认证
- [ ] Token/Session认证
- [ ] 权限验证
- [ ] 会话管理
### 3.5 消息通知链接
- [ ] 生成Web TUI访问链接
- [ ] 通讯工具发送提示
- [ ] 任务上下文传递
---
## Phase 4: 高级特性 (10-14天)
### 4.1 定时任务
- [ ] Cron任务调度
- [ ] 定时任务配置
- [ ] 任务执行监控
### 4.2 权限控制
- [ ] 工具危险级别定义
- [ ] 确认机制
- [ ] 审计日志
### 4.3 (可选) MCP扩展
- [ ] MCP Server加载框架
- [ ] 基础MCP集成测试
### 4.4 (可选) RAG知识库
- [ ] Qdrant集成
- [ ] 向量化和检索
- [ ] 知识库管理
---
## Phase 5: 生产就绪 (7-10天)
### 5.1 错误处理与监控
- [ ] 完善错误处理
- [ ] 日志优化
- [ ] 监控告警
### 5.2 性能优化
- [ ] 并发优化
- [ ] 缓存策略
- [ ] 资源限制
### 5.3 Docker部署
- [ ] Dockerfile编写
- [ ] docker-compose配置
- [ ] 部署文档
### 5.4 测试与文档
- [ ] 集成测试
- [ ] 用户文档
- [ ] 运维文档
---
## 总时间估算
**总计**: 52-81天
## 技术栈总结
| 组件 | 技术选择 |
|------|----------|
| 后端 | FastAPI + Anthropic SDK |
| 通讯 | 企业微信API + 飞书API |
| 消息队列 | Redis + Celery |
| Web终端 | xterm.js + WebSocket |
| SSH | paramiko (Python) |
| 存储 | SQLite + JSONL |
| 部署 | Docker |
---
*当真正开始开发时每个Phase会展开详细的子任务和验收标准*

506
设计文档.md Normal file
View File

@@ -0,0 +1,506 @@
# MineNASAI 设计文档
**项目名称**: MineNASAI
**版本**: v1.0-design
**创建日期**: 2025-02-03
**目标**: 基于NAS的智能个人AI助理支持企业微信/飞书通讯集成Claude Code CLI编程能力
---
## 1. 项目概述
### 1.1 目标
构建一个**自我进化的个人全能AI助理**,具备以下核心能力:
- **多渠道通讯**: 企业微信、飞书(可扩展)
- **强权限操作**: 对NAS环境拥有足够权限
- **编程能力**: 唤起Claude Code CLI进行复杂编程
- **快速验证**: Python解释器直接验证简单任务
- **工具扩展**: 支持MCP Server、Skills插件
- **联网能力**: 搜索查询、API调用
- **自主运行**: 定时任务、主动触发
- **多Agent协作**: 复杂任务多Agent讨论
### 1.2 设计原则
- **智能分流**: 简单任务快速处理,复杂任务深度分析
- **用户可控**: 智能路由默认行为,用户可显式覆盖
- **安全优先**: 沙箱隔离、权限分级、审计日志
- **可扩展**: MCP/Skills插件化架构
---
## 2. 整体架构
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 通讯接入层 (Channels) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 企业微信Bot │ │ 飞书Bot │ │ Web UI(预留) │ │
│ │ (Webhook) │ │ (Webhook) │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────┴───────────────────────────────────────────┐
│ Gateway 服务 (FastAPI) │
│ - WebSocket协议 (类OpenClaw协议设计) │
│ - 消息队列/流控/重试 │
│ - 设备配对/权限验证 │
│ - 事件分发 (agent/chat/presence/cron) │
└─────────────────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────┴───────────────────────────────────────────┐
│ 智能路由层 (SmartRouter) │
│ - 任务复杂度评估 (token预估/工具需求分析) │
│ - 单/多Agent决策 │
│ - 快速通道 vs 深度讨论 │
│ - 用户指令覆盖 (/快速 /深度 /确认) │
└─────────────────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────┴───────────────────────────────────────────┐
│ 工具管理层 (ToolManager) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ MCP │ │ Skills │ │ 系统命令 │ │Claude Code │ │
│ │ Server │ │ 插件化 │ │ 封装 │ │ CLI桥接 │ │
│ │ (动态加载) │ │(AgentSkills)│ │(权限分级) │ │ (编程任务) │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ ↓ 权限白名单 / 危险操作确认 / 执行日志 / 沙箱隔离 │
└─────────────────────────────┬───────────────────────────────────────────┘
┌────────────────────┼────────────────────┐
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│快速执行通道 │ │深度讨论组 │ │定时任务器 │
│Python直验 │ │多Agent协作 │ │Cron触发 │
│沙箱执行 │ │Sub-agents │ │自主任务 │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────────────────────┴───────────────────────────────────────────┐
│ 信息三层架构 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────────────────┐ │
│ │记忆层 │ │ 知识库层 │ │ 文件索引 + Web搜索 │ │
│ │(对话上下) │ │(RAG向量) │ │ NAS文件 │ │ │
│ │Session │ │Qdrant │ │ │ │
│ └──────────┘ └──────────┘ └──────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────┘
```
---
## 3. 技术选型
| 层级 | 技术选择 | 说明 |
|------|----------|------|
| **后端框架** | FastAPI (Python) | WebSocket支持、异步高性能、类型安全 |
| **Agent Runtime** | Anthropic SDK | 直接API调用稳定可靠 |
| **消息队列** | Redis + Celery | 任务调度、定时任务、并发控制 |
| **通讯接入** | 企业微信API + 飞书API | Webhook回调支持应用消息/机器人 |
| **HTTPS** | Caddy | 自动HTTPS配合lucky转发 |
| **工具层** | 内置工具 + MCP(可选) | 简化实现,后期可扩展 |
| **Claude集成** | Anthropic API + Web TUI | 简单任务用API复杂任务用Web终端 |
| **Web终端** | xterm.js + WebSocket | Web前端终端模拟器 |
| **SSH连接** | paramiko (Python) | SSH客户端库连接localhost |
| **Python沙箱** | Docker / restricted Python | 隔离执行 |
| **向量数据库** | Qdrant (可选) | 轻量、高性能Phase 2 |
| **文件索引** | 基础文件系统 (可选) | Phase 2扩展 |
| **存储** | SQLite + JSONL | 配置、状态、会话 |
| **日志** | structured JSON + logrotate | 结构化日志 |
---
## 4. 数据流设计
### 4.1 消息处理流程(企业微信/飞书)
```
企业微信/飞书消息 → Gateway WebSocket接收
消息网关 (解析/去重/用户识别)
智能路由 (复杂度评估)
↙ ↘ ↘
快速查询 中等任务 编程任务
↓ ↓ ↓
Python沙箱 → 单Agent → 通知用户
直接执行 执行 "请用TUI处理"
↓ ↓
└───────→ 结果聚合
知识库更新 (RAG入库)
回传通讯工具
```
### 4.2 Web TUI + SSH 编程流程(推荐方案)
```
用户收到消息提示
"此任务需要在Web TUI中处理"
用户点击链接 → 打开Web界面
┌───────────────────────────────────┐
│ Web Terminal (xterm.js) │
│ ↓ │
│ WebSocket连接后端 │
│ ↓ │
│ SSH连接 localhost (NAS自身) │
│ ↓ │
│ $ claude "实现一个xxx功能" │
│ → Claude分析/编码/执行 │
│ → 实时输出显示在Web终端 │
│ → 用户直接在浏览器中交互 │
└───────────────────────────────────┘
完成 → (可选)结果同步到知识库
```
**核心优势**:
-**无需进程间通信** - 避免subprocess的所有问题
-**完整CLI能力** - 用户直接与Claude CLI交互
-**实现简单** - 成熟的Web终端方案xterm.js
-**权限隔离** - SSH本身提供隔离机制
-**跨平台访问** - 浏览器即可使用
### 4.3 双界面模式
| 界面 | 用途 | 技术方案 | 示例 |
|------|------|----------|------|
| 通讯工具 | 日常交互/简单任务 | Anthropic API | "NAS状态?"、"搜索xxx"、"定时提醒" |
| Web TUI | 深度编程/复杂项目 | xterm.js + SSH + Claude CLI | "重构这个模块"、"实现新功能"、"调试问题" |
---
## 5. 核心组件
### 5.1 Gateway核心模块
```
src/gateway/
├── __init__.py
├── server.py # WebSocket服务器入口
├── protocol/ # 协议定义
│ ├── schema.py # TypeBox/JSON Schema
│ └── frames.py # 帧构造/解析
├── channels/ # 通讯渠道
│ ├── __init__.py
│ ├── base.py # 基类
│ ├── wework.py # 企业微信
│ └── feishu.py # 飞书
├── router.py # 智能路由
├── tool_registry.py # 内置工具注册
├── anthropic_client.py # Anthropic API客户端
├── session_manager.py # 会话管理
└── scheduler.py # 定时任务
```
### 5.2 Agent Runtime
```
src/agent/
├── __init__.py
├── runtime.py # Agent运行时
├── tools/ # 内置工具
│ ├── read.py
│ ├── exec.py
│ ├── python.py # Python沙箱
│ └── web_search.py
├── memory.py # 记忆管理
├── knowledge.py # RAG知识库
└── subagent.py # Sub-agent支持
```
### 5.3 Web TUI界面
```
src/webtui/
├── __init__.py
├── server.py # WebSocket服务器终端通信
├── static/ # 前端静态文件
│ ├── index.html # Web终端页面
│ ├── xterm.css # xterm.js样式
│ └── app.js # 前端逻辑
├── ssh_manager.py # SSH连接管理paramiko
└── auth.py # 用户认证
```
**实现细节**:
- **前端**: xterm.js创建Web终端
- **通信**: WebSocket双向传输终端I/O
- **后端**: paramiko连接SSH到localhost
- **认证**: 基于Token或Session的用户验证
### 5.4 文件结构
```
~/.config/minenasai/
├── config.json5 # 主配置文件
├── database.db # SQLite数据库
├── workspace/ # 主Agent工作目录
│ ├── skills/ # 自定义Skills
│ ├── AGENTS.md # Agent指令/记忆
│ ├── SOUL.md # 人格/边界
│ └── TOOLS.md # 工具使用规范
├── agents/ # 多Agent状态
│ └── <agentId>/
│ ├── sessions/ # JSONL对话历史
│ └── auth.json # 认证配置
├── skills/ # 共享Skills
├── knowledge/ # Qdrant数据目录
├── logs/ # 日志文件
│ ├── gateway.log
│ └── audit.jsonl
└── mcp-servers/ # MCP Server配置
└── config.json
```
---
## 6. 数据模型
### 6.1 SQLite Schema
```sql
-- Agents表
CREATE TABLE agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
workspace_path TEXT NOT NULL,
model TEXT DEFAULT 'anthropic/claude-sonnet-4-5',
sandbox_mode TEXT DEFAULT 'workspace',
created_at INTEGER,
updated_at INTEGER
);
-- Sessions表
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents(id),
channel TEXT, -- 'wework', 'feishu', 'tui'
peer_id TEXT, -- 用户ID
session_key TEXT, -- agent:main:xxx
status TEXT DEFAULT 'active',
created_at INTEGER,
updated_at INTEGER,
metadata JSON -- 扩展字段
);
-- Messages表轻量详细在JSONL
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
role TEXT NOT NULL, -- 'user', 'assistant', 'system'
content TEXT,
tool_calls JSON,
timestamp INTEGER,
tokens_used INTEGER
);
-- Cron任务表
CREATE TABLE cron_jobs (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents(id),
name TEXT NOT NULL,
schedule TEXT NOT NULL, -- cron表达式
task TEXT NOT NULL,
enabled BOOLEAN DEFAULT 1,
last_run INTEGER,
next_run INTEGER
);
-- 审计日志表
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
agent_id TEXT,
tool_name TEXT NOT NULL,
danger_level TEXT,
params JSON,
result TEXT,
duration_ms INTEGER,
timestamp INTEGER
);
```
---
## 7. 权限与安全控制
### 7.1 权限分级
```python
DANGER_LEVELS = {
"safe": [], # 只读操作:查询文件、读取状态
"low": ["read"], # 低风险写入工作目录、Python验证
"medium": ["exec"], # 中风险系统命令、Docker操作
"high": [], # 高风险:删除文件、系统配置
"critical": [] # 极高危:格式化磁盘、清空数据库
}
```
### 7.2 确认策略
| 等级 | 处理方式 |
|------|----------|
| safe/low | 自动执行 |
| medium | AI评估后决定 |
| high/critical | **强制用户确认** |
### 7.3 沙箱隔离
- Python快速验证`restricted` Python或Docker容器
- MCP Server独立进程运行限资源/网络
- Claude Code CLI指定工作目录限制可访问路径
### 7.4 配置示例
```json5
{
agents: {
list: [
{
id: "main",
tools: {
allow: ["read", "python", "web_search", "claude_cli"],
deny: ["delete", "system_config"],
},
sandbox: {
mode: "workspace",
},
},
],
},
}
```
---
## 8. 代理服务支持
### 8.1 代理配置
```python
PROXY_CONFIG = {
"enabled": true,
"mode": "system",
"system": {
"http_proxy": "http://127.0.0.1:7890",
"https_proxy": "http://127.0.0.1:7890",
"no_proxy": "localhost,127.0.0.1,.local",
},
"api_specific": {
"anthropic": "http://127.0.0.1:7890",
"openai": "http://127.0.0.1:7890",
"github": "http://127.0.0.1:7890",
},
"auto_detect": true,
"fallback_ports": [7890, 7891, 1080, 1087, 10808],
}
```
### 8.2 代理支持场景
- LLM API调用
- MCP Server
- Web搜索
- ClawHub同步
- Git操作
---
## 9. 错误处理与容错
### 9.1 错误分类
```python
ERROR_HANDLING = {
"retryable": [
"network_timeout", "api_rate_limit",
"mcp_unavailable", "proxy_timeout",
],
"degrade": [
"vector_db_down", "web_search_fail",
"claude_cli_timeout", "proxy_unavailable",
],
"critical": [
"auth_failed", "sandbox_escape",
"disk_full", "proxy_auth_failed",
],
}
```
### 9.2 重试策略
- 指数退避1s → 2s → 4s → 8s最多3次
- Circuit Breaker连续失败后暂停5分钟
### 9.3 优雅降级
- RAG离线 → 直接对话
- MCP挂了 → 其他MCP继续
- Claude CLI不可用 → 通知用户
---
## 10. 实现路线图
### Phase 1核心框架MVP
- [ ] Gateway基础服务 + WebSocket协议
- [ ] 企业微信/飞书通讯接入
- [ ] 智能路由基础逻辑
- [ ] Python沙箱执行
- [ ] 基础配置管理
### Phase 2工具与知识库
- [ ] MCP Server动态加载
- [ ] Skills系统AgentSkills兼容
- [ ] RAG向量库集成
- [ ] NAS文件索引
### Phase 3Web TUI与Claude集成
- [ ] Anthropic API集成日常任务
- [ ] xterm.js Web终端界面
- [ ] SSH连接管理paramiko
- [ ] WebSocket终端通信
- [ ] 用户认证与会话
### Phase 4高级特性
- [ ] 多Agent协作Sub-agents
- [ ] 定时任务调度器
- [ ] 主动任务触发
### Phase 5生产就绪
- [ ] 完整权限控制
- [ ] 审计日志
- [ ] 错误处理与监控
- [ ] Docker部署
---
## 11. 技术栈总结
| 层级 | 技术选择 |
|------|----------|
| Gateway | FastAPI + WebSocket |
| 通讯 | 企业微信API + 飞书API |
| Agent | Anthropic SDK |
| Claude集成 | API简单任务+ Web TUI复杂任务|
| Web终端 | xterm.js + paramiko SSH |
| 工具 | 内置工具可扩展MCP|
| 存储 | SQLite + JSONL |
| 队列 | Redis + Celery |
| 代理 | system/http(s)_proxy |
---
## 附录:参考项目
- **OpenClaw**: Gateway架构、AgentSkills、多Agent设计
- **Claude Code**: CLI集成、工具调用模式
- **FastMCP**: MCP Server开发框架
---
*文档版本: v1.0*
*最后更新: 2025-02-03*

381
进度.md Normal file
View File

@@ -0,0 +1,381 @@
# MineNASAI 项目进度跟踪
**更新日期**: 2026-02-04
**当前阶段**: Phase 3 完成,准备 Phase 4 开发
**整体进度**: 80% (4/5 Phase 完成)
**技术方案**: Web TUI集成方案C+ 多 LLM 支持
---
## 进度概览
| Phase | 名称 | 状态 | 完成度 | 预计时间 | 实际时间 |
|-------|------|------|---------|----------|----------|
| Phase 0 | 项目初始化 | ✅ 已完成 | 7/7 | 2-3天 | 1天 |
| Phase 1 | 核心框架(MVP) | ✅ 已完成 | 7/7 | 14-21天 | 1天 |
| Phase 2 | Agent与工具系统 | ✅ 已完成 | 6/6 | 14-21天 | 1天 |
| Phase 3 | Web TUI与Claude集成 | ✅ 已完成 | 5/5 | 7-10天 | 1天 |
| Phase 4 | 高级特性 | ⏸️ 未开始 | 0/2 | 10-14天 | - |
| Phase 5 | 生产就绪 | ⏸️ 未开始 | 0/3 | 7-10天 | - |
**图例**: ⏸️ 未开始 | 🔄 进行中 | ✅ 已完成 | ⚠️ 阻塞
---
## Phase 0: 项目初始化 (100%) ✅
### 0.1 创建项目目录结构
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] 所有目录已创建
- [x] 每个Python包有 `__init__.py`
- [x] 目录结构与设计文档一致
---
### 0.2 配置Python项目管理
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] pyproject.toml 配置完成
- [x] 虚拟环境已创建 (.venv)
- [x] 依赖包已安装
- [x] Python 3.13 验证通过
- 备注: 使用 hatchling 作为构建后端
---
### 0.3 配置代码质量工具
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] pre-commit 配置完成
- [x] Ruff 配置完成
- [x] Mypy 配置完成
---
### 0.4 创建配置文件模板
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] Settings 可以成功加载
- [x] 环境变量可以覆盖配置
- [x] 配置验证正常工作
- 备注: config/config.json5 + .env.example
---
### 0.5 配置日志系统
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] structlog 日志系统
- [x] JSON 格式支持
- [x] 审计日志独立存储 (AuditLogger)
---
### 0.6 创建README和开发文档
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] README.md 清晰易懂
- [x] 安装步骤可执行
- [x] 项目结构说明完整
---
### 0.7 配置Git仓库
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 验收标准:
- [x] Git 仓库已初始化
- [x] .gitignore 配置完整
- [ ] 首次提交(待用户确认)
---
## Phase 1: 核心框架(MVP) (100%) ✅
### 1.1 数据存储层 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 子任务进度: 3/3
- [x] 1.1.1 设计数据库Schema (agents, sessions, messages, cron_jobs, audit_logs)
- [x] 1.1.2 实现数据库管理器 (core/database.py - aiosqlite)
- [x] 1.1.3 JSONL会话存储 (core/session_store.py)
---
### 1.2 Gateway WebSocket服务 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 子任务进度: 4/4
- [x] 1.2.1 定义消息协议 (gateway/protocol/schema.py)
- [x] 1.2.2 实现WebSocket处理器 (gateway/server.py)
- [x] 1.2.3 创建FastAPI应用
- [x] 1.2.4 协议测试 (13个测试通过)
---
### 1.3 智能路由(Router) ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 子任务进度: 1/1
- [x] 1.3.1 实现启发式路由 (gateway/router.py)
- 备注: 支持命令覆盖 (/快速, /深度),关键词检测,代码/多步骤检测
---
### 1.4 Python沙箱执行 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 子任务进度: 2/2
- [x] 1.4.1 基础Python eval工具 (agent/tools/basic.py)
- [x] 1.4.2 安全白名单机制 (SAFE_BUILTINS + 危险关键词过滤)
- 备注: Docker沙箱延后到 Phase 4
---
### 1.5 通讯渠道基础框架 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 子任务进度: 3/3
- [x] 1.5.1 渠道基类 (gateway/channels/base.py)
- [x] 1.5.2 企业微信渠道 (gateway/channels/wework.py)
- [x] 1.5.3 飞书渠道 (gateway/channels/feishu.py)
- 备注: 基础框架完成,实际接入需配置 API Key
---
## Phase 2: Agent与工具系统 (100%) ✅
### 2.1 多 LLM API 架构 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 支持的提供商:
- [x] Anthropic (Claude) - 境外,需代理
- [x] OpenAI (GPT) - 境外,需代理
- [x] Google Gemini - 境外,需代理
- [x] DeepSeek - 国内
- [x] 智谱 GLM - 国内
- [x] MiniMax - 国内
- [x] Moonshot (Kimi) - 国内
### 2.2 LLM 管理器 ✅
- 状态: ✅ 已完成
- 功能:
- [x] 统一调用接口
- [x] 代理自动配置(境外服务)
- [x] 故障自动转移
- [x] 流式响应支持
- [x] 工具调用支持
### 2.3 Agent 运行时集成 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 子任务:
- [x] 基础 Agent 运行时
- [x] 与 LLM Manager 集成
- [x] 支持动态切换提供商
---
## Phase 3: Web TUI 集成 (100%) ✅
### 3.1 Web 终端前端 ✅
- 状态: ✅ 已完成
- 实际完成: 2026-02-04
- 组件:
- [x] xterm.js 终端集成
- [x] 响应式 UI 设计
- [x] 主题切换支持
- [x] 全屏模式
### 3.2 WebSocket 终端服务器 ✅
- 状态: ✅ 已完成
- 功能:
- [x] 终端 I/O 双向传输
- [x] 连接管理
- [x] 终端大小调整
### 3.3 SSH 连接管理 ✅
- 状态: ✅ 已完成
- 功能:
- [x] paramiko SSH 客户端
- [x] 密钥/密码认证
- [x] 会话池管理
- [x] 空闲会话清理
### 3.4 用户认证 ✅
- 状态: ✅ 已完成
- 功能:
- [x] Token 生成与验证
- [x] 令牌刷新
- [x] 会话管理
- [x] 过期清理
### 3.5 Web TUI 服务器 ✅
- 状态: ✅ 已完成
- 功能:
- [x] FastAPI 服务器
- [x] 静态文件服务
- [x] WebSocket 端点
---
## Phase 4-5 (详细任务待展开)
Phase 4-5 的详细任务清单参见 [开发步骤.md](./开发步骤.md)
---
## 关键里程碑
### Milestone 1: MVP可运行 (Phase 1完成)
- 目标日期: -
- 实际日期: -
- 标准:
- [ ] Gateway可接收企业微信/飞书消息
- [ ] 消息可通过WebSocket转发
- [ ] Python代码可在沙箱执行
- [ ] 基本配置和日志系统运行
---
### Milestone 2: Agent系统完整 (Phase 2完成)
- 目标日期: -
- 实际日期: -
- 标准:
- [ ] Agent可调用内置工具
- [ ] MCP Server可动态加载
- [ ] RAG知识库可搜索
---
### Milestone 3: 双界面模式 (Phase 3完成)
- 目标日期: -
- 实际日期: -
- 标准:
- [ ] Claude CLI可通过TUI调用
- [ ] 编程任务可在TUI完成
- [ ] 通讯工具集成完成
---
### Milestone 4: 高级特性 (Phase 4完成)
- 目标日期: -
- 实际日期: -
- 标准:
- [ ] 多Agent协作运行
- [ ] 定时任务自动执行
---
### Milestone 5: 生产就绪 (Phase 5完成)
- 目标日期: -
- 实际日期: -
- 标准:
- [ ] 系统可生产部署
- [ ] 完整的监控和日志
- [ ] 用户文档完善
---
## 当前工作项
### 正在进行
- Phase 1: 核心框架(MVP) 开发
### 待办事项
1. 完成 Phase 1.1 数据存储层
2. 完成 Phase 1.2 Gateway WebSocket 服务
### 已完成
- ✅ 2025-02-04: 完成设计文档
- ✅ 2025-02-04: 完成项目分析报告
- ✅ 2025-02-04: 完成开发步骤规划
- ✅ 2025-02-04: 创建PoC验证文档和测试脚本
- ✅ 2025-02-04: 完成PoC验证
- PoC 1: Claude CLI - 部分可行subprocess有问题
- PoC 2: 智能路由 - 通过85.7%准确率)
- PoC 3: MCP Server - 测试未完成
- ✅ 2025-02-04: 确定技术方案方案C - Web TUI集成
- ✅ 2025-02-04: 更新设计文档和开发步骤
- ✅ 2026-02-04: **Phase 0 项目初始化完成**
- 项目目录结构创建
- pyproject.toml + 依赖管理
- 代码质量工具配置 (ruff, mypy, pre-commit)
- 配置系统 (Pydantic Settings + JSON5)
- 日志系统 (structlog + 审计日志)
- Gateway 基础服务骨架
- 测试框架 (pytest, 9/9 通过)
---
## 问题与风险
### 当前问题
-
### 风险追踪
| 风险 | 影响 | 缓解措施 | 状态 |
|------|------|----------|------|
| Claude CLI集成复杂 | 高 | 采用Web TUI方案避免subprocess | ✅ 已解决 |
| 智能路由效果不佳 | 中 | PoC验证85.7%准确率 | ✅ 已验证 |
| MCP Server不稳定 | 低 | 延后到Phase 4MVP不依赖 | 📋 已降级 |
---
## 变更记录
### 2025-02-04
#### 上午:项目初始化
- **项目初始化**: 创建设计文档、分析报告、开发步骤文档
- **设计调整**: 智能路由改为Agent实现(基于用户反馈)
- **设计确认**: 权限控制通过沙箱隔离实现
- **PoC准备**: 创建完整的PoC验证计划和测试脚本
#### 下午PoC验证
- **PoC 1**: Claude CLI集成测试 - 命令行可用Python subprocess有问题
- **PoC 2**: 智能路由算法 - 通过准确率85.7%
- **PoC 3**: MCP Server加载 - 未完成(可延后)
#### 晚上:方案确定
- **重大决策**: 采用**Web TUI集成方案**方案C
- 简单任务Anthropic API
- 复杂任务Web终端 + SSH + Claude CLI
- 优势避开subprocess保留CLI完整能力
- **文档更新**:
- 设计文档.md - 添加Web TUI架构
- PoC总结.md - 添加方案C详细说明
- 开发步骤.md - 调整Phase 3为Web TUI集成
- 进度.md - 更新状态
- **清理工作**: 准备删除poc目录待手动完成
---
## 下一步行动
1. **立即执行**: 开始 Phase 0 项目初始化
2. **本周目标**: 完成项目骨架搭建
3. **两周目标**: 完成 Phase 1 MVP
4. **一个月目标**: 完成 Phase 2 Agent系统
---
## 团队成员
| 成员 | 角色 | 负责模块 |
|------|------|----------|
| - | 开发者 | 全部 |
---
**备注**:
- 本文档应随开发进度实时更新
- 每完成一个任务,更新对应的状态和时间
- 遇到问题及时记录在"问题与风险"部分

880
项目分析.md Normal file
View File

@@ -0,0 +1,880 @@
# MineNASAI 项目分析报告
**生成日期**: 2025-02-04
**基于文档**: 2025-02-03-minenasai-design.md
**项目状态**: 设计阶段(无代码实现)
---
## 一、项目现状分析
### 1.1 当前状态
- ✅ 完整的设计文档已完成
- ❌ 无任何代码实现
- ❌ 无依赖配置文件
- ❌ 无项目结构
- ✅ 有 .claude 配置(命令和代理定义)
### 1.2 技术栈概述
| 组件 | 技术选择 | 用途 |
|------|----------|------|
| 后端框架 | FastAPI | WebSocket服务器、REST API |
| 消息队列 | Redis + Celery | 任务调度、异步处理 |
| 通讯渠道 | 企业微信API + 飞书API | 消息接收与发送 |
| Agent运行时 | pi-mono风格 | Agent执行引擎 |
| 工具层 | FastMCP + AgentSkills | 插件化工具系统 |
| 编程能力 | Claude Code CLI | 复杂编程任务 |
| 向量数据库 | Qdrant | RAG知识库 |
| 全文搜索 | Whoosh/Meilisearch | 文件索引 |
| 存储 | SQLite | 会话、配置、日志 |
| TUI界面 | textual/rich | 终端用户界面 |
---
## 二、可行性分析
### 2.1 技术可行性评估
#### ✅ 可行且成熟的技术
1. **FastAPI + WebSocket**: 成熟框架Python生态完善
2. **企业微信/飞书API**: 官方SDK支持良好文档完整
3. **Redis + Celery**: 成熟的任务队列解决方案
4. **SQLite**: 轻量级存储,适合单机部署
5. **textual/rich**: Python TUI开发成熟库
#### ⚠️ 需要特别注意的技术
1. **Claude Code CLI集成**:
- 需要子进程管理和stdout/stderr解析
- 需要处理交互式输入/输出
- 需要考虑超时和资源限制
2. **MCP Server动态加载**:
- FastMCP框架较新需要深入了解
- 进程管理和通信协议需要仔细设计
3. **智能路由(任务复杂度评估)**:
- 需要设计评估算法token预估、工具需求分析
- 可能需要机器学习模型辅助
4. **多Agent协作**:
- 需要设计消息总线和状态同步机制
- 并发控制和死锁预防
#### ⚠️ 潜在风险点
1. **权限控制复杂度**: 需要精细的权限分级和审计
2. **沙箱隔离**: Python沙箱实现需要考虑安全性
3. **代理服务稳定性**: 网络代理可能影响服务可用性
4. **性能瓶颈**: WebSocket连接数、并发任务处理
5. **RAG向量库规模**: Qdrant性能随数据量增长
### 2.2 依赖关系分析
```mermaid
graph TD
A[FastAPI Gateway] --> B[通讯渠道层]
A --> C[智能路由]
C --> D[Agent Runtime]
D --> E[工具管理器]
E --> F1[MCP Server]
E --> F2[AgentSkills]
E --> F3[Claude CLI]
E --> F4[Python沙箱]
D --> G[知识库]
G --> H1[Qdrant]
G --> H2[文件索引]
A --> I[会话管理]
I --> J[SQLite]
A --> K[定时任务]
K --> L[Redis+Celery]
```
### 2.3 实施难度评估
| Phase | 模块 | 难度 | 时间估算 | 风险等级 |
|-------|------|------|----------|----------|
| Phase 1 | Gateway基础服务 | 中 | 5-7天 | 低 |
| Phase 1 | 企业微信/飞书接入 | 中 | 3-5天 | 中 |
| Phase 1 | 智能路由基础 | 高 | 5-7天 | 高 |
| Phase 1 | Python沙箱 | 高 | 3-5天 | 高 |
| Phase 2 | MCP Server加载 | 高 | 5-7天 | 中 |
| Phase 2 | Skills系统 | 中 | 3-5天 | 低 |
| Phase 2 | RAG集成 | 中 | 5-7天 | 中 |
| Phase 3 | Claude CLI桥接 | 高 | 7-10天 | 高 |
| Phase 3 | TUI界面 | 中 | 5-7天 | 低 |
| Phase 4 | 多Agent协作 | 高 | 10-14天 | 高 |
| Phase 5 | 权限与审计 | 中 | 5-7天 | 中 |
**总计**: 约 56-84 天开发时间(不含测试和调试)
---
## 三、模块分解
### 3.1 核心模块划分
#### 模块1: Gateway服务gateway/
**职责**: WebSocket服务器、消息路由、协议处理
```
gateway/
├── server.py # FastAPI应用入口
├── websocket_handler.py # WebSocket连接管理
├── protocol/
│ ├── schema.py # 消息Schema定义
│ └── frames.py # 帧构造/解析
├── router.py # 智能路由器
└── middleware.py # 认证、限流中间件
```
**依赖**: FastAPI, websockets, pydantic
**关键功能**:
- WebSocket连接管理心跳、重连
- 消息验证和反序列化
- 智能路由决策(任务复杂度评估)
- 流量控制和限流
#### 模块2: 通讯渠道channels/
**职责**: 企业微信、飞书等通讯平台接入
```
channels/
├── base.py # Channel基类
├── wework/
│ ├── client.py # 企业微信API客户端
│ ├── webhook.py # Webhook处理
│ └── message.py # 消息适配器
└── feishu/
├── client.py # 飞书API客户端
├── webhook.py # Webhook处理
└── message.py # 消息适配器
```
**依赖**: httpx, pycryptodome消息加解密
**关键功能**:
- Webhook接收和验证
- 消息加解密
- API调用发送消息、上传文件
- 事件订阅管理
#### 模块3: Agent运行时agent/
**职责**: Agent执行引擎、工具调用、记忆管理
```
agent/
├── runtime.py # Agent主运行循环
├── executor.py # 工具执行器
├── memory.py # 短期记忆Session
├── context.py # 上下文管理
└── subagent.py # Sub-agent支持
```
**依赖**: anthropic, aiohttp
**关键功能**:
- LLM API调用Claude
- 工具调用编排
- 上下文窗口管理
- 多轮对话状态维护
#### 模块4: 工具管理tools/
**职责**: 工具注册、权限控制、执行隔离
```
tools/
├── registry.py # 工具注册表
├── executor.py # 工具执行框架
├── builtin/ # 内置工具
│ ├── read.py
│ ├── write.py
│ ├── shell.py
│ └── python.py
├── mcp_loader.py # MCP Server加载器
├── skills_loader.py # AgentSkills加载器
├── sandbox.py # 沙箱隔离
└── permission.py # 权限检查
```
**依赖**: docker-py, mcp, subprocess
**关键功能**:
- 动态工具加载
- 权限白名单检查
- 沙箱执行Docker/restricted
- 审计日志记录
#### 模块5: Claude Code CLI桥接claude_bridge/
**职责**: Claude Code CLI子进程管理
```
claude_bridge/
├── cli.py # CLI调用封装
├── process.py # 子进程管理
├── parser.py # 输出解析
└── session.py # CLI会话管理
```
**依赖**: subprocess, pty
**关键功能**:
- 子进程启动和监控
- 实时输出流解析
- 交互式输入处理
- 超时和资源限制
#### 模块6: 知识库knowledge/
**职责**: RAG向量存储、文件索引、语义搜索
```
knowledge/
├── vector_store.py # Qdrant向量库
├── embedder.py # Embedding模型
├── file_index.py # 文件索引器
├── searcher.py # 语义搜索
└── ingestion.py # 数据摄取管道
```
**依赖**: qdrant-client, sentence-transformers, whoosh
**关键功能**:
- 文档向量化
- 语义搜索
- 全文索引
- 增量更新
#### 模块7: TUI界面tui/
**职责**: 终端用户界面
```
tui/
├── app.py # Textual应用主入口
├── screens/
│ ├── chat.py # 聊天界面
│ ├── sessions.py # 会话列表
│ ├── logs.py # 日志查看
│ └── settings.py # 设置界面
└── widgets/
├── message.py # 消息组件
└── input.py # 输入框
```
**依赖**: textual, rich
**关键功能**:
- 实时聊天界面
- Claude CLI输出展示
- 日志查看
- 配置管理
#### 模块8: 数据存储storage/
**职责**: 数据库、会话持久化
```
storage/
├── database.py # SQLite ORM
├── models.py # 数据模型
├── session_store.py # JSONL会话存储
└── migrations/ # 数据库迁移
```
**依赖**: sqlalchemy, alembic
**关键功能**:
- SQLite数据库管理
- JSONL日志写入
- 数据迁移
#### 模块9: 任务调度scheduler/
**职责**: 定时任务、异步任务队列
```
scheduler/
├── celery_app.py # Celery应用
├── tasks.py # 任务定义
├── cron.py # Cron任务管理
└── worker.py # Worker进程
```
**依赖**: celery, redis, croniter
**关键功能**:
- Cron表达式解析
- 任务调度
- 分布式任务执行
- 任务结果存储
#### 模块10: 配置管理config/
**职责**: 配置加载、环境管理
```
config/
├── settings.py # 配置Schema
├── loader.py # 配置加载器
└── validator.py # 配置验证
```
**依赖**: pydantic, json5
**关键功能**:
- JSON5配置解析
- 环境变量覆盖
- 配置验证
- 热重载
### 3.2 模块依赖关系
```
配置管理 ← 所有模块
Gateway服务 → 通讯渠道
智能路由 → Agent运行时
工具管理 → {MCP, Skills, Claude CLI, 沙箱}
知识库 ← Agent运行时
数据存储 ← {Gateway, Agent, 任务调度}
TUI界面 → Gateway服务
```
### 3.3 公共基础设施
```
core/
├── logging.py # 结构化日志
├── errors.py # 异常定义
├── utils.py # 通用工具函数
├── proxy.py # 代理服务管理
└── security.py # 安全工具
```
---
## 四、开发步骤规划
### 阶段划分原则
1. **先基础后高级**: 从核心框架到高级特性
2. **最小可用**: 每阶段产出可运行版本
3. **风险前置**: 高风险模块优先验证
4. **并行开发**: 独立模块可并行
### Phase 0: 项目初始化2-3天
#### 任务清单
- [ ] 创建项目目录结构
- [ ] 配置开发环境pyproject.toml
- [ ] 设置依赖管理Poetry/pip-tools
- [ ] 配置代码质量工具ruff, mypy, pre-commit
- [ ] 编写 README.md
- [ ] 初始化 Git 仓库
- [ ] 配置 Docker 开发环境
#### 可交付成果
- ✅ 完整的项目骨架
- ✅ 开发环境可一键启动
- ✅ CI/CD基础配置
---
### Phase 1: 核心框架MVP14-21天
#### 1.1 配置管理系统2-3天
**优先级**: 🔥 最高
**依赖**: 无
**任务清单**:
- [ ] 定义配置SchemaPydantic模型
- [ ] 实现JSON5配置加载器
- [ ] 支持环境变量覆盖
- [ ] 配置验证和错误提示
- [ ] 编写配置文档
**验收标准**:
```python
from config import load_config
config = load_config("~/.config/minenasai/config.json5")
assert config.gateway.host == "0.0.0.0"
```
#### 1.2 日志和监控基础2天
**优先级**: 🔥 高
**依赖**: 配置管理
**任务清单**:
- [ ] 结构化日志JSON格式
- [ ] 日志级别和轮转
- [ ] 审计日志独立输出
- [ ] 性能指标收集(基础)
**验收标准**:
- 日志可读性良好
- 支持按时间/级别过滤
- 审计日志包含完整上下文
#### 1.3 数据存储层3-4天
**优先级**: 🔥 高
**依赖**: 配置管理
**任务清单**:
- [ ] 设计SQLite Schema
- [ ] SQLAlchemy ORM模型
- [ ] 数据库迁移脚本Alembic
- [ ] JSONL会话存储
- [ ] 数据库连接池
**验收标准**:
```python
from storage import Database
db = Database()
agent = db.create_agent(name="main", model="claude-sonnet-4")
session = db.create_session(agent_id=agent.id, channel="wework")
```
#### 1.4 Gateway WebSocket服务5-7天
**优先级**: 🔥 最高
**依赖**: 配置管理、日志
**任务清单**:
- [ ] FastAPI应用骨架
- [ ] WebSocket连接管理
- [ ] 消息协议定义schema.py
- [ ] 帧序列化/反序列化
- [ ] 心跳和重连机制
- [ ] 基础路由agent/chat/presence
- [ ] 单元测试
**验收标准**:
- WebSocket客户端可连接
- 支持多客户端并发
- 消息正确路由
- 断线自动重连
**测试脚本**:
```python
async with websockets.connect("ws://localhost:8000/ws") as ws:
await ws.send(json.dumps({"type": "agent/chat", "content": "hello"}))
response = await ws.recv()
assert json.loads(response)["status"] == "ok"
```
#### 1.5 通讯渠道接入4-6天
**优先级**: 🔥 高
**依赖**: Gateway
**任务清单**:
- [ ] 企业微信Webhook接收
- [ ] 企业微信消息加解密
- [ ] 飞书Webhook接收
- [ ] 飞书消息加解密
- [ ] 消息适配器(统一格式)
- [ ] 消息发送API封装
- [ ] 错误重试机制
**验收标准**:
- 企业微信消息可接收和回复
- 飞书消息可接收和回复
- 支持文本、图片、文件消息
#### 1.6 智能路由基础3-5天
**优先级**: 🔥 中
**依赖**: Gateway
**任务清单**:
- [ ] 任务复杂度评估算法(启发式)
- [ ] 路由决策引擎
- [ ] 用户指令覆盖(/快速 /深度)
- [ ] 路由日志和统计
**验收标准**:
```python
router = SmartRouter()
decision = router.evaluate("NAS状态?")
assert decision.mode == "fast"
decision = router.evaluate("实现一个Web服务")
assert decision.mode == "deep"
```
#### 1.7 Python沙箱执行3-5天
**优先级**: 🔥 高(安全关键)
**依赖**: 工具管理
**任务清单**:
- [ ] Docker沙箱容器镜像
- [ ] Python代码执行API
- [ ] 超时和资源限制
- [ ] 输出捕获stdout/stderr
- [ ] 安全验证和测试
**验收标准**:
```python
sandbox = PythonSandbox()
result = sandbox.execute("print(1+1)", timeout=5)
assert result.stdout == "2\n"
assert result.exit_code == 0
```
#### Phase 1 里程碑
- ✅ Gateway可接收企业微信/飞书消息
- ✅ 消息可通过WebSocket转发
- ✅ Python代码可在沙箱执行
- ✅ 基本配置和日志系统运行
---
### Phase 2: Agent与工具系统14-21天
#### 2.1 Agent运行时5-7天
**优先级**: 🔥 最高
**依赖**: Gateway、数据存储
**任务清单**:
- [ ] Agent主循环实现
- [ ] Claude API集成anthropic SDK
- [ ] 工具调用编排
- [ ] 上下文窗口管理
- [ ] 会话状态持久化
- [ ] 流式响应支持
**验收标准**:
```python
agent = Agent(id="main", model="claude-sonnet-4")
response = await agent.chat("你好")
assert response.content != ""
```
#### 2.2 工具管理框架4-5天
**优先级**: 🔥 高
**依赖**: Agent运行时
**任务清单**:
- [ ] 工具注册表实现
- [ ] 工具Schema定义
- [ ] 权限检查机制
- [ ] 审计日志记录
- [ ] 内置工具实现read/write/shell
**验收标准**:
```python
registry = ToolRegistry()
registry.register(ReadFileTool())
tool = registry.get("read_file")
result = await tool.execute({"path": "/etc/hosts"})
```
#### 2.3 MCP Server集成5-7天
**优先级**: 🔥 中
**依赖**: 工具管理
**任务清单**:
- [ ] MCP Server发现和加载
- [ ] MCP协议实现stdio/sse
- [ ] 工具动态注册
- [ ] 进程生命周期管理
- [ ] 错误处理和重启
**验收标准**:
- 可加载官方MCP Server示例
- MCP工具可被Agent调用
- MCP Server崩溃可自动重启
#### 2.4 AgentSkills集成3-4天
**优先级**: 🔥 中
**依赖**: 工具管理
**任务清单**:
- [ ] Skill目录扫描
- [ ] Skill.md解析
- [ ] Skill指令注入
- [ ] Skill版本管理
**验收标准**:
```python
skills = SkillsLoader().load("~/.config/minenasai/skills")
assert "web-search" in skills
```
#### 2.5 RAG知识库5-7天
**优先级**: 🔥 中
**依赖**: 配置管理
**任务清单**:
- [ ] Qdrant向量库初始化
- [ ] Embedding模型集成sentence-transformers
- [ ] 文档切片和向量化
- [ ] 语义搜索API
- [ ] 增量更新机制
**验收标准**:
```python
kb = KnowledgeBase()
kb.ingest("这是一段测试文本")
results = kb.search("测试", top_k=5)
assert len(results) > 0
```
#### 2.6 文件索引3-4天
**优先级**: 🔥 低
**依赖**: 知识库
**任务清单**:
- [ ] 文件系统扫描
- [ ] Whoosh全文索引
- [ ] 增量索引更新
- [ ] 搜索API
**验收标准**:
```python
indexer = FileIndexer("/mnt/nas")
indexer.index()
results = indexer.search("*.py")
```
#### Phase 2 里程碑
- ✅ Agent可调用内置工具
- ✅ MCP Server可动态加载
- ✅ RAG知识库可搜索
- ✅ 完整的MVP可运行
---
### Phase 3: Claude CLI与TUI12-17天
#### 3.1 Claude Code CLI桥接7-10天
**优先级**: 🔥 高(核心能力)
**依赖**: Agent运行时
**任务清单**:
- [ ] Claude CLI子进程启动
- [ ] PTY伪终端管理
- [ ] 实时输出流解析
- [ ] 交互式输入处理
- [ ] 超时和资源限制
- [ ] 会话保持和恢复
- [ ] 错误处理
**验收标准**:
```python
cli = ClaudeCLI()
session = cli.start_session(workspace="/tmp/test")
response = await cli.send("实现一个计算器")
assert "def calculator" in response.code_changes
```
**关键技术点**:
- 使用 `pty.openpty()` 创建伪终端
- 使用 `asyncio.create_subprocess_exec` 启动子进程
- 解析ANSI转义序列
- 处理Claude CLI的交互式提示
#### 3.2 TUI界面5-7天
**优先级**: 🔥 中
**依赖**: Gateway、Claude CLI
**任务清单**:
- [ ] Textual应用骨架
- [ ] 聊天界面(消息列表+输入框)
- [ ] Claude CLI输出展示
- [ ] 会话列表和切换
- [ ] 日志查看器
- [ ] 设置界面
**验收标准**:
- TUI可启动并连接Gateway
- 可实时查看Agent对话
- Claude CLI输出实时展示
- 支持键盘快捷键
#### Phase 3 里程碑
- ✅ Claude CLI可通过TUI调用
- ✅ 编程任务可在TUI完成
- ✅ 双界面模式(通讯工具+TUI运行
---
### Phase 4: 高级特性10-14天
#### 4.1 Sub-agent协作7-10天
**优先级**: 🔥 中
**依赖**: Agent运行时
**任务清单**:
- [ ] Sub-agent生命周期管理
- [ ] 消息总线实现
- [ ] Agent间通信协议
- [ ] 并发控制和同步
- [ ] 死锁检测和预防
**验收标准**:
```python
main_agent = Agent(id="main")
sub = main_agent.spawn_subagent(task="分析日志")
result = await sub.wait()
```
#### 4.2 定时任务调度3-4天
**优先级**: 🔥 低
**依赖**: Celery、数据存储
**任务清单**:
- [ ] Celery应用配置
- [ ] Cron任务管理API
- [ ] 任务执行器
- [ ] 任务结果存储
- [ ] Web界面管理可选
**验收标准**:
```python
scheduler = CronScheduler()
scheduler.add_job(
name="morning_report",
schedule="0 9 * * *",
task="生成昨日总结"
)
```
#### Phase 4 里程碑
- ✅ 多Agent协作运行
- ✅ 定时任务自动执行
---
### Phase 5: 生产就绪7-10天
#### 5.1 权限与审计3-4天
**优先级**: 🔥 高(安全)
**依赖**: 工具管理
**任务清单**:
- [ ] 权限白名单配置
- [ ] 危险操作确认流程
- [ ] 完整审计日志
- [ ] 敏感信息脱敏
- [ ] 权限测试用例
#### 5.2 错误处理与监控2-3天
**优先级**: 🔥 中
**依赖**: 所有模块
**任务清单**:
- [ ] 全局异常处理
- [ ] 重试策略实现
- [ ] Circuit Breaker
- [ ] 健康检查接口
- [ ] Prometheus指标导出
#### 5.3 部署与文档2-3天
**优先级**: 🔥 中
**依赖**: 所有模块
**任务清单**:
- [ ] Docker Compose配置
- [ ] 部署文档
- [ ] 配置示例
- [ ] 故障排查手册
- [ ] API文档
#### Phase 5 里程碑
- ✅ 系统可生产部署
- ✅ 完整的监控和日志
- ✅ 用户文档完善
---
## 五、关键技术验证PoC
### 优先验证项
以下技术点建议在正式开发前进行PoC验证
#### PoC 1: Claude Code CLI集成2-3天
**目标**: 验证子进程调用和输出解析可行性
```python
# 测试脚本
import subprocess
import pty
import os
master, slave = pty.openpty()
proc = subprocess.Popen(
["claude", "实现一个计算器"],
stdin=slave, stdout=slave, stderr=slave
)
os.close(slave)
while True:
output = os.read(master, 1024)
if not output:
break
print(output.decode())
```
#### PoC 2: 智能路由算法1-2天
**目标**: 验证任务复杂度评估效果
```python
# 简单规则
def evaluate_complexity(message):
if any(kw in message for kw in ["实现", "开发", "编写"]):
return "high"
if any(kw in message for kw in ["查询", "搜索", "状态"]):
return "low"
return "medium"
```
#### PoC 3: MCP Server加载1-2天
**目标**: 验证MCP协议通信
```python
from mcp import MCPClient
client = MCPClient.connect("npx", ["-y", "@modelcontextprotocol/server-filesystem"])
tools = client.list_tools()
```
---
## 六、风险与缓解措施
### 风险清单
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| Claude CLI集成失败 | 🔥高 | 中 | PoC验证备选方案直接API调用 |
| 智能路由效果差 | 🔥中 | 高 | 先用简单规则,后续迭代优化 |
| MCP Server不稳定 | 🔥中 | 中 | 进程监控和自动重启 |
| 代理服务不可用 | 🔥中 | 低 | 自动检测和Fallback |
| 性能瓶颈 | 🔥中 | 中 | 压力测试,异步优化 |
| 权限控制漏洞 | 🔥高 | 低 | 安全审计,沙箱隔离 |
### 备选方案
1. **Claude CLI不可用**: 直接使用Anthropic API失去IDE集成能力
2. **Qdrant性能不足**: 降级为简单向量搜索numpy
3. **企业微信API限流**: 增加消息队列缓冲
---
## 七、开发规范
### 代码规范
- Python 3.11+
- 类型注解mypy检查
- DocstringGoogle风格
- 测试覆盖率 > 80%
### Git工作流
- 主分支: `main`
- 开发分支: `develop`
- 功能分支: `feature/<name>`
- 提交格式: `<type>: <description>`
### 文档要求
- 每个模块有README
- 关键函数有Docstring
- API有OpenAPI文档
- 部署有详细步骤
---
## 八、总结与建议
### 项目可行性: ✅ 可行
**理由**:
1. 技术栈成熟,社区支持良好
2. 核心难点Claude CLI集成可通过PoC验证
3. 模块划分清晰,可并行开发
4. 风险可控,有备选方案
### 关键成功因素
1. ✅ 先完成PoC验证关键技术
2. ✅ 严格按Phase推进避免跳跃
3. ✅ 每个Phase产出可运行版本
4. ✅ 重视测试和文档
5. ✅ 预留20%时间处理意外问题
### 下一步行动
1. **立即执行**: Phase 0 项目初始化
2. **本周完成**: PoC 1-3 关键技术验证
3. **两周目标**: Phase 1 MVP可运行
4. **一个月目标**: Phase 2 完整Agent系统
### 资源需求
- **人力**: 1-2名全职开发者
- **时间**: 2-3个月完整实现
- **硬件**: NAS服务器已有
- **成本**: 主要是LLM API费用按需
---
**文档结束**