feat: 添加项目规则、环境配置示例及开发文档
This commit is contained in:
6
.claude/agents/analyze-project.md
Normal file
6
.claude/agents/analyze-project.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: analyze-project
|
||||
description: 分析当前项目
|
||||
model: opus
|
||||
---
|
||||
请分析当前项目的架构、功能、技术栈和代码结构
|
||||
6
.claude/agents/backend-unit-testing.md
Normal file
6
.claude/agents/backend-unit-testing.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: backend-unit-testing
|
||||
description: 对后端模块执行单元测试
|
||||
model: sonnet
|
||||
---
|
||||
在所有定义的模块上执行单元测试。验证每个模块通过所有测试且逻辑符合预期行为。
|
||||
6
.claude/agents/compare-requirements.md
Normal file
6
.claude/agents/compare-requirements.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: compare-requirements
|
||||
description: 需求比对分析
|
||||
model: opus
|
||||
---
|
||||
对比分析当前项目与需求的差异,识别需要修改的地方
|
||||
6
.claude/agents/develop-step-1.md
Normal file
6
.claude/agents/develop-step-1.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: develop-step-1
|
||||
description: 开发当前步骤
|
||||
model: sonnet
|
||||
---
|
||||
根据要求开发当前步骤。专注于实现本步骤描述的核心功能。
|
||||
6
.claude/agents/error-no-docs.md
Normal file
6
.claude/agents/error-no-docs.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: error-no-docs
|
||||
description: 处理缺少步骤文档的情况
|
||||
model: sonnet
|
||||
---
|
||||
错误:未找到步骤文档。此工作流需要步骤文档才能继续。请创建一个包含开发步骤的 step_documentation.md 文件。
|
||||
6
.claude/agents/extract-steps-1.md
Normal file
6
.claude/agents/extract-steps-1.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: extract-steps-1
|
||||
description: 从文档中提取开发步骤
|
||||
model: sonnet
|
||||
---
|
||||
从步骤文档中提取并列出所有开发步骤。对于每个步骤,识别任务描述、要求和验收标准。以编号列表格式输出步骤。
|
||||
6
.claude/agents/fix-bugs-1.md
Normal file
6
.claude/agents/fix-bugs-1.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: fix-bugs-1
|
||||
description: 修复错误或完成开发
|
||||
model: sonnet
|
||||
---
|
||||
修复验证过程中发现的任何错误或问题。继续在此步骤上工作,直到验证通过为止。
|
||||
6
.claude/agents/fix-issues.md
Normal file
6
.claude/agents/fix-issues.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: fix-issues
|
||||
description: 修复编译和语法问题
|
||||
model: sonnet
|
||||
---
|
||||
修复所有已识别的编译和语法问题。修复后返回检查问题是否已解决。
|
||||
6
.claude/agents/foreach-loop-start.md
Normal file
6
.claude/agents/foreach-loop-start.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: foreach-loop-start
|
||||
description: 初始化foreach循环处理步骤
|
||||
model: sonnet
|
||||
---
|
||||
初始化foreach循环以按顺序处理每个开发步骤。跟踪当前步骤索引和总步骤数。
|
||||
6
.claude/agents/loop-back.md
Normal file
6
.claude/agents/loop-back.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: loop-back
|
||||
description: 返回开发阶段
|
||||
model: sonnet
|
||||
---
|
||||
返回开发阶段以继续修复当前步骤。循环返回直到验证通过。
|
||||
6
.claude/agents/modify-direct.md
Normal file
6
.claude/agents/modify-direct.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: modify-direct
|
||||
description: 直接修改
|
||||
model: opus
|
||||
---
|
||||
直接进行修改并验证结果
|
||||
6
.claude/agents/module-division.md
Normal file
6
.claude/agents/module-division.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: module-division
|
||||
description: 将代码划分为测试模块
|
||||
model: sonnet
|
||||
---
|
||||
分析代码库并将其划分为单元测试的逻辑模块。定义清晰的模块边界和职责。
|
||||
6
.claude/agents/plan-steps.md
Normal file
6
.claude/agents/plan-steps.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: plan-steps
|
||||
description: 步骤规划
|
||||
model: opus
|
||||
---
|
||||
规划可验证的修改步骤,确保每个步骤都可验证,并形成步骤文档
|
||||
6
.claude/agents/step-complete.md
Normal file
6
.claude/agents/step-complete.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: step-complete
|
||||
description: 移动到下一步
|
||||
model: sonnet
|
||||
---
|
||||
当前步骤成功完成。移动到序列中的下一步。如果这是最后一步,则标记工作流为完成。
|
||||
6
.claude/agents/syntax-check.md
Normal file
6
.claude/agents/syntax-check.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: syntax-check
|
||||
description: 检查编译和语法问题
|
||||
model: sonnet
|
||||
---
|
||||
分析代码库中的编译错误和语法问题。识别所有需要在测试前修复的问题。
|
||||
6
.claude/agents/unit-test-planning.md
Normal file
6
.claude/agents/unit-test-planning.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: unit-test-planning
|
||||
description: 为前端规划单元测试
|
||||
model: sonnet
|
||||
---
|
||||
为前端模块规划全面的单元测试。定义测试用例和预期行为。
|
||||
6
.claude/agents/verify-step-1.md
Normal file
6
.claude/agents/verify-step-1.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: verify-step-1
|
||||
description: 验证完成的步骤
|
||||
model: sonnet
|
||||
---
|
||||
验证当前步骤是否已成功完成。检查是否满足所有要求,功能是否按预期工作。
|
||||
93
.claude/commands/ceshi.md
Normal file
93
.claude/commands/ceshi.md
Normal 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
90
.claude/commands/code.md
Normal 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
85
.claude/commands/req.md
Normal 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.
|
||||
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dir:*)",
|
||||
"Bash(tree:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
113
.cursorrules
Normal file
113
.cursorrules
Normal 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
85
.env.example
Normal 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
75
.gitignore
vendored
Normal 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
29
.pre-commit-config.yaml
Normal 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
263
PoC总结.md
Normal 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 3(Claude 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.js(Web终端模拟器)
|
||||
- 通信:WebSocket(双向I/O)
|
||||
- 后端:paramiko(Python SSH库)
|
||||
- 连接:SSH localhost
|
||||
|
||||
**影响**:
|
||||
- Phase 3工作量适中(约5-7天)
|
||||
- 无subprocess调试成本
|
||||
- 用户体验最优
|
||||
|
||||
---
|
||||
|
||||
## 下一步建议
|
||||
|
||||
### 立即行动
|
||||
|
||||
1. **✅ 采用方案C(Web TUI集成)**
|
||||
- 简单任务用Anthropic API
|
||||
- 复杂任务用Web TUI + SSH + Claude CLI
|
||||
- 避免所有subprocess问题
|
||||
- 保留完整CLI能力
|
||||
|
||||
2. **开始 Phase 0: 项目初始化**
|
||||
- 创建项目结构
|
||||
- 配置开发环境
|
||||
- 设置依赖管理
|
||||
|
||||
3. **✅ 设计文档已更新**
|
||||
- 技术栈已调整
|
||||
- Phase 3为Web TUI集成
|
||||
- 时间估算已优化
|
||||
|
||||
### 风险管理
|
||||
|
||||
**已降低的风险**:
|
||||
- ✅ 智能路由效果 → 85.7%准确率,风险低
|
||||
- ✅ Python技术栈 → 成熟可靠
|
||||
|
||||
**需要关注的风险**:
|
||||
- ⚠️ MCP Server集成 → 建议延后
|
||||
- ⚠️ 性能优化 → 需要后期压力测试
|
||||
|
||||
---
|
||||
|
||||
## 资源和时间重估
|
||||
|
||||
### 方案C(Web 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项目初始化
|
||||
149
README.md
Normal file
149
README.md
Normal 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
131
config/config.json5
Normal 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
143
pyproject.toml
Normal 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",
|
||||
]
|
||||
3
src/minenasai/__init__.py
Normal file
3
src/minenasai/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""MineNASAI - 基于NAS的智能个人AI助理"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
31
src/minenasai/agent/__init__.py
Normal file
31
src/minenasai/agent/__init__.py
Normal 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",
|
||||
]
|
||||
382
src/minenasai/agent/permissions.py
Normal file
382
src/minenasai/agent/permissions.py
Normal 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
|
||||
344
src/minenasai/agent/runtime.py
Normal file
344
src/minenasai/agent/runtime.py
Normal 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
|
||||
409
src/minenasai/agent/tool_registry.py
Normal file
409
src/minenasai/agent/tool_registry.py
Normal 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()))
|
||||
13
src/minenasai/agent/tools/__init__.py
Normal file
13
src/minenasai/agent/tools/__init__.py
Normal 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",
|
||||
]
|
||||
286
src/minenasai/agent/tools/basic.py
Normal file
286
src/minenasai/agent/tools/basic.py
Normal 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
168
src/minenasai/cli.py
Normal 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())
|
||||
23
src/minenasai/core/__init__.py
Normal file
23
src/minenasai/core/__init__.py
Normal 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",
|
||||
]
|
||||
309
src/minenasai/core/config.py
Normal file
309
src/minenasai/core/config.py
Normal 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
|
||||
390
src/minenasai/core/database.py
Normal file
390
src/minenasai/core/database.py
Normal 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
|
||||
212
src/minenasai/core/logging.py
Normal file
212
src/minenasai/core/logging.py
Normal 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
|
||||
282
src/minenasai/core/session_store.py
Normal file
282
src/minenasai/core/session_store.py
Normal 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
|
||||
6
src/minenasai/gateway/__init__.py
Normal file
6
src/minenasai/gateway/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Gateway 模块
|
||||
|
||||
提供 WebSocket 服务、通讯渠道接入、智能路由
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
11
src/minenasai/gateway/channels/__init__.py
Normal file
11
src/minenasai/gateway/channels/__init__.py
Normal 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",
|
||||
]
|
||||
89
src/minenasai/gateway/channels/base.py
Normal file
89
src/minenasai/gateway/channels/base.py
Normal 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}
|
||||
194
src/minenasai/gateway/channels/feishu.py
Normal file
194
src/minenasai/gateway/channels/feishu.py
Normal 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
|
||||
155
src/minenasai/gateway/channels/wework.py
Normal file
155
src/minenasai/gateway/channels/wework.py
Normal 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
|
||||
39
src/minenasai/gateway/protocol/__init__.py
Normal file
39
src/minenasai/gateway/protocol/__init__.py
Normal 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",
|
||||
]
|
||||
199
src/minenasai/gateway/protocol/schema.py
Normal file
199
src/minenasai/gateway/protocol/schema.py
Normal 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)
|
||||
203
src/minenasai/gateway/router.py
Normal file
203
src/minenasai/gateway/router.py
Normal 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
|
||||
238
src/minenasai/gateway/server.py
Normal file
238
src/minenasai/gateway/server.py
Normal 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)
|
||||
16
src/minenasai/llm/__init__.py
Normal file
16
src/minenasai/llm/__init__.py
Normal 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
215
src/minenasai/llm/base.py
Normal 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]
|
||||
19
src/minenasai/llm/clients/__init__.py
Normal file
19
src/minenasai/llm/clients/__init__.py
Normal 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",
|
||||
]
|
||||
232
src/minenasai/llm/clients/anthropic.py
Normal file
232
src/minenasai/llm/clients/anthropic.py
Normal 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
|
||||
45
src/minenasai/llm/clients/deepseek.py
Normal file
45
src/minenasai/llm/clients/deepseek.py
Normal 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
|
||||
246
src/minenasai/llm/clients/gemini.py
Normal file
246
src/minenasai/llm/clients/gemini.py
Normal 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
|
||||
78
src/minenasai/llm/clients/minimax.py
Normal file
78
src/minenasai/llm/clients/minimax.py
Normal 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
|
||||
46
src/minenasai/llm/clients/moonshot.py
Normal file
46
src/minenasai/llm/clients/moonshot.py
Normal 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
|
||||
209
src/minenasai/llm/clients/openai_compat.py
Normal file
209
src/minenasai/llm/clients/openai_compat.py
Normal 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
|
||||
51
src/minenasai/llm/clients/zhipu.py
Normal file
51
src/minenasai/llm/clients/zhipu.py
Normal 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
|
||||
341
src/minenasai/llm/manager.py
Normal file
341
src/minenasai/llm/manager.py
Normal 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
|
||||
12
src/minenasai/scheduler/__init__.py
Normal file
12
src/minenasai/scheduler/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""定时任务调度模块
|
||||
|
||||
提供 Cron 任务调度功能
|
||||
"""
|
||||
|
||||
from minenasai.scheduler.cron import CronJob, CronScheduler, get_scheduler
|
||||
|
||||
__all__ = [
|
||||
"CronJob",
|
||||
"CronScheduler",
|
||||
"get_scheduler",
|
||||
]
|
||||
357
src/minenasai/scheduler/cron.py
Normal file
357
src/minenasai/scheduler/cron.py
Normal 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
|
||||
16
src/minenasai/webtui/__init__.py
Normal file
16
src/minenasai/webtui/__init__.py
Normal 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",
|
||||
]
|
||||
236
src/minenasai/webtui/auth.py
Normal file
236
src/minenasai/webtui/auth.py
Normal 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
|
||||
314
src/minenasai/webtui/server.py
Normal file
314
src/minenasai/webtui/server.py
Normal 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,
|
||||
})
|
||||
313
src/minenasai/webtui/ssh_manager.py
Normal file
313
src/minenasai/webtui/ssh_manager.py
Normal 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
|
||||
377
src/minenasai/webtui/static/css/style.css
Normal file
377
src/minenasai/webtui/static/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
128
src/minenasai/webtui/static/index.html
Normal file
128
src/minenasai/webtui/static/index.html
Normal 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="关闭">×</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">×</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>
|
||||
393
src/minenasai/webtui/static/js/app.js
Normal file
393
src/minenasai/webtui/static/js/app.js
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""MineNASAI 测试套件"""
|
||||
1
tests/agent/__init__.py
Normal file
1
tests/agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Agent 测试"""
|
||||
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal 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
|
||||
1
tests/gateway/__init__.py
Normal file
1
tests/gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Gateway 测试"""
|
||||
124
tests/test_core.py
Normal file
124
tests/test_core.py
Normal 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
144
tests/test_gateway.py
Normal 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
135
tests/test_llm.py
Normal 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
227
tests/test_permissions.py
Normal 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
165
tests/test_scheduler.py
Normal 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
150
tests/test_tools.py
Normal 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
158
tests/test_webtui.py
Normal 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
1
tests/webtui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web TUI 测试"""
|
||||
168
开发步骤.md
Normal file
168
开发步骤.md
Normal 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
506
设计文档.md
Normal 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 3:Web 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
381
进度.md
Normal 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 4,MVP不依赖 | 📋 已降级 |
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
### 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
880
项目分析.md
Normal 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: 核心框架(MVP)(14-21天)
|
||||
|
||||
#### 1.1 配置管理系统(2-3天)
|
||||
**优先级**: 🔥 最高
|
||||
**依赖**: 无
|
||||
|
||||
**任务清单**:
|
||||
- [ ] 定义配置Schema(Pydantic模型)
|
||||
- [ ] 实现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与TUI(12-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检查)
|
||||
- Docstring(Google风格)
|
||||
- 测试覆盖率 > 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费用(按需)
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
Reference in New Issue
Block a user