Spaces:
Sleeping
Sleeping
Upload 28 files
Browse files- .env +63 -0
- .env.example +63 -0
- .gitignore +46 -0
- CLAUDE.md +230 -0
- Dockerfile +32 -0
- README.md +371 -10
- app.py +1039 -0
- config/popup.json +37 -0
- docker-compose.yml +25 -0
- main.py +264 -0
- plan.md +0 -0
- prompts.py +273 -0
- requirements.txt +8 -0
- src/__init__.py +5 -0
- src/__pycache__/__init__.cpython-310.pyc +0 -0
- src/__pycache__/agent.cpython-310.pyc +0 -0
- src/__pycache__/api_key_manager.cpython-310.pyc +0 -0
- src/__pycache__/index.cpython-310.pyc +0 -0
- src/__pycache__/repo_manager.cpython-310.pyc +0 -0
- src/__pycache__/searcher.cpython-310.pyc +0 -0
- src/agent.py +706 -0
- src/api_key_manager.py +192 -0
- src/index.py +286 -0
- src/repo_manager.py +310 -0
- src/searcher.py +257 -0
- src/session_storage.py +375 -0
- static/index.html +1337 -0
- tests/__init__.py +0 -0
.env
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenAI API Key(必需)
|
| 2 |
+
OPENAI_API_KEY=your-api-key-here
|
| 3 |
+
|
| 4 |
+
# API 基础 URL(可选,默认值:https://api.openai.com/v1)
|
| 5 |
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
| 6 |
+
|
| 7 |
+
# 模型名称(可选,默认值:gpt-4)
|
| 8 |
+
OPENAI_MODEL=gpt-4
|
| 9 |
+
|
| 10 |
+
# 代码目录路径(仓库根目录)
|
| 11 |
+
CODE_DIR=./repos
|
| 12 |
+
|
| 13 |
+
# 预加载目录树深度(可选,默认值:3,0 表示不限制)
|
| 14 |
+
TREE_DEPTH=3
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# 最大步骤数(可选,默认值:10)
|
| 18 |
+
MAX_STEPS=50
|
| 19 |
+
|
| 20 |
+
# 是否启用流式输出(可选,默认值:true)
|
| 21 |
+
STREAM_OUTPUT=true
|
| 22 |
+
|
| 23 |
+
# ============ 仓库自动同步配置 ============
|
| 24 |
+
|
| 25 |
+
# 方式1:逗号分隔的 URL 列表
|
| 26 |
+
# REPO_URLS=https://github.com/user/repo1.git,https://github.com/user/repo2.git
|
| 27 |
+
|
| 28 |
+
# 方式2:带编号的配置(推荐)
|
| 29 |
+
|
| 30 |
+
# 仓库1 - AstrBot-docs
|
| 31 |
+
REPO_1_URL=https://github.com/AstrBotDevs/AstrBot-docs.git
|
| 32 |
+
REPO_1_NAME=AstrBot-docs
|
| 33 |
+
REPO_1_BRANCH=v4
|
| 34 |
+
REPO_1_AUTO_UPDATE=true
|
| 35 |
+
|
| 36 |
+
# 仓库2 - AstrBot
|
| 37 |
+
REPO_2_URL=https://github.com/AstrBotDevs/AstrBot.git
|
| 38 |
+
REPO_2_NAME=AstrBot
|
| 39 |
+
REPO_2_BRANCH=master
|
| 40 |
+
REPO_2_AUTO_UPDATE=true
|
| 41 |
+
|
| 42 |
+
# 仓库3 - NapCatDocs
|
| 43 |
+
REPO_3_URL=https://github.com/NapNeko/NapCatDocs.git
|
| 44 |
+
REPO_3_NAME=NapCatDocs
|
| 45 |
+
REPO_3_BRANCH=main
|
| 46 |
+
REPO_3_AUTO_UPDATE=true
|
| 47 |
+
|
| 48 |
+
# 仓库4 - NapCatQQ
|
| 49 |
+
REPO_4_URL=https://github.com/NapNeko/NapCatQQ.git
|
| 50 |
+
REPO_4_NAME=NapCatQQ
|
| 51 |
+
REPO_4_BRANCH=main
|
| 52 |
+
REPO_4_AUTO_UPDATE=true
|
| 53 |
+
|
| 54 |
+
# 启动时自动同步(可选,默认值:true)
|
| 55 |
+
# REPO_SYNC_ON_STARTUP=true
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ============ 重试配置 ============
|
| 59 |
+
# API 调用最大重试次数(可选,默认值:3)
|
| 60 |
+
MAX_RETRIES=5
|
| 61 |
+
|
| 62 |
+
# 重试延迟策略(可选,默认值:1,2,4,单位:秒)
|
| 63 |
+
RETRY_DELAYS=0,0,1,2,4
|
.env.example
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read Agent 环境变量配置
|
| 2 |
+
# 复制此文件为 .env 并填写实际值
|
| 3 |
+
|
| 4 |
+
# ============ API 配置(必需) ============
|
| 5 |
+
# API Key(单个或多个,支持逗号分隔的多Key随机选择)
|
| 6 |
+
# 单个Key: OPENAI_API_KEY=sk-xxx
|
| 7 |
+
# 多个Key: OPENAI_API_KEY=sk-xxx1,sk-xxx2,sk-xxx3
|
| 8 |
+
OPENAI_API_KEY=your-api-key-here
|
| 9 |
+
|
| 10 |
+
# API 基础 URL(可选,默认值:https://api.openai.com/v1)
|
| 11 |
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
| 12 |
+
|
| 13 |
+
# 模型名称(可选,默认值:gpt-4)
|
| 14 |
+
OPENAI_MODEL=gpt-4
|
| 15 |
+
|
| 16 |
+
# ============ 仓库自动下载配置 ============
|
| 17 |
+
# 代码目录路径(可选,默认值:./repos)
|
| 18 |
+
CODE_DIR=./repos
|
| 19 |
+
|
| 20 |
+
# 方式1:逗号分隔的 URL 列表
|
| 21 |
+
# REPO_URLS=https://github.com/user/repo1.git,https://github.com/user/repo2.git
|
| 22 |
+
|
| 23 |
+
# 方式2:带编号的配置(推荐)
|
| 24 |
+
# 仓库1
|
| 25 |
+
REPO_1_URL=https://github.com/AstrBotDevs/AstrBot-docs.git
|
| 26 |
+
REPO_1_NAME=AstrBot-docs # 可选,不指定则自动从URL提取
|
| 27 |
+
REPO_1_BRANCH=v4 # 可选,默认 main
|
| 28 |
+
|
| 29 |
+
# 仓库2
|
| 30 |
+
REPO_2_URL=https://github.com/AstrBotDevs/AstrBot.git
|
| 31 |
+
REPO_2_NAME=AstrBot
|
| 32 |
+
REPO_2_BRANCH=master
|
| 33 |
+
|
| 34 |
+
# 仓库3
|
| 35 |
+
REPO_3_URL=https://github.com/NapNeko/NapCatQQ.git
|
| 36 |
+
REPO_3_NAME=NapCatQQ
|
| 37 |
+
REPO_3_BRANCH=main
|
| 38 |
+
|
| 39 |
+
# 启动时是否自动同步仓库(可选,默认值:true)
|
| 40 |
+
REPO_SYNC_ON_STARTUP=true
|
| 41 |
+
|
| 42 |
+
# ============ 其他配置 ============
|
| 43 |
+
# 最大步骤数(可选,默认值:10)
|
| 44 |
+
MAX_STEPS=10
|
| 45 |
+
|
| 46 |
+
# 预加载目录树深度(可选,默认值:3,0 表示不限制)
|
| 47 |
+
TREE_DEPTH=3
|
| 48 |
+
|
| 49 |
+
# 是否启用流式输出(可选,默认值:true)
|
| 50 |
+
STREAM_OUTPUT=true
|
| 51 |
+
|
| 52 |
+
# Web 服务器端口(可选,默认值:7860)
|
| 53 |
+
WEB_PORT=7860
|
| 54 |
+
|
| 55 |
+
# 调试模式(可选,默认值:false)
|
| 56 |
+
DEBUG=false
|
| 57 |
+
|
| 58 |
+
# ============ 重试配置 ============
|
| 59 |
+
# API 调用最大重试次数(可选,默认值:3)
|
| 60 |
+
MAX_RETRIES=3
|
| 61 |
+
|
| 62 |
+
# 重试延迟策略(可选,默认值:1,2,4,单位:秒)
|
| 63 |
+
RETRY_DELAYS=1,2,4
|
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 环境变量文件
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
*.so
|
| 9 |
+
.Python
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# 虚拟环境
|
| 27 |
+
venv/
|
| 28 |
+
ENV/
|
| 29 |
+
env/
|
| 30 |
+
|
| 31 |
+
# IDE
|
| 32 |
+
.vscode/
|
| 33 |
+
.idea/
|
| 34 |
+
*.swp
|
| 35 |
+
*.swo
|
| 36 |
+
*~
|
| 37 |
+
|
| 38 |
+
# 代码目录
|
| 39 |
+
code/
|
| 40 |
+
|
| 41 |
+
# 日志
|
| 42 |
+
*.log
|
| 43 |
+
|
| 44 |
+
# 操作系统
|
| 45 |
+
.DS_Store
|
| 46 |
+
Thumbs.db
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
## Project Overview
|
| 6 |
+
|
| 7 |
+
Read Agent is an AI-powered code analysis assistant that uses OpenAI-compatible APIs with the ReAct (Reasoning + Acting) pattern for iterative code exploration. It provides both CLI and Web interfaces.
|
| 8 |
+
|
| 9 |
+
## Common Commands
|
| 10 |
+
|
| 11 |
+
### Running the Application
|
| 12 |
+
|
| 13 |
+
```bash
|
| 14 |
+
# CLI interface (interactive terminal)
|
| 15 |
+
python main.py
|
| 16 |
+
|
| 17 |
+
# CLI with specific code directory
|
| 18 |
+
python main.py --code-dir /path/to/code
|
| 19 |
+
|
| 20 |
+
# CLI with multiple API keys (comma-separated)
|
| 21 |
+
python main.py --api-key "key1,key2,key3"
|
| 22 |
+
|
| 23 |
+
# Web server (default port 7860)
|
| 24 |
+
python app.py
|
| 25 |
+
|
| 26 |
+
# Web server with debug mode
|
| 27 |
+
DEBUG=true python app.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### Docker
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
# Using docker-compose
|
| 34 |
+
docker-compose up -d
|
| 35 |
+
|
| 36 |
+
# Build and run manually
|
| 37 |
+
docker build -t read-agent .
|
| 38 |
+
docker run -p 7860:7860 read-agent
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
### Testing
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
pytest # Run tests
|
| 45 |
+
pytest --cov # Run with coverage
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### Dependencies
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
pip install -r requirements.txt
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## Architecture
|
| 55 |
+
|
| 56 |
+
### Core Pattern: ReAct Loop
|
| 57 |
+
|
| 58 |
+
The ReadAgent (src/agent.py) implements a ReAct (Reasoning + Acting) pattern:
|
| 59 |
+
1. LLM generates a "thought" about what to do next
|
| 60 |
+
2. LLM specifies an "action" using available tools (read_file, search_code, etc.)
|
| 61 |
+
3. ToolExecutor executes the action and returns observations
|
| 62 |
+
4. Loop continues until the LLM decides it has enough information
|
| 63 |
+
5. Final answer is generated based on accumulated observations
|
| 64 |
+
|
| 65 |
+
This pattern enables iterative exploration without requiring all context upfront.
|
| 66 |
+
|
| 67 |
+
### Multi API Key Rotation (ApiKeyManager)
|
| 68 |
+
|
| 69 |
+
**src/api_key_manager.py** - ApiKeyManager class
|
| 70 |
+
- Manages multiple API keys for load balancing and reliability
|
| 71 |
+
- Round-robin rotation across keys
|
| 72 |
+
- Thread-safe operations with locks
|
| 73 |
+
- Tracks statistics: request count, success rate, errors
|
| 74 |
+
- Global singleton pattern via `get_global_manager()` or `init_manager()`
|
| 75 |
+
|
| 76 |
+
**Usage:**
|
| 77 |
+
```python
|
| 78 |
+
from src.api_key_manager import ApiKeyManager
|
| 79 |
+
|
| 80 |
+
# Single key
|
| 81 |
+
manager = ApiKeyManager("sk-xxx")
|
| 82 |
+
|
| 83 |
+
# Multiple keys (comma-separated)
|
| 84 |
+
manager = ApiKeyManager("sk-key1,sk-key2,sk-key3")
|
| 85 |
+
|
| 86 |
+
# Get next key (round-robin)
|
| 87 |
+
key = manager.get_key()
|
| 88 |
+
|
| 89 |
+
# Record results
|
| 90 |
+
manager.record_success(key)
|
| 91 |
+
manager.record_error(key, "Error message")
|
| 92 |
+
|
| 93 |
+
# Get statistics
|
| 94 |
+
stats = manager.get_stats()
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**Integration with ReadAgent:**
|
| 98 |
+
- ReadAgent accepts `api_key_manager` parameter
|
| 99 |
+
- If provided, uses ApiKeyManager to get keys via rotation
|
| 100 |
+
- Records success/failure statistics automatically
|
| 101 |
+
- Falls back to legacy single-key mode if no manager provided
|
| 102 |
+
|
| 103 |
+
### Memory Management
|
| 104 |
+
|
| 105 |
+
To prevent context expansion across multiple steps, the agent uses a Memory dataclass:
|
| 106 |
+
|
| 107 |
+
```python
|
| 108 |
+
@dataclass
|
| 109 |
+
class Memory:
|
| 110 |
+
file_path: str # File being analyzed
|
| 111 |
+
overview: str # One-sentence summary
|
| 112 |
+
key_definitions: List[str] # Key function/class names
|
| 113 |
+
core_logic: str # Core logic description
|
| 114 |
+
dependencies: List[str] # Dependencies
|
| 115 |
+
needed_info: str # Information to verify
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
After reading a file, the agent creates a Memory object instead of keeping full file content. Subsequent tool calls can reference previously analyzed files without re-reading them.
|
| 119 |
+
|
| 120 |
+
### Key Components
|
| 121 |
+
|
| 122 |
+
**src/agent.py** - ReadAgent class
|
| 123 |
+
- Main orchestration of ReAct loop
|
| 124 |
+
- Manages Memory objects to optimize context
|
| 125 |
+
- Supports streaming output via `ask(stream=True)`
|
| 126 |
+
- Batch action support for parallel independent operations
|
| 127 |
+
- Integrates with ApiKeyManager for multi-key rotation
|
| 128 |
+
|
| 129 |
+
**src/searcher.py** - CodeSearcher class
|
| 130 |
+
- Provides all file/code interaction tools
|
| 131 |
+
- Integrates with CodeIndex for fast keyword/symbol search
|
| 132 |
+
- Tools: read_file, find_files, search_code, find_by_ext, list_dir, get_file_info, get_dir_tree
|
| 133 |
+
|
| 134 |
+
**src/index.py** - CodeIndex class
|
| 135 |
+
- Inverted index for fast code search
|
| 136 |
+
- Lazy building: builds on first search if not exists
|
| 137 |
+
- Supports both keyword search and symbol extraction
|
| 138 |
+
- Tokenization handles camelCase, PascalCase, snake_case
|
| 139 |
+
|
| 140 |
+
**src/repo_manager.py** - RepoManager class
|
| 141 |
+
- Downloads GitHub repos as ZIP files
|
| 142 |
+
- Skip detection: won't re-download existing repos unless forced
|
| 143 |
+
- Parallel sync support (threading)
|
| 144 |
+
- Configured via environment variables (REPO_1_URL, REPO_2_URL, etc.)
|
| 145 |
+
|
| 146 |
+
**src/session_storage.py** - SessionStorage class
|
| 147 |
+
- SQLite-based persistent storage for sessions
|
| 148 |
+
- Thread-safe with locks
|
| 149 |
+
- Stores: session metadata, conversation history, memories
|
| 150 |
+
- Cleanup of old sessions
|
| 151 |
+
|
| 152 |
+
**prompts.py** - Prompt configuration
|
| 153 |
+
- ReAct format instructions
|
| 154 |
+
- Information need tree construction strategy
|
| 155 |
+
- Priority-based search (docs → config → code)
|
| 156 |
+
- Recursive validation protocol
|
| 157 |
+
|
| 158 |
+
### Entry Points
|
| 159 |
+
|
| 160 |
+
1. **main.py** - CLI interface with interactive commands (quit, clear, status, help)
|
| 161 |
+
2. **app.py** - Flask web application with REST APIs
|
| 162 |
+
|
| 163 |
+
### Session Isolation
|
| 164 |
+
|
| 165 |
+
Each user session (web) has:
|
| 166 |
+
- Independent ReadAgent instance
|
| 167 |
+
- Separate Memory objects
|
| 168 |
+
- Isolated conversation history
|
| 169 |
+
- SQLite persistence (can be restored)
|
| 170 |
+
- Shared ApiKeyManager instance (for efficient key rotation)
|
| 171 |
+
|
| 172 |
+
### Streaming Support
|
| 173 |
+
|
| 174 |
+
The agent supports streaming responses (`STREAM_OUTPUT=true`):
|
| 175 |
+
- Thoughts and actions stream in real-time
|
| 176 |
+
- Final answer detection via special tokens
|
| 177 |
+
- Provides immediate feedback during long-running analysis
|
| 178 |
+
|
| 179 |
+
## Environment Variables
|
| 180 |
+
|
| 181 |
+
### API Configuration
|
| 182 |
+
- `OPENAI_API_KEY` - Required (can be multiple keys separated by commas)
|
| 183 |
+
- `OPENAI_BASE_URL` - Default: https://api.openai.com/v1
|
| 184 |
+
- `OPENAI_MODEL` - Default: gpt-4
|
| 185 |
+
|
| 186 |
+
### Repository Configuration
|
| 187 |
+
- `CODE_DIR` - Default: ./repos
|
| 188 |
+
- `REPO_SYNC_ON_STARTUP` - Default: true
|
| 189 |
+
- `REPO_1_URL`, `REPO_2_URL`, etc. - GitHub repo URLs
|
| 190 |
+
- `REPO_1_NAME`, `REPO_1_BRANCH`, etc. - Per-repo settings
|
| 191 |
+
|
| 192 |
+
### Agent Configuration
|
| 193 |
+
- `MAX_STEPS` - Maximum reasoning steps (default: 10)
|
| 194 |
+
- `TREE_DEPTH` - Directory tree preload depth (default: 3)
|
| 195 |
+
- `STREAM_OUTPUT` - Enable streaming (default: true)
|
| 196 |
+
- `WEB_PORT` - Web server port (default: 7860)
|
| 197 |
+
- `DEBUG` - Debug mode (default: false)
|
| 198 |
+
|
| 199 |
+
## API Endpoints (app.py)
|
| 200 |
+
|
| 201 |
+
### Question API
|
| 202 |
+
- `POST /api/ask` - Main question endpoint (supports streaming via query param or JSON field)
|
| 203 |
+
|
| 204 |
+
### Session Management
|
| 205 |
+
- `POST /api/session/new` - Create new session
|
| 206 |
+
- `POST /api/session/clear` - Clear session(s)
|
| 207 |
+
- `GET /status` - Service status
|
| 208 |
+
|
| 209 |
+
### Repository Management
|
| 210 |
+
- `GET /api/repos` - List repositories
|
| 211 |
+
- `POST /api/repos/sync` - Sync repositories
|
| 212 |
+
- `GET /api/repos/config` - Get repository configuration
|
| 213 |
+
- `POST /api/repos/clear` - Clear all repositories
|
| 214 |
+
|
| 215 |
+
### API Key Management
|
| 216 |
+
- `GET /api/api-keys/stats` - Get API key usage statistics
|
| 217 |
+
- `POST /api/api-keys/reset-stats` - Reset API key statistics
|
| 218 |
+
|
| 219 |
+
### Health Check
|
| 220 |
+
- `GET /health` - Health check
|
| 221 |
+
- `GET /prompt` - Return system prompt
|
| 222 |
+
|
| 223 |
+
## Technical Notes
|
| 224 |
+
|
| 225 |
+
- **Pure Python** - Uses only standard library (urllib) and minimal dependencies (Flask, python-dotenv)
|
| 226 |
+
- **No async/await** - Uses threading for parallel operations
|
| 227 |
+
- **SQLite** for session persistence (file-based, no external DB required)
|
| 228 |
+
- **Symbol extraction** for Python and JavaScript in CodeIndex (AST-based)
|
| 229 |
+
- **ReAct format** - LLM outputs structured JSON with "thought" and "action" fields
|
| 230 |
+
- **Thread-safe API Key Management** - Uses locks for concurrent access to ApiKeyManager
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read Agent 容器镜像
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# 设置工作目录
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 设置环境变量
|
| 8 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 9 |
+
ENV PYTHONUNBUFFERED=1
|
| 10 |
+
ENV PORT=7860
|
| 11 |
+
|
| 12 |
+
# 安装系统依赖
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
curl \
|
| 15 |
+
git \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# 复制依赖文件并安装
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# 复制应用代码
|
| 23 |
+
COPY . .
|
| 24 |
+
|
| 25 |
+
# 创建代码目录
|
| 26 |
+
RUN mkdir -p /app/code
|
| 27 |
+
|
| 28 |
+
# 暴露端口
|
| 29 |
+
EXPOSE 7860
|
| 30 |
+
|
| 31 |
+
# 启动命令 - 使用 Flask 内置服务器
|
| 32 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,371 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: astrbot-help
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Read Agent - AI 代码读取与分析助手
|
| 12 |
+
|
| 13 |
+
## 项目简介
|
| 14 |
+
|
| 15 |
+
Read Agent 是一个基于 AI 的技术支持助手,专门帮助开发人员读取、搜索和分析代码仓库。它使用 OpenAI 兼容的 API 和 ReAct(Reasoning + Acting)模式进行迭代代码探索,并通过内存管理控制多步骤分析过程中的令牌使用。
|
| 16 |
+
|
| 17 |
+
## 核心功能
|
| 18 |
+
|
| 19 |
+
- **智能代码分析**:使用 LLM 分析代码结构和功能
|
| 20 |
+
- **代码搜索**:支持正则表达式搜索和文件内容读取
|
| 21 |
+
- **仓库管理**:自动同步和管理 GitHub 代码仓库
|
| 22 |
+
- **会话管理**:支持多用户并发会话,每个会话有独立的记忆
|
| 23 |
+
- **流式响应**:提供实时的思考过程和答案输出
|
| 24 |
+
- **内存优化**:使用 Memory 机制替代完整文件内容,防止上下文膨胀
|
| 25 |
+
- **多 API Key 随机选择**:支持配置多个 API Key,自动随机选择分发请求,提升并发能力和稳定性
|
| 26 |
+
|
| 27 |
+
## 架构设计
|
| 28 |
+
|
| 29 |
+
```mermaid
|
| 30 |
+
graph TD
|
| 31 |
+
A[CLI 入口<br/>main.py] --> B[ReadAgent<br/>src/agent.py]
|
| 32 |
+
C[Web 入口<br/>app.py] --> B
|
| 33 |
+
B --> D[工具执行器<br/>ToolExecutor]
|
| 34 |
+
D --> E[搜索工具<br/>src/searcher.py]
|
| 35 |
+
D --> F[仓库管理器<br/>src/repo_manager.py]
|
| 36 |
+
B --> G[会话存储<br/>app.py]
|
| 37 |
+
B --> H[内存管理<br/>Memory 类]
|
| 38 |
+
B --> I[LLM API<br/>OpenAI 兼容接口]
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## 核心组件
|
| 42 |
+
|
| 43 |
+
### 1. ReadAgent (src/agent.py)
|
| 44 |
+
- 实现 ReAct 模式的核心代理
|
| 45 |
+
- 管理思考过程、工具执行和结果观察
|
| 46 |
+
- 控制多步骤分析流程
|
| 47 |
+
- 处理内存管理和上下文压缩
|
| 48 |
+
|
| 49 |
+
### 2. 工具执行器 (ToolExecutor)
|
| 50 |
+
- 注册和管理可用工具
|
| 51 |
+
- 解析 LLM 响应中的动作指令
|
| 52 |
+
- 执行 read_file、search_code 等操作
|
| 53 |
+
- 提供统一的工具调用接口
|
| 54 |
+
|
| 55 |
+
### 3. 搜索工具 (src/searcher.py)
|
| 56 |
+
- read_file:读取文件内容
|
| 57 |
+
- search_code:正则表达式搜索
|
| 58 |
+
- find_files:查找文件
|
| 59 |
+
- find_by_ext:按扩展名搜索
|
| 60 |
+
- list_dir:列出目录内容
|
| 61 |
+
- get_file_info:获取文件信息
|
| 62 |
+
- get_directory_tree:获取目录树
|
| 63 |
+
|
| 64 |
+
### 4. 仓库管理器 (src/repo_manager.py)
|
| 65 |
+
- 从 GitHub 下载仓库(ZIP 格式)
|
| 66 |
+
- 自动同步多个仓库
|
| 67 |
+
- 支持并行同步(线程)
|
| 68 |
+
- 管理仓库存储和更新
|
| 69 |
+
|
| 70 |
+
### 5. 会话管理 (app.py)
|
| 71 |
+
- 管理多个并发会话
|
| 72 |
+
- 每个会话有独立的 Agent 实例
|
| 73 |
+
- 支持会话的创建、清除和查询
|
| 74 |
+
- 线程安全的会话存储
|
| 75 |
+
|
| 76 |
+
## 运行方式
|
| 77 |
+
|
| 78 |
+
### 1. 环境变量配置
|
| 79 |
+
|
| 80 |
+
复制 `.env.example` 为 `.env` 并配置:
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
# API Key(支持单个或多个,多Key时会自动轮询)
|
| 84 |
+
OPENAI_API_KEY=sk-xxx1,sk-xxx2,sk-xxx3
|
| 85 |
+
|
| 86 |
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
| 87 |
+
OPENAI_MODEL=gpt-4
|
| 88 |
+
CODE_DIR=./repos
|
| 89 |
+
MAX_STEPS=10
|
| 90 |
+
TREE_DEPTH=3
|
| 91 |
+
STREAM_OUTPUT=true
|
| 92 |
+
WEB_PORT=7860
|
| 93 |
+
REPO_SYNC_ON_STARTUP=true
|
| 94 |
+
# 仓库配置
|
| 95 |
+
REPO_1_URL=https://github.com/owner/repo1
|
| 96 |
+
REPO_1_NAME=repo1
|
| 97 |
+
REPO_1_BRANCH=main
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### 2. 安装依赖
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
pip install -r requirements.txt
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### 3. 启动方式
|
| 107 |
+
|
| 108 |
+
#### 命令行模式
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
# 交互式终端
|
| 112 |
+
python main.py
|
| 113 |
+
|
| 114 |
+
# 指定代码目录
|
| 115 |
+
python main.py --code-dir /path/to/code
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
#### Web 服务模式
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
python app.py
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
#### Docker 容器化
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
# 使用 Docker Compose
|
| 128 |
+
docker-compose up -d
|
| 129 |
+
|
| 130 |
+
# 或单独构建镜像
|
| 131 |
+
docker build -t read-agent .
|
| 132 |
+
docker run -p 7860:7860 read-agent
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## API 接口
|
| 136 |
+
|
| 137 |
+
### 1. 问答接口
|
| 138 |
+
|
| 139 |
+
**POST /api/ask**
|
| 140 |
+
|
| 141 |
+
```json
|
| 142 |
+
{
|
| 143 |
+
"question": "代码的核心功能是什么?",
|
| 144 |
+
"stream": true,
|
| 145 |
+
"session_id": "optional_session_id"
|
| 146 |
+
}
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### 2. 会话管理
|
| 150 |
+
|
| 151 |
+
- **POST /api/session/new**:创建新会话
|
| 152 |
+
- **POST /api/session/clear**:清除会话
|
| 153 |
+
- **GET /status**:获取服务状态
|
| 154 |
+
|
| 155 |
+
### 3. 仓库管理
|
| 156 |
+
|
| 157 |
+
- **GET /api/repos**:获取仓库列表
|
| 158 |
+
- **POST /api/repos/sync**:同步仓库
|
| 159 |
+
- **GET /api/repos/config**:获取仓库配置
|
| 160 |
+
- **POST /api/repos/clear**:清空仓库
|
| 161 |
+
|
| 162 |
+
### 4. API Key 管理
|
| 163 |
+
|
| 164 |
+
- **GET /api/api-keys/stats**:获取 API Key 统计信息
|
| 165 |
+
```json
|
| 166 |
+
{
|
| 167 |
+
"total_keys": 3,
|
| 168 |
+
"keys": [
|
| 169 |
+
{
|
| 170 |
+
"key_hash": "a1b2c3d4",
|
| 171 |
+
"total_requests": 10,
|
| 172 |
+
"success_count": 9,
|
| 173 |
+
"error_count": 1,
|
| 174 |
+
"success_rate": "90.00%",
|
| 175 |
+
"last_used": 1704067200.0,
|
| 176 |
+
"last_error": "HTTP 429: Rate limit exceeded"
|
| 177 |
+
}
|
| 178 |
+
]
|
| 179 |
+
}
|
| 180 |
+
```
|
| 181 |
+
- **POST /api/api-keys/reset-stats**:重置 API Key 统计信息
|
| 182 |
+
|
| 183 |
+
### 5. 健康检查
|
| 184 |
+
|
| 185 |
+
**GET /health**
|
| 186 |
+
|
| 187 |
+
```json
|
| 188 |
+
{
|
| 189 |
+
"status": "ok",
|
| 190 |
+
"message": "Read Agent 服务运行中"
|
| 191 |
+
}
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
## 工作流程
|
| 195 |
+
|
| 196 |
+
```mermaid
|
| 197 |
+
graph LR
|
| 198 |
+
A[用户提问] --> B[构建信息需求树]
|
| 199 |
+
B --> C[按优先级搜索<br/>文档→配置→代码]
|
| 200 |
+
C --> D[执行工具操作<br/>read_file/search_code]
|
| 201 |
+
D --> E[分析结果<br/>交叉验证]
|
| 202 |
+
E --> F{是否有最终答案?}
|
| 203 |
+
F -- 是 --> G[生成 Memory<br/>优化上下文]
|
| 204 |
+
F -- 否 --> C
|
| 205 |
+
G --> H[输出答案]
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
## 内存管理
|
| 209 |
+
|
| 210 |
+
Read Agent 使用 Memory 机制优化上下文:
|
| 211 |
+
|
| 212 |
+
```python
|
| 213 |
+
@dataclass
|
| 214 |
+
class Memory:
|
| 215 |
+
file_path: str # 文件路径
|
| 216 |
+
overview: str # 一句话概述
|
| 217 |
+
key_definitions: List[str] # 关键函数/类名
|
| 218 |
+
core_logic: str # 核心逻辑
|
| 219 |
+
dependencies: List[str] # 依赖模块
|
| 220 |
+
needed_info: str # 待验证信息
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
每次读取文件后,会创建 Memory 对象替代完整内容,防止上下文膨胀。
|
| 224 |
+
|
| 225 |
+
## 技术特点
|
| 226 |
+
|
| 227 |
+
- **无外部依赖**:仅使用 Python 标准库和少数轻量级库(Flask, python-dotenv)
|
| 228 |
+
- **流式处理**:支持 token 级别的实时响应
|
| 229 |
+
- **正则搜索**:使用 re 模块实现强大的搜索功能
|
| 230 |
+
- **会话隔离**:每个用户会话完全独立
|
| 231 |
+
- **线程安全**:使用锁机制确保并发安全
|
| 232 |
+
- **自动同步**:启动时自动同步配置的仓库
|
| 233 |
+
|
| 234 |
+
## 使用场景
|
| 235 |
+
|
| 236 |
+
- **代码理解**:快速了解陌生代码库的功能和架构
|
| 237 |
+
- **问题定位**:通过搜索和分析找到 bug 的根源
|
| 238 |
+
- **文档生成**:自动生成代码文档和架构说明
|
| 239 |
+
- **代码审查**:辅助进行代码质量评估和审查
|
| 240 |
+
- **学习工具**:帮助开发者学习新代码和技术
|
| 241 |
+
|
| 242 |
+
## 配置说明
|
| 243 |
+
|
| 244 |
+
### 环境变量
|
| 245 |
+
|
| 246 |
+
| 变量名 | 默认值 | 说明 |
|
| 247 |
+
|--------|--------|------|
|
| 248 |
+
| OPENAI_API_KEY | 必填 | LLM API 密钥(支持多个,逗号分隔) |
|
| 249 |
+
| OPENAI_BASE_URL | https://api.openai.com/v1 | API 端点 |
|
| 250 |
+
| OPENAI_MODEL | gpt-4 | 模型名称 |
|
| 251 |
+
| CODE_DIR | ./repos | 仓库存储目录 |
|
| 252 |
+
| MAX_STEPS | 10 | 最大推理步数 |
|
| 253 |
+
| TREE_DEPTH | 3 | 目录树预加载深度 |
|
| 254 |
+
| STREAM_OUTPUT | true | 启用流式输出 |
|
| 255 |
+
| WEB_PORT | 7860 | Web 服务端口 |
|
| 256 |
+
| REPO_SYNC_ON_STARTUP | true | 启动时自动同步仓库 |
|
| 257 |
+
| MAX_RETRIES | 3 | API 调用最大重试次数 |
|
| 258 |
+
| RETRY_DELAYS | 1,2,4 | 重试延迟策略(秒,逗号分隔) |
|
| 259 |
+
|
| 260 |
+
### 多 API Key 配置
|
| 261 |
+
|
| 262 |
+
支持配置多个 API Key 以实现请求轮询,提升并发能力和稳定性:
|
| 263 |
+
|
| 264 |
+
```bash
|
| 265 |
+
# 单个 Key
|
| 266 |
+
OPENAI_API_KEY=sk-xxx
|
| 267 |
+
|
| 268 |
+
# 多个 Key(逗号分隔)
|
| 269 |
+
OPENAI_API_KEY=sk-xxx1,sk-xxx2,sk-xxx3
|
| 270 |
+
|
| 271 |
+
# 通过命令行参数(CLI)
|
| 272 |
+
python main.py --api-key "sk-xxx1,sk-xxx2,sk-xxx3"
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
当配置多个 Key 时,系统会自动随机选择:
|
| 276 |
+
- 每次请求时随机使用不同的 Key(分散负载)
|
| 277 |
+
- 记录每个 Key 的使用统计(请求数、成功率等)
|
| 278 |
+
- 可通过 `/api/api-keys/stats` API 查看统计信息
|
| 279 |
+
- 可通过 `/api/api-keys/reset-stats` API 重置统计
|
| 280 |
+
|
| 281 |
+
### 重试配置
|
| 282 |
+
|
| 283 |
+
系统支持 API 调用失败时自动重试:
|
| 284 |
+
|
| 285 |
+
```bash
|
| 286 |
+
# 最大重试次数(默认: 3)
|
| 287 |
+
MAX_RETRIES=3
|
| 288 |
+
|
| 289 |
+
# 重试延迟策略,单位:秒(默认: 1,2,4)
|
| 290 |
+
# 指数退避策略:第1次重试等1秒,第2次等2秒,第3次等4秒
|
| 291 |
+
RETRY_DELAYS=1,2,4
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
**重试机制:**
|
| 295 |
+
- 可重试的 HTTP 状态码:`429, 500, 502, 503, 504`(速率限制、服务器错误)
|
| 296 |
+
- 可重试的网络错误:超时、连接错误
|
| 297 |
+
- 重试时仍然会随机使用不同的 API key
|
| 298 |
+
- 可通过命令行参数配置:`--max-retries 5 --retry-delays 1,2,4,8,16`
|
| 299 |
+
|
| 300 |
+
**示例配置:**
|
| 301 |
+
```bash
|
| 302 |
+
# 激进重试(5次,更长的退避时间)
|
| 303 |
+
MAX_RETRIES=5
|
| 304 |
+
RETRY_DELAYS=1,3,9,27,81
|
| 305 |
+
|
| 306 |
+
# 快速重试(2次,短延迟)
|
| 307 |
+
MAX_RETRIES=2
|
| 308 |
+
RETRY_DELAYS=0.5,1
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
### 仓库配置
|
| 312 |
+
|
| 313 |
+
可以配置多个仓库:
|
| 314 |
+
|
| 315 |
+
```bash
|
| 316 |
+
REPO_1_URL=https://github.com/owner/repo1
|
| 317 |
+
REPO_1_NAME=repo1
|
| 318 |
+
REPO_1_BRANCH=main
|
| 319 |
+
REPO_2_URL=https://github.com/owner/repo2
|
| 320 |
+
REPO_2_NAME=repo2
|
| 321 |
+
REPO_2_BRANCH=dev
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
## 开发
|
| 325 |
+
|
| 326 |
+
### 项目结构
|
| 327 |
+
|
| 328 |
+
```
|
| 329 |
+
├── app.py # Flask Web 应用
|
| 330 |
+
├── main.py # CLI 入口
|
| 331 |
+
├── prompts.py # 提示词配置
|
| 332 |
+
├── requirements.txt # 依赖文件
|
| 333 |
+
├── Dockerfile # Docker 构建文件
|
| 334 |
+
├── docker-compose.yml # Docker 组合配置
|
| 335 |
+
├── src/ # 核心代码
|
| 336 |
+
│ ├── agent.py # ReadAgent 实现
|
| 337 |
+
│ ├── searcher.py # 搜索工具
|
| 338 |
+
│ ├── repo_manager.py # 仓库管理器
|
| 339 |
+
│ └── index.py # 工具索引
|
| 340 |
+
├── static/ # 前端资源
|
| 341 |
+
│ └── index.html # 主页面
|
| 342 |
+
└── tests/ # 测试文件
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
### 提示词配置
|
| 346 |
+
|
| 347 |
+
提示词在 `prompts.py` 中定义,包含:
|
| 348 |
+
|
| 349 |
+
- **ReAct 格式说明**:指导 LLM 输出格式
|
| 350 |
+
- **系统提示词**:包含角色、搜索策略、验证协议等
|
| 351 |
+
- **答案提示词**:用于生成最终答案
|
| 352 |
+
|
| 353 |
+
### 调试
|
| 354 |
+
|
| 355 |
+
启用调试模式:
|
| 356 |
+
|
| 357 |
+
```bash
|
| 358 |
+
DEBUG=true python app.py
|
| 359 |
+
```
|
| 360 |
+
|
| 361 |
+
## 许可证
|
| 362 |
+
|
| 363 |
+
[MIT License](LICENSE)
|
| 364 |
+
|
| 365 |
+
## 贡献
|
| 366 |
+
|
| 367 |
+
欢迎提交 Issue 和 Pull Request!
|
| 368 |
+
|
| 369 |
+
---
|
| 370 |
+
|
| 371 |
+
**Read Agent** - 让 AI 帮助你更好地理解代码!
|
app.py
ADDED
|
@@ -0,0 +1,1039 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Read Agent Web 服务
|
| 4 |
+
|
| 5 |
+
Flask Web 应用,提供 API 接口供前端调用
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import queue
|
| 13 |
+
import threading
|
| 14 |
+
import uuid
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from urllib.parse import urlparse
|
| 18 |
+
from flask import Flask, request, jsonify, send_from_directory, Response
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
from src.agent import ReadAgent, Memory
|
| 22 |
+
from src.repo_manager import RepoManager
|
| 23 |
+
from src.api_key_manager import init_manager, get_global_manager
|
| 24 |
+
from src.session_storage import SessionStorage
|
| 25 |
+
from prompts import get_system_prompt, get_react_format_prompt
|
| 26 |
+
|
| 27 |
+
# 加载环境变量
|
| 28 |
+
load_dotenv()
|
| 29 |
+
|
| 30 |
+
# 配置日志
|
| 31 |
+
logging.basicConfig(
|
| 32 |
+
level=logging.INFO,
|
| 33 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
# 创建 Flask 应用
|
| 38 |
+
app = Flask(__name__, static_folder='static')
|
| 39 |
+
|
| 40 |
+
# API Key 管理器(全局共享)
|
| 41 |
+
key_manager = None
|
| 42 |
+
|
| 43 |
+
# 仓库管理器(全局共享)
|
| 44 |
+
repo_manager = None
|
| 45 |
+
|
| 46 |
+
# 会话持久化存储
|
| 47 |
+
session_storage = SessionStorage("./data/sessions.db")
|
| 48 |
+
|
| 49 |
+
# 会话存储:{session_id: agent_instance}
|
| 50 |
+
sessions = {}
|
| 51 |
+
|
| 52 |
+
# 线程安全锁
|
| 53 |
+
sessions_lock = threading.Lock()
|
| 54 |
+
repo_manager_lock = threading.Lock()
|
| 55 |
+
key_manager_lock = threading.Lock()
|
| 56 |
+
|
| 57 |
+
# 仓库同步标志(避免重复同步)
|
| 58 |
+
_repo_synced = False
|
| 59 |
+
_repo_sync_lock = threading.Lock()
|
| 60 |
+
|
| 61 |
+
# 弹窗配置文件路径
|
| 62 |
+
POPUP_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config', 'popup.json')
|
| 63 |
+
|
| 64 |
+
# 弹窗配置缓存
|
| 65 |
+
_popup_config_cache = None
|
| 66 |
+
_popup_config_cache_lock = threading.Lock()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_env(key: str, default: str = "") -> str:
|
| 70 |
+
"""获取环境变量"""
|
| 71 |
+
return os.getenv(key, default)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_env_bool(key: str, default: bool = False) -> bool:
|
| 75 |
+
"""获取布尔型环境变量"""
|
| 76 |
+
value = get_env(key, "").lower()
|
| 77 |
+
if value in ("true", "1", "yes", "on"):
|
| 78 |
+
return True
|
| 79 |
+
elif value in ("false", "0", "no", "off"):
|
| 80 |
+
return False
|
| 81 |
+
return default
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_popup_config():
|
| 85 |
+
"""加载弹窗配置(带缓存)"""
|
| 86 |
+
global _popup_config_cache
|
| 87 |
+
|
| 88 |
+
with _popup_config_cache_lock:
|
| 89 |
+
if _popup_config_cache is not None:
|
| 90 |
+
return _popup_config_cache
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
if os.path.exists(POPUP_CONFIG_PATH):
|
| 94 |
+
with open(POPUP_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
| 95 |
+
config = json.load(f)
|
| 96 |
+
_popup_config_cache = config
|
| 97 |
+
logger.info(f"加载弹窗配置: {POPUP_CONFIG_PATH}")
|
| 98 |
+
return config
|
| 99 |
+
else:
|
| 100 |
+
logger.warning(f"弹窗配置文件不存在: {POPUP_CONFIG_PATH}")
|
| 101 |
+
return None
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"加载弹窗配置失败: {e}")
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def reload_popup_config():
|
| 108 |
+
"""重新加载弹窗配置"""
|
| 109 |
+
global _popup_config_cache
|
| 110 |
+
|
| 111 |
+
with _popup_config_cache_lock:
|
| 112 |
+
_popup_config_cache = None
|
| 113 |
+
return load_popup_config()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def format_action_args(action_args: dict) -> str:
|
| 117 |
+
"""格式化 action_args 为字符串"""
|
| 118 |
+
if not action_args:
|
| 119 |
+
return ""
|
| 120 |
+
return ", ".join(f'{k}="{v}"' for k, v in action_args.items())
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_repo_manager():
|
| 124 |
+
"""获取或创建仓库管理器(线程安全)"""
|
| 125 |
+
global repo_manager
|
| 126 |
+
with repo_manager_lock:
|
| 127 |
+
if repo_manager is None:
|
| 128 |
+
code_dir = get_env("CODE_DIR", "./code")
|
| 129 |
+
repo_manager = RepoManager(code_dir)
|
| 130 |
+
return repo_manager
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def get_key_manager():
|
| 134 |
+
"""获取或创建 API Key 管理器(线程安全)"""
|
| 135 |
+
global key_manager
|
| 136 |
+
with key_manager_lock:
|
| 137 |
+
if key_manager is None:
|
| 138 |
+
api_keys = get_env("OPENAI_API_KEY", "")
|
| 139 |
+
if api_keys:
|
| 140 |
+
key_manager = init_manager(api_keys)
|
| 141 |
+
logger.info(f"API Key 管理器初始化完成,共 {key_manager.key_count} 个 Key")
|
| 142 |
+
else:
|
| 143 |
+
logger.warning("未配置 OPENAI_API_KEY")
|
| 144 |
+
return key_manager
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def get_agent(session_id: str = None):
|
| 148 |
+
"""获取或创建会话的 Agent,返回 (agent, session_id)(线程安全)"""
|
| 149 |
+
with sessions_lock:
|
| 150 |
+
# 如果没有 session_id 或会话不存在,创建新的
|
| 151 |
+
if not session_id or session_id not in sessions:
|
| 152 |
+
# 生成 session_id(如果未提供)
|
| 153 |
+
session_id = session_id or str(uuid.uuid4())
|
| 154 |
+
|
| 155 |
+
# 在锁外获取环境变量和初始化耗时操作
|
| 156 |
+
# 注意:这里先释放锁,然后再重新获取锁进行 sessions 写入
|
| 157 |
+
# 但为了避免竞态条件,我们在创建完成前保持 session_id 的唯一性
|
| 158 |
+
else:
|
| 159 |
+
return sessions[session_id], session_id
|
| 160 |
+
|
| 161 |
+
# 释放锁后执行耗时操作(避免阻塞其他线程)
|
| 162 |
+
|
| 163 |
+
# 首先尝试从存储恢复会话
|
| 164 |
+
if session_id:
|
| 165 |
+
saved_session = session_storage.load_session(session_id)
|
| 166 |
+
if saved_session:
|
| 167 |
+
# 恢复会话
|
| 168 |
+
agent = ReadAgent(
|
| 169 |
+
code_dir=saved_session["code_dir"],
|
| 170 |
+
base_url=saved_session["base_url"],
|
| 171 |
+
model=saved_session["model"],
|
| 172 |
+
max_steps=saved_session["max_steps"],
|
| 173 |
+
stream_output=saved_session["stream_output"],
|
| 174 |
+
tree_depth=saved_session["tree_depth"],
|
| 175 |
+
api_key_manager=get_key_manager()
|
| 176 |
+
)
|
| 177 |
+
# 恢复状态
|
| 178 |
+
agent.conversation_history = saved_session["conversation_history"]
|
| 179 |
+
for m in saved_session["memories"]:
|
| 180 |
+
agent.memories.append(Memory(
|
| 181 |
+
file_path=m["file_path"],
|
| 182 |
+
overview=m["overview"],
|
| 183 |
+
key_definitions=m["key_definitions"],
|
| 184 |
+
core_logic=m["core_logic"],
|
| 185 |
+
dependencies=m["dependencies"],
|
| 186 |
+
needed_info=m["needed_info"]
|
| 187 |
+
))
|
| 188 |
+
|
| 189 |
+
# 重新获取锁并写入 sessions(防止重复创建)
|
| 190 |
+
with sessions_lock:
|
| 191 |
+
if session_id not in sessions:
|
| 192 |
+
sessions[session_id] = agent
|
| 193 |
+
logger.info(f"恢复会话: {session_id}")
|
| 194 |
+
else:
|
| 195 |
+
agent = sessions[session_id]
|
| 196 |
+
|
| 197 |
+
return agent, session_id
|
| 198 |
+
|
| 199 |
+
# 没有找到已保存的会话,创建新的
|
| 200 |
+
# 获取 API Key 管理器
|
| 201 |
+
key_manager = get_key_manager()
|
| 202 |
+
if not key_manager or not key_manager.has_keys:
|
| 203 |
+
logger.warning("OPENAI_API_KEY 未设置")
|
| 204 |
+
|
| 205 |
+
base_url = get_env("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 206 |
+
model = get_env("OPENAI_MODEL", "gpt-4")
|
| 207 |
+
code_dir = get_env("CODE_DIR", "./code")
|
| 208 |
+
max_steps = int(get_env("MAX_STEPS", "10"))
|
| 209 |
+
stream_output = get_env_bool("STREAM_OUTPUT", True)
|
| 210 |
+
tree_depth = int(get_env("TREE_DEPTH", "3"))
|
| 211 |
+
|
| 212 |
+
# 初始化仓库管理器
|
| 213 |
+
repo_manager = get_repo_manager()
|
| 214 |
+
# 仓库同步已在启动时完成,无需再次同步
|
| 215 |
+
|
| 216 |
+
# 创建代码目录
|
| 217 |
+
Path(code_dir).mkdir(parents=True, exist_ok=True)
|
| 218 |
+
|
| 219 |
+
agent = ReadAgent(
|
| 220 |
+
code_dir=code_dir,
|
| 221 |
+
base_url=base_url,
|
| 222 |
+
model=model,
|
| 223 |
+
max_steps=max_steps,
|
| 224 |
+
stream_output=stream_output,
|
| 225 |
+
tree_depth=tree_depth,
|
| 226 |
+
api_key_manager=key_manager
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# 保存新会话到持久化存储
|
| 230 |
+
session_storage.save_session(
|
| 231 |
+
session_id=session_id,
|
| 232 |
+
model=model,
|
| 233 |
+
base_url=base_url,
|
| 234 |
+
code_dir=code_dir,
|
| 235 |
+
max_steps=max_steps,
|
| 236 |
+
stream_output=stream_output,
|
| 237 |
+
tree_depth=tree_depth,
|
| 238 |
+
conversation_history=agent.conversation_history,
|
| 239 |
+
memories=[m.to_dict() for m in agent.memories]
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# 重新获取锁并写入 sessions(防止重复创建)
|
| 243 |
+
with sessions_lock:
|
| 244 |
+
# 再次检查,可能在创建过程中另一个线程已经创建了
|
| 245 |
+
if session_id not in sessions:
|
| 246 |
+
sessions[session_id] = agent
|
| 247 |
+
logger.info(f"创建新会话: {session_id}, model={model}")
|
| 248 |
+
else:
|
| 249 |
+
# 如果已存在,使用现有的 agent
|
| 250 |
+
agent = sessions[session_id]
|
| 251 |
+
logger.info(f"使用现有会话: {session_id}")
|
| 252 |
+
|
| 253 |
+
return agent, session_id
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def save_agent_state(session_id: str, agent: ReadAgent):
|
| 257 |
+
"""保存 Agent 状态到持久化存储"""
|
| 258 |
+
try:
|
| 259 |
+
session_storage.save_session(
|
| 260 |
+
session_id=session_id,
|
| 261 |
+
model=agent.model,
|
| 262 |
+
base_url=agent.base_url,
|
| 263 |
+
code_dir=str(agent.searcher.root_dir),
|
| 264 |
+
max_steps=agent.max_steps,
|
| 265 |
+
stream_output=agent.stream_output,
|
| 266 |
+
tree_depth=agent.tree_depth,
|
| 267 |
+
conversation_history=agent.conversation_history,
|
| 268 |
+
memories=[m.to_dict() for m in agent.memories]
|
| 269 |
+
)
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.warning(f"保存会话状态失败: {e}")
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@app.route('/')
|
| 275 |
+
def index():
|
| 276 |
+
"""主页"""
|
| 277 |
+
return send_from_directory('static', 'index.html')
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@app.route('/static/<path:filename>')
|
| 281 |
+
def static_files(filename):
|
| 282 |
+
"""静态文件"""
|
| 283 |
+
return send_from_directory('static', filename)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@app.route('/prompt')
|
| 287 |
+
def prompt():
|
| 288 |
+
"""返回提示词"""
|
| 289 |
+
from src.agent import ReadAgent
|
| 290 |
+
from prompts import get_system_prompt
|
| 291 |
+
|
| 292 |
+
# 获取 Agent 的工具信息
|
| 293 |
+
agent = ReadAgent()
|
| 294 |
+
tools_info = agent.tool_executor.get_available_tools()
|
| 295 |
+
max_steps = agent.max_steps
|
| 296 |
+
|
| 297 |
+
# 使用 prompts.py 中的函数生成提示词
|
| 298 |
+
return get_system_prompt(tools_info, max_steps)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
@app.route('/use_document')
|
| 302 |
+
def use_document():
|
| 303 |
+
"""返回参考文档"""
|
| 304 |
+
# 可以扩展为返回实际文档内容
|
| 305 |
+
return ""
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@app.route('/api/ask', methods=['POST'])
|
| 309 |
+
def ask():
|
| 310 |
+
"""Read Agent 问答 API"""
|
| 311 |
+
data = request.json
|
| 312 |
+
if not data:
|
| 313 |
+
return jsonify({"error": "请求体不能为空"}), 400
|
| 314 |
+
|
| 315 |
+
question = data.get('question', '')
|
| 316 |
+
stream = data.get('stream', True) # 默认启用流式输出
|
| 317 |
+
session_id = data.get('session_id') # 获取会话 ID
|
| 318 |
+
if not question:
|
| 319 |
+
return jsonify({"error": "问题不能为空"}), 400
|
| 320 |
+
|
| 321 |
+
# 获取或创建会话的 Agent
|
| 322 |
+
agent, session_id = get_agent(session_id)
|
| 323 |
+
|
| 324 |
+
if stream:
|
| 325 |
+
# 流式响应 - 真正的 token 级别流式输出
|
| 326 |
+
def generate():
|
| 327 |
+
try:
|
| 328 |
+
import sys
|
| 329 |
+
|
| 330 |
+
# 发送会话 ID
|
| 331 |
+
yield "data: " + json.dumps({"type": "session_id", "session_id": session_id}) + "\n\n"
|
| 332 |
+
sys.stdout.flush()
|
| 333 |
+
|
| 334 |
+
# 发送开始信号
|
| 335 |
+
yield "data: " + json.dumps({"type": "start"}) + "\n\n"
|
| 336 |
+
sys.stdout.flush()
|
| 337 |
+
|
| 338 |
+
# 发送问题
|
| 339 |
+
yield "data: " + json.dumps({"type": "question", "content": question}) + "\n\n"
|
| 340 |
+
sys.stdout.flush()
|
| 341 |
+
|
| 342 |
+
# 初始化当前会话的 Agent
|
| 343 |
+
agent.steps = []
|
| 344 |
+
agent.conversation_history.append({"role": "user", "content": question})
|
| 345 |
+
full_answer = ""
|
| 346 |
+
current_step = 0
|
| 347 |
+
|
| 348 |
+
for step in range(1, agent.max_steps + 1):
|
| 349 |
+
current_step = step
|
| 350 |
+
|
| 351 |
+
# 创建流式回调来实时发送 LLM 响应
|
| 352 |
+
msg_queue = queue.Queue()
|
| 353 |
+
|
| 354 |
+
def stream_callback(chunk):
|
| 355 |
+
msg_queue.put(("chunk", chunk))
|
| 356 |
+
|
| 357 |
+
def done_callback():
|
| 358 |
+
msg_queue.put(("done", None))
|
| 359 |
+
|
| 360 |
+
# 在后台线程中调用 LLM(支持重试)
|
| 361 |
+
def call_llm():
|
| 362 |
+
import time
|
| 363 |
+
|
| 364 |
+
# 使用 agent 的重试配置
|
| 365 |
+
max_retries = agent.max_retries
|
| 366 |
+
retry_delays = agent.retry_delays
|
| 367 |
+
|
| 368 |
+
# 可重试的 HTTP 状态码
|
| 369 |
+
retryable_status_codes = [401, 429, 500, 502, 503, 504]
|
| 370 |
+
|
| 371 |
+
for attempt in range(max_retries):
|
| 372 |
+
try:
|
| 373 |
+
# 使用 http.client 实现真正的流式读取
|
| 374 |
+
import http.client
|
| 375 |
+
|
| 376 |
+
# 从 ApiKeyManager 获取 key(随机选择)
|
| 377 |
+
api_key = None
|
| 378 |
+
if agent.api_key_manager:
|
| 379 |
+
api_key = agent.api_key_manager.get_key()
|
| 380 |
+
|
| 381 |
+
if not api_key:
|
| 382 |
+
raise Exception("未配置 API Key")
|
| 383 |
+
|
| 384 |
+
# 解析 base_url
|
| 385 |
+
parsed_url = urlparse(agent.base_url)
|
| 386 |
+
host = parsed_url.hostname or "api.openai.com"
|
| 387 |
+
port = parsed_url.port or 443
|
| 388 |
+
# 拼接 path 和 endpoint
|
| 389 |
+
api_path = parsed_url.path.strip() if parsed_url.path else "/v1"
|
| 390 |
+
endpoint = "/chat/completions"
|
| 391 |
+
path = f"{api_path}{endpoint}"
|
| 392 |
+
|
| 393 |
+
headers = {
|
| 394 |
+
"Content-Type": "application/json",
|
| 395 |
+
"Authorization": f"Bearer {api_key}",
|
| 396 |
+
"Host": host
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
messages = [
|
| 400 |
+
{"role": "system", "content": agent._build_system_prompt()}
|
| 401 |
+
]
|
| 402 |
+
for msg in agent.conversation_history:
|
| 403 |
+
messages.append(msg)
|
| 404 |
+
messages.append({
|
| 405 |
+
"role": "user",
|
| 406 |
+
"content": f"用户问题:{question}\n\n{get_react_format_prompt()}"
|
| 407 |
+
})
|
| 408 |
+
|
| 409 |
+
body = json.dumps({
|
| 410 |
+
"model": agent.model,
|
| 411 |
+
"messages": messages,
|
| 412 |
+
"temperature": 0.3,
|
| 413 |
+
"stream": True
|
| 414 |
+
})
|
| 415 |
+
|
| 416 |
+
conn = http.client.HTTPSConnection(host, port, timeout=60)
|
| 417 |
+
conn.request("POST", path, body, headers)
|
| 418 |
+
|
| 419 |
+
response = conn.getresponse()
|
| 420 |
+
|
| 421 |
+
if response.status != 200:
|
| 422 |
+
# 检查是否可重试
|
| 423 |
+
if response.status in retryable_status_codes and attempt < max_retries - 1:
|
| 424 |
+
conn.close()
|
| 425 |
+
if agent.api_key_manager and api_key:
|
| 426 |
+
agent.api_key_manager.record_error(api_key, f"HTTP {response.status} (重试 {attempt + 1}/{max_retries})")
|
| 427 |
+
|
| 428 |
+
# 获取延迟(如果超出数组长度,使用最后一个值)
|
| 429 |
+
delay = retry_delays[min(attempt, len(retry_delays) - 1)]
|
| 430 |
+
msg_queue.put(("retry", delay, response.status, attempt + 1, max_retries))
|
| 431 |
+
time.sleep(delay)
|
| 432 |
+
continue
|
| 433 |
+
else:
|
| 434 |
+
# 最后一次重试或不可重试的错误
|
| 435 |
+
conn.close()
|
| 436 |
+
if agent.api_key_manager and api_key:
|
| 437 |
+
agent.api_key_manager.record_error(api_key, f"HTTP {response.status}")
|
| 438 |
+
raise Exception(f"API 错误: {response.status}")
|
| 439 |
+
|
| 440 |
+
# 真正流式读取响应
|
| 441 |
+
llm_response = ""
|
| 442 |
+
while True:
|
| 443 |
+
line = response.readline()
|
| 444 |
+
if not line:
|
| 445 |
+
break
|
| 446 |
+
|
| 447 |
+
line = line.decode("utf-8").strip()
|
| 448 |
+
if not line.startswith("data: "):
|
| 449 |
+
continue
|
| 450 |
+
if line == "data: [DONE]":
|
| 451 |
+
break
|
| 452 |
+
|
| 453 |
+
data_str = line[6:]
|
| 454 |
+
try:
|
| 455 |
+
chunk = json.loads(data_str)
|
| 456 |
+
if chunk.get("choices") and len(chunk["choices"]) > 0:
|
| 457 |
+
delta = chunk["choices"][0].get("delta", {})
|
| 458 |
+
content = delta.get("content", "")
|
| 459 |
+
if content:
|
| 460 |
+
stream_callback(content)
|
| 461 |
+
except json.JSONDecodeError:
|
| 462 |
+
continue
|
| 463 |
+
|
| 464 |
+
conn.close()
|
| 465 |
+
|
| 466 |
+
# 记录成功
|
| 467 |
+
if agent.api_key_manager and api_key:
|
| 468 |
+
agent.api_key_manager.record_success(api_key)
|
| 469 |
+
|
| 470 |
+
done_callback()
|
| 471 |
+
return
|
| 472 |
+
|
| 473 |
+
except Exception as e:
|
| 474 |
+
# 网络错误,可重试
|
| 475 |
+
if attempt < max_retries - 1 and ("timeout" in str(e).lower() or "connection" in str(e).lower()):
|
| 476 |
+
if agent.api_key_manager and api_key:
|
| 477 |
+
agent.api_key_manager.record_error(api_key, f"网络错误: {str(e)} (重试 {attempt + 1}/{max_retries})")
|
| 478 |
+
|
| 479 |
+
# 获取延迟(如果超出数组长度,使用最后一个值)
|
| 480 |
+
delay = retry_delays[min(attempt, len(retry_delays) - 1)]
|
| 481 |
+
msg_queue.put(("retry", delay, "网络错误", attempt + 1, max_retries))
|
| 482 |
+
time.sleep(delay)
|
| 483 |
+
continue
|
| 484 |
+
else:
|
| 485 |
+
# 最后一次重试或不可重试的错误
|
| 486 |
+
if agent.api_key_manager and api_key:
|
| 487 |
+
agent.api_key_manager.record_error(api_key, str(e))
|
| 488 |
+
msg_queue.put(("error", str(e)))
|
| 489 |
+
return
|
| 490 |
+
|
| 491 |
+
llm_thread = threading.Thread(target=call_llm)
|
| 492 |
+
llm_thread.start()
|
| 493 |
+
|
| 494 |
+
# 收集 LLM 响应(支持超时后重试当前步骤)
|
| 495 |
+
llm_timeout_retries = int(os.getenv("LLM_TIMEOUT_RETRIES", "2"))
|
| 496 |
+
llm_timeout_delay = int(os.getenv("LLM_TIMEOUT_DELAY", "2"))
|
| 497 |
+
|
| 498 |
+
for timeout_attempt in range(llm_timeout_retries + 1):
|
| 499 |
+
llm_response = ""
|
| 500 |
+
received_done = False
|
| 501 |
+
try:
|
| 502 |
+
while True:
|
| 503 |
+
msg = msg_queue.get(timeout=60)
|
| 504 |
+
msg_type = msg[0]
|
| 505 |
+
|
| 506 |
+
if msg_type == "error":
|
| 507 |
+
raise Exception(msg[1])
|
| 508 |
+
elif msg_type == "done":
|
| 509 |
+
received_done = True
|
| 510 |
+
break
|
| 511 |
+
elif msg_type == "retry":
|
| 512 |
+
# 重试消息: (retry, delay, status_or_error, attempt, max_retries)
|
| 513 |
+
delay, status_or_error, attempt, max_retries = msg[1], msg[2], msg[3], msg[4]
|
| 514 |
+
yield "data: " + json.dumps({
|
| 515 |
+
"type": "retry",
|
| 516 |
+
"delay": delay,
|
| 517 |
+
"status": status_or_error,
|
| 518 |
+
"attempt": attempt,
|
| 519 |
+
"max_retries": max_retries
|
| 520 |
+
}, ensure_ascii=False) + "\n\n"
|
| 521 |
+
sys.stdout.flush()
|
| 522 |
+
else: # chunk - (msg_type, content)
|
| 523 |
+
content = msg[1]
|
| 524 |
+
llm_response += content
|
| 525 |
+
# 发送思考内容流式输出
|
| 526 |
+
yield "data: " + json.dumps({
|
| 527 |
+
"type": "chunk",
|
| 528 |
+
"step": step,
|
| 529 |
+
"content": content,
|
| 530 |
+
"stream_type": "thought"
|
| 531 |
+
}, ensure_ascii=False) + "\n\n"
|
| 532 |
+
sys.stdout.flush()
|
| 533 |
+
break # 成功获取响应,退出重试循环
|
| 534 |
+
except queue.Empty:
|
| 535 |
+
if timeout_attempt < llm_timeout_retries:
|
| 536 |
+
logger.warning(f"步骤 {step} 等待 LLM 响应超时,{llm_timeout_delay} 秒后重试 ({timeout_attempt + 1}/{llm_timeout_retries + 1})")
|
| 537 |
+
yield "data: " + json.dumps({
|
| 538 |
+
"type": "step_timeout",
|
| 539 |
+
"step": step,
|
| 540 |
+
"retry_delay": llm_timeout_delay,
|
| 541 |
+
"attempt": timeout_attempt + 1,
|
| 542 |
+
"max_retries": llm_timeout_retries + 1,
|
| 543 |
+
"message": f"等待 LLM 响应超时,{llm_timeout_delay} 秒后重试..."
|
| 544 |
+
}, ensure_ascii=False) + "\n\n"
|
| 545 |
+
sys.stdout.flush()
|
| 546 |
+
time.sleep(llm_timeout_delay)
|
| 547 |
+
# 等待 LLM 线程结束
|
| 548 |
+
if llm_thread.is_alive():
|
| 549 |
+
llm_thread.join(timeout=1)
|
| 550 |
+
# 重新启动 LLM 调用
|
| 551 |
+
llm_thread = threading.Thread(target=call_llm)
|
| 552 |
+
llm_thread.start()
|
| 553 |
+
else:
|
| 554 |
+
logger.error(f"步骤 {step} 等待 LLM 响应超时,已达最大重试次数")
|
| 555 |
+
break
|
| 556 |
+
|
| 557 |
+
llm_thread.join()
|
| 558 |
+
|
| 559 |
+
# 调试日志
|
| 560 |
+
logger.info(f"步骤 {step} LLM 响应: done={received_done}, length={len(llm_response)}, response_preview={llm_response[:200]}...")
|
| 561 |
+
|
| 562 |
+
# 如果没有收到有效响应,触发重试
|
| 563 |
+
if not llm_response:
|
| 564 |
+
if timeout_attempt < llm_timeout_retries:
|
| 565 |
+
logger.warning(f"步骤 {step} 收到空响应,{llm_timeout_delay} 秒后重试 ({timeout_attempt + 1}/{llm_timeout_retries + 1})")
|
| 566 |
+
yield "data: " + json.dumps({
|
| 567 |
+
"type": "step_empty_response",
|
| 568 |
+
"step": step,
|
| 569 |
+
"retry_delay": llm_timeout_delay,
|
| 570 |
+
"attempt": timeout_attempt + 1,
|
| 571 |
+
"max_retries": llm_timeout_retries + 1,
|
| 572 |
+
"message": f"LLM 返回空响应,{llm_timeout_delay} 秒后重试..."
|
| 573 |
+
}, ensure_ascii=False) + "\n\n"
|
| 574 |
+
sys.stdout.flush()
|
| 575 |
+
time.sleep(llm_timeout_delay)
|
| 576 |
+
# 重新启动 LLM 调用(继续外层重试循环)
|
| 577 |
+
llm_thread = threading.Thread(target=call_llm)
|
| 578 |
+
llm_thread.start()
|
| 579 |
+
continue
|
| 580 |
+
else:
|
| 581 |
+
logger.error(f"步骤 {step} 收到空响应,已达最大重试次数")
|
| 582 |
+
# 发送错误信号
|
| 583 |
+
yield "data: " + json.dumps({"type": "error", "error": "LLM 未返回有效响应"}) + "\n\n"
|
| 584 |
+
return
|
| 585 |
+
|
| 586 |
+
# 处理 LLM 响应(支持批量 Actions)
|
| 587 |
+
thought, actions_list = agent._extract_thought_action(llm_response)
|
| 588 |
+
final_answer, memory_data = agent._extract_final_answer(llm_response)
|
| 589 |
+
|
| 590 |
+
# 获取第一个 action 用于日志
|
| 591 |
+
first_action = None
|
| 592 |
+
if actions_list and len(actions_list) > 0:
|
| 593 |
+
# actions_list 的每个元素是 (action_name, args_dict)
|
| 594 |
+
first_action_tuple = actions_list[0]
|
| 595 |
+
if first_action_tuple and len(first_action_tuple) > 0:
|
| 596 |
+
first_action = first_action_tuple[0] # action_name
|
| 597 |
+
|
| 598 |
+
logger.info(f"步骤 {step} 解析: thought={thought[:30] if thought else 'None'}..., action={first_action}, has_final={bool(final_answer)}, actions_count={len(actions_list)}")
|
| 599 |
+
|
| 600 |
+
# 发送完成当前步骤的思考
|
| 601 |
+
yield "data: " + json.dumps({
|
| 602 |
+
"type": "step_thought_done",
|
| 603 |
+
"step": step,
|
| 604 |
+
"thought": thought,
|
| 605 |
+
"has_action": len(actions_list) > 0
|
| 606 |
+
}, ensure_ascii=False) + "\n\n"
|
| 607 |
+
sys.stdout.flush()
|
| 608 |
+
|
| 609 |
+
# 如果有 Memory,保存
|
| 610 |
+
if memory_data:
|
| 611 |
+
path = memory_data.get("file", "")
|
| 612 |
+
if path:
|
| 613 |
+
existing = [m for m in agent.memories if m.file_path == path]
|
| 614 |
+
if existing:
|
| 615 |
+
agent.memories.remove(existing[0])
|
| 616 |
+
# 使用 src.agent 中定义的 Memory 类
|
| 617 |
+
from src.agent import Memory
|
| 618 |
+
memory = Memory(
|
| 619 |
+
file_path=path,
|
| 620 |
+
overview=memory_data.get("overview", ""),
|
| 621 |
+
key_definitions=memory_data.get("key_definitions", []),
|
| 622 |
+
core_logic=memory_data.get("core_logic", ""),
|
| 623 |
+
dependencies=memory_data.get("dependencies", []),
|
| 624 |
+
needed_info=memory_data.get("needed_info", "")
|
| 625 |
+
)
|
| 626 |
+
agent.memories.append(memory)
|
| 627 |
+
|
| 628 |
+
if final_answer:
|
| 629 |
+
# 发送最终答案流式输出
|
| 630 |
+
for char in final_answer:
|
| 631 |
+
yield "data: " + json.dumps({
|
| 632 |
+
"type": "chunk",
|
| 633 |
+
"step": step,
|
| 634 |
+
"content": char,
|
| 635 |
+
"stream_type": "answer"
|
| 636 |
+
}, ensure_ascii=False) + "\n\n"
|
| 637 |
+
sys.stdout.flush()
|
| 638 |
+
|
| 639 |
+
# 发送完成信号
|
| 640 |
+
yield "data: " + json.dumps({
|
| 641 |
+
"type": "step",
|
| 642 |
+
"step": step,
|
| 643 |
+
"final_answer": final_answer
|
| 644 |
+
}, ensure_ascii=False) + "\n\n"
|
| 645 |
+
sys.stdout.flush()
|
| 646 |
+
|
| 647 |
+
yield "data: " + json.dumps({"type": "done"}) + "\n\n"
|
| 648 |
+
sys.stdout.flush()
|
| 649 |
+
return
|
| 650 |
+
|
| 651 |
+
# 执行工具调用(支持批量)
|
| 652 |
+
if actions_list:
|
| 653 |
+
# 如果是批量执行
|
| 654 |
+
if len(actions_list) > 1:
|
| 655 |
+
batch_results = []
|
| 656 |
+
|
| 657 |
+
# 发送批量执行提示
|
| 658 |
+
yield "data: " + json.dumps({
|
| 659 |
+
"type": "batch_start",
|
| 660 |
+
"step": step,
|
| 661 |
+
"count": len(actions_list)
|
| 662 |
+
}, ensure_ascii=False) + "\n\n"
|
| 663 |
+
sys.stdout.flush()
|
| 664 |
+
|
| 665 |
+
# 批量执行所有 Actions
|
| 666 |
+
for i, (action, action_args) in enumerate(actions_list, 1):
|
| 667 |
+
# 使用 ** 解包字典作为关键字参数
|
| 668 |
+
tool_result = agent.tool_executor.execute_tool(action, **action_args)
|
| 669 |
+
batch_results.append({
|
| 670 |
+
"index": i,
|
| 671 |
+
"action": f"{action}({format_action_args(action_args)})",
|
| 672 |
+
"result": tool_result
|
| 673 |
+
})
|
| 674 |
+
|
| 675 |
+
# 发送单个 Action 结果
|
| 676 |
+
yield "data: " + json.dumps({
|
| 677 |
+
"type": "batch_item",
|
| 678 |
+
"step": step,
|
| 679 |
+
"index": i,
|
| 680 |
+
"action": f"{action}({format_action_args(action_args)})",
|
| 681 |
+
"observation": tool_result
|
| 682 |
+
}, ensure_ascii=False) + "\n\n"
|
| 683 |
+
sys.stdout.flush()
|
| 684 |
+
|
| 685 |
+
# 发送批量完成信号
|
| 686 |
+
yield "data: " + json.dumps({
|
| 687 |
+
"type": "batch_done",
|
| 688 |
+
"step": step,
|
| 689 |
+
"results": batch_results
|
| 690 |
+
}, ensure_ascii=False) + "\n\n"
|
| 691 |
+
sys.stdout.flush()
|
| 692 |
+
|
| 693 |
+
# 将批量观察结果添加到对话
|
| 694 |
+
agent.conversation_history.append({
|
| 695 |
+
"role": "user",
|
| 696 |
+
"content": f"Observation: {json.dumps(batch_results, ensure_ascii=False)}"
|
| 697 |
+
})
|
| 698 |
+
else:
|
| 699 |
+
# 单个 Action
|
| 700 |
+
action, action_args = actions_list[0]
|
| 701 |
+
tool_result = agent.tool_executor.execute_tool(action, **action_args)
|
| 702 |
+
|
| 703 |
+
# 发送工具调用
|
| 704 |
+
yield "data: " + json.dumps({
|
| 705 |
+
"type": "step",
|
| 706 |
+
"step": step,
|
| 707 |
+
"thought": thought,
|
| 708 |
+
"action": f"{action}({action_args})",
|
| 709 |
+
"observation": tool_result
|
| 710 |
+
}, ensure_ascii=False) + "\n\n"
|
| 711 |
+
sys.stdout.flush()
|
| 712 |
+
|
| 713 |
+
# 将观察结果添加到对话
|
| 714 |
+
agent.conversation_history.append({
|
| 715 |
+
"role": "user",
|
| 716 |
+
"content": f"Observation: {json.dumps(tool_result, ensure_ascii=False)}"
|
| 717 |
+
})
|
| 718 |
+
|
| 719 |
+
# 超时
|
| 720 |
+
yield "data: " + json.dumps({"type": "done"}) + "\n\n"
|
| 721 |
+
sys.stdout.flush()
|
| 722 |
+
|
| 723 |
+
except Exception as e:
|
| 724 |
+
logger.error(f"流式响应错误: {e}")
|
| 725 |
+
yield "data: " + json.dumps({"type": "error", "error": str(e)}) + "\n\n"
|
| 726 |
+
sys.stdout.flush()
|
| 727 |
+
finally:
|
| 728 |
+
# 保存会话状态
|
| 729 |
+
save_agent_state(session_id, agent)
|
| 730 |
+
|
| 731 |
+
response = Response(generate(), mimetype='text/event-stream')
|
| 732 |
+
response.headers['Cache-Control'] = 'no-cache'
|
| 733 |
+
response.headers['X-Accel-Buffering'] = 'no'
|
| 734 |
+
return response
|
| 735 |
+
else:
|
| 736 |
+
# 非流式响应
|
| 737 |
+
try:
|
| 738 |
+
agent.stream_output = False
|
| 739 |
+
agent.ask(question)
|
| 740 |
+
|
| 741 |
+
# 保存会话状态
|
| 742 |
+
save_agent_state(session_id, agent)
|
| 743 |
+
|
| 744 |
+
# 构建响应
|
| 745 |
+
steps = []
|
| 746 |
+
final_answer = ""
|
| 747 |
+
|
| 748 |
+
for step_info in agent.steps:
|
| 749 |
+
step = {
|
| 750 |
+
"step": step_info.get("step"),
|
| 751 |
+
"thought": step_info.get("thought", ""),
|
| 752 |
+
"action": step_info.get("action_str", ""),
|
| 753 |
+
"observation": step_info.get("observation"),
|
| 754 |
+
"final_answer": step_info.get("final_answer", "")
|
| 755 |
+
}
|
| 756 |
+
steps.append(step)
|
| 757 |
+
|
| 758 |
+
if step_info.get("final_answer"):
|
| 759 |
+
final_answer = step_info["final_answer"]
|
| 760 |
+
|
| 761 |
+
return jsonify({
|
| 762 |
+
"success": True,
|
| 763 |
+
"question": question,
|
| 764 |
+
"answer": final_answer or "未找到答案",
|
| 765 |
+
"steps": steps
|
| 766 |
+
})
|
| 767 |
+
except Exception as e:
|
| 768 |
+
logger.error(f"响应错误: {e}")
|
| 769 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
@app.route('/health')
|
| 773 |
+
def health():
|
| 774 |
+
"""健康检查"""
|
| 775 |
+
return jsonify({"status": "ok", "message": "Read Agent 服务运行中"})
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
@app.route('/status')
|
| 779 |
+
def status():
|
| 780 |
+
"""获取服务状态"""
|
| 781 |
+
session_id = request.args.get('session_id')
|
| 782 |
+
agent, _ = get_agent(session_id)
|
| 783 |
+
stats = agent.get_stats()
|
| 784 |
+
return jsonify({
|
| 785 |
+
"status": "running",
|
| 786 |
+
"stats": stats
|
| 787 |
+
})
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
# ============ 会话管理 API ============
|
| 791 |
+
|
| 792 |
+
@app.route('/api/session/clear', methods=['POST'])
|
| 793 |
+
def clear_session():
|
| 794 |
+
"""清除指定会话(线程安全)"""
|
| 795 |
+
data = request.json or {}
|
| 796 |
+
session_id = data.get('session_id')
|
| 797 |
+
|
| 798 |
+
with sessions_lock:
|
| 799 |
+
if session_id and session_id in sessions:
|
| 800 |
+
del sessions[session_id]
|
| 801 |
+
# 同时删除持久化存储
|
| 802 |
+
session_storage.delete_session(session_id)
|
| 803 |
+
return jsonify({
|
| 804 |
+
"success": True,
|
| 805 |
+
"message": f"会话已清除: {session_id}"
|
| 806 |
+
})
|
| 807 |
+
elif not session_id:
|
| 808 |
+
# 清除所有会话
|
| 809 |
+
count = len(sessions)
|
| 810 |
+
sessions.clear()
|
| 811 |
+
# 同时清除持久化存储
|
| 812 |
+
session_storage.clear_all()
|
| 813 |
+
return jsonify({
|
| 814 |
+
"success": True,
|
| 815 |
+
"message": f"已清除 {count} 个会话"
|
| 816 |
+
})
|
| 817 |
+
else:
|
| 818 |
+
return jsonify({
|
| 819 |
+
"success": False,
|
| 820 |
+
"error": "会话不存在"
|
| 821 |
+
}), 404
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
@app.route('/api/session', methods=['GET'])
|
| 825 |
+
def get_or_create_session():
|
| 826 |
+
"""获取或创建会话(前端页面加载时自动调用)"""
|
| 827 |
+
session_id = request.args.get('session_id')
|
| 828 |
+
agent, session_id = get_agent(session_id)
|
| 829 |
+
|
| 830 |
+
return jsonify({
|
| 831 |
+
"success": True,
|
| 832 |
+
"session_id": session_id,
|
| 833 |
+
"model": agent.model,
|
| 834 |
+
"code_dir": str(agent.searcher.root_dir),
|
| 835 |
+
"max_steps": agent.max_steps,
|
| 836 |
+
"tree_depth": agent.tree_depth
|
| 837 |
+
})
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
@app.route('/api/session/new', methods=['POST'])
|
| 841 |
+
def new_session():
|
| 842 |
+
"""创建新会话"""
|
| 843 |
+
_, session_id = get_agent(None)
|
| 844 |
+
return jsonify({
|
| 845 |
+
"success": True,
|
| 846 |
+
"session_id": session_id
|
| 847 |
+
})
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
@app.route('/api/sessions', methods=['GET'])
|
| 851 |
+
def list_sessions():
|
| 852 |
+
"""列出所有会话(从持久化存储)"""
|
| 853 |
+
sessions_list = session_storage.list_sessions()
|
| 854 |
+
return jsonify({
|
| 855 |
+
"success": True,
|
| 856 |
+
"sessions": sessions_list,
|
| 857 |
+
"count": len(sessions_list)
|
| 858 |
+
})
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
# ============ 仓库管理 API ============
|
| 862 |
+
|
| 863 |
+
@app.route('/api/repos', methods=['GET'])
|
| 864 |
+
def list_repos():
|
| 865 |
+
"""获取已同步的仓库列表(线程安全)"""
|
| 866 |
+
repo_manager = get_repo_manager()
|
| 867 |
+
repos = repo_manager.get_repo_list()
|
| 868 |
+
return jsonify({
|
| 869 |
+
"repos": repos,
|
| 870 |
+
"count": len(repos)
|
| 871 |
+
})
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
@app.route('/api/repos/sync', methods=['POST'])
|
| 875 |
+
def sync_repos():
|
| 876 |
+
"""手动触发仓库同步(线程安全)"""
|
| 877 |
+
data = request.json or {}
|
| 878 |
+
force = data.get('force', False)
|
| 879 |
+
|
| 880 |
+
repo_manager = get_repo_manager()
|
| 881 |
+
results = repo_manager.sync_all(parallel=True, force=force)
|
| 882 |
+
return jsonify({
|
| 883 |
+
"success": results["success"],
|
| 884 |
+
"skipped": results.get("skipped", []),
|
| 885 |
+
"failed": results["failed"],
|
| 886 |
+
"message": f"同步完成: 成功 {len(results['success'])}, 跳过 {len(results.get('skipped', []))}, 失败 {len(results['failed'])}"
|
| 887 |
+
})
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
@app.route('/api/repos/config', methods=['GET'])
|
| 891 |
+
def get_repo_config():
|
| 892 |
+
"""获取仓库配置(线程安全)"""
|
| 893 |
+
repo_manager = get_repo_manager()
|
| 894 |
+
repos = repo_manager.load_from_env()
|
| 895 |
+
return jsonify({
|
| 896 |
+
"repos": [
|
| 897 |
+
{
|
| 898 |
+
"name": r.name,
|
| 899 |
+
"url": r.url,
|
| 900 |
+
"branch": r.branch,
|
| 901 |
+
"auto_update": r.auto_update
|
| 902 |
+
}
|
| 903 |
+
for r in repos
|
| 904 |
+
],
|
| 905 |
+
"count": len(repos)
|
| 906 |
+
})
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
@app.route('/api/repos/clear', methods=['POST'])
|
| 910 |
+
def clear_repos():
|
| 911 |
+
"""清空所有仓库(线程安全)"""
|
| 912 |
+
repo_manager = get_repo_manager()
|
| 913 |
+
count = repo_manager.clear_all()
|
| 914 |
+
return jsonify({
|
| 915 |
+
"message": f"已清空 {count} 个仓库",
|
| 916 |
+
"count": count
|
| 917 |
+
})
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
# ============ API Key 管理 API ============
|
| 921 |
+
|
| 922 |
+
@app.route('/api/api-keys/stats', methods=['GET'])
|
| 923 |
+
def get_api_keys_stats():
|
| 924 |
+
"""获取 API Key 统计信息(线程安全)"""
|
| 925 |
+
key_manager = get_key_manager()
|
| 926 |
+
if not key_manager:
|
| 927 |
+
return jsonify({
|
| 928 |
+
"error": "未配置 API Key"
|
| 929 |
+
}), 404
|
| 930 |
+
|
| 931 |
+
return jsonify(key_manager.get_stats())
|
| 932 |
+
|
| 933 |
+
|
| 934 |
+
@app.route('/api/api-keys/reset-stats', methods=['POST'])
|
| 935 |
+
def reset_api_keys_stats():
|
| 936 |
+
"""重置 API Key 统计信息(线程安全)"""
|
| 937 |
+
key_manager = get_key_manager()
|
| 938 |
+
if not key_manager:
|
| 939 |
+
return jsonify({
|
| 940 |
+
"error": "未配置 API Key"
|
| 941 |
+
}), 404
|
| 942 |
+
|
| 943 |
+
key_manager.reset_stats()
|
| 944 |
+
return jsonify({
|
| 945 |
+
"success": True,
|
| 946 |
+
"message": "统计信息已重置"
|
| 947 |
+
})
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
# ============ 弹窗管理 API ============
|
| 951 |
+
|
| 952 |
+
@app.route('/api/popup/config', methods=['GET'])
|
| 953 |
+
def get_popup_config():
|
| 954 |
+
"""获取弹窗配置"""
|
| 955 |
+
config = load_popup_config()
|
| 956 |
+
|
| 957 |
+
if not config:
|
| 958 |
+
return jsonify({
|
| 959 |
+
"enabled": False,
|
| 960 |
+
"message": "弹窗配置未找到"
|
| 961 |
+
})
|
| 962 |
+
|
| 963 |
+
return jsonify({
|
| 964 |
+
"enabled": config.get("enabled", False),
|
| 965 |
+
"id": config.get("id", ""),
|
| 966 |
+
"display": config.get("display", {}),
|
| 967 |
+
"content": config.get("content", {}),
|
| 968 |
+
"buttons": config.get("buttons", []),
|
| 969 |
+
"storage": config.get("storage", {}),
|
| 970 |
+
"showRules": config.get("showRules", {})
|
| 971 |
+
})
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
@app.route('/api/popup/reload', methods=['POST'])
|
| 975 |
+
def reload_popup():
|
| 976 |
+
"""重新加载弹窗配置(管理接口)"""
|
| 977 |
+
try:
|
| 978 |
+
config = reload_popup_config()
|
| 979 |
+
return jsonify({
|
| 980 |
+
"success": True,
|
| 981 |
+
"message": "弹窗配置已重新加载",
|
| 982 |
+
"config": config
|
| 983 |
+
})
|
| 984 |
+
except Exception as e:
|
| 985 |
+
logger.error(f"重新加载弹窗配置失败: {e}")
|
| 986 |
+
return jsonify({
|
| 987 |
+
"success": False,
|
| 988 |
+
"error": str(e)
|
| 989 |
+
}), 500
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
def initialize_app():
|
| 993 |
+
"""启动时预初始化全局组件"""
|
| 994 |
+
global key_manager, repo_manager, _repo_synced
|
| 995 |
+
|
| 996 |
+
logger.info("=" * 60)
|
| 997 |
+
logger.info("Read Agent 初始化中...")
|
| 998 |
+
|
| 999 |
+
# 初始化 API Key 管理器
|
| 1000 |
+
logger.info("初始化 API Key 管理器...")
|
| 1001 |
+
key_manager = get_key_manager()
|
| 1002 |
+
if key_manager and key_manager.has_keys:
|
| 1003 |
+
logger.info(f" ✓ 已加载 {key_manager.key_count} 个 API Key")
|
| 1004 |
+
else:
|
| 1005 |
+
logger.warning(" ⚠ 未配置 API Key")
|
| 1006 |
+
|
| 1007 |
+
# 初始化仓库管理器
|
| 1008 |
+
logger.info("初始化仓库管理器...")
|
| 1009 |
+
repo_manager = get_repo_manager()
|
| 1010 |
+
logger.info(f" ✓ 代码目录: {repo_manager.base_dir}")
|
| 1011 |
+
|
| 1012 |
+
# 预同步仓库(如果配置启用)
|
| 1013 |
+
sync_on_startup = get_env_bool("REPO_SYNC_ON_STARTUP", True)
|
| 1014 |
+
if sync_on_startup:
|
| 1015 |
+
logger.info("预同步仓库...")
|
| 1016 |
+
results = repo_manager.sync_all(parallel=True)
|
| 1017 |
+
success_count = len(results['success'])
|
| 1018 |
+
skipped_count = len(results.get('skipped', []))
|
| 1019 |
+
failed_count = len(results['failed'])
|
| 1020 |
+
logger.info(f" ✓ 同步完成: 成功 {success_count}, 跳过 {skipped_count}, 失败 {failed_count}")
|
| 1021 |
+
with _repo_sync_lock:
|
| 1022 |
+
_repo_synced = True
|
| 1023 |
+
else:
|
| 1024 |
+
logger.info(" ✓ 跳过仓库同步 (REPO_SYNC_ON_STARTUP=false)")
|
| 1025 |
+
|
| 1026 |
+
logger.info("=" * 60)
|
| 1027 |
+
logger.info("初始化完成,等待请求...")
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
if __name__ == '__main__':
|
| 1031 |
+
port = int(get_env("WEB_PORT", "7860"))
|
| 1032 |
+
debug = get_env_bool("DEBUG", False)
|
| 1033 |
+
|
| 1034 |
+
# 启动时预初始化
|
| 1035 |
+
initialize_app()
|
| 1036 |
+
|
| 1037 |
+
logger.info(f"启动 Read Agent Web 服务,端口: {port}")
|
| 1038 |
+
logger.info(f"MAX_STEPS={get_env('MAX_STEPS', '10')}")
|
| 1039 |
+
app.run(host='0.0.0.0', port=port, debug=debug)
|
config/popup.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"enabled": true,
|
| 3 |
+
"id": "welcome-popup",
|
| 4 |
+
"display": {
|
| 5 |
+
"delay": 500,
|
| 6 |
+
"autoCloseAfter": 0,
|
| 7 |
+
"closeOnOutsideClick": true,
|
| 8 |
+
"closeOnEscapeKey": true,
|
| 9 |
+
"showCloseButton": true
|
| 10 |
+
},
|
| 11 |
+
"content": {
|
| 12 |
+
"title": "友情链接",
|
| 13 |
+
"html": "<p>以下是一些优质的公益站点推荐:</p><ul style=\"list-style: none; padding: 0;\"><li style=\"margin-bottom: 10px;\"><a href=\"https://proxy.pieixan.icu/\" target=\"_blank\" style=\"color: var(--primary); text-decoration: none; font-weight: 500;\">🔗 piexian的公益站</a></li><li style=\"margin-bottom: 10px;\"><a href=\"https://91vip.futureppo.top/\" target=\"_blank\" style=\"color: var(--primary); text-decoration: none; font-weight: 500;\">🔗 futureppo的公益站</a></li><li style=\"margin-bottom: 10px;\"><a href=\"https://ai.amethyst.ltd/\" target=\"_blank\" style=\"color: var(--primary); text-decoration: none; font-weight: 500;\">🔗 amethyst的公益站</a></li></ul>",
|
| 14 |
+
"icon": "fas fa-link"
|
| 15 |
+
},
|
| 16 |
+
"buttons": [
|
| 17 |
+
{
|
| 18 |
+
"text": "我知道了",
|
| 19 |
+
"type": "primary",
|
| 20 |
+
"action": "close"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"text": "不再显示",
|
| 24 |
+
"type": "secondary",
|
| 25 |
+
"action": "dismiss"
|
| 26 |
+
}
|
| 27 |
+
],
|
| 28 |
+
"storage": {
|
| 29 |
+
"type": "localStorage",
|
| 30 |
+
"key": "popup_dismissed",
|
| 31 |
+
"expiresIn": 0
|
| 32 |
+
},
|
| 33 |
+
"showRules": {
|
| 34 |
+
"maxShows": 0,
|
| 35 |
+
"frequency": "once"
|
| 36 |
+
}
|
| 37 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read Agent Docker Compose 配置
|
| 2 |
+
version: '3.8'
|
| 3 |
+
|
| 4 |
+
services:
|
| 5 |
+
read-agent:
|
| 6 |
+
build: .
|
| 7 |
+
container_name: read-agent-web
|
| 8 |
+
ports:
|
| 9 |
+
- "${WEB_PORT:-8080}:8080"
|
| 10 |
+
environment:
|
| 11 |
+
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
| 12 |
+
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
| 13 |
+
- OPENAI_MODEL=${OPENAI_MODEL}
|
| 14 |
+
- CODE_DIR=${CODE_DIR:-./code}
|
| 15 |
+
- MAX_STEPS=${MAX_STEPS:-10}
|
| 16 |
+
- STREAM_OUTPUT=${STREAM_OUTPUT:-true}
|
| 17 |
+
- DEBUG=${DEBUG:-false}
|
| 18 |
+
volumes:
|
| 19 |
+
- ${CODE_DIR:-./code}:/app/code
|
| 20 |
+
restart: unless-stopped
|
| 21 |
+
healthcheck:
|
| 22 |
+
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
| 23 |
+
interval: 30s
|
| 24 |
+
timeout: 10s
|
| 25 |
+
retries: 3
|
main.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
代码阅读智能助手
|
| 4 |
+
|
| 5 |
+
使用方法:
|
| 6 |
+
python main.py
|
| 7 |
+
python main.py --code-dir /path/to/code
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import argparse
|
| 13 |
+
import re
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from src.agent import ReadAgent
|
| 17 |
+
from src.api_key_manager import init_manager, ApiKeyManager
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_env_file(env_path: str = ".env") -> dict:
|
| 21 |
+
"""
|
| 22 |
+
使用标准库加载 .env 文件
|
| 23 |
+
"""
|
| 24 |
+
env_vars = {}
|
| 25 |
+
env_file = Path(env_path)
|
| 26 |
+
|
| 27 |
+
if env_file.exists():
|
| 28 |
+
with open(env_file, "r", encoding="utf-8") as f:
|
| 29 |
+
for line in f:
|
| 30 |
+
line = line.strip()
|
| 31 |
+
# 跳过注释和空行
|
| 32 |
+
if not line or line.startswith("#"):
|
| 33 |
+
continue
|
| 34 |
+
# 解析 KEY=VALUE 格式
|
| 35 |
+
match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)$', line)
|
| 36 |
+
if match:
|
| 37 |
+
key = match.group(1)
|
| 38 |
+
value = match.group(2).strip('"').strip("'")
|
| 39 |
+
env_vars[key] = value
|
| 40 |
+
|
| 41 |
+
return env_vars
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_env(key: str, default: str = "") -> str:
|
| 45 |
+
"""获取环境变量,优先使用系统环境变量,其次使用 .env 文件"""
|
| 46 |
+
value = os.getenv(key)
|
| 47 |
+
if value is not None:
|
| 48 |
+
return value
|
| 49 |
+
|
| 50 |
+
# 尝试从 .env 加载
|
| 51 |
+
if not hasattr(get_env, "_env_cache"):
|
| 52 |
+
get_env._env_cache = load_env_file()
|
| 53 |
+
|
| 54 |
+
return get_env._env_cache.get(key, default)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_env_bool(key: str, default: bool = False) -> bool:
|
| 58 |
+
"""获取布尔型环境变量"""
|
| 59 |
+
value = get_env(key, "").lower()
|
| 60 |
+
if value in ("true", "1", "yes", "on"):
|
| 61 |
+
return True
|
| 62 |
+
elif value in ("false", "0", "no", "off"):
|
| 63 |
+
return False
|
| 64 |
+
return default
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def parse_args():
|
| 68 |
+
"""解析命令行参数"""
|
| 69 |
+
parser = argparse.ArgumentParser(
|
| 70 |
+
description="Read Agent - 代码阅读智能助手",
|
| 71 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 72 |
+
epilog="""
|
| 73 |
+
示例:
|
| 74 |
+
python main.py
|
| 75 |
+
python main.py --code-dir /path/to/your/code
|
| 76 |
+
python main.py --api-key sk-xxx
|
| 77 |
+
"""
|
| 78 |
+
)
|
| 79 |
+
parser.add_argument(
|
| 80 |
+
"--code-dir", "-d",
|
| 81 |
+
default=get_env("CODE_DIR", "."),
|
| 82 |
+
help="代码目录路径 (默认: 当前目录)"
|
| 83 |
+
)
|
| 84 |
+
parser.add_argument(
|
| 85 |
+
"--api-key", "-k",
|
| 86 |
+
default=get_env("OPENAI_API_KEY", ""),
|
| 87 |
+
help="OpenAI API Key"
|
| 88 |
+
)
|
| 89 |
+
parser.add_argument(
|
| 90 |
+
"--base-url", "-b",
|
| 91 |
+
default=get_env("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
| 92 |
+
help="API 基础 URL"
|
| 93 |
+
)
|
| 94 |
+
parser.add_argument(
|
| 95 |
+
"--model", "-m",
|
| 96 |
+
default=get_env("OPENAI_MODEL", "gpt-4"),
|
| 97 |
+
help="模型名称"
|
| 98 |
+
)
|
| 99 |
+
parser.add_argument(
|
| 100 |
+
"--max-steps", "-s",
|
| 101 |
+
type=int,
|
| 102 |
+
default=int(get_env("MAX_STEPS", "10")),
|
| 103 |
+
help="最大步骤数"
|
| 104 |
+
)
|
| 105 |
+
parser.add_argument(
|
| 106 |
+
"--stream-output", "--stream",
|
| 107 |
+
action="store_true",
|
| 108 |
+
default=get_env_bool("STREAM_OUTPUT", True),
|
| 109 |
+
help="启用流式输出(每步实时显示)"
|
| 110 |
+
)
|
| 111 |
+
parser.add_argument(
|
| 112 |
+
"--no-stream",
|
| 113 |
+
action="store_true",
|
| 114 |
+
default=False,
|
| 115 |
+
help="禁用流式输出"
|
| 116 |
+
)
|
| 117 |
+
parser.add_argument(
|
| 118 |
+
"--tree-depth",
|
| 119 |
+
type=int,
|
| 120 |
+
default=int(get_env("TREE_DEPTH", "3")),
|
| 121 |
+
help="预加载目录树深度(默认: 3)"
|
| 122 |
+
)
|
| 123 |
+
parser.add_argument(
|
| 124 |
+
"--max-retries",
|
| 125 |
+
type=int,
|
| 126 |
+
default=int(get_env("MAX_RETRIES", "3")),
|
| 127 |
+
help="API 调用最大重试次数(默认: 3)"
|
| 128 |
+
)
|
| 129 |
+
parser.add_argument(
|
| 130 |
+
"--retry-delays",
|
| 131 |
+
type=str,
|
| 132 |
+
default=get_env("RETRY_DELAYS", "1,2,4"),
|
| 133 |
+
help="重试延迟策略,逗号分隔,单位:秒(默认: 1,2,4)"
|
| 134 |
+
)
|
| 135 |
+
return parser.parse_args()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def print_welcome():
|
| 139 |
+
"""打印欢迎信息"""
|
| 140 |
+
print("""
|
| 141 |
+
╔════════════════════════════════════════════════════════════╗
|
| 142 |
+
║ Read Agent v1.0.0 ║
|
| 143 |
+
╠════════════════════════════════════════════════════════════╣
|
| 144 |
+
║ 命令: ║
|
| 145 |
+
║ quit / exit / q - 退出 ║
|
| 146 |
+
║ clear - 清空对话历史 ║
|
| 147 |
+
║ status - 查看状态 ║
|
| 148 |
+
║ help - 显示帮助 ║
|
| 149 |
+
╚═══════════════════════════════════════════════════════════��╝
|
| 150 |
+
""")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def print_help():
|
| 154 |
+
"""打印帮助信息"""
|
| 155 |
+
print("""
|
| 156 |
+
可用命令:
|
| 157 |
+
quit / exit / q - 退出程序
|
| 158 |
+
clear - 清空对话历史和 Memory
|
| 159 |
+
status - 查看当前状态(对话轮数、Memory数量等)
|
| 160 |
+
help - 显示此帮助信息
|
| 161 |
+
|
| 162 |
+
示例问题:
|
| 163 |
+
🤔 这个项目是做什么的?
|
| 164 |
+
🤔 用户认证是如何实现的?
|
| 165 |
+
🤔 找到处理 API 请求的代码
|
| 166 |
+
🤔 这个函数的作用是什么?
|
| 167 |
+
🤔 数据库连接是怎么配置的?
|
| 168 |
+
""")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def main():
|
| 172 |
+
"""主函数"""
|
| 173 |
+
# 解析参数
|
| 174 |
+
args = parse_args()
|
| 175 |
+
|
| 176 |
+
# 初始化 API Key 管理器
|
| 177 |
+
api_keys = args.api_key or get_env("OPENAI_API_KEY", "")
|
| 178 |
+
if api_keys:
|
| 179 |
+
key_manager = init_manager(api_keys)
|
| 180 |
+
print(f"✅ API Key 管理器已初始化,共 {key_manager.key_count} 个 Key")
|
| 181 |
+
else:
|
| 182 |
+
print("❌ 错误: 请设置 OPENAI_API_KEY 环境变量或使用 --api-key 参数")
|
| 183 |
+
print("\n设置方式:")
|
| 184 |
+
print(" 方式1: echo 'OPENAI_API_KEY=your-api-key' > .env")
|
| 185 |
+
print(" 方式2: export OPENAI_API_KEY=your-api-key")
|
| 186 |
+
print(" 方式3: python main.py --api-key your-api-key")
|
| 187 |
+
print("\n支持多 key(逗号分隔):")
|
| 188 |
+
print(" OPENAI_API_KEY=key1,key2,key3")
|
| 189 |
+
print(" python main.py --api-key 'key1,key2,key3'")
|
| 190 |
+
sys.exit(1)
|
| 191 |
+
|
| 192 |
+
# 创建 Agent
|
| 193 |
+
stream_output = args.stream_output and not args.no_stream
|
| 194 |
+
# 解析重试延迟配置(支持浮点数)
|
| 195 |
+
retry_delays = [float(d.strip()) for d in args.retry_delays.split(",")] if args.retry_delays else [1, 2, 4]
|
| 196 |
+
|
| 197 |
+
agent = ReadAgent(
|
| 198 |
+
code_dir=args.code_dir,
|
| 199 |
+
base_url=args.base_url,
|
| 200 |
+
model=args.model,
|
| 201 |
+
max_steps=args.max_steps,
|
| 202 |
+
stream_output=stream_output,
|
| 203 |
+
tree_depth=args.tree_depth,
|
| 204 |
+
api_key_manager=key_manager,
|
| 205 |
+
max_retries=args.max_retries,
|
| 206 |
+
retry_delays=retry_delays
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# 打印欢迎信息
|
| 210 |
+
print_welcome()
|
| 211 |
+
print(f"📁 代码目录: {agent.searcher.root_dir}")
|
| 212 |
+
print(f"🤖 使用模型: {agent.model}")
|
| 213 |
+
print(f"📝 最大步骤: {agent.max_steps}")
|
| 214 |
+
print(f"🌳 目录树深度: {agent.tree_depth}")
|
| 215 |
+
print()
|
| 216 |
+
|
| 217 |
+
# 初始化提示
|
| 218 |
+
print("💡 输入问题开始对话,输入 help 查看帮助")
|
| 219 |
+
print()
|
| 220 |
+
|
| 221 |
+
# 主循环
|
| 222 |
+
while True:
|
| 223 |
+
try:
|
| 224 |
+
user_input = input("🤔 ").strip()
|
| 225 |
+
|
| 226 |
+
# 处理空输入
|
| 227 |
+
if not user_input:
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
# 处理命令
|
| 231 |
+
if user_input.lower() in ["quit", "exit", "q"]:
|
| 232 |
+
print("👋 再见!")
|
| 233 |
+
break
|
| 234 |
+
elif user_input.lower() == "clear":
|
| 235 |
+
agent.clear_history()
|
| 236 |
+
print("✅ 已清空对话历史和 Memory")
|
| 237 |
+
continue
|
| 238 |
+
elif user_input.lower() == "status":
|
| 239 |
+
stats = agent.get_stats()
|
| 240 |
+
print(f"\n📊 状态统计:")
|
| 241 |
+
print(f" 对话轮数: {stats['conversation_length']}")
|
| 242 |
+
print(f" Memory 数量: {stats['memory_count']}")
|
| 243 |
+
print(f" 总步骤数: {stats['total_steps']}")
|
| 244 |
+
print(f" 代码目录: {stats['code_dir']}")
|
| 245 |
+
continue
|
| 246 |
+
elif user_input.lower() == "help":
|
| 247 |
+
print_help()
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
# 处理问题
|
| 251 |
+
print()
|
| 252 |
+
response = agent.ask(user_input)
|
| 253 |
+
print(response)
|
| 254 |
+
|
| 255 |
+
except KeyboardInterrupt:
|
| 256 |
+
print("\n👋 再见!")
|
| 257 |
+
break
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"\n❌ 错误: {e}")
|
| 260 |
+
print("💡 提示: 检查 API Key 和网络连接")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
if __name__ == "__main__":
|
| 264 |
+
main()
|
plan.md
ADDED
|
File without changes
|
prompts.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
提示词配置 - 包含系统提示词和 Memory 提示词
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import List, Dict
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_react_format_prompt() -> str:
|
| 10 |
+
"""获取 ReAct 格式说明 prompt(JSON 格式)"""
|
| 11 |
+
return """你必须按照以下 JSON 格式输出(不要重复这些指令):
|
| 12 |
+
|
| 13 |
+
【格式一:单步执行】
|
| 14 |
+
需要读取单个文件或搜索代码时:
|
| 15 |
+
{"thought": "我需要读取 xxx 文件来了解 xxx", "action": {"tool": "read_file", "args": {"path": "xxx/xxx.py"}}}
|
| 16 |
+
|
| 17 |
+
【格式二:批量执行】
|
| 18 |
+
当你需要同时执行多个相关操作时(如读取多个相关文件):
|
| 19 |
+
{"thought": "我需要读取这几个文件来了解完整流程", "actions": [{"tool": "read_file", "args": {"path": "xxx/xxx.py"}}, {"tool": "read_file", "args": {"path": "yyy/yyy.py"}}]}
|
| 20 |
+
|
| 21 |
+
【何时使用批量执行】
|
| 22 |
+
✅ 适用场景:
|
| 23 |
+
- 读取多个相关文件(如同时看配置文件和对应的代码)
|
| 24 |
+
- 搜索多个相关关键词
|
| 25 |
+
- 查看同一目录下的多个文件
|
| 26 |
+
|
| 27 |
+
❌ 不适用场景:
|
| 28 |
+
- 需要根据前一个文件的结果决定下一步操作
|
| 29 |
+
- 不同操作之间有依赖关系
|
| 30 |
+
|
| 31 |
+
【已有足够信息回答时】
|
| 32 |
+
{"thought": "我已经收集到足够的信息来回答用户的问题", "final_answer": "这是具体的答案..."}
|
| 33 |
+
|
| 34 |
+
如果读取了文件,在 final_answer 后添加 memory:
|
| 35 |
+
{"thought": "...", "final_answer": "...", "memory": {"file": "文件路径", "overview": "一句话概述文件功能", "key_definitions": ["关键函数名1", "关键函数名2"], "core_logic": "核心逻辑简述", "dependencies": ["依赖模块1", "依赖模块2"], "needed_info": ""}}
|
| 36 |
+
|
| 37 |
+
重要规则:
|
| 38 |
+
1. 不要重复或解释这些格式指令
|
| 39 |
+
2. 直接执行,不要说"我将..."或"我打算..."
|
| 40 |
+
3. 使用工具:read_file, find_files, search_code, find_by_ext, list_dir, get_file_info
|
| 41 |
+
4. 批量 actions 中所有操作应该相互独立,不需要等待上一个结果
|
| 42 |
+
5. 必须输出合法的 JSON 格式"""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_system_prompt(tools_info: List[Dict], max_steps: int = 10, memories: str = "", dir_tree: str = "") -> str:
|
| 46 |
+
"""获取格式化后的系统提示词"""
|
| 47 |
+
tools_json = json.dumps(tools_info, ensure_ascii=False, indent=2)
|
| 48 |
+
return f"""## 角色与背景
|
| 49 |
+
你是一个 astrbot 的技术支持助手,专门帮助新手解决问题。你的目标是高效、准确地提供解决方案。
|
| 50 |
+
|
| 51 |
+
## 核心指令
|
| 52 |
+
1. **时刻记得你的对象是新手**
|
| 53 |
+
2. **参考文档**:你的所有回答都必须严格基于提供的文档知识(不是丢一个文档链接让用户看),不编造不幻觉
|
| 54 |
+
3. **优先Web方案**:尽可能引导用户通过Web管理界面解决问题,因为这对新手更友好。只有当Web界面无法操作时,才提及配置文件或命令
|
| 55 |
+
4. **追问信息**:如果用户信息不足以判断问题,主动索要关键信息(如日志、配置文件、报错截图描述)
|
| 56 |
+
5. **保持质疑**:如果用户的前提不明确或听起来有问题,可以提出质疑。例如,用户说"命令没用",你可以问"你输入的完整命令是什么?"
|
| 57 |
+
6. **请尽可能建议简单的方案**:如win一键部署等简单方案
|
| 58 |
+
7. **你的能力限制**:你只有对话能力,无法执行命令、安装软件、操作文件系统等
|
| 59 |
+
8. **输出规范**:不要输出混乱的文字,保持回答简洁清晰
|
| 60 |
+
9. **你看到的不能代表用户环境**:你所看到的内容使现场从github拉取下来的,不能够代表用户环境
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## 搜索规划阶段
|
| 65 |
+
|
| 66 |
+
**重要:在执行任何文件搜索之前,你必须先构建信息需求树。**
|
| 67 |
+
|
| 68 |
+
### 需求拆解
|
| 69 |
+
把用户问题拆解成 5 个核心子问题:
|
| 70 |
+
- 子问题1:用户具体想知道什么?
|
| 71 |
+
- 子问题2:需要什么上下文才能回答?
|
| 72 |
+
- 子问题3:有哪些边缘情况需要考虑?
|
| 73 |
+
- 子问题4:依赖哪些其他部分?
|
| 74 |
+
- 子问题5:可能缺失什么信息?
|
| 75 |
+
|
| 76 |
+
### 术语映射
|
| 77 |
+
列出将帮助搜索的技术术语:
|
| 78 |
+
- 术语1:可能的函数名/类名
|
| 79 |
+
- 术语2:可能的配置键名
|
| 80 |
+
- 术语3:可能出现的错误信息
|
| 81 |
+
|
| 82 |
+
### 搜索策略
|
| 83 |
+
对每个子问题,列出 3 个具体的搜索方向:
|
| 84 |
+
- 子问题1的搜索方向:
|
| 85 |
+
* 先找文档说明(docs/、README.md)
|
| 86 |
+
* 再找配置模板(.env.example、config/)
|
| 87 |
+
* 最后找代码实现(src/)
|
| 88 |
+
|
| 89 |
+
### 执行顺序
|
| 90 |
+
按以下优先级执行搜索:
|
| 91 |
+
1. **文档优先** → docs/、README.md、CHANGELOG.md
|
| 92 |
+
2. **配置文件** → config/、.env.example、settings.py
|
| 93 |
+
3. **主入口** → main.py、app.py、index.js
|
| 94 |
+
4. **核心模块** → 具体实现文件
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 信息源优先级
|
| 99 |
+
|
| 100 |
+
当你在多个文件中找到相关信息时,按以下权重处理:
|
| 101 |
+
|
| 102 |
+
| 层级 | 类型 | 示例 | 权重 |
|
| 103 |
+
|------|------|------|------|
|
| 104 |
+
| Tier 1 | 官方文档 | README.md、docs/、CHANGELOG.md | ★★★★★ |
|
| 105 |
+
| Tier 2 | 配置文件 | .env.example、config/、settings.py | ★★★☆☆ |
|
| 106 |
+
| Tier 3 | 主代码 | main.py、app.py、核���模块 | ★★★☆☆ |
|
| 107 |
+
| Tier 4 | 辅助代码 | 工具函数、测试代码、utils/ | ★★☆☆☆ |
|
| 108 |
+
|
| 109 |
+
### 冲突解决规则
|
| 110 |
+
- 文档说 A,代码是 B → 明确指出差异,以代码为准,说明文档可能过时
|
| 111 |
+
- 文档说 A,配置默认是 B → 解释差异可能的原因(如版本变化)
|
| 112 |
+
- 多个代码文件不一致 → 追溯调用关系,找到最终执行的位置
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## 递归验证协议
|
| 117 |
+
|
| 118 |
+
当你找到信息后,必须执行以下验证:
|
| 119 |
+
|
| 120 |
+
### 交叉验证
|
| 121 |
+
- 文档说明了,检查代码实现是否一致
|
| 122 |
+
- 代码里这么写,检查测试是否覆盖
|
| 123 |
+
- 配置定义了,检查是否有实际使用
|
| 124 |
+
|
| 125 |
+
### 发现矛盾时
|
| 126 |
+
- 不要忽视矛盾,标记为"⚠️ 注意:XXX 与 XXX 不一致"
|
| 127 |
+
- 搜索是否有相关的 issue 或说明
|
| 128 |
+
- 根据实际代码行为给出结论,同时说明文档的差异
|
| 129 |
+
|
| 130 |
+
### 补充验证
|
| 131 |
+
- 如果只看到配置,去搜索实际使用场景
|
| 132 |
+
- 如果只看到函数调用,去搜索函数定义
|
| 133 |
+
- 如果只看到代码片段,搜索完整上下文
|
| 134 |
+
|
| 135 |
+
### 示例流程
|
| 136 |
+
**用户问**:"这个功能怎么用?"
|
| 137 |
+
|
| 138 |
+
正确的递归搜索:
|
| 139 |
+
1. 找到函数定义
|
| 140 |
+
2. 验证是否有文档说明 → 搜索 docs/
|
| 141 |
+
3. 验证是否有使用示例 → 搜索 tests/
|
| 142 |
+
4. 验证默认配置是什么 → 搜索 .env.example
|
| 143 |
+
5. 综合这些信息回答
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## 模糊信息处理流程
|
| 148 |
+
|
| 149 |
+
当用户描述不够具体时:
|
| 150 |
+
|
| 151 |
+
### 第一步:识别模糊点
|
| 152 |
+
- 用户说"报错了",但没有说错误消息
|
| 153 |
+
- 用户说"不工作",没有说具体现象
|
| 154 |
+
- 用户说"配置怎么改",没有说哪个配置
|
| 155 |
+
|
| 156 |
+
### 第二步:主动澄清
|
| 157 |
+
在 Final Answer 中明确询问:
|
| 158 |
+
```
|
| 159 |
+
为了准确回答您的问题,我需要更多信息:
|
| 160 |
+
|
| 161 |
+
1. 错误发生在哪个阶段?
|
| 162 |
+
- [ ] Docker 启动
|
| 163 |
+
- [ ] 依赖安装
|
| 164 |
+
- [ ] 数据库连接
|
| 165 |
+
- [ ] 服务启动
|
| 166 |
+
|
| 167 |
+
2. 您看到的错误消息是什么?(请复制粘贴完整错误)
|
| 168 |
+
|
| 169 |
+
3. 您使用的是什么部署方式?
|
| 170 |
+
- [ ] docker-compose
|
| 171 |
+
- [ ] Docker
|
| 172 |
+
- [ ] 直接运行
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### 第三步:模糊搜索(如果用户无法提供)
|
| 176 |
+
- 用关键词模糊搜索相关文件
|
| 177 |
+
- 列出可能匹配的多个选项
|
| 178 |
+
- 让用户确认哪个是正确的
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
## 综合输出格式
|
| 183 |
+
|
| 184 |
+
当你从多个文件收集到信息后,按以下结构输出:
|
| 185 |
+
|
| 186 |
+
### 核心答案
|
| 187 |
+
直接回答用户的问题(1-2段话)
|
| 188 |
+
|
| 189 |
+
### 详细说明
|
| 190 |
+
| 来源 | 内容 | 可信度 |
|
| 191 |
+
|------|------|--------|
|
| 192 |
+
| docs/guide.md | 官方说明... | 高 |
|
| 193 |
+
| config/defaults.py | 默认配置... | 高 |
|
| 194 |
+
| src/core.py | 代码实现... | 中 |
|
| 195 |
+
|
| 196 |
+
### 注意事项
|
| 197 |
+
- ⚠️ 注意事项1
|
| 198 |
+
- ⚠️ 注意事项2
|
| 199 |
+
|
| 200 |
+
### 相关文件
|
| 201 |
+
已查看的文件:
|
| 202 |
+
- docs/guide.md:12-18
|
| 203 |
+
- config/defaults.py:45-50
|
| 204 |
+
- src/core.py:123-145
|
| 205 |
+
|
| 206 |
+
### 后续建议
|
| 207 |
+
如果问题未完全解决:
|
| 208 |
+
- 建议1:检查...
|
| 209 |
+
- 建议2:尝试...
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## 工作流程
|
| 214 |
+
1. 构建信息需求树(**必须步骤**)
|
| 215 |
+
2. 按优先级执行搜索(文档 → 配置 → 代码)
|
| 216 |
+
3. 交叉验证信息(文档vs代码、测试vs实现)
|
| 217 |
+
4. 观察结果
|
| 218 |
+
5. 如果读取了文件,请在 <final_answer> 后添加该文件的 <memory>
|
| 219 |
+
6. 后续步骤使用 Memory 替代原文,避免上下文膨胀
|
| 220 |
+
7. 综合输出答案
|
| 221 |
+
|
| 222 |
+
## 可用工具
|
| 223 |
+
{tools_json}
|
| 224 |
+
|
| 225 |
+
## 输出前的自我检查
|
| 226 |
+
|
| 227 |
+
在给出 Final Answer 前,问自己:
|
| 228 |
+
|
| 229 |
+
### 准确性检查
|
| 230 |
+
- [ ] 我的回答直接解决了用户的问题吗?
|
| 231 |
+
- [ ] 我引用的代码行号准确吗?
|
| 232 |
+
- [ ] 我说的配置项确实存在吗?
|
| 233 |
+
|
| 234 |
+
### 完整性检查
|
| 235 |
+
- [ ] 有没有遗漏重要的前置条件?
|
| 236 |
+
- [ ] 有没有忽略可能的失败原因?
|
| 237 |
+
- [ ] 有没有说明依赖关系?
|
| 238 |
+
|
| 239 |
+
### 一致性检查
|
| 240 |
+
- [ ] 文档、代码、配置之间有冲突吗?
|
| 241 |
+
- [ ] 如果有冲突,我明确指出了吗?
|
| 242 |
+
|
| 243 |
+
### 实用性检查
|
| 244 |
+
- [ ] 如果我是用户,看了这个回答能解决问题吗?
|
| 245 |
+
- [ ] 我提供的信息是当前代码库的实际状态吗?
|
| 246 |
+
|
| 247 |
+
如果有不确定的地方,用 ⚠️ 明确标注。
|
| 248 |
+
|
| 249 |
+
## 重要规则
|
| 250 |
+
- **必须先构建信息需求树**,再开始搜索
|
| 251 |
+
- 只在当前步骤使用读取的文件原文进行分析
|
| 252 |
+
- 分析完成后在 Final Answer 中生成 Memory(包含文件概述、关键定义、核心逻辑、依赖关系、待验证信息)
|
| 253 |
+
- 不要额外调用 LLM 提取 Memory
|
| 254 |
+
- 后续步骤使用 Memory 替代原文
|
| 255 |
+
- 最多使用 {max_steps} 步完成一个问题
|
| 256 |
+
- 始终用中文回答
|
| 257 |
+
- 回答要简洁明了,优先给出具体步骤
|
| 258 |
+
{memories}{dir_tree}"""
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def get_answer_prompt(question: str, memories: str, file_contents: str) -> str:
|
| 262 |
+
"""获取格式化后的回答提示词"""
|
| 263 |
+
return f"""基于以下信息回答用户的问题:
|
| 264 |
+
|
| 265 |
+
问题:{question}
|
| 266 |
+
|
| 267 |
+
已收集的 Memory:
|
| 268 |
+
{memories}
|
| 269 |
+
|
| 270 |
+
原始文件内容:
|
| 271 |
+
{file_contents}
|
| 272 |
+
|
| 273 |
+
请用清晰、简洁的中文回答问题。如果信息不足,请明确说明需要进一步了解什么。"""
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read Agent Web 依赖
|
| 2 |
+
flask>=2.3.0
|
| 3 |
+
python-dotenv>=1.0.0
|
| 4 |
+
gunicorn>=21.0.0
|
| 5 |
+
|
| 6 |
+
# 测试依赖
|
| 7 |
+
pytest>=7.4.0
|
| 8 |
+
pytest-cov>=4.1.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Read Agent - 纯 Python 标准库实现的代码阅读智能助手
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
__version__ = "1.0.0"
|
src/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (245 Bytes). View file
|
|
|
src/__pycache__/agent.cpython-310.pyc
ADDED
|
Binary file (19.2 kB). View file
|
|
|
src/__pycache__/api_key_manager.cpython-310.pyc
ADDED
|
Binary file (5.67 kB). View file
|
|
|
src/__pycache__/index.cpython-310.pyc
ADDED
|
Binary file (6.89 kB). View file
|
|
|
src/__pycache__/repo_manager.cpython-310.pyc
ADDED
|
Binary file (8.28 kB). View file
|
|
|
src/__pycache__/searcher.cpython-310.pyc
ADDED
|
Binary file (7.09 kB). View file
|
|
|
src/agent.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent 核心逻辑 - 实现 ReAct 模式和 Memory 机制
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from typing import List, Dict, Optional, Any, Callable
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from src.searcher import CodeSearcher
|
| 13 |
+
from src.api_key_manager import ApiKeyManager
|
| 14 |
+
from prompts import get_system_prompt, get_react_format_prompt
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class Memory:
|
| 19 |
+
"""记忆结构"""
|
| 20 |
+
file_path: str
|
| 21 |
+
overview: str = ""
|
| 22 |
+
key_definitions: List[str] = field(default_factory=list)
|
| 23 |
+
core_logic: str = ""
|
| 24 |
+
dependencies: List[str] = field(default_factory=list)
|
| 25 |
+
needed_info: str = ""
|
| 26 |
+
|
| 27 |
+
def to_dict(self) -> Dict:
|
| 28 |
+
return {
|
| 29 |
+
"file": self.file_path,
|
| 30 |
+
"overview": self.overview,
|
| 31 |
+
"key_definitions": self.key_definitions,
|
| 32 |
+
"core_logic": self.core_logic,
|
| 33 |
+
"dependencies": self.dependencies,
|
| 34 |
+
"needed_info": self.needed_info
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def to_string(self) -> str:
|
| 38 |
+
parts = [f"📄 {self.file_path}"]
|
| 39 |
+
if self.overview:
|
| 40 |
+
parts.append(f"概述: {self.overview}")
|
| 41 |
+
if self.key_definitions:
|
| 42 |
+
parts.append(f"关键定义: {'; '.join(self.key_definitions)}")
|
| 43 |
+
if self.core_logic:
|
| 44 |
+
parts.append(f"核心逻辑: {self.core_logic}")
|
| 45 |
+
if self.dependencies:
|
| 46 |
+
parts.append(f"依赖: {' -> '.join(self.dependencies)}")
|
| 47 |
+
if self.needed_info:
|
| 48 |
+
parts.append(f"待验证: {self.needed_info}")
|
| 49 |
+
return "\n".join(parts)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ToolExecutor:
|
| 53 |
+
"""工具执行器"""
|
| 54 |
+
|
| 55 |
+
def __init__(self, searcher: CodeSearcher):
|
| 56 |
+
self.searcher = searcher
|
| 57 |
+
self._tool_registry: Dict[str, Callable] = {}
|
| 58 |
+
|
| 59 |
+
def register_tools(self):
|
| 60 |
+
"""注册可用工具"""
|
| 61 |
+
self._tool_registry = {
|
| 62 |
+
"read_file": self._read_file,
|
| 63 |
+
"find_files": self._find_files,
|
| 64 |
+
"search_code": self._search_code,
|
| 65 |
+
"find_by_ext": self._find_by_ext,
|
| 66 |
+
"list_dir": self._list_dir,
|
| 67 |
+
"get_file_info": self._get_file_info,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
def execute_tool(self, tool_name: str, **kwargs) -> Dict:
|
| 71 |
+
"""执行工具"""
|
| 72 |
+
if tool_name not in self._tool_registry:
|
| 73 |
+
return {"error": f"未知工具: {tool_name}"}
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
result = self._tool_registry[tool_name](**kwargs)
|
| 77 |
+
return {"success": True, "tool": tool_name, "result": result}
|
| 78 |
+
except Exception as e:
|
| 79 |
+
return {"success": False, "tool": tool_name, "error": str(e)}
|
| 80 |
+
|
| 81 |
+
def _read_file(self, path: str, max_lines: int = 500, start_line: int = 1) -> Dict:
|
| 82 |
+
return self.searcher.read_file(path, max_lines, start_line)
|
| 83 |
+
|
| 84 |
+
def _find_files(self, pattern: str = "*", path: str = ".", max_results: int = 20) -> List[str]:
|
| 85 |
+
return self.searcher.find_files(pattern, path, max_results)
|
| 86 |
+
|
| 87 |
+
def _search_code(self, keyword: str, extensions: str = "*", max_results: int = 20) -> List[Dict]:
|
| 88 |
+
return self.searcher.search_code(keyword, extensions, max_results)
|
| 89 |
+
|
| 90 |
+
def _find_by_ext(self, extensions: str = "py", max_results: int = 20) -> List[str]:
|
| 91 |
+
return self.searcher.find_by_ext(extensions, max_results)
|
| 92 |
+
|
| 93 |
+
def _list_dir(self, path: str = ".") -> Dict:
|
| 94 |
+
return self.searcher.list_dir(path)
|
| 95 |
+
|
| 96 |
+
def _get_file_info(self, path: str) -> Dict:
|
| 97 |
+
return self.searcher.get_file_info(path)
|
| 98 |
+
|
| 99 |
+
def get_available_tools(self) -> List[Dict]:
|
| 100 |
+
"""获取可用工具列表"""
|
| 101 |
+
return [
|
| 102 |
+
{
|
| 103 |
+
"name": "read_file",
|
| 104 |
+
"description": "读取文件内容",
|
| 105 |
+
"params": {
|
| 106 |
+
"path": {"type": "string", "description": "文件路径"},
|
| 107 |
+
"max_lines": {"type": "integer", "description": "最大行数", "default": 500},
|
| 108 |
+
"start_line": {"type": "integer", "description": "起始行号", "default": 1}
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"name": "find_files",
|
| 113 |
+
"description": "按文件名模式查找文件",
|
| 114 |
+
"params": {
|
| 115 |
+
"pattern": {"type": "string", "description": "文件名模式,如 *.py"},
|
| 116 |
+
"max_results": {"type": "integer", "description": "最大结果数", "default": 20}
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"name": "search_code",
|
| 121 |
+
"description": "搜索代码内容",
|
| 122 |
+
"params": {
|
| 123 |
+
"keyword": {"type": "string", "description": "搜索关键词"},
|
| 124 |
+
"extensions": {"type": "string", "description": "文件扩展名", "default": "*"},
|
| 125 |
+
"max_results": {"type": "integer", "description": "最大结果数", "default": 20}
|
| 126 |
+
}
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"name": "find_by_ext",
|
| 130 |
+
"description": "按扩展名查找文件",
|
| 131 |
+
"params": {
|
| 132 |
+
"extensions": {"type": "string", "description": "扩展名,如 py,js"},
|
| 133 |
+
"max_results": {"type": "integer", "description": "最大结果数", "default": 20}
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"name": "list_dir",
|
| 138 |
+
"description": "列出目录内容",
|
| 139 |
+
"params": {
|
| 140 |
+
"path": {"type": "string", "description": "目录路径", "default": "."}
|
| 141 |
+
}
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"name": "get_file_info",
|
| 145 |
+
"description": "获取文件信息",
|
| 146 |
+
"params": {
|
| 147 |
+
"path": {"type": "string", "description": "文件路径"}
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class ReadAgent:
|
| 154 |
+
"""Read Agent 主类"""
|
| 155 |
+
|
| 156 |
+
def __init__(
|
| 157 |
+
self,
|
| 158 |
+
code_dir: str = "./repos",
|
| 159 |
+
api_key: Optional[str] = None,
|
| 160 |
+
base_url: Optional[str] = None,
|
| 161 |
+
model: str = "gpt-4",
|
| 162 |
+
max_steps: int = 10,
|
| 163 |
+
stream_output: bool = True,
|
| 164 |
+
tree_depth: int = 3,
|
| 165 |
+
api_key_manager=None,
|
| 166 |
+
max_retries: int = None,
|
| 167 |
+
retry_delays: list = None
|
| 168 |
+
):
|
| 169 |
+
self.searcher = CodeSearcher(code_dir, use_index=True, lazy_index=True)
|
| 170 |
+
self.tool_executor = ToolExecutor(self.searcher)
|
| 171 |
+
self.tool_executor.register_tools()
|
| 172 |
+
|
| 173 |
+
self.base_url = base_url or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 174 |
+
self.model = model or os.getenv("OPENAI_MODEL", "gpt-4")
|
| 175 |
+
self.max_steps = max_steps
|
| 176 |
+
self.stream_output = stream_output
|
| 177 |
+
self.tree_depth = tree_depth
|
| 178 |
+
|
| 179 |
+
# 重试配置
|
| 180 |
+
self.max_retries = max_retries or int(os.getenv("MAX_RETRIES", "3"))
|
| 181 |
+
self.retry_delays = retry_delays or [float(d.strip()) for d in os.getenv("RETRY_DELAYS", "1,2,4").split(",")]
|
| 182 |
+
|
| 183 |
+
# API Key 管理器(支持多 key 随机选择)
|
| 184 |
+
self.api_key_manager = api_key_manager
|
| 185 |
+
if self.api_key_manager is None:
|
| 186 |
+
# 如果没有提供 ApiKeyManager,创建一个单 key 的
|
| 187 |
+
key = api_key or os.getenv("OPENAI_API_KEY")
|
| 188 |
+
if key:
|
| 189 |
+
from src.api_key_manager import ApiKeyManager
|
| 190 |
+
self.api_key_manager = ApiKeyManager(key)
|
| 191 |
+
else:
|
| 192 |
+
self.api_key_manager = None
|
| 193 |
+
self.api_key = None
|
| 194 |
+
|
| 195 |
+
self.conversation_history: List[Dict] = []
|
| 196 |
+
self.memories: List[Memory] = []
|
| 197 |
+
self.steps: List[Dict] = []
|
| 198 |
+
|
| 199 |
+
# 预加载目录树(延迟化,需要时才生成)
|
| 200 |
+
self._dir_tree_cached = None
|
| 201 |
+
self.tree_depth = tree_depth
|
| 202 |
+
|
| 203 |
+
def _extract_thought_action(self, response: str) -> tuple:
|
| 204 |
+
"""从响应中提取 Thought 和 Action(JSON 格式)
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
(thought, actions_list) 其中 actions_list 是 [(action_name, args_dict), ...]
|
| 208 |
+
如果是单个 action,actions_list 长度为 1
|
| 209 |
+
如果是批量 actions,actions_list 长度 > 1
|
| 210 |
+
如果没有 action,actions_list 为空列表
|
| 211 |
+
"""
|
| 212 |
+
import logging
|
| 213 |
+
logger = logging.getLogger(__name__)
|
| 214 |
+
|
| 215 |
+
thought = ""
|
| 216 |
+
actions_list = []
|
| 217 |
+
|
| 218 |
+
# 检查响应是否为空
|
| 219 |
+
if not response or not response.strip():
|
| 220 |
+
logger.warning("[_extract_thought_action] LLM 返回空响应")
|
| 221 |
+
return thought, actions_list
|
| 222 |
+
|
| 223 |
+
# 尝试提取 JSON 块
|
| 224 |
+
json_match = re.search(r'\{.*\}', response, re.DOTALL)
|
| 225 |
+
if not json_match:
|
| 226 |
+
logger.warning("[_extract_thought_action] 未能找到 JSON 格式")
|
| 227 |
+
return thought, actions_list
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
data = json.loads(json_match.group())
|
| 231 |
+
except json.JSONDecodeError as e:
|
| 232 |
+
logger.warning(f"[_extract_thought_action] JSON 解析失败: {e}")
|
| 233 |
+
return thought, actions_list
|
| 234 |
+
|
| 235 |
+
# 提取 thought
|
| 236 |
+
thought = data.get("thought", "")
|
| 237 |
+
if len(thought) > 5000:
|
| 238 |
+
logger.warning(f"[_extract_thought_action] Thought 过长 ({len(thought)} chars),截断到 5000")
|
| 239 |
+
thought = thought[:5000]
|
| 240 |
+
|
| 241 |
+
valid_tools = set(self.tool_executor._tool_registry.keys())
|
| 242 |
+
|
| 243 |
+
# 检查批量 actions
|
| 244 |
+
if "actions" in data and isinstance(data["actions"], list):
|
| 245 |
+
for action_item in data["actions"]:
|
| 246 |
+
tool = action_item.get("tool")
|
| 247 |
+
args = action_item.get("args", {})
|
| 248 |
+
if tool and tool in valid_tools:
|
| 249 |
+
actions_list.append((tool, args))
|
| 250 |
+
elif tool:
|
| 251 |
+
logger.warning(f"[_extract_thought_action] 未知的 Action: '{tool}'")
|
| 252 |
+
logger.debug(f"[_extract_thought_action] 批量提取了 {len(actions_list)} 个 Actions")
|
| 253 |
+
# 检查单个 action
|
| 254 |
+
elif "action" in data:
|
| 255 |
+
action_item = data.get("action", {})
|
| 256 |
+
tool = action_item.get("tool")
|
| 257 |
+
args = action_item.get("args", {})
|
| 258 |
+
if tool and tool in valid_tools:
|
| 259 |
+
actions_list.append((tool, args))
|
| 260 |
+
elif tool:
|
| 261 |
+
logger.warning(f"[_extract_thought_action] 未知的 Action: '{tool}'")
|
| 262 |
+
|
| 263 |
+
return thought, actions_list
|
| 264 |
+
|
| 265 |
+
def _extract_final_answer(self, response: str) -> tuple:
|
| 266 |
+
"""提取最终答案和 Memory(JSON 格式)"""
|
| 267 |
+
import logging
|
| 268 |
+
logger = logging.getLogger(__name__)
|
| 269 |
+
|
| 270 |
+
answer = ""
|
| 271 |
+
memory_data = None
|
| 272 |
+
|
| 273 |
+
# 检查响应是否为空
|
| 274 |
+
if not response or not response.strip():
|
| 275 |
+
logger.warning("[_extract_final_answer] 响应为空")
|
| 276 |
+
return answer, memory_data
|
| 277 |
+
|
| 278 |
+
# 尝试提取 JSON 块
|
| 279 |
+
json_match = re.search(r'\{.*\}', response, re.DOTALL)
|
| 280 |
+
if not json_match:
|
| 281 |
+
logger.warning("[_extract_final_answer] 未能找到 JSON 格式")
|
| 282 |
+
return answer, memory_data
|
| 283 |
+
|
| 284 |
+
try:
|
| 285 |
+
data = json.loads(json_match.group())
|
| 286 |
+
except json.JSONDecodeError as e:
|
| 287 |
+
logger.warning(f"[_extract_final_answer] JSON 解析失败: {e}")
|
| 288 |
+
return answer, memory_data
|
| 289 |
+
|
| 290 |
+
# 提取 final_answer
|
| 291 |
+
if "final_answer" in data:
|
| 292 |
+
answer = data.get("final_answer", "")
|
| 293 |
+
if len(answer) > 10000:
|
| 294 |
+
logger.warning(f"[_extract_final_answer] Final Answer 过长 ({len(answer)} chars),截断到 10000")
|
| 295 |
+
answer = answer[:10000]
|
| 296 |
+
|
| 297 |
+
# 提取 memory
|
| 298 |
+
if "memory" in data:
|
| 299 |
+
memory = data.get("memory", {})
|
| 300 |
+
if "file" in memory:
|
| 301 |
+
memory_data = {
|
| 302 |
+
"file": memory.get("file", ""),
|
| 303 |
+
"overview": memory.get("overview", ""),
|
| 304 |
+
"key_definitions": memory.get("key_definitions", []),
|
| 305 |
+
"core_logic": memory.get("core_logic", ""),
|
| 306 |
+
"dependencies": memory.get("dependencies", []),
|
| 307 |
+
"needed_info": memory.get("needed_info", "")
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
return answer, memory_data
|
| 311 |
+
|
| 312 |
+
def _call_llm(self, messages: List[Dict]) -> str:
|
| 313 |
+
"""调用 LLM API(支持流式输出和自动重试)"""
|
| 314 |
+
import urllib.request
|
| 315 |
+
import urllib.error
|
| 316 |
+
import time
|
| 317 |
+
|
| 318 |
+
# 使用实例的重试配置
|
| 319 |
+
max_retries = self.max_retries
|
| 320 |
+
retry_delays = self.retry_delays
|
| 321 |
+
|
| 322 |
+
# 可重试的 HTTP 状态码
|
| 323 |
+
retryable_status_codes = [401, 429, 500, 502, 503, 504]
|
| 324 |
+
|
| 325 |
+
for attempt in range(max_retries):
|
| 326 |
+
# 从管理器获取 API key(轮询)
|
| 327 |
+
api_key = None
|
| 328 |
+
if self.api_key_manager:
|
| 329 |
+
api_key = self.api_key_manager.get_key()
|
| 330 |
+
else:
|
| 331 |
+
api_key = self.api_key
|
| 332 |
+
|
| 333 |
+
if not api_key:
|
| 334 |
+
raise Exception("未配置 API Key")
|
| 335 |
+
|
| 336 |
+
headers = {
|
| 337 |
+
"Content-Type": "application/json",
|
| 338 |
+
"Authorization": f"Bearer {api_key}"
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
data = {
|
| 342 |
+
"model": self.model,
|
| 343 |
+
"messages": messages,
|
| 344 |
+
"temperature": 0.3,
|
| 345 |
+
"stream": True # 启用流式输出
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
full_content = ""
|
| 349 |
+
try:
|
| 350 |
+
req = urllib.request.Request(
|
| 351 |
+
f"{self.base_url}/chat/completions",
|
| 352 |
+
headers=headers,
|
| 353 |
+
data=json.dumps(data).encode("utf-8"),
|
| 354 |
+
method="POST"
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
with urllib.request.urlopen(req, timeout=60) as response:
|
| 358 |
+
for line in response:
|
| 359 |
+
line = line.decode("utf-8").strip()
|
| 360 |
+
if not line.startswith("data: "):
|
| 361 |
+
continue
|
| 362 |
+
if line == "data: [DONE]":
|
| 363 |
+
break
|
| 364 |
+
|
| 365 |
+
data_str = line[6:] # 移除 "data: " 前缀
|
| 366 |
+
try:
|
| 367 |
+
chunk = json.loads(data_str)
|
| 368 |
+
if chunk.get("choices") and len(chunk["choices"]) > 0:
|
| 369 |
+
delta = chunk["choices"][0].get("delta", {})
|
| 370 |
+
content = delta.get("content", "")
|
| 371 |
+
if content:
|
| 372 |
+
# 流式输出思考内容
|
| 373 |
+
if self.stream_output:
|
| 374 |
+
print(content, end="", flush=True)
|
| 375 |
+
full_content += content
|
| 376 |
+
except json.JSONDecodeError:
|
| 377 |
+
continue
|
| 378 |
+
|
| 379 |
+
# 流式输出完成后换行
|
| 380 |
+
if self.stream_output:
|
| 381 |
+
print()
|
| 382 |
+
|
| 383 |
+
# 记录成功
|
| 384 |
+
if self.api_key_manager:
|
| 385 |
+
self.api_key_manager.record_success(api_key)
|
| 386 |
+
|
| 387 |
+
return full_content
|
| 388 |
+
|
| 389 |
+
except urllib.error.HTTPError as e:
|
| 390 |
+
error_body = e.read().decode("utf-8") if e.fp else ""
|
| 391 |
+
|
| 392 |
+
# 检查是否可重试
|
| 393 |
+
if e.code in retryable_status_codes and attempt < max_retries - 1:
|
| 394 |
+
# 记录失败但不立即抛出
|
| 395 |
+
if self.api_key_manager:
|
| 396 |
+
self.api_key_manager.record_error(api_key, f"HTTP {e.code}: {error_body} (重试 {attempt + 1}/{max_retries})")
|
| 397 |
+
|
| 398 |
+
# 获取延迟(如果超出数组长度,使用最后一个值)
|
| 399 |
+
delay = retry_delays[min(attempt, len(retry_delays) - 1)]
|
| 400 |
+
if self.stream_output:
|
| 401 |
+
print(f"\n\n⏳ API 返回 {e.code},{delay} 秒后重试... ({attempt + 1}/{max_retries})", flush=True)
|
| 402 |
+
else:
|
| 403 |
+
import logging
|
| 404 |
+
logging.getLogger(__name__).warning(f"API 返回 {e.code},{delay} 秒后重试... ({attempt + 1}/{max_retries})")
|
| 405 |
+
|
| 406 |
+
time.sleep(delay)
|
| 407 |
+
continue
|
| 408 |
+
else:
|
| 409 |
+
# 最后一次重试或不可重试的错误
|
| 410 |
+
if self.api_key_manager:
|
| 411 |
+
self.api_key_manager.record_error(api_key, f"HTTP {e.code}: {error_body}")
|
| 412 |
+
raise Exception(f"API 错误: {e.code} - {error_body}")
|
| 413 |
+
|
| 414 |
+
except urllib.error.URLError as e:
|
| 415 |
+
# 网络错误,可重试
|
| 416 |
+
if attempt < max_retries - 1:
|
| 417 |
+
if self.api_key_manager:
|
| 418 |
+
self.api_key_manager.record_error(api_key, f"网络错误: {str(e)} (重试 {attempt + 1}/{max_retries})")
|
| 419 |
+
|
| 420 |
+
# 获取延迟(如果超出数组长度,使用最后一个值)
|
| 421 |
+
delay = retry_delays[min(attempt, len(retry_delays) - 1)]
|
| 422 |
+
if self.stream_output:
|
| 423 |
+
print(f"\n\n⏳ 网络错误,{delay} 秒后重试... ({attempt + 1}/{max_retries})", flush=True)
|
| 424 |
+
else:
|
| 425 |
+
import logging
|
| 426 |
+
logging.getLogger(__name__).warning(f"网络错误,{delay} 秒后重试... ({attempt + 1}/{max_retries})")
|
| 427 |
+
|
| 428 |
+
time.sleep(delay)
|
| 429 |
+
continue
|
| 430 |
+
else:
|
| 431 |
+
if self.api_key_manager:
|
| 432 |
+
self.api_key_manager.record_error(api_key, f"网络错误: {str(e)}")
|
| 433 |
+
raise Exception(f"网络错误: {str(e)}")
|
| 434 |
+
|
| 435 |
+
except Exception as e:
|
| 436 |
+
# 避免在错误信息中泄露敏感信息
|
| 437 |
+
error_msg = str(e)
|
| 438 |
+
if "sk-" in error_msg:
|
| 439 |
+
error_msg = "API 配置错误: OPENAI_BASE_URL 设置不正确"
|
| 440 |
+
|
| 441 |
+
# 记录失败(不重试其他类型的错误)
|
| 442 |
+
if self.api_key_manager:
|
| 443 |
+
self.api_key_manager.record_error(api_key, error_msg)
|
| 444 |
+
raise Exception(f"请求错误: {error_msg}")
|
| 445 |
+
|
| 446 |
+
def _build_system_prompt(self) -> str:
|
| 447 |
+
"""构建系统提示词"""
|
| 448 |
+
tools_info = self.tool_executor.get_available_tools()
|
| 449 |
+
|
| 450 |
+
memories_info = ""
|
| 451 |
+
if self.memories:
|
| 452 |
+
memories_info = "\n\n已读取文件的 Memory:\n" + "\n".join(
|
| 453 |
+
[m.to_string() for m in self.memories]
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
# 目录树信息(延迟化,只在第一次调用时生成)
|
| 457 |
+
dir_tree_info = ""
|
| 458 |
+
if self._dir_tree_cached is None and self.tree_depth > 0:
|
| 459 |
+
# 第一次需要时才生成,之后缓存起来
|
| 460 |
+
import logging
|
| 461 |
+
logger = logging.getLogger(__name__)
|
| 462 |
+
logger.info("正在生成目录树...")
|
| 463 |
+
self._dir_tree_cached = self.searcher.get_dir_tree(self.tree_depth)
|
| 464 |
+
logger.info(f"目录树生成完成,共 {len(self._dir_tree_cached)} 个节点")
|
| 465 |
+
elif self._dir_tree_cached:
|
| 466 |
+
dir_tree_info = f"\n\n代码目录结构({self.tree_depth}层):\n{self._dir_tree_cached}"
|
| 467 |
+
|
| 468 |
+
# 使用 prompts.py 中的函数生成系统提示词
|
| 469 |
+
return get_system_prompt(
|
| 470 |
+
tools_info=tools_info,
|
| 471 |
+
max_steps=self.max_steps,
|
| 472 |
+
memories=memories_info,
|
| 473 |
+
dir_tree=dir_tree_info
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
def _format_step(self, step: Dict) -> str:
|
| 477 |
+
"""格式化步骤显示(支持批量执行)"""
|
| 478 |
+
parts = [f"\n🔄 步骤 {step['step']}"]
|
| 479 |
+
|
| 480 |
+
if step.get("thought"):
|
| 481 |
+
parts.append(f"💭 思考: {step['thought']}")
|
| 482 |
+
|
| 483 |
+
if step.get("action"):
|
| 484 |
+
parts.append(f"🔧 行动: {step['action']}")
|
| 485 |
+
|
| 486 |
+
# 处理批量执行结果
|
| 487 |
+
if step.get("batch_results"):
|
| 488 |
+
parts.append(f"🔧 批量执行完成 ({step['batch_actions']} 个操作)")
|
| 489 |
+
for item in step['batch_results']:
|
| 490 |
+
idx = item.get('index', 0)
|
| 491 |
+
action_str = item.get('action', '')
|
| 492 |
+
result = item.get('result', {})
|
| 493 |
+
parts.append(f" [{idx}] {action_str}")
|
| 494 |
+
if isinstance(result, dict):
|
| 495 |
+
if result.get("success"):
|
| 496 |
+
parts.append(f" ✅ 成功: {str(result.get('result', ''))[:200]}")
|
| 497 |
+
else:
|
| 498 |
+
parts.append(f" ❌ 错误: {result.get('error', 'Unknown')}")
|
| 499 |
+
else:
|
| 500 |
+
parts.append(f" 📋 结果: {str(result)[:200]}")
|
| 501 |
+
elif step.get("observation"):
|
| 502 |
+
# 单个执行结果
|
| 503 |
+
obs = step['observation']
|
| 504 |
+
if isinstance(obs, dict) and not obs.get('batch'):
|
| 505 |
+
if obs.get("success"):
|
| 506 |
+
parts.append(f"✅ 结果: {json.dumps(obs.get('result'), ensure_ascii=False, indent=2)[:500]}")
|
| 507 |
+
else:
|
| 508 |
+
parts.append(f"❌ 错误: {obs.get('error')}")
|
| 509 |
+
else:
|
| 510 |
+
parts.append(f"📋 结果: {str(obs)[:500]}")
|
| 511 |
+
|
| 512 |
+
return "\n".join(parts)
|
| 513 |
+
|
| 514 |
+
def _think_and_act(self, user_question: str) -> str:
|
| 515 |
+
"""思考并执行行动"""
|
| 516 |
+
# 构建消息
|
| 517 |
+
messages = [
|
| 518 |
+
{"role": "system", "content": self._build_system_prompt()}
|
| 519 |
+
]
|
| 520 |
+
|
| 521 |
+
# 添加对话历史
|
| 522 |
+
for msg in self.conversation_history:
|
| 523 |
+
messages.append(msg)
|
| 524 |
+
|
| 525 |
+
# 添加当前问题
|
| 526 |
+
messages.append({
|
| 527 |
+
"role": "user",
|
| 528 |
+
"content": f"用户问题:{user_question}\n\n{get_react_format_prompt()}"
|
| 529 |
+
})
|
| 530 |
+
|
| 531 |
+
return self._call_llm(messages)
|
| 532 |
+
|
| 533 |
+
def ask(self, question: str) -> str:
|
| 534 |
+
"""
|
| 535 |
+
询问关于代码库的问题
|
| 536 |
+
|
| 537 |
+
Args:
|
| 538 |
+
question: 用户问题
|
| 539 |
+
|
| 540 |
+
Returns:
|
| 541 |
+
Agent 的回答
|
| 542 |
+
"""
|
| 543 |
+
self.steps = []
|
| 544 |
+
self.conversation_history.append({"role": "user", "content": question})
|
| 545 |
+
|
| 546 |
+
# 确保索引已构建(首次调用时)
|
| 547 |
+
self.searcher._ensure_index()
|
| 548 |
+
|
| 549 |
+
# 流式模式下输出标题
|
| 550 |
+
if self.stream_output:
|
| 551 |
+
print(f"\n{'='*60}")
|
| 552 |
+
print(f"🤔 问题: {question}")
|
| 553 |
+
print(f"\n📝 分析过程:")
|
| 554 |
+
|
| 555 |
+
for step in range(1, self.max_steps + 1):
|
| 556 |
+
# 获取思考和行动
|
| 557 |
+
response = self._think_and_act(question)
|
| 558 |
+
|
| 559 |
+
# 记录步骤
|
| 560 |
+
step_info = {"step": step, "raw_response": response}
|
| 561 |
+
thought, actions_list = self._extract_thought_action(response)
|
| 562 |
+
step_info["thought"] = thought
|
| 563 |
+
|
| 564 |
+
# 检查是否有最终答案和 Memory
|
| 565 |
+
final_answer, memory_data = self._extract_final_answer(response)
|
| 566 |
+
|
| 567 |
+
# 如果有 Memory,保存到列表
|
| 568 |
+
if memory_data:
|
| 569 |
+
path = memory_data.get("file", "")
|
| 570 |
+
if path:
|
| 571 |
+
# 检查是否已存在
|
| 572 |
+
existing = [m for m in self.memories if m.file_path == path]
|
| 573 |
+
if existing:
|
| 574 |
+
self.memories.remove(existing[0])
|
| 575 |
+
# 创建新的 Memory 对象
|
| 576 |
+
memory = Memory(
|
| 577 |
+
file_path=path,
|
| 578 |
+
overview=memory_data.get("overview", ""),
|
| 579 |
+
key_definitions=memory_data.get("key_definitions", []),
|
| 580 |
+
core_logic=memory_data.get("core_logic", ""),
|
| 581 |
+
dependencies=memory_data.get("dependencies", []),
|
| 582 |
+
needed_info=memory_data.get("needed_info", "")
|
| 583 |
+
)
|
| 584 |
+
self.memories.append(memory)
|
| 585 |
+
|
| 586 |
+
if final_answer:
|
| 587 |
+
step_info["final_answer"] = final_answer
|
| 588 |
+
self.steps.append(step_info)
|
| 589 |
+
self.conversation_history.append({"role": "assistant", "content": final_answer})
|
| 590 |
+
|
| 591 |
+
# 流式输出最终答案
|
| 592 |
+
if self.stream_output:
|
| 593 |
+
print(f"\n{'='*60}")
|
| 594 |
+
print(f"💡 回答:\n{final_answer}")
|
| 595 |
+
return ""
|
| 596 |
+
else:
|
| 597 |
+
return self._format_output(question, final_answer)
|
| 598 |
+
|
| 599 |
+
# 执行工具调用(支持批量)
|
| 600 |
+
# 注意:actions_list 可能是空列表(只有 Thought,没有 Action)
|
| 601 |
+
if actions_list:
|
| 602 |
+
# 如果是批量执行
|
| 603 |
+
if len(actions_list) > 1:
|
| 604 |
+
step_info["batch_actions"] = len(actions_list)
|
| 605 |
+
step_info["action"] = f"批量执行 {len(actions_list)} 个操作"
|
| 606 |
+
batch_results = []
|
| 607 |
+
|
| 608 |
+
# 流式输出批量执行提示
|
| 609 |
+
if self.stream_output:
|
| 610 |
+
print(f"\n🔄 步骤 {step}")
|
| 611 |
+
print(f"💭 思考: {thought}")
|
| 612 |
+
print(f"🔧 批量执行 {len(actions_list)} 个操作:")
|
| 613 |
+
|
| 614 |
+
# 批量执行所有 Actions
|
| 615 |
+
for i, (action, action_args) in enumerate(actions_list, 1):
|
| 616 |
+
tool_result = self.tool_executor.execute_tool(action, **action_args)
|
| 617 |
+
batch_results.append({
|
| 618 |
+
"index": i,
|
| 619 |
+
"action": f"{action}({action_args})",
|
| 620 |
+
"result": tool_result
|
| 621 |
+
})
|
| 622 |
+
|
| 623 |
+
# 流式输出单个 Action 结果
|
| 624 |
+
if self.stream_output:
|
| 625 |
+
print(f" [{i}/{len(actions_list)}] {action}({action_args})")
|
| 626 |
+
if tool_result.get("success"):
|
| 627 |
+
print(f" ✅ 完成")
|
| 628 |
+
else:
|
| 629 |
+
print(f" ❌ 错误: {tool_result.get('error', 'Unknown')}")
|
| 630 |
+
|
| 631 |
+
step_info["batch_results"] = batch_results
|
| 632 |
+
step_info["observation"] = {"batch": True, "results": batch_results}
|
| 633 |
+
|
| 634 |
+
# 将批量观察结果添加到对话(纯文本)
|
| 635 |
+
obs_text = "结果: " + "; ".join([
|
| 636 |
+
f"{r['action']}: {r['result'].get('success') and '成功' or r['result'].get('error', '完成')}"
|
| 637 |
+
for r in batch_results
|
| 638 |
+
])
|
| 639 |
+
self.conversation_history.append({
|
| 640 |
+
"role": "user",
|
| 641 |
+
"content": obs_text
|
| 642 |
+
})
|
| 643 |
+
else:
|
| 644 |
+
# 单个 Action
|
| 645 |
+
action, action_args = actions_list[0]
|
| 646 |
+
step_info["action"] = f"{action}({action_args})"
|
| 647 |
+
tool_result = self.tool_executor.execute_tool(action, **action_args)
|
| 648 |
+
step_info["observation"] = tool_result
|
| 649 |
+
|
| 650 |
+
# 流式输出当前步骤
|
| 651 |
+
if self.stream_output:
|
| 652 |
+
print(self._format_step(step_info))
|
| 653 |
+
|
| 654 |
+
# 将观察结果添加到对话(纯文本)
|
| 655 |
+
if tool_result.get("success"):
|
| 656 |
+
result = tool_result.get("result", "")
|
| 657 |
+
obs_text = f"结果: {str(result)[:500]}"
|
| 658 |
+
else:
|
| 659 |
+
obs_text = f"错误: {tool_result.get('error', '未知错误')}"
|
| 660 |
+
self.conversation_history.append({
|
| 661 |
+
"role": "user",
|
| 662 |
+
"content": obs_text
|
| 663 |
+
})
|
| 664 |
+
|
| 665 |
+
self.steps.append(step_info)
|
| 666 |
+
|
| 667 |
+
# 超时,返回最后的结果
|
| 668 |
+
if self.stream_output:
|
| 669 |
+
print(f"\n{'='*60}")
|
| 670 |
+
print(f"💡 回答:\n已达到最大步骤数限制,请尝试更具体的问题。")
|
| 671 |
+
return ""
|
| 672 |
+
else:
|
| 673 |
+
return self._format_output(question, "已达到最大步骤数限制,请尝试更具体的问题。")
|
| 674 |
+
|
| 675 |
+
def _format_output(self, question: str, answer: str) -> str:
|
| 676 |
+
"""格式化输出"""
|
| 677 |
+
output = [f"\n{'='*60}"]
|
| 678 |
+
output.append(f"🤔 问题: {question}")
|
| 679 |
+
output.append(f"\n📝 分析过程:")
|
| 680 |
+
|
| 681 |
+
for step_info in self.steps:
|
| 682 |
+
output.append(self._format_step(step_info))
|
| 683 |
+
|
| 684 |
+
output.append(f"\n{'='*60}")
|
| 685 |
+
output.append(f"💡 回答:\n{answer}")
|
| 686 |
+
|
| 687 |
+
return "\n".join(output)
|
| 688 |
+
|
| 689 |
+
def clear_memory(self):
|
| 690 |
+
"""清空 Memory"""
|
| 691 |
+
self.memories = []
|
| 692 |
+
|
| 693 |
+
def clear_history(self):
|
| 694 |
+
"""清空对话历史"""
|
| 695 |
+
self.conversation_history = []
|
| 696 |
+
self.memories = []
|
| 697 |
+
self.steps = []
|
| 698 |
+
|
| 699 |
+
def get_stats(self) -> Dict:
|
| 700 |
+
"""获取统计信息"""
|
| 701 |
+
return {
|
| 702 |
+
"conversation_length": len(self.conversation_history),
|
| 703 |
+
"memory_count": len(self.memories),
|
| 704 |
+
"total_steps": len(self.steps),
|
| 705 |
+
"code_dir": str(self.searcher.root_dir)
|
| 706 |
+
}
|
src/api_key_manager.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Key Manager - 多Key随机管理器
|
| 3 |
+
支持线程安全的Key随机选择,提供良好的负载分散
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import threading
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class KeyStats:
|
| 19 |
+
"""单个Key的统计信息"""
|
| 20 |
+
key_hash: str # Key的hash(用于日志,不泄露真实Key)
|
| 21 |
+
total_requests: int = 0
|
| 22 |
+
success_count: int = 0
|
| 23 |
+
error_count: int = 0
|
| 24 |
+
last_used: Optional[float] = None
|
| 25 |
+
last_error: Optional[str] = None
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def success_rate(self) -> float:
|
| 29 |
+
"""成功率"""
|
| 30 |
+
if self.total_requests == 0:
|
| 31 |
+
return 1.0
|
| 32 |
+
return self.success_count / self.total_requests
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ApiKeyManager:
|
| 36 |
+
"""API Key 管理器 - 支持多Key随机选择"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, api_keys: Optional[str | List[str]] = None):
|
| 39 |
+
"""
|
| 40 |
+
初始化API Key管理器
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
api_keys: 可以是单个key、逗号分隔的字符串,或key列表
|
| 44 |
+
"""
|
| 45 |
+
self._keys: List[str] = []
|
| 46 |
+
self._lock = threading.Lock() # 线程安全锁
|
| 47 |
+
self._stats: dict[str, KeyStats] = {} # key_hash -> KeyStats
|
| 48 |
+
self._key_to_hash: dict[str, str] = {} # key -> key_hash
|
| 49 |
+
|
| 50 |
+
# 加载keys
|
| 51 |
+
if api_keys:
|
| 52 |
+
self._load_keys(api_keys)
|
| 53 |
+
else:
|
| 54 |
+
# 从环境变量加载
|
| 55 |
+
env_keys = os.getenv("OPENAI_API_KEY", "")
|
| 56 |
+
if env_keys:
|
| 57 |
+
self._load_keys(env_keys)
|
| 58 |
+
|
| 59 |
+
if not self._keys:
|
| 60 |
+
logger.warning("未配置任何API Key")
|
| 61 |
+
|
| 62 |
+
logger.info(f"API Key管理器初始化完成,共加载 {len(self._keys)} 个Key")
|
| 63 |
+
|
| 64 |
+
def _load_keys(self, keys: str | List[str]):
|
| 65 |
+
"""加载API Keys"""
|
| 66 |
+
if isinstance(keys, str):
|
| 67 |
+
# 支持逗号分隔的多个key
|
| 68 |
+
keys_list = [k.strip() for k in keys.split(",") if k.strip()]
|
| 69 |
+
else:
|
| 70 |
+
keys_list = keys
|
| 71 |
+
|
| 72 |
+
# 去重
|
| 73 |
+
seen = set()
|
| 74 |
+
unique_keys = []
|
| 75 |
+
for key in keys_list:
|
| 76 |
+
if key and key not in seen:
|
| 77 |
+
seen.add(key)
|
| 78 |
+
unique_keys.append(key)
|
| 79 |
+
|
| 80 |
+
self._keys = unique_keys
|
| 81 |
+
|
| 82 |
+
# 初始化统计信息
|
| 83 |
+
import time
|
| 84 |
+
import hashlib
|
| 85 |
+
for key in self._keys:
|
| 86 |
+
key_hash = hashlib.sha256(key.encode()).hexdigest()[:8]
|
| 87 |
+
self._key_to_hash[key] = key_hash
|
| 88 |
+
self._stats[key_hash] = KeyStats(key_hash=key_hash)
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def key_count(self) -> int:
|
| 92 |
+
"""获取Key数量"""
|
| 93 |
+
return len(self._keys)
|
| 94 |
+
|
| 95 |
+
@property
|
| 96 |
+
def has_keys(self) -> bool:
|
| 97 |
+
"""是否有可用的Key"""
|
| 98 |
+
return len(self._keys) > 0
|
| 99 |
+
|
| 100 |
+
def get_key(self) -> Optional[str]:
|
| 101 |
+
"""
|
| 102 |
+
获取下一个可用的API Key(随机选择)
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
API Key字符串,如果没有可用Key返回None
|
| 106 |
+
"""
|
| 107 |
+
if not self._keys:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
with self._lock:
|
| 111 |
+
# 随机选择一个 key
|
| 112 |
+
key = random.choice(self._keys)
|
| 113 |
+
|
| 114 |
+
# 更新统计
|
| 115 |
+
import time
|
| 116 |
+
key_hash = self._key_to_hash[key]
|
| 117 |
+
stats = self._stats[key_hash]
|
| 118 |
+
stats.total_requests += 1
|
| 119 |
+
stats.last_used = time.time()
|
| 120 |
+
|
| 121 |
+
return key
|
| 122 |
+
|
| 123 |
+
def record_success(self, key: str):
|
| 124 |
+
"""记录请求成功"""
|
| 125 |
+
if key not in self._key_to_hash:
|
| 126 |
+
return
|
| 127 |
+
|
| 128 |
+
with self._lock:
|
| 129 |
+
key_hash = self._key_to_hash[key]
|
| 130 |
+
self._stats[key_hash].success_count += 1
|
| 131 |
+
|
| 132 |
+
def record_error(self, key: str, error: str):
|
| 133 |
+
"""记录请求失败"""
|
| 134 |
+
if key not in self._key_to_hash:
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
with self._lock:
|
| 138 |
+
key_hash = self._key_to_hash[key]
|
| 139 |
+
stats = self._stats[key_hash]
|
| 140 |
+
stats.error_count += 1
|
| 141 |
+
stats.last_error = error[:200] # 限制错误信息长度
|
| 142 |
+
|
| 143 |
+
def get_stats(self) -> dict:
|
| 144 |
+
"""获取所有Key的统计信息"""
|
| 145 |
+
with self._lock:
|
| 146 |
+
return {
|
| 147 |
+
"total_keys": len(self._keys),
|
| 148 |
+
"keys": [
|
| 149 |
+
{
|
| 150 |
+
"key_hash": stats.key_hash,
|
| 151 |
+
"total_requests": stats.total_requests,
|
| 152 |
+
"success_count": stats.success_count,
|
| 153 |
+
"error_count": stats.error_count,
|
| 154 |
+
"success_rate": f"{stats.success_rate:.2%}",
|
| 155 |
+
"last_used": stats.last_used,
|
| 156 |
+
"last_error": stats.last_error,
|
| 157 |
+
}
|
| 158 |
+
for stats in self._stats.values()
|
| 159 |
+
],
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
def reset_stats(self):
|
| 163 |
+
"""重置统计信息"""
|
| 164 |
+
with self._lock:
|
| 165 |
+
for stats in self._stats.values():
|
| 166 |
+
stats.total_requests = 0
|
| 167 |
+
stats.success_count = 0
|
| 168 |
+
stats.error_count = 0
|
| 169 |
+
stats.last_used = None
|
| 170 |
+
stats.last_error = None
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# 全局单例实例
|
| 174 |
+
_global_manager: Optional[ApiKeyManager] = None
|
| 175 |
+
_global_lock = threading.Lock()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def get_global_manager() -> ApiKeyManager:
|
| 179 |
+
"""获取全局单例"""
|
| 180 |
+
if _global_manager is None:
|
| 181 |
+
with _global_lock:
|
| 182 |
+
if _global_manager is None:
|
| 183 |
+
_global_manager = ApiKeyManager()
|
| 184 |
+
return _global_manager
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def init_manager(api_keys: Optional[str | List[str]] = None) -> ApiKeyManager:
|
| 188 |
+
"""初始化全局管理器"""
|
| 189 |
+
global _global_manager
|
| 190 |
+
with _global_lock:
|
| 191 |
+
_global_manager = ApiKeyManager(api_keys)
|
| 192 |
+
return _global_manager
|
src/index.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
代码索引 - 提供高性能的代码搜索功能
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Set, Tuple
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class IndexEntry:
|
| 15 |
+
"""索引条目"""
|
| 16 |
+
file_path: str
|
| 17 |
+
line: int
|
| 18 |
+
context: str # 行内容上下文
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CodeIndex:
|
| 22 |
+
"""代码索引器 - 使用倒排索引加速搜索"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, root_dir: Path):
|
| 25 |
+
self.root_dir = root_dir
|
| 26 |
+
self._keyword_index: Dict[str, List[IndexEntry]] = defaultdict(list)
|
| 27 |
+
self._symbol_index: Dict[str, List[IndexEntry]] = defaultdict(list)
|
| 28 |
+
self._indexed_files: Set[str] = set()
|
| 29 |
+
self._last_build_time = 0
|
| 30 |
+
|
| 31 |
+
def build_index(self, extensions: str = "*") -> Dict:
|
| 32 |
+
"""构建或重建索引
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
extensions: 要索引的文件扩展名,"*" 表示全部
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
构建统计信息
|
| 39 |
+
"""
|
| 40 |
+
import os
|
| 41 |
+
|
| 42 |
+
start_time = time.time()
|
| 43 |
+
self._keyword_index.clear()
|
| 44 |
+
self._symbol_index.clear()
|
| 45 |
+
self._indexed_files.clear()
|
| 46 |
+
|
| 47 |
+
ext_list = extensions.split(',') if extensions != "*" else None
|
| 48 |
+
|
| 49 |
+
files_processed = 0
|
| 50 |
+
files_skipped = 0
|
| 51 |
+
|
| 52 |
+
# 使用 os.walk 而不是 Path.rglob 来避免符号链接循环
|
| 53 |
+
# followlinks=False 表示不跟随符号链接
|
| 54 |
+
for dirpath, dirnames, filenames in os.walk(self.root_dir, followlinks=False):
|
| 55 |
+
for filename in filenames:
|
| 56 |
+
file_path = Path(dirpath) / filename
|
| 57 |
+
|
| 58 |
+
# 跳过符号链接文件
|
| 59 |
+
if file_path.is_symlink():
|
| 60 |
+
files_skipped += 1
|
| 61 |
+
continue
|
| 62 |
+
|
| 63 |
+
if not file_path.is_file():
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
# 检查扩展名
|
| 67 |
+
if ext_list and file_path.suffix.lstrip('.') not in ext_list:
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
rel_path = str(file_path.relative_to(self.root_dir))
|
| 72 |
+
self._index_file(file_path, rel_path)
|
| 73 |
+
files_processed += 1
|
| 74 |
+
except (ValueError, OSError):
|
| 75 |
+
# 处理无法计算相对路径的情况(符号链接到外部)
|
| 76 |
+
files_skipped += 1
|
| 77 |
+
continue
|
| 78 |
+
|
| 79 |
+
build_time = time.time() - start_time
|
| 80 |
+
self._last_build_time = time.time()
|
| 81 |
+
|
| 82 |
+
stats = {
|
| 83 |
+
"files_processed": files_processed,
|
| 84 |
+
"files_skipped": files_skipped,
|
| 85 |
+
"keyword_entries": sum(len(entries) for entries in self._keyword_index.values()),
|
| 86 |
+
"symbol_entries": sum(len(entries) for entries in self._symbol_index.values()),
|
| 87 |
+
"unique_keywords": len(self._keyword_index),
|
| 88 |
+
"unique_symbols": len(self._symbol_index),
|
| 89 |
+
"build_time_seconds": round(build_time, 2)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return stats
|
| 93 |
+
|
| 94 |
+
def _index_file(self, file_path: Path, rel_path: str):
|
| 95 |
+
"""索引单个文件"""
|
| 96 |
+
try:
|
| 97 |
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
| 98 |
+
content = f.read()
|
| 99 |
+
|
| 100 |
+
lines = content.split('\n')
|
| 101 |
+
|
| 102 |
+
# 提取符号(函数、类、变量定义)
|
| 103 |
+
# Python: def foo(), class Bar:, VAR = value
|
| 104 |
+
# JavaScript: function foo(), class Bar {}, const x = ...
|
| 105 |
+
symbol_patterns = [
|
| 106 |
+
# Python
|
| 107 |
+
r'\bdef\s+(\w+)\s*\(',
|
| 108 |
+
r'\bclass\s+(\w+)\s*[:\(]',
|
| 109 |
+
r'^\s*(\w+)\s*=\s*[\'"\w\[]',
|
| 110 |
+
# JavaScript/TypeScript
|
| 111 |
+
r'\bfunction\s+(\w+)\s*\(',
|
| 112 |
+
r'\bclass\s+(\w+)\s*\{',
|
| 113 |
+
r'\bconst\s+(\w+)\s*=',
|
| 114 |
+
r'\blet\s+(\w+)\s*=',
|
| 115 |
+
r'\bvar\s+(\w+)\s*=',
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
for i, line in enumerate(lines, 1):
|
| 119 |
+
# 索引符号
|
| 120 |
+
for pattern in symbol_patterns:
|
| 121 |
+
matches = re.finditer(pattern, line)
|
| 122 |
+
for match in matches:
|
| 123 |
+
symbol = match.group(1)
|
| 124 |
+
self._symbol_index[symbol].append(IndexEntry(
|
| 125 |
+
file_path=rel_path,
|
| 126 |
+
line=i,
|
| 127 |
+
context=line.strip()[:100]
|
| 128 |
+
))
|
| 129 |
+
|
| 130 |
+
# 索引关键词
|
| 131 |
+
# 分词为单词(标识符)和保留部分特殊字符
|
| 132 |
+
words = self._tokenize(line)
|
| 133 |
+
for word in words:
|
| 134 |
+
if len(word) >= 2: # 忽略单个字符
|
| 135 |
+
self._keyword_index[word].append(IndexEntry(
|
| 136 |
+
file_path=rel_path,
|
| 137 |
+
line=i,
|
| 138 |
+
context=line.strip()[:100]
|
| 139 |
+
))
|
| 140 |
+
|
| 141 |
+
self._indexed_files.add(rel_path)
|
| 142 |
+
|
| 143 |
+
except Exception:
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
def _tokenize(self, text: str) -> List[str]:
|
| 147 |
+
"""将文本分词为关键词
|
| 148 |
+
|
| 149 |
+
返回标识符、数字和保留特定连接符的词
|
| 150 |
+
"""
|
| 151 |
+
# 匹配标识符、数字和一些常见的组合词
|
| 152 |
+
# 包括:snake_case, camelCase, PascalCase, 数字, 单词
|
| 153 |
+
tokens = []
|
| 154 |
+
|
| 155 |
+
# 拆分 camelCase 和 PascalCase
|
| 156 |
+
# 例如: MyFunction -> My Function
|
| 157 |
+
def split_camel_case(s):
|
| 158 |
+
# 在大写字母前插入空格(连续大写作为整体)
|
| 159 |
+
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1 \2', s)
|
| 160 |
+
return re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', s1)
|
| 161 |
+
|
| 162 |
+
# 提取标识符和数字
|
| 163 |
+
matches = re.finditer(r'\b[a-zA-Z_][a-zA-Z0-9_]*\b|\b\d+\b', text)
|
| 164 |
+
for match in matches:
|
| 165 |
+
word = match.group()
|
| 166 |
+
|
| 167 |
+
# 拆分驼峰命名
|
| 168 |
+
subwords = split_camel_case(word).lower().split()
|
| 169 |
+
tokens.extend(subwords)
|
| 170 |
+
|
| 171 |
+
return tokens
|
| 172 |
+
|
| 173 |
+
def search_keywords(self, keyword: str, max_results: int = 20) -> List[Dict]:
|
| 174 |
+
"""搜索关键词(使用索引)
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
keyword: 搜索关键词
|
| 178 |
+
max_results: 最大结果数
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
匹配结果列表
|
| 182 |
+
"""
|
| 183 |
+
# 如果没有构建索引,返回空
|
| 184 |
+
if not self._keyword_index:
|
| 185 |
+
return []
|
| 186 |
+
|
| 187 |
+
# 分词并搜索
|
| 188 |
+
keywords = self._tokenize(keyword)
|
| 189 |
+
if not keywords:
|
| 190 |
+
return []
|
| 191 |
+
|
| 192 |
+
# 收集所有匹配的条目
|
| 193 |
+
all_entries: List[Tuple[int, IndexEntry]] = []
|
| 194 |
+
|
| 195 |
+
for kw in keywords:
|
| 196 |
+
if kw in self._keyword_index:
|
| 197 |
+
for entry in self._keyword_index[kw]:
|
| 198 |
+
# 计算匹配得分(关键词数量)
|
| 199 |
+
score = sum(1 for k in keywords if k in entry.context.lower())
|
| 200 |
+
all_entries.append((score, entry))
|
| 201 |
+
|
| 202 |
+
# 按得分排序
|
| 203 |
+
all_entries.sort(key=lambda x: x[0], reverse=True)
|
| 204 |
+
|
| 205 |
+
# 去重(同一行只返回一次)
|
| 206 |
+
seen: Set[Tuple[str, int]] = set()
|
| 207 |
+
results = []
|
| 208 |
+
|
| 209 |
+
for score, entry in all_entries:
|
| 210 |
+
key = (entry.file_path, entry.line)
|
| 211 |
+
if key not in seen:
|
| 212 |
+
seen.add(key)
|
| 213 |
+
results.append({
|
| 214 |
+
"file": entry.file_path,
|
| 215 |
+
"line": entry.line,
|
| 216 |
+
"content": entry.context,
|
| 217 |
+
"score": score
|
| 218 |
+
})
|
| 219 |
+
if len(results) >= max_results:
|
| 220 |
+
break
|
| 221 |
+
|
| 222 |
+
return results
|
| 223 |
+
|
| 224 |
+
def search_symbols(self, symbol: str, max_results: int = 20) -> List[Dict]:
|
| 225 |
+
"""搜索符号定义(函数、类、变量)
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
symbol: 符号名称
|
| 229 |
+
max_results: 最大结果数
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
匹配的符号定义
|
| 233 |
+
"""
|
| 234 |
+
if not self._symbol_index:
|
| 235 |
+
return []
|
| 236 |
+
|
| 237 |
+
results = []
|
| 238 |
+
seen: Set[Tuple[str, int]] = set()
|
| 239 |
+
|
| 240 |
+
# 精确匹配
|
| 241 |
+
if symbol in self._symbol_index:
|
| 242 |
+
for entry in self._symbol_index[symbol]:
|
| 243 |
+
key = (entry.file_path, entry.line)
|
| 244 |
+
if key not in seen:
|
| 245 |
+
seen.add(key)
|
| 246 |
+
results.append({
|
| 247 |
+
"file": entry.file_path,
|
| 248 |
+
"line": entry.line,
|
| 249 |
+
"content": entry.context,
|
| 250 |
+
"type": "definition"
|
| 251 |
+
})
|
| 252 |
+
if len(results) >= max_results:
|
| 253 |
+
return results
|
| 254 |
+
|
| 255 |
+
# 模糊匹配(包含)
|
| 256 |
+
for sym, entries in self._symbol_index.items():
|
| 257 |
+
if symbol.lower() in sym.lower():
|
| 258 |
+
for entry in entries:
|
| 259 |
+
key = (entry.file_path, entry.line)
|
| 260 |
+
if key not in seen:
|
| 261 |
+
seen.add(key)
|
| 262 |
+
results.append({
|
| 263 |
+
"file": entry.file_path,
|
| 264 |
+
"line": entry.line,
|
| 265 |
+
"content": entry.context,
|
| 266 |
+
"type": "definition"
|
| 267 |
+
})
|
| 268 |
+
if len(results) >= max_results:
|
| 269 |
+
return results
|
| 270 |
+
|
| 271 |
+
return results
|
| 272 |
+
|
| 273 |
+
def get_stats(self) -> Dict:
|
| 274 |
+
"""获取索引统计信息"""
|
| 275 |
+
return {
|
| 276 |
+
"indexed_files": len(self._indexed_files),
|
| 277 |
+
"keyword_entries": sum(len(entries) for entries in self._keyword_index.values()),
|
| 278 |
+
"symbol_entries": sum(len(entries) for entries in self._symbol_index.values()),
|
| 279 |
+
"unique_keywords": len(self._keyword_index),
|
| 280 |
+
"unique_symbols": len(self._symbol_index),
|
| 281 |
+
"last_build_time": self._last_build_time
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
def is_built(self) -> bool:
|
| 285 |
+
"""检查索引是否已构建"""
|
| 286 |
+
return len(self._indexed_files) > 0
|
src/repo_manager.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
仓库管理器 - 自动下载 Git 仓库
|
| 3 |
+
|
| 4 |
+
功能:
|
| 5 |
+
- 从环境变量读取仓库列表
|
| 6 |
+
- 自动下载并解压 ZIP 文件
|
| 7 |
+
- 支持多个仓库并行处理
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import shutil
|
| 12 |
+
import threading
|
| 13 |
+
import zipfile
|
| 14 |
+
import urllib.request
|
| 15 |
+
import urllib.error
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import List
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
import logging
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class RepoConfig:
|
| 26 |
+
"""仓库配置"""
|
| 27 |
+
name: str # 仓库名称(目录名)
|
| 28 |
+
url: str # Git URL (支持 GitHub HTTPS)
|
| 29 |
+
branch: str = "main" # 分支名
|
| 30 |
+
auto_update: bool = True # 是否自动更新
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class RepoManager:
|
| 34 |
+
"""仓库管理器"""
|
| 35 |
+
|
| 36 |
+
def __init__(self, base_dir: str = "./repos"):
|
| 37 |
+
self.base_dir = Path(base_dir)
|
| 38 |
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
self.repos: List[RepoConfig] = []
|
| 40 |
+
self._lock = threading.Lock()
|
| 41 |
+
|
| 42 |
+
def load_from_env(self):
|
| 43 |
+
"""从环境变量加载仓库配置
|
| 44 |
+
|
| 45 |
+
环境变量格式:
|
| 46 |
+
REPO_URLS=repo1,repo2,repo3
|
| 47 |
+
REPO_1_URL=https://github.com/user/repo1.git
|
| 48 |
+
REPO_2_URL=https://github.com/user/repo2.git
|
| 49 |
+
"""
|
| 50 |
+
self.repos = []
|
| 51 |
+
|
| 52 |
+
# 方式1:逗号分隔的 URL 列表
|
| 53 |
+
repo_urls = os.getenv("REPO_URLS", "")
|
| 54 |
+
if repo_urls:
|
| 55 |
+
for url in repo_urls.split(","):
|
| 56 |
+
url = url.strip()
|
| 57 |
+
if url:
|
| 58 |
+
name = self._extract_repo_name(url)
|
| 59 |
+
self.repos.append(RepoConfig(name=name, url=url))
|
| 60 |
+
|
| 61 |
+
# 方式2:带编号的配置
|
| 62 |
+
idx = 1
|
| 63 |
+
while True:
|
| 64 |
+
url_key = f"REPO_{idx}_URL"
|
| 65 |
+
url = os.getenv(url_key, "")
|
| 66 |
+
if not url:
|
| 67 |
+
break
|
| 68 |
+
|
| 69 |
+
name = os.getenv(f"REPO_{idx}_NAME", "")
|
| 70 |
+
if not name:
|
| 71 |
+
name = self._extract_repo_name(url)
|
| 72 |
+
|
| 73 |
+
branch = os.getenv(f"REPO_{idx}_BRANCH", "main")
|
| 74 |
+
auto_update = os.getenv(f"REPO_{idx}_AUTO_UPDATE", "true").lower() == "true"
|
| 75 |
+
|
| 76 |
+
self.repos.append(RepoConfig(
|
| 77 |
+
name=name,
|
| 78 |
+
url=url,
|
| 79 |
+
branch=branch,
|
| 80 |
+
auto_update=auto_update
|
| 81 |
+
))
|
| 82 |
+
|
| 83 |
+
idx += 1
|
| 84 |
+
|
| 85 |
+
logger.info(f"加载了 {len(self.repos)} 个仓库配置")
|
| 86 |
+
return self.repos
|
| 87 |
+
|
| 88 |
+
def _extract_repo_name(self, url: str) -> str:
|
| 89 |
+
"""从 URL 提取仓库名称"""
|
| 90 |
+
# 统一处理 URL
|
| 91 |
+
url = url.replace("git@github.com:", "https://github.com/")
|
| 92 |
+
url = url.rstrip("/")
|
| 93 |
+
|
| 94 |
+
# 提取最后一部分
|
| 95 |
+
if "/" in url:
|
| 96 |
+
name = url.split("/")[-1]
|
| 97 |
+
# 去除 .git 后缀
|
| 98 |
+
if name.endswith(".git"):
|
| 99 |
+
name = name[:-4]
|
| 100 |
+
return name
|
| 101 |
+
return url
|
| 102 |
+
|
| 103 |
+
def _get_repo_dir(self, name: str) -> Path:
|
| 104 |
+
"""获取仓库目录路径"""
|
| 105 |
+
return self.base_dir / name
|
| 106 |
+
|
| 107 |
+
def _get_zip_url(self, url: str, branch: str) -> str:
|
| 108 |
+
"""将 Git URL 转换为 ZIP 下载 URL"""
|
| 109 |
+
# git@github.com:user/repo.git -> https://github.com/user/repo/archive/refs/heads/branch.zip
|
| 110 |
+
# https://github.com/user/repo.git -> https://github.com/user/repo/archive/refs/heads/branch.zip
|
| 111 |
+
|
| 112 |
+
url = url.replace("git@github.com:", "https://github.com/")
|
| 113 |
+
url = url.removesuffix(".git")
|
| 114 |
+
|
| 115 |
+
# 提取 owner/repo
|
| 116 |
+
parts = url.rstrip("/").split("/")
|
| 117 |
+
if len(parts) >= 2:
|
| 118 |
+
owner = parts[-2]
|
| 119 |
+
repo = parts[-1]
|
| 120 |
+
return f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
|
| 121 |
+
|
| 122 |
+
raise ValueError(f"无法解析仓库 URL: {url}")
|
| 123 |
+
|
| 124 |
+
def download_repo(self, repo: RepoConfig, force: bool = False) -> bool:
|
| 125 |
+
"""下载并解压仓库
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
repo: 仓库配置
|
| 129 |
+
force: 是否强制重新下载
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
bool: 是否成功
|
| 133 |
+
"""
|
| 134 |
+
repo_dir = self._get_repo_dir(repo.name)
|
| 135 |
+
|
| 136 |
+
# 检查仓库是否已存在且有效(无需重新下载)
|
| 137 |
+
if not force and repo_dir.exists() and any(repo_dir.iterdir()):
|
| 138 |
+
logger.info(f"仓库已存在,跳过下载: {repo.name}")
|
| 139 |
+
return True
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
# 删除已存在的目录
|
| 143 |
+
if repo_dir.exists():
|
| 144 |
+
logger.info(f"删除旧版本: {repo.name}")
|
| 145 |
+
shutil.rmtree(repo_dir)
|
| 146 |
+
|
| 147 |
+
repo_dir.mkdir(parents=True, exist_ok=True)
|
| 148 |
+
|
| 149 |
+
# 下载 ZIP
|
| 150 |
+
zip_url = self._get_zip_url(repo.url, repo.branch)
|
| 151 |
+
zip_path = repo_dir / "repo.zip"
|
| 152 |
+
|
| 153 |
+
logger.info(f"下载仓库: {repo.name} ({zip_url})")
|
| 154 |
+
|
| 155 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 156 |
+
req = urllib.request.Request(zip_url, headers=headers)
|
| 157 |
+
|
| 158 |
+
with urllib.request.urlopen(req, timeout=120) as response:
|
| 159 |
+
with open(zip_path, 'wb') as f:
|
| 160 |
+
f.write(response.read())
|
| 161 |
+
|
| 162 |
+
# 解压
|
| 163 |
+
logger.info(f"解压仓库: {repo.name}")
|
| 164 |
+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
| 165 |
+
zip_ref.extractall(repo_dir)
|
| 166 |
+
|
| 167 |
+
# ZIP 包会多一层目录,需要移动内容
|
| 168 |
+
extracted_dir = repo_dir / f"{repo.name}-{repo.branch}"
|
| 169 |
+
if extracted_dir.exists():
|
| 170 |
+
for item in extracted_dir.iterdir():
|
| 171 |
+
item.rename(repo_dir / item.name)
|
| 172 |
+
extracted_dir.rmdir()
|
| 173 |
+
|
| 174 |
+
# 删除 ZIP 文件
|
| 175 |
+
zip_path.unlink()
|
| 176 |
+
|
| 177 |
+
logger.info(f"仓库 {repo.name} 下载成功")
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"下载仓库 {repo.name} 失败: {e}")
|
| 182 |
+
# 清理失败的目录
|
| 183 |
+
if repo_dir.exists():
|
| 184 |
+
shutil.rmtree(repo_dir)
|
| 185 |
+
return False
|
| 186 |
+
|
| 187 |
+
def sync_all(self, parallel: bool = True, force: bool = False) -> dict:
|
| 188 |
+
"""同步所有仓库
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
parallel: 是否并行下载
|
| 192 |
+
force: 是否强制重新下载所有仓库
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
dict: { "success": [成功列表], "skipped": [跳过列表], "failed": [失败列表] }
|
| 196 |
+
"""
|
| 197 |
+
if not self.repos:
|
| 198 |
+
self.load_from_env()
|
| 199 |
+
|
| 200 |
+
if not self.repos:
|
| 201 |
+
logger.warning("没有配置任何仓库")
|
| 202 |
+
return {"success": [], "skipped": [], "failed": []}
|
| 203 |
+
|
| 204 |
+
results = {"success": [], "skipped": [], "failed": []}
|
| 205 |
+
|
| 206 |
+
if parallel:
|
| 207 |
+
# 并行下载
|
| 208 |
+
threads = []
|
| 209 |
+
for repo in self.repos:
|
| 210 |
+
t = threading.Thread(target=self._sync_single, args=(repo, results, force))
|
| 211 |
+
t.start()
|
| 212 |
+
threads.append(t)
|
| 213 |
+
|
| 214 |
+
for t in threads:
|
| 215 |
+
t.join()
|
| 216 |
+
else:
|
| 217 |
+
# 顺序下载
|
| 218 |
+
for repo in self.repos:
|
| 219 |
+
self._sync_single(repo, results, force)
|
| 220 |
+
|
| 221 |
+
logger.info(f"仓库同步完成: 成功 {len(results['success'])}, 跳过 {len(results['skipped'])}, 失败 {len(results['failed'])}")
|
| 222 |
+
return results
|
| 223 |
+
|
| 224 |
+
def _sync_single(self, repo: RepoConfig, results: dict, force: bool = False):
|
| 225 |
+
"""同步单个仓库(线程安全)"""
|
| 226 |
+
# 先检查是否已存在且有效(非强制模式)
|
| 227 |
+
if not force:
|
| 228 |
+
repo_dir = self._get_repo_dir(repo.name)
|
| 229 |
+
if repo_dir.exists() and any(repo_dir.iterdir()):
|
| 230 |
+
with self._lock:
|
| 231 |
+
results["skipped"].append(repo.name)
|
| 232 |
+
return
|
| 233 |
+
|
| 234 |
+
if self.download_repo(repo, force):
|
| 235 |
+
with self._lock:
|
| 236 |
+
results["success"].append(repo.name)
|
| 237 |
+
else:
|
| 238 |
+
with self._lock:
|
| 239 |
+
results["failed"].append(repo.name)
|
| 240 |
+
|
| 241 |
+
def get_repo_list(self) -> List[dict]:
|
| 242 |
+
"""获取已下载的仓库列表"""
|
| 243 |
+
repos = []
|
| 244 |
+
for item in self.base_dir.iterdir():
|
| 245 |
+
if item.is_dir():
|
| 246 |
+
repos.append({
|
| 247 |
+
"name": item.name,
|
| 248 |
+
"path": str(item)
|
| 249 |
+
})
|
| 250 |
+
return repos
|
| 251 |
+
|
| 252 |
+
def remove_repo(self, name: str) -> bool:
|
| 253 |
+
"""删除仓库"""
|
| 254 |
+
repo_dir = self._get_repo_dir(name)
|
| 255 |
+
if repo_dir.exists():
|
| 256 |
+
try:
|
| 257 |
+
shutil.rmtree(repo_dir)
|
| 258 |
+
logger.info(f"删除仓库: {name}")
|
| 259 |
+
return True
|
| 260 |
+
except Exception as e:
|
| 261 |
+
logger.error(f"删除仓库 {name} 失败: {e}")
|
| 262 |
+
return False
|
| 263 |
+
return False
|
| 264 |
+
|
| 265 |
+
def clear_all(self) -> int:
|
| 266 |
+
"""清空所有仓库"""
|
| 267 |
+
count = 0
|
| 268 |
+
for item in self.base_dir.iterdir():
|
| 269 |
+
if item.is_dir():
|
| 270 |
+
try:
|
| 271 |
+
shutil.rmtree(item)
|
| 272 |
+
count += 1
|
| 273 |
+
except Exception as e:
|
| 274 |
+
logger.warning(f"删除 {item.name} 失败: {e}")
|
| 275 |
+
logger.info(f"清空仓库: 删除 {count} 个")
|
| 276 |
+
return count
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def sync_repos_on_startup():
|
| 280 |
+
"""启动时同步仓库"""
|
| 281 |
+
manager = RepoManager()
|
| 282 |
+
repos = manager.load_from_env()
|
| 283 |
+
|
| 284 |
+
if repos:
|
| 285 |
+
logger.info(f"启动同步 {len(repos)} 个仓库...")
|
| 286 |
+
results = manager.sync_all(parallel=True)
|
| 287 |
+
return results
|
| 288 |
+
else:
|
| 289 |
+
logger.info("未配置仓库,跳过同步")
|
| 290 |
+
return {"success": [], "failed": []}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
if __name__ == "__main__":
|
| 294 |
+
# 测试
|
| 295 |
+
logging.basicConfig(level=logging.INFO)
|
| 296 |
+
|
| 297 |
+
# 示例:设置环境变量后测试
|
| 298 |
+
os.environ["REPO_1_URL"] = "https://github.com/psyche/astronomy.git"
|
| 299 |
+
os.environ["REPO_1_NAME"] = "astronomy"
|
| 300 |
+
os.environ["REPO_1_BRANCH"] = "main"
|
| 301 |
+
|
| 302 |
+
manager = RepoManager("./test-code")
|
| 303 |
+
manager.load_from_env()
|
| 304 |
+
|
| 305 |
+
print(f"配置了 {len(manager.repos)} 个仓库:")
|
| 306 |
+
for repo in manager.repos:
|
| 307 |
+
print(f" - {repo.name}: {repo.url}")
|
| 308 |
+
|
| 309 |
+
results = manager.sync_all()
|
| 310 |
+
print(f"\n同步结果: 成功 {len(results['success'])}, 失败 {len(results['failed'])}")
|
src/searcher.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
代码搜索器 - 提供文件搜索和读取功能
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import glob
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import threading
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Dict, Optional
|
| 12 |
+
|
| 13 |
+
from src.index import CodeIndex
|
| 14 |
+
|
| 15 |
+
# 全局目录树缓存
|
| 16 |
+
_dir_tree_cache: Dict[str, tuple] = {} # {cache_key: (tree, timestamp)}
|
| 17 |
+
_dir_tree_lock = threading.Lock()
|
| 18 |
+
_CACHE_TTL = 3600 # 缓存有效期(秒)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CodeSearcher:
|
| 22 |
+
"""初始化代码搜索器
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
root_dir: 代码根目录
|
| 26 |
+
use_index: 是否使用索引
|
| 27 |
+
lazy_index: 是否延迟构建索引(首次使用时才构建)
|
| 28 |
+
"""
|
| 29 |
+
def __init__(self, root_dir: str, use_index: bool = True, lazy_index: bool = False):
|
| 30 |
+
self.root_dir = Path(root_dir).resolve()
|
| 31 |
+
self.use_index = use_index
|
| 32 |
+
self.index: Optional[CodeIndex] = None
|
| 33 |
+
|
| 34 |
+
if use_index:
|
| 35 |
+
self.index = CodeIndex(self.root_dir)
|
| 36 |
+
if not lazy_index:
|
| 37 |
+
# 立即构建索引
|
| 38 |
+
self._build_index()
|
| 39 |
+
else:
|
| 40 |
+
# 延迟构建索引,在第一次调用时才构建
|
| 41 |
+
self._index_built = False
|
| 42 |
+
|
| 43 |
+
def _build_index(self, extensions: str = "*") -> Dict:
|
| 44 |
+
"""构建代码索引"""
|
| 45 |
+
if not self.index:
|
| 46 |
+
return {"error": "索引未启用"}
|
| 47 |
+
|
| 48 |
+
return self.index.build_index(extensions)
|
| 49 |
+
|
| 50 |
+
def rebuild_index(self, extensions: str = "*") -> Dict:
|
| 51 |
+
"""重建索引(强制)"""
|
| 52 |
+
self._index_built = True # 重置延迟标志
|
| 53 |
+
return self._build_index(extensions)
|
| 54 |
+
|
| 55 |
+
def _ensure_index(self, extensions: str = "*") -> Dict:
|
| 56 |
+
"""确保索引已构建(如果是首次则构建)"""
|
| 57 |
+
if not self._index_built:
|
| 58 |
+
return self._build_index(extensions)
|
| 59 |
+
return {"status": "索引已构建"}
|
| 60 |
+
|
| 61 |
+
def read_file(self, path: str, max_lines: int = 500, start_line: int = 1) -> Dict:
|
| 62 |
+
"""读取文件内容"""
|
| 63 |
+
try:
|
| 64 |
+
file_path = self.root_dir / path
|
| 65 |
+
if not file_path.exists():
|
| 66 |
+
return {"error": f"文件不存在: {path}"}
|
| 67 |
+
|
| 68 |
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
| 69 |
+
lines = f.readlines()
|
| 70 |
+
|
| 71 |
+
total_lines = len(lines)
|
| 72 |
+
end_line = min(start_line + max_lines - 1, total_lines)
|
| 73 |
+
content = ''.join(lines[start_line-1:end_line])
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"path": str(file_path.relative_to(self.root_dir)),
|
| 77 |
+
"total_lines": total_lines,
|
| 78 |
+
"start_line": start_line,
|
| 79 |
+
"end_line": end_line,
|
| 80 |
+
"content": content
|
| 81 |
+
}
|
| 82 |
+
except Exception as e:
|
| 83 |
+
return {"error": str(e)}
|
| 84 |
+
|
| 85 |
+
def find_files(self, pattern: str = "*", path: str = ".", max_results: int = 20) -> List[str]:
|
| 86 |
+
"""按文件名模式查找文件"""
|
| 87 |
+
try:
|
| 88 |
+
search_dir = self.root_dir / path
|
| 89 |
+
if not search_dir.exists():
|
| 90 |
+
return [f"错误: 路径不存在: {path}"]
|
| 91 |
+
matches = list(search_dir.glob(pattern))
|
| 92 |
+
results = []
|
| 93 |
+
for m in matches[:max_results]:
|
| 94 |
+
if m.is_file():
|
| 95 |
+
results.append(str(m.relative_to(self.root_dir)))
|
| 96 |
+
return results
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return [f"错误: {str(e)}"]
|
| 99 |
+
|
| 100 |
+
def search_code(self, keyword: str, extensions: str = "*", max_results: int = 20) -> List[Dict]:
|
| 101 |
+
"""搜索代码内容(优先使用索引)"""
|
| 102 |
+
# 如果启用了索引且索引已构建,使用索引搜索
|
| 103 |
+
if self.use_index and self.index and self.index.is_built():
|
| 104 |
+
try:
|
| 105 |
+
# 尝试判断是否为符号搜索
|
| 106 |
+
# 如果是单个单词且匹配符号模式,使用符号索引
|
| 107 |
+
if re.match(r'^\w+$', keyword):
|
| 108 |
+
symbol_results = self.index.search_symbols(keyword, max_results)
|
| 109 |
+
if symbol_results:
|
| 110 |
+
return symbol_results
|
| 111 |
+
|
| 112 |
+
# 使用关键词索引
|
| 113 |
+
return self.index.search_keywords(keyword, max_results)
|
| 114 |
+
except Exception as e:
|
| 115 |
+
# 索引搜索失败,回退到线性扫描
|
| 116 |
+
pass
|
| 117 |
+
|
| 118 |
+
# 线性扫描(回退方案)
|
| 119 |
+
results = []
|
| 120 |
+
ext_list = extensions.split(',') if extensions != "*" else None
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
for file_path in self.root_dir.rglob("*"):
|
| 124 |
+
if file_path.is_file():
|
| 125 |
+
# 检查扩展名
|
| 126 |
+
if ext_list and file_path.suffix.lstrip('.') not in ext_list:
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
| 131 |
+
content = f.read()
|
| 132 |
+
|
| 133 |
+
lines = content.split('\n')
|
| 134 |
+
for i, line in enumerate(lines, 1):
|
| 135 |
+
if re.search(keyword, line):
|
| 136 |
+
results.append({
|
| 137 |
+
"file": str(file_path.relative_to(self.root_dir)),
|
| 138 |
+
"line": i,
|
| 139 |
+
"content": line.strip()
|
| 140 |
+
})
|
| 141 |
+
if len(results) >= max_results:
|
| 142 |
+
return results
|
| 143 |
+
except Exception:
|
| 144 |
+
continue
|
| 145 |
+
except Exception as e:
|
| 146 |
+
return [{"error": str(e)}]
|
| 147 |
+
|
| 148 |
+
return results
|
| 149 |
+
|
| 150 |
+
def find_by_ext(self, extensions: str = "py", max_results: int = 20) -> List[str]:
|
| 151 |
+
"""按扩展名查找文件"""
|
| 152 |
+
results = []
|
| 153 |
+
ext_list = [e.strip() for e in extensions.split(',')]
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
for file_path in self.root_dir.rglob("*"):
|
| 157 |
+
if file_path.is_file() and file_path.suffix.lstrip('.') in ext_list:
|
| 158 |
+
results.append(str(file_path.relative_to(self.root_dir)))
|
| 159 |
+
if len(results) >= max_results:
|
| 160 |
+
break
|
| 161 |
+
except Exception as e:
|
| 162 |
+
return [f"错误: {str(e)}"]
|
| 163 |
+
|
| 164 |
+
return results
|
| 165 |
+
|
| 166 |
+
def list_dir(self, path: str = ".") -> Dict:
|
| 167 |
+
"""列出目录内容"""
|
| 168 |
+
try:
|
| 169 |
+
# 处理 path 参数,避免 JSON 双重序列化问题
|
| 170 |
+
if isinstance(path, dict):
|
| 171 |
+
# 如果 path 已经是 dict(被 JSON 序列化过了),直接使用
|
| 172 |
+
path = path.get('path', path)
|
| 173 |
+
logger.debug(f"[list_dir] path 参数已经是 dict: {path}")
|
| 174 |
+
|
| 175 |
+
dir_path = self.root_dir / path
|
| 176 |
+
if not dir_path.exists():
|
| 177 |
+
return {"error": f"目录不存在: {path}"}
|
| 178 |
+
|
| 179 |
+
items = []
|
| 180 |
+
for item in dir_path.iterdir():
|
| 181 |
+
items.append({
|
| 182 |
+
"name": item.name,
|
| 183 |
+
"type": "directory" if item.is_dir() else "file",
|
| 184 |
+
"path": str(item.relative_to(self.root_dir))
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
"path": str(dir_path.relative_to(self.root_dir)),
|
| 189 |
+
"items": items
|
| 190 |
+
}
|
| 191 |
+
except Exception as e:
|
| 192 |
+
return {"error": str(e)}
|
| 193 |
+
|
| 194 |
+
def get_file_info(self, path: str) -> Dict:
|
| 195 |
+
"""获取文件信息"""
|
| 196 |
+
try:
|
| 197 |
+
file_path = self.root_dir / path
|
| 198 |
+
if not file_path.exists():
|
| 199 |
+
return {"error": f"文件不存在: {path}"}
|
| 200 |
+
|
| 201 |
+
stat = file_path.stat()
|
| 202 |
+
return {
|
| 203 |
+
"path": str(file_path.relative_to(self.root_dir)),
|
| 204 |
+
"name": file_path.name,
|
| 205 |
+
"size": stat.st_size,
|
| 206 |
+
"created": stat.st_ctime,
|
| 207 |
+
"modified": stat.st_mtime,
|
| 208 |
+
"extension": file_path.suffix
|
| 209 |
+
}
|
| 210 |
+
except Exception as e:
|
| 211 |
+
return {"error": str(e)}
|
| 212 |
+
|
| 213 |
+
def get_dir_tree(self, max_depth: int = 3) -> str:
|
| 214 |
+
"""获取目录树结构(使用全局缓存)
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
max_depth: 最大深度,0 表示不限制
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
目录树字符串
|
| 221 |
+
"""
|
| 222 |
+
cache_key = f"{self.root_dir}:{max_depth}"
|
| 223 |
+
current_time = time.time()
|
| 224 |
+
|
| 225 |
+
# 检查缓存
|
| 226 |
+
with _dir_tree_lock:
|
| 227 |
+
if cache_key in _dir_tree_cache:
|
| 228 |
+
tree, timestamp = _dir_tree_cache[cache_key]
|
| 229 |
+
if current_time - timestamp < _CACHE_TTL:
|
| 230 |
+
return tree
|
| 231 |
+
|
| 232 |
+
# 生成新的目录树
|
| 233 |
+
lines = []
|
| 234 |
+
self._build_tree(self.root_dir, "", 0, max_depth, lines)
|
| 235 |
+
tree = "\n".join(lines)
|
| 236 |
+
|
| 237 |
+
# 缓存结果
|
| 238 |
+
with _dir_tree_lock:
|
| 239 |
+
_dir_tree_cache[cache_key] = (tree, current_time)
|
| 240 |
+
|
| 241 |
+
return tree
|
| 242 |
+
|
| 243 |
+
def _build_tree(self, path: Path, prefix: str, depth: int, max_depth: int, lines: List[str]):
|
| 244 |
+
"""递归构建目录树"""
|
| 245 |
+
try:
|
| 246 |
+
items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
| 247 |
+
except Exception:
|
| 248 |
+
return
|
| 249 |
+
|
| 250 |
+
for i, item in enumerate(items):
|
| 251 |
+
is_last = i == len(items) - 1
|
| 252 |
+
current_prefix = "└── " if is_last else "├── "
|
| 253 |
+
lines.append(f"{prefix}{current_prefix}{item.name}")
|
| 254 |
+
|
| 255 |
+
if item.is_dir() and (max_depth == 0 or depth < max_depth - 1):
|
| 256 |
+
next_prefix = prefix + (" " if is_last else "│ ")
|
| 257 |
+
self._build_tree(item, next_prefix, depth + 1, max_depth, lines)
|
src/session_storage.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
会话持久化存储
|
| 3 |
+
|
| 4 |
+
使用 SQLite 存储会话状态,支持服务器重启后恢复会话
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sqlite3
|
| 8 |
+
import json
|
| 9 |
+
import threading
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Dict, List, Optional, Any
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SessionStorage:
|
| 19 |
+
"""会话存储管理器"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, db_path: str = "./sessions.db"):
|
| 22 |
+
"""
|
| 23 |
+
初始化会话存储
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
db_path: SQLite 数据库文件路径
|
| 27 |
+
"""
|
| 28 |
+
self.db_path = Path(db_path)
|
| 29 |
+
self._lock = threading.Lock()
|
| 30 |
+
|
| 31 |
+
# 确保数据库目录存在
|
| 32 |
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
# 初始化数据库表
|
| 35 |
+
self._init_db()
|
| 36 |
+
|
| 37 |
+
def _init_db(self):
|
| 38 |
+
"""初始化数据库表结构"""
|
| 39 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 40 |
+
cursor = conn.cursor()
|
| 41 |
+
|
| 42 |
+
# 会话表:存储会话元数据和配置
|
| 43 |
+
cursor.execute("""
|
| 44 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 45 |
+
session_id TEXT PRIMARY KEY,
|
| 46 |
+
model TEXT NOT NULL,
|
| 47 |
+
base_url TEXT NOT NULL,
|
| 48 |
+
code_dir TEXT NOT NULL,
|
| 49 |
+
max_steps INTEGER NOT NULL,
|
| 50 |
+
stream_output INTEGER NOT NULL,
|
| 51 |
+
tree_depth INTEGER NOT NULL,
|
| 52 |
+
created_at TEXT NOT NULL,
|
| 53 |
+
updated_at TEXT NOT NULL,
|
| 54 |
+
last_active TEXT
|
| 55 |
+
)
|
| 56 |
+
""")
|
| 57 |
+
|
| 58 |
+
# 对话历史表:存储会话的对话记录
|
| 59 |
+
cursor.execute("""
|
| 60 |
+
CREATE TABLE IF NOT EXISTS conversation_history (
|
| 61 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 62 |
+
session_id TEXT NOT NULL,
|
| 63 |
+
role TEXT NOT NULL,
|
| 64 |
+
content TEXT NOT NULL,
|
| 65 |
+
FOREIGN KEY (session_id) REFERENCES sessions (session_id) ON DELETE CASCADE
|
| 66 |
+
)
|
| 67 |
+
""")
|
| 68 |
+
|
| 69 |
+
# 记忆表:存储 Memory 对象
|
| 70 |
+
cursor.execute("""
|
| 71 |
+
CREATE TABLE IF NOT EXISTS memories (
|
| 72 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 73 |
+
session_id TEXT NOT NULL,
|
| 74 |
+
file_path TEXT NOT NULL,
|
| 75 |
+
overview TEXT,
|
| 76 |
+
key_definitions TEXT, -- JSON 数组
|
| 77 |
+
core_logic TEXT,
|
| 78 |
+
dependencies TEXT, -- JSON 数组
|
| 79 |
+
needed_info TEXT,
|
| 80 |
+
FOREIGN KEY (session_id) REFERENCES sessions (session_id) ON DELETE CASCADE
|
| 81 |
+
)
|
| 82 |
+
""")
|
| 83 |
+
|
| 84 |
+
# 创建索引
|
| 85 |
+
cursor.execute("""
|
| 86 |
+
CREATE INDEX IF NOT EXISTS idx_session_conversation
|
| 87 |
+
ON conversation_history (session_id)
|
| 88 |
+
""")
|
| 89 |
+
cursor.execute("""
|
| 90 |
+
CREATE INDEX IF NOT EXISTS idx_session_memories
|
| 91 |
+
ON memories (session_id)
|
| 92 |
+
""")
|
| 93 |
+
|
| 94 |
+
conn.commit()
|
| 95 |
+
|
| 96 |
+
def save_session(
|
| 97 |
+
self,
|
| 98 |
+
session_id: str,
|
| 99 |
+
model: str,
|
| 100 |
+
base_url: str,
|
| 101 |
+
code_dir: str,
|
| 102 |
+
max_steps: int,
|
| 103 |
+
stream_output: bool,
|
| 104 |
+
tree_depth: int,
|
| 105 |
+
conversation_history: List[Dict],
|
| 106 |
+
memories: List[Dict]
|
| 107 |
+
) -> bool:
|
| 108 |
+
"""
|
| 109 |
+
保存会话完整状态
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
session_id: 会话ID
|
| 113 |
+
model: LLM 模型名称
|
| 114 |
+
base_url: API 基础URL
|
| 115 |
+
code_dir: 代码目录
|
| 116 |
+
max_steps: 最大步骤数
|
| 117 |
+
stream_output: 是否流式输出
|
| 118 |
+
tree_depth: 目录树深度
|
| 119 |
+
conversation_history: 对话历史列表
|
| 120 |
+
memories: Memory 对象列表
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
是否保存成功
|
| 124 |
+
"""
|
| 125 |
+
with self._lock:
|
| 126 |
+
try:
|
| 127 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 128 |
+
cursor = conn.cursor()
|
| 129 |
+
now = datetime.now().isoformat()
|
| 130 |
+
|
| 131 |
+
# 检查会话是否已存在
|
| 132 |
+
cursor.execute("SELECT session_id FROM sessions WHERE session_id = ?", (session_id,))
|
| 133 |
+
exists = cursor.fetchone() is not None
|
| 134 |
+
|
| 135 |
+
# 更新或插入会话元数据
|
| 136 |
+
if exists:
|
| 137 |
+
cursor.execute("""
|
| 138 |
+
UPDATE sessions
|
| 139 |
+
SET model = ?, base_url = ?, code_dir = ?,
|
| 140 |
+
max_steps = ?, stream_output = ?, tree_depth = ?,
|
| 141 |
+
updated_at = ?, last_active = ?
|
| 142 |
+
WHERE session_id = ?
|
| 143 |
+
""", (
|
| 144 |
+
model, base_url, code_dir,
|
| 145 |
+
max_steps, 1 if stream_output else 0, tree_depth,
|
| 146 |
+
now, now, session_id
|
| 147 |
+
))
|
| 148 |
+
else:
|
| 149 |
+
cursor.execute("""
|
| 150 |
+
INSERT INTO sessions
|
| 151 |
+
(session_id, model, base_url, code_dir, max_steps,
|
| 152 |
+
stream_output, tree_depth, created_at, updated_at, last_active)
|
| 153 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 154 |
+
""", (
|
| 155 |
+
session_id, model, base_url, code_dir, max_steps,
|
| 156 |
+
1 if stream_output else 0, tree_depth, now, now, now
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
# 删除旧的对话历史和记忆
|
| 160 |
+
cursor.execute("DELETE FROM conversation_history WHERE session_id = ?", (session_id,))
|
| 161 |
+
cursor.execute("DELETE FROM memories WHERE session_id = ?", (session_id,))
|
| 162 |
+
|
| 163 |
+
# 插入对话历史
|
| 164 |
+
for msg in conversation_history:
|
| 165 |
+
cursor.execute("""
|
| 166 |
+
INSERT INTO conversation_history (session_id, role, content)
|
| 167 |
+
VALUES (?, ?, ?)
|
| 168 |
+
""", (session_id, msg["role"], msg["content"]))
|
| 169 |
+
|
| 170 |
+
# 插入记忆
|
| 171 |
+
for memory in memories:
|
| 172 |
+
cursor.execute("""
|
| 173 |
+
INSERT INTO memories
|
| 174 |
+
(session_id, file_path, overview, key_definitions,
|
| 175 |
+
core_logic, dependencies, needed_info)
|
| 176 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 177 |
+
""", (
|
| 178 |
+
session_id,
|
| 179 |
+
memory["file"],
|
| 180 |
+
memory.get("overview", ""),
|
| 181 |
+
json.dumps(memory.get("key_definitions", []), ensure_ascii=False),
|
| 182 |
+
memory.get("core_logic", ""),
|
| 183 |
+
json.dumps(memory.get("dependencies", []), ensure_ascii=False),
|
| 184 |
+
memory.get("needed_info", "")
|
| 185 |
+
))
|
| 186 |
+
|
| 187 |
+
conn.commit()
|
| 188 |
+
logger.debug(f"[SessionStorage] 保存会话: {session_id}")
|
| 189 |
+
return True
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"[SessionStorage] 保存会话失败: {e}")
|
| 193 |
+
return False
|
| 194 |
+
|
| 195 |
+
def load_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 196 |
+
"""
|
| 197 |
+
加载会话状态
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
session_id: 会话ID
|
| 201 |
+
|
| 202 |
+
Returns:
|
| 203 |
+
会话数据字典,如果不存在返回 None
|
| 204 |
+
"""
|
| 205 |
+
with self._lock:
|
| 206 |
+
try:
|
| 207 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 208 |
+
conn.row_factory = sqlite3.Row
|
| 209 |
+
cursor = conn.cursor()
|
| 210 |
+
|
| 211 |
+
# 查询会话元数据
|
| 212 |
+
cursor.execute("""
|
| 213 |
+
SELECT * FROM sessions WHERE session_id = ?
|
| 214 |
+
""", (session_id,))
|
| 215 |
+
session_row = cursor.fetchone()
|
| 216 |
+
|
| 217 |
+
if not session_row:
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
# 加载对话历史
|
| 221 |
+
cursor.execute("""
|
| 222 |
+
SELECT role, content FROM conversation_history
|
| 223 |
+
WHERE session_id = ? ORDER BY id
|
| 224 |
+
""", (session_id,))
|
| 225 |
+
conversation_history = [
|
| 226 |
+
{"role": row["role"], "content": row["content"]}
|
| 227 |
+
for row in cursor.fetchall()
|
| 228 |
+
]
|
| 229 |
+
|
| 230 |
+
# 加载记忆
|
| 231 |
+
cursor.execute("""
|
| 232 |
+
SELECT * FROM memories WHERE session_id = ?
|
| 233 |
+
""", (session_id,))
|
| 234 |
+
memories = [
|
| 235 |
+
{
|
| 236 |
+
"file_path": row["file_path"],
|
| 237 |
+
"overview": row["overview"],
|
| 238 |
+
"key_definitions": json.loads(row["key_definitions"]) if row["key_definitions"] else [],
|
| 239 |
+
"core_logic": row["core_logic"],
|
| 240 |
+
"dependencies": json.loads(row["dependencies"]) if row["dependencies"] else [],
|
| 241 |
+
"needed_info": row["needed_info"]
|
| 242 |
+
}
|
| 243 |
+
for row in cursor.fetchall()
|
| 244 |
+
]
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"session_id": session_row["session_id"],
|
| 248 |
+
"model": session_row["model"],
|
| 249 |
+
"base_url": session_row["base_url"],
|
| 250 |
+
"code_dir": session_row["code_dir"],
|
| 251 |
+
"max_steps": session_row["max_steps"],
|
| 252 |
+
"stream_output": bool(session_row["stream_output"]),
|
| 253 |
+
"tree_depth": session_row["tree_depth"],
|
| 254 |
+
"created_at": session_row["created_at"],
|
| 255 |
+
"updated_at": session_row["updated_at"],
|
| 256 |
+
"last_active": session_row["last_active"],
|
| 257 |
+
"conversation_history": conversation_history,
|
| 258 |
+
"memories": memories
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.error(f"[SessionStorage] 加载会话失败: {e}")
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
def delete_session(self, session_id: str) -> bool:
|
| 266 |
+
"""
|
| 267 |
+
删除会话
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
session_id: 会话ID
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
是否删除成功
|
| 274 |
+
"""
|
| 275 |
+
with self._lock:
|
| 276 |
+
try:
|
| 277 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 278 |
+
cursor = conn.cursor()
|
| 279 |
+
cursor.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
| 280 |
+
conn.commit()
|
| 281 |
+
logger.debug(f"[SessionStorage] 删除会话: {session_id}")
|
| 282 |
+
return cursor.rowcount > 0
|
| 283 |
+
|
| 284 |
+
except Exception as e:
|
| 285 |
+
logger.error(f"[SessionStorage] 删除会话失败: {e}")
|
| 286 |
+
return False
|
| 287 |
+
|
| 288 |
+
def list_sessions(self) -> List[Dict[str, Any]]:
|
| 289 |
+
"""
|
| 290 |
+
列出所有会话(不含详细内容)
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
会话列表
|
| 294 |
+
"""
|
| 295 |
+
with self._lock:
|
| 296 |
+
try:
|
| 297 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 298 |
+
conn.row_factory = sqlite3.Row
|
| 299 |
+
cursor = conn.cursor()
|
| 300 |
+
|
| 301 |
+
cursor.execute("""
|
| 302 |
+
SELECT session_id, model, code_dir, created_at, updated_at, last_active,
|
| 303 |
+
(SELECT COUNT(*) FROM conversation_history WHERE session_id = s.session_id) as message_count,
|
| 304 |
+
(SELECT COUNT(*) FROM memories WHERE session_id = s.session_id) as memory_count
|
| 305 |
+
FROM sessions s
|
| 306 |
+
ORDER BY updated_at DESC
|
| 307 |
+
""")
|
| 308 |
+
|
| 309 |
+
return [
|
| 310 |
+
{
|
| 311 |
+
"session_id": row["session_id"],
|
| 312 |
+
"model": row["model"],
|
| 313 |
+
"code_dir": row["code_dir"],
|
| 314 |
+
"created_at": row["created_at"],
|
| 315 |
+
"updated_at": row["updated_at"],
|
| 316 |
+
"last_active": row["last_active"],
|
| 317 |
+
"message_count": row["message_count"],
|
| 318 |
+
"memory_count": row["memory_count"]
|
| 319 |
+
}
|
| 320 |
+
for row in cursor.fetchall()
|
| 321 |
+
]
|
| 322 |
+
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logger.error(f"[SessionStorage] 列出会话失败: {e}")
|
| 325 |
+
return []
|
| 326 |
+
|
| 327 |
+
def clear_all(self) -> bool:
|
| 328 |
+
"""
|
| 329 |
+
清空所有会话
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
是否清空成功
|
| 333 |
+
"""
|
| 334 |
+
with self._lock:
|
| 335 |
+
try:
|
| 336 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 337 |
+
cursor = conn.cursor()
|
| 338 |
+
cursor.execute("DELETE FROM sessions")
|
| 339 |
+
conn.commit()
|
| 340 |
+
logger.info(f"[SessionStorage] 清空所有会话")
|
| 341 |
+
return True
|
| 342 |
+
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.error(f"[SessionStorage] 清空会话失败: {e}")
|
| 345 |
+
return False
|
| 346 |
+
|
| 347 |
+
def cleanup_old_sessions(self, days: int = 30) -> int:
|
| 348 |
+
"""
|
| 349 |
+
清理超过指定天数未活跃的会话
|
| 350 |
+
|
| 351 |
+
Args:
|
| 352 |
+
days: 天数阈值
|
| 353 |
+
|
| 354 |
+
Returns:
|
| 355 |
+
删除的会话数
|
| 356 |
+
"""
|
| 357 |
+
with self._lock:
|
| 358 |
+
try:
|
| 359 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 360 |
+
cursor = conn.cursor()
|
| 361 |
+
|
| 362 |
+
# 删除超过 N 天未活跃的会话
|
| 363 |
+
cursor.execute("""
|
| 364 |
+
DELETE FROM sessions
|
| 365 |
+
WHERE datetime(last_active) < datetime('now', '-' || ? || ' days')
|
| 366 |
+
""", (days,))
|
| 367 |
+
|
| 368 |
+
count = cursor.rowcount
|
| 369 |
+
conn.commit()
|
| 370 |
+
logger.info(f"[SessionStorage] 清理了 {count} 个旧会话")
|
| 371 |
+
return count
|
| 372 |
+
|
| 373 |
+
except Exception as e:
|
| 374 |
+
logger.error(f"[SessionStorage] 清理旧会话失败: {e}")
|
| 375 |
+
return 0
|
static/index.html
ADDED
|
@@ -0,0 +1,1337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>astrbot-问题帮助助手</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--primary: #4361ee;
|
| 11 |
+
--primary-light: #4895ef;
|
| 12 |
+
--secondary: #3f37c9;
|
| 13 |
+
--accent: #f72585;
|
| 14 |
+
--light: #ffffff;
|
| 15 |
+
--light-gray: #f8f9fa;
|
| 16 |
+
--gray: #e9ecef;
|
| 17 |
+
--dark-gray: #6c757d;
|
| 18 |
+
--dark: #212529;
|
| 19 |
+
--shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
| 20 |
+
--shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.12);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
* {
|
| 24 |
+
margin: 0;
|
| 25 |
+
padding: 0;
|
| 26 |
+
box-sizing: border-box;
|
| 27 |
+
font-family: 'Inter', 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
body {
|
| 31 |
+
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
|
| 32 |
+
color: var(--dark);
|
| 33 |
+
min-height: 100vh;
|
| 34 |
+
display: flex;
|
| 35 |
+
align-items: center;
|
| 36 |
+
justify-content: center;
|
| 37 |
+
padding: 20px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.container {
|
| 41 |
+
max-width: 900px;
|
| 42 |
+
width: 100%;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.chat-card {
|
| 46 |
+
background: var(--light);
|
| 47 |
+
border-radius: 20px;
|
| 48 |
+
box-shadow: var(--shadow);
|
| 49 |
+
overflow: hidden;
|
| 50 |
+
height: 85vh;
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-direction: column;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.chat-header {
|
| 56 |
+
padding: 20px;
|
| 57 |
+
border-bottom: 1px solid var(--gray);
|
| 58 |
+
display: flex;
|
| 59 |
+
align-items: center;
|
| 60 |
+
justify-content: space-between;
|
| 61 |
+
background: var(--light);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.header-title {
|
| 65 |
+
font-size: 1.5rem;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
color: var(--primary);
|
| 68 |
+
letter-spacing: -0.5px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.header-actions {
|
| 72 |
+
display: flex;
|
| 73 |
+
gap: 10px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.clear-btn {
|
| 77 |
+
background: transparent;
|
| 78 |
+
border: 1px solid var(--gray);
|
| 79 |
+
color: var(--dark-gray);
|
| 80 |
+
border-radius: 8px;
|
| 81 |
+
padding: 8px 12px;
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
font-size: 0.85rem;
|
| 84 |
+
transition: all 0.3s;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.clear-btn:hover {
|
| 88 |
+
background: #ffebee;
|
| 89 |
+
border-color: #ef5350;
|
| 90 |
+
color: #c62828;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.chat-messages {
|
| 94 |
+
flex: 1;
|
| 95 |
+
padding: 20px;
|
| 96 |
+
overflow-y: auto;
|
| 97 |
+
display: flex;
|
| 98 |
+
flex-direction: column;
|
| 99 |
+
gap: 15px;
|
| 100 |
+
background: var(--light-gray);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.message {
|
| 104 |
+
max-width: 80%;
|
| 105 |
+
padding: 12px 18px;
|
| 106 |
+
border-radius: 18px;
|
| 107 |
+
position: relative;
|
| 108 |
+
animation: messageAppear 0.3s ease;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@keyframes messageAppear {
|
| 112 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 113 |
+
to { opacity: 1; transform: translateY(0); }
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.user-message {
|
| 117 |
+
align-self: flex-end;
|
| 118 |
+
background: var(--primary);
|
| 119 |
+
color: white;
|
| 120 |
+
border-bottom-right-radius: 5px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.bot-message {
|
| 124 |
+
align-self: flex-start;
|
| 125 |
+
background: var(--light);
|
| 126 |
+
color: var(--dark);
|
| 127 |
+
border-bottom-left-radius: 5px;
|
| 128 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.chat-input-container {
|
| 132 |
+
padding: 20px;
|
| 133 |
+
border-top: 1px solid var(--gray);
|
| 134 |
+
display: flex;
|
| 135 |
+
flex-direction: column;
|
| 136 |
+
gap: 15px;
|
| 137 |
+
background: var(--light);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.input-row {
|
| 141 |
+
display: flex;
|
| 142 |
+
gap: 10px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.chat-input {
|
| 146 |
+
flex: 1;
|
| 147 |
+
padding: 15px;
|
| 148 |
+
background: var(--light-gray);
|
| 149 |
+
border: 1px solid var(--gray);
|
| 150 |
+
border-radius: 12px;
|
| 151 |
+
font-size: 1rem;
|
| 152 |
+
color: var(--dark);
|
| 153 |
+
outline: none;
|
| 154 |
+
transition: all 0.3s;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.chat-input:focus {
|
| 158 |
+
border-color: var(--primary);
|
| 159 |
+
box-shadow: 0 0 0 2px rgba(67, 97, 238, 0.1);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.chat-input::placeholder {
|
| 163 |
+
color: var(--dark-gray);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.send-button {
|
| 167 |
+
background: var(--primary);
|
| 168 |
+
color: white;
|
| 169 |
+
border: none;
|
| 170 |
+
border-radius: 12px;
|
| 171 |
+
padding: 0 25px;
|
| 172 |
+
cursor: pointer;
|
| 173 |
+
font-weight: 600;
|
| 174 |
+
transition: all 0.3s;
|
| 175 |
+
display: flex;
|
| 176 |
+
align-items: center;
|
| 177 |
+
gap: 8px;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.send-button:hover {
|
| 181 |
+
background: var(--secondary);
|
| 182 |
+
transform: translateY(-2px);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.send-button:disabled {
|
| 186 |
+
opacity: 0.6;
|
| 187 |
+
cursor: not-allowed;
|
| 188 |
+
transform: none;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.typing-indicator {
|
| 192 |
+
display: flex;
|
| 193 |
+
align-items: center;
|
| 194 |
+
gap: 5px;
|
| 195 |
+
padding: 15px;
|
| 196 |
+
background: var(--light);
|
| 197 |
+
border-radius: 18px;
|
| 198 |
+
align-self: flex-start;
|
| 199 |
+
width: fit-content;
|
| 200 |
+
margin-bottom: 10px;
|
| 201 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.typing-dot {
|
| 205 |
+
width: 8px;
|
| 206 |
+
height: 8px;
|
| 207 |
+
background: var(--dark-gray);
|
| 208 |
+
border-radius: 50%;
|
| 209 |
+
animation: typing 1.4s infinite ease-in-out;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
|
| 213 |
+
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
|
| 214 |
+
|
| 215 |
+
@keyframes typing {
|
| 216 |
+
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
|
| 217 |
+
40% { transform: scale(1); opacity: 1; }
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/* 步骤容器样式 */
|
| 221 |
+
.step-container {
|
| 222 |
+
margin: 8px 0;
|
| 223 |
+
background: rgba(67, 97, 238, 0.05);
|
| 224 |
+
border: 1px solid rgba(67, 97, 238, 0.2);
|
| 225 |
+
border-radius: 8px;
|
| 226 |
+
overflow: hidden;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.step-header {
|
| 230 |
+
padding: 8px 12px;
|
| 231 |
+
background: rgba(67, 97, 238, 0.1);
|
| 232 |
+
cursor: pointer;
|
| 233 |
+
display: flex;
|
| 234 |
+
align-items: center;
|
| 235 |
+
justify-content: space-between;
|
| 236 |
+
font-weight: 600;
|
| 237 |
+
color: var(--primary);
|
| 238 |
+
transition: background 0.3s;
|
| 239 |
+
font-size: 0.9rem;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.step-header:hover {
|
| 243 |
+
background: rgba(67, 97, 238, 0.15);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.step-toggle {
|
| 247 |
+
font-size: 0.7rem;
|
| 248 |
+
transition: transform 0.3s;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.step-toggle.expanded {
|
| 252 |
+
transform: rotate(180deg);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.step-content {
|
| 256 |
+
max-height: 300px;
|
| 257 |
+
overflow: auto;
|
| 258 |
+
transition: max-height 0.3s ease, padding 0.3s ease;
|
| 259 |
+
padding: 12px;
|
| 260 |
+
background: var(--light);
|
| 261 |
+
border-top: 1px solid rgba(67, 97, 238, 0.1);
|
| 262 |
+
font-size: 0.9rem;
|
| 263 |
+
line-height: 1.4;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.step-content.collapsed {
|
| 267 |
+
max-height: 0 !important;
|
| 268 |
+
padding: 0 !important;
|
| 269 |
+
border-top: none !important;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.step-item {
|
| 273 |
+
margin-bottom: 10px;
|
| 274 |
+
padding: 8px;
|
| 275 |
+
background: var(--light-gray);
|
| 276 |
+
border-radius: 6px;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.step-item:last-child {
|
| 280 |
+
margin-bottom: 0;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.step-thought {
|
| 284 |
+
color: var(--primary);
|
| 285 |
+
font-weight: 500;
|
| 286 |
+
margin-bottom: 5px;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.step-action {
|
| 290 |
+
color: var(--secondary);
|
| 291 |
+
font-family: monospace;
|
| 292 |
+
font-size: 0.85rem;
|
| 293 |
+
margin-bottom: 5px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.step-observation {
|
| 297 |
+
color: var(--dark-gray);
|
| 298 |
+
font-size: 0.85rem;
|
| 299 |
+
white-space: pre-wrap;
|
| 300 |
+
word-break: break-word;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.step-observation.success {
|
| 304 |
+
color: #2e7d32;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.step-observation.error {
|
| 308 |
+
color: #c62828;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
/* 最终答案样式 */
|
| 312 |
+
.final-answer {
|
| 313 |
+
margin-top: 10px;
|
| 314 |
+
padding: 12px;
|
| 315 |
+
border-radius: 8px;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.final-answer-title {
|
| 319 |
+
font-weight: 600;
|
| 320 |
+
margin-bottom: 8px;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.final-answer-content {
|
| 324 |
+
line-height: 1.5;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
@media (max-width: 768px) {
|
| 328 |
+
.chat-card {
|
| 329 |
+
height: 100vh;
|
| 330 |
+
border-radius: 0;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.message {
|
| 334 |
+
max-width: 90%;
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.disclaimer {
|
| 339 |
+
font-size: 0.75rem;
|
| 340 |
+
color: var(--dark-gray);
|
| 341 |
+
text-align: center;
|
| 342 |
+
margin-top: 8px;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.page-footer {
|
| 346 |
+
font-size: 0.75rem;
|
| 347 |
+
color: var(--dark-gray);
|
| 348 |
+
text-align: center;
|
| 349 |
+
margin-top: 12px;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/* 弹窗样式 */
|
| 353 |
+
.modal-overlay {
|
| 354 |
+
position: fixed;
|
| 355 |
+
top: 0;
|
| 356 |
+
left: 0;
|
| 357 |
+
right: 0;
|
| 358 |
+
bottom: 0;
|
| 359 |
+
background: rgba(0, 0, 0, 0.5);
|
| 360 |
+
display: flex;
|
| 361 |
+
align-items: center;
|
| 362 |
+
justify-content: center;
|
| 363 |
+
z-index: 9999;
|
| 364 |
+
opacity: 0;
|
| 365 |
+
visibility: hidden;
|
| 366 |
+
transition: all 0.3s ease;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.modal-overlay.active {
|
| 370 |
+
opacity: 1;
|
| 371 |
+
visibility: visible;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.modal-overlay.fade-out {
|
| 375 |
+
opacity: 0;
|
| 376 |
+
visibility: hidden;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.modal-container {
|
| 380 |
+
background: var(--light);
|
| 381 |
+
border-radius: 16px;
|
| 382 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 383 |
+
max-width: 500px;
|
| 384 |
+
width: 90%;
|
| 385 |
+
max-height: 80vh;
|
| 386 |
+
overflow: auto;
|
| 387 |
+
transform: scale(0.9) translateY(20px);
|
| 388 |
+
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.modal-overlay.active .modal-container {
|
| 392 |
+
transform: scale(1) translateY(0);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.modal-header {
|
| 396 |
+
padding: 20px 24px;
|
| 397 |
+
border-bottom: 1px solid var(--gray);
|
| 398 |
+
display: flex;
|
| 399 |
+
align-items: center;
|
| 400 |
+
gap: 12px;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.modal-header-icon {
|
| 404 |
+
font-size: 1.5rem;
|
| 405 |
+
color: var(--primary);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.modal-header-title {
|
| 409 |
+
font-size: 1.25rem;
|
| 410 |
+
font-weight: 600;
|
| 411 |
+
color: var(--dark);
|
| 412 |
+
flex: 1;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
.modal-close-button {
|
| 416 |
+
background: transparent;
|
| 417 |
+
border: none;
|
| 418 |
+
color: var(--dark-gray);
|
| 419 |
+
font-size: 1.5rem;
|
| 420 |
+
cursor: pointer;
|
| 421 |
+
padding: 4px 8px;
|
| 422 |
+
border-radius: 4px;
|
| 423 |
+
transition: all 0.2s;
|
| 424 |
+
line-height: 1;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.modal-close-button:hover {
|
| 428 |
+
background: var(--light-gray);
|
| 429 |
+
color: var(--dark);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.modal-content {
|
| 433 |
+
padding: 24px;
|
| 434 |
+
line-height: 1.6;
|
| 435 |
+
color: var(--dark);
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.modal-content p {
|
| 439 |
+
margin-bottom: 12px;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.modal-content p:last-child {
|
| 443 |
+
margin-bottom: 0;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.modal-footer {
|
| 447 |
+
padding: 16px 24px;
|
| 448 |
+
border-top: 1px solid var(--gray);
|
| 449 |
+
display: flex;
|
| 450 |
+
gap: 12px;
|
| 451 |
+
justify-content: flex-end;
|
| 452 |
+
flex-wrap: wrap;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.modal-button {
|
| 456 |
+
padding: 10px 20px;
|
| 457 |
+
border-radius: 8px;
|
| 458 |
+
font-size: 0.95rem;
|
| 459 |
+
font-weight: 500;
|
| 460 |
+
cursor: pointer;
|
| 461 |
+
transition: all 0.2s;
|
| 462 |
+
border: none;
|
| 463 |
+
outline: none;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.modal-button-primary {
|
| 467 |
+
background: var(--primary);
|
| 468 |
+
color: white;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.modal-button-primary:hover {
|
| 472 |
+
background: var(--secondary);
|
| 473 |
+
transform: translateY(-1px);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.modal-button-secondary {
|
| 477 |
+
background: var(--light-gray);
|
| 478 |
+
color: var(--dark);
|
| 479 |
+
border: 1px solid var(--gray);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.modal-button-secondary:hover {
|
| 483 |
+
background: var(--gray);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.modal-button-link {
|
| 487 |
+
background: transparent;
|
| 488 |
+
color: var(--primary);
|
| 489 |
+
padding: 10px 16px;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.modal-button-link:hover {
|
| 493 |
+
background: rgba(67, 97, 238, 0.1);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
@media (max-width: 600px) {
|
| 497 |
+
.modal-container {
|
| 498 |
+
width: 95%;
|
| 499 |
+
max-height: 90vh;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.modal-footer {
|
| 503 |
+
flex-direction: column;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.modal-button {
|
| 507 |
+
width: 100%;
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
</style>
|
| 511 |
+
</head>
|
| 512 |
+
<body>
|
| 513 |
+
<div class="container">
|
| 514 |
+
<div class="chat-card">
|
| 515 |
+
<div class="chat-header">
|
| 516 |
+
<div class="header-title">astrbot-问题帮助助手</div>
|
| 517 |
+
<div class="header-actions">
|
| 518 |
+
<button class="clear-btn" id="clearBtn" title="开始新对话">
|
| 519 |
+
<i class="fas fa-plus"></i> 新对话
|
| 520 |
+
</button>
|
| 521 |
+
</div>
|
| 522 |
+
</div>
|
| 523 |
+
|
| 524 |
+
<div class="chat-messages" id="chatMessages">
|
| 525 |
+
<div class="message bot-message">
|
| 526 |
+
您好!我是astrbot帮助助手,一个智能帮助助手。<br><br>
|
| 527 |
+
💡 我可以帮您:<br>
|
| 528 |
+
• 了解astrbot<br>
|
| 529 |
+
• 协助安装<br>
|
| 530 |
+
• 解决遇到的问题<br>
|
| 531 |
+
请直接输入您的问题!
|
| 532 |
+
</div>
|
| 533 |
+
</div>
|
| 534 |
+
|
| 535 |
+
<div class="chat-input-container">
|
| 536 |
+
<div class="input-row">
|
| 537 |
+
<input type="text" class="chat-input" id="questionInput" placeholder="输入关于代码的问题...">
|
| 538 |
+
<button class="send-button" id="sendButton">
|
| 539 |
+
<i class="fas fa-paper-plane"></i>
|
| 540 |
+
</button>
|
| 541 |
+
</div>
|
| 542 |
+
</div>
|
| 543 |
+
</div>
|
| 544 |
+
<div class="page-footer">
|
| 545 |
+
⚠️ AI 回复仅供参考,请自行鉴别准确性
|
| 546 |
+
</div>
|
| 547 |
+
</div>
|
| 548 |
+
|
| 549 |
+
<!-- 弹窗容器 -->
|
| 550 |
+
<div id="modalOverlay" class="modal-overlay">
|
| 551 |
+
<div class="modal-container" id="modalContainer">
|
| 552 |
+
<div class="modal-header">
|
| 553 |
+
<i id="modalHeaderIcon" class="modal-header-icon"></i>
|
| 554 |
+
<h3 id="modalHeaderTitle" class="modal-header-title"></h3>
|
| 555 |
+
<button id="modalCloseButton" class="modal-close-button" aria-label="关闭">
|
| 556 |
+
<i class="fas fa-times"></i>
|
| 557 |
+
</button>
|
| 558 |
+
</div>
|
| 559 |
+
<div id="modalContent" class="modal-content"></div>
|
| 560 |
+
<div id="modalFooter" class="modal-footer"></div>
|
| 561 |
+
</div>
|
| 562 |
+
</div>
|
| 563 |
+
|
| 564 |
+
<script>
|
| 565 |
+
// 会话管理
|
| 566 |
+
const SESSION_KEY = 'astrbot_session_id';
|
| 567 |
+
|
| 568 |
+
function getSessionId() {
|
| 569 |
+
let sessionId = localStorage.getItem(SESSION_KEY);
|
| 570 |
+
if (!sessionId) {
|
| 571 |
+
sessionId = generateSessionId();
|
| 572 |
+
localStorage.setItem(SESSION_KEY, sessionId);
|
| 573 |
+
}
|
| 574 |
+
return sessionId;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
function generateSessionId() {
|
| 578 |
+
return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
function clearSession() {
|
| 582 |
+
localStorage.removeItem(SESSION_KEY);
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
function setSessionId(sessionId) {
|
| 586 |
+
localStorage.setItem(SESSION_KEY, sessionId);
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
// 消息历史记录
|
| 590 |
+
let messageHistory = [];
|
| 591 |
+
const MAX_HISTORY_LENGTH = 20;
|
| 592 |
+
|
| 593 |
+
// 防止重复发送的标志
|
| 594 |
+
let isSending = false;
|
| 595 |
+
|
| 596 |
+
// 获取DOM元素
|
| 597 |
+
const chatMessages = document.getElementById('chatMessages');
|
| 598 |
+
const questionInput = document.getElementById('questionInput');
|
| 599 |
+
const sendButton = document.getElementById('sendButton');
|
| 600 |
+
const clearBtn = document.getElementById('clearBtn');
|
| 601 |
+
|
| 602 |
+
// 新建会话
|
| 603 |
+
clearBtn.addEventListener('click', async function() {
|
| 604 |
+
if (!confirm('确定要开始新对话吗?当前对话将被清除。')) {
|
| 605 |
+
return;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
try {
|
| 609 |
+
const sessionId = getSessionId();
|
| 610 |
+
await fetch('/api/session/clear', {
|
| 611 |
+
method: 'POST',
|
| 612 |
+
headers: { 'Content-Type': 'application/json' },
|
| 613 |
+
body: JSON.stringify({ session_id: sessionId })
|
| 614 |
+
});
|
| 615 |
+
} catch (e) {
|
| 616 |
+
console.error('清除会话失败:', e);
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
// 生成新的会话 ID
|
| 620 |
+
const newSessionId = generateSessionId();
|
| 621 |
+
setSessionId(newSessionId);
|
| 622 |
+
|
| 623 |
+
// 清除聊天记录
|
| 624 |
+
chatMessages.innerHTML = `
|
| 625 |
+
<div class="message bot-message">
|
| 626 |
+
您好!我是astrbot帮助助手。(非官方)<br><br>
|
| 627 |
+
💡 我可以帮您:<br>
|
| 628 |
+
• 了解astrbot<br>
|
| 629 |
+
• 协助安装<br>
|
| 630 |
+
• 解决遇到的问题<br>
|
| 631 |
+
请直接输入您的问题!
|
| 632 |
+
</div>
|
| 633 |
+
`;
|
| 634 |
+
messageHistory = [];
|
| 635 |
+
});
|
| 636 |
+
|
| 637 |
+
// 发送消息
|
| 638 |
+
async function sendMessage() {
|
| 639 |
+
// 防止重复发送
|
| 640 |
+
if (isSending) {
|
| 641 |
+
return;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
const message = questionInput.value.trim();
|
| 645 |
+
|
| 646 |
+
if (!message) {
|
| 647 |
+
return;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
// 设置发送标志
|
| 651 |
+
isSending = true;
|
| 652 |
+
sendButton.disabled = true;
|
| 653 |
+
sendButton.style.opacity = '0.6';
|
| 654 |
+
|
| 655 |
+
// 添加用户消息
|
| 656 |
+
addMessage(message, 'user');
|
| 657 |
+
messageHistory.push({ role: 'user', content: message });
|
| 658 |
+
|
| 659 |
+
// 清空输入
|
| 660 |
+
questionInput.value = '';
|
| 661 |
+
|
| 662 |
+
// 显示机器人正在输入
|
| 663 |
+
showTypingIndicator();
|
| 664 |
+
|
| 665 |
+
try {
|
| 666 |
+
console.log('发送请求到 /api/ask...');
|
| 667 |
+
|
| 668 |
+
// 获取会话 ID
|
| 669 |
+
const sessionId = getSessionId();
|
| 670 |
+
|
| 671 |
+
// 调用 Read Agent API(流式)
|
| 672 |
+
const response = await fetch('/api/ask', {
|
| 673 |
+
method: 'POST',
|
| 674 |
+
headers: {
|
| 675 |
+
'Content-Type': 'application/json'
|
| 676 |
+
},
|
| 677 |
+
body: JSON.stringify({
|
| 678 |
+
question: message,
|
| 679 |
+
stream: true,
|
| 680 |
+
session_id: sessionId
|
| 681 |
+
})
|
| 682 |
+
});
|
| 683 |
+
|
| 684 |
+
console.log('���应状态:', response.status);
|
| 685 |
+
|
| 686 |
+
if (!response.ok) {
|
| 687 |
+
const errorText = await response.text();
|
| 688 |
+
console.error('API 错误:', response.status, errorText);
|
| 689 |
+
throw new Error(`API请求失败: ${response.status} - ${errorText}`);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
console.log('开始读取流式响应...');
|
| 693 |
+
console.log('response.body:', response.body);
|
| 694 |
+
|
| 695 |
+
if (!response.body) {
|
| 696 |
+
console.error('response.body is null!');
|
| 697 |
+
throw new Error('响应体为空');
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
// 创建消息容器(但不移除 loading,等收到第一个数据再移除)
|
| 701 |
+
const messageDiv = document.createElement('div');
|
| 702 |
+
messageDiv.classList.add('message', 'bot-message');
|
| 703 |
+
chatMessages.appendChild(messageDiv);
|
| 704 |
+
|
| 705 |
+
// 创建步骤容器
|
| 706 |
+
const stepContainer = document.createElement('div');
|
| 707 |
+
stepContainer.className = 'step-container';
|
| 708 |
+
stepContainer.style.display = 'none';
|
| 709 |
+
|
| 710 |
+
const stepHeader = document.createElement('div');
|
| 711 |
+
stepHeader.className = 'step-header';
|
| 712 |
+
|
| 713 |
+
const stepTitle = document.createElement('span');
|
| 714 |
+
stepTitle.textContent = '分析过程';
|
| 715 |
+
|
| 716 |
+
const stepToggle = document.createElement('span');
|
| 717 |
+
stepToggle.className = 'step-toggle';
|
| 718 |
+
stepToggle.textContent = '▼';
|
| 719 |
+
|
| 720 |
+
const stepContent = document.createElement('div');
|
| 721 |
+
stepContent.className = 'step-content';
|
| 722 |
+
|
| 723 |
+
stepHeader.appendChild(stepTitle);
|
| 724 |
+
stepHeader.appendChild(stepToggle);
|
| 725 |
+
stepContainer.appendChild(stepHeader);
|
| 726 |
+
stepContainer.appendChild(stepContent);
|
| 727 |
+
|
| 728 |
+
// 添加点击事件
|
| 729 |
+
stepHeader.onclick = function() {
|
| 730 |
+
const isCollapsing = stepContent.classList.contains('collapsed');
|
| 731 |
+
stepContent.classList.toggle('collapsed');
|
| 732 |
+
stepToggle.classList.toggle('expanded');
|
| 733 |
+
// 展开时滚动到底部
|
| 734 |
+
if (isCollapsing) {
|
| 735 |
+
requestAnimationFrame(() => {
|
| 736 |
+
stepContent.scrollTop = stepContent.scrollHeight;
|
| 737 |
+
});
|
| 738 |
+
}
|
| 739 |
+
};
|
| 740 |
+
|
| 741 |
+
messageDiv.appendChild(stepContainer);
|
| 742 |
+
|
| 743 |
+
// 创建最终答案容器(打字机效果)
|
| 744 |
+
const finalAnswerDiv = document.createElement('div');
|
| 745 |
+
finalAnswerDiv.className = 'final-answer';
|
| 746 |
+
finalAnswerDiv.style.display = 'none';
|
| 747 |
+
|
| 748 |
+
const answerTitle = document.createElement('div');
|
| 749 |
+
answerTitle.className = 'final-answer-title';
|
| 750 |
+
answerTitle.textContent = '';
|
| 751 |
+
answerTitle.style.display = 'none';
|
| 752 |
+
|
| 753 |
+
const answerContent = document.createElement('div');
|
| 754 |
+
answerContent.className = 'final-answer-content';
|
| 755 |
+
|
| 756 |
+
finalAnswerDiv.appendChild(answerTitle);
|
| 757 |
+
finalAnswerDiv.appendChild(answerContent);
|
| 758 |
+
messageDiv.appendChild(finalAnswerDiv);
|
| 759 |
+
|
| 760 |
+
let currentThoughtDiv = null;
|
| 761 |
+
let currentThoughtText = '';
|
| 762 |
+
let currentStepItem = null;
|
| 763 |
+
let finalAnswer = '';
|
| 764 |
+
let hasSteps = false;
|
| 765 |
+
let isThinking = false;
|
| 766 |
+
let isAnswering = false;
|
| 767 |
+
let hasReceivedData = false; // 标记是否收到数据
|
| 768 |
+
|
| 769 |
+
// 处理流式响应
|
| 770 |
+
const reader = response.body.getReader();
|
| 771 |
+
const decoder = new TextDecoder();
|
| 772 |
+
|
| 773 |
+
console.log('开始读取流式响应...');
|
| 774 |
+
|
| 775 |
+
while (true) {
|
| 776 |
+
const { done, value } = await reader.read();
|
| 777 |
+
if (done) {
|
| 778 |
+
console.log('流式响应结束');
|
| 779 |
+
break;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
const chunk = decoder.decode(value);
|
| 783 |
+
console.log('收到数据:', chunk);
|
| 784 |
+
|
| 785 |
+
const lines = chunk.split('\n');
|
| 786 |
+
|
| 787 |
+
for (const line of lines) {
|
| 788 |
+
if (line.startsWith('data: ')) {
|
| 789 |
+
const data = line.slice(6);
|
| 790 |
+
if (data === '[DONE]') {
|
| 791 |
+
continue;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
try {
|
| 795 |
+
const dataObj = JSON.parse(data);
|
| 796 |
+
|
| 797 |
+
if (dataObj.type === 'session_id') {
|
| 798 |
+
// 更新本地会话 ID
|
| 799 |
+
if (dataObj.session_id) {
|
| 800 |
+
setSessionId(dataObj.session_id);
|
| 801 |
+
}
|
| 802 |
+
continue;
|
| 803 |
+
} else if (dataObj.type === 'start') {
|
| 804 |
+
// 开始
|
| 805 |
+
continue;
|
| 806 |
+
} else if (dataObj.type === 'question') {
|
| 807 |
+
// 问题
|
| 808 |
+
continue;
|
| 809 |
+
} else if (dataObj.type === 'chunk') {
|
| 810 |
+
// 收到第一个数据时移除 loading
|
| 811 |
+
if (!hasReceivedData) {
|
| 812 |
+
hasReceivedData = true;
|
| 813 |
+
removeTypingIndicator();
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
// 打字机效果 - 逐字输出
|
| 817 |
+
if (dataObj.stream_type === 'thought') {
|
| 818 |
+
// 思考输出
|
| 819 |
+
if (!isThinking) {
|
| 820 |
+
isThinking = true;
|
| 821 |
+
hasSteps = true;
|
| 822 |
+
stepContainer.style.display = 'block';
|
| 823 |
+
// 确保展开状态
|
| 824 |
+
stepContent.classList.remove('collapsed');
|
| 825 |
+
stepToggle.classList.add('expanded');
|
| 826 |
+
|
| 827 |
+
currentStepItem = document.createElement('div');
|
| 828 |
+
currentStepItem.className = 'step-item';
|
| 829 |
+
stepContent.appendChild(currentStepItem);
|
| 830 |
+
requestAnimationFrame(() => {
|
| 831 |
+
console.log('滚动前 - scrollHeight:', stepContent.scrollHeight, 'scrollTop:', stepContent.scrollTop, 'clientHeight:', stepContent.clientHeight, 'collapsed:', stepContent.classList.contains('collapsed'));
|
| 832 |
+
stepContent.scrollTop = stepContent.scrollHeight;
|
| 833 |
+
console.log('滚动后 - scrollHeight:', stepContent.scrollHeight, 'scrollTop:', stepContent.scrollTop);
|
| 834 |
+
});
|
| 835 |
+
|
| 836 |
+
currentThoughtDiv = document.createElement('div');
|
| 837 |
+
currentThoughtDiv.className = 'step-thought';
|
| 838 |
+
currentThoughtDiv.textContent = '💭 ';
|
| 839 |
+
currentStepItem.appendChild(currentThoughtDiv);
|
| 840 |
+
|
| 841 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 842 |
+
}
|
| 843 |
+
currentThoughtText += dataObj.content;
|
| 844 |
+
currentThoughtDiv.textContent = '💭 ' + currentThoughtText;
|
| 845 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 846 |
+
requestAnimationFrame(() => {
|
| 847 |
+
stepContent.scrollTop = stepContent.scrollHeight;
|
| 848 |
+
});
|
| 849 |
+
} else if (dataObj.stream_type === 'answer') {
|
| 850 |
+
// 最终答案输出
|
| 851 |
+
if (!isAnswering) {
|
| 852 |
+
isAnswering = true;
|
| 853 |
+
finalAnswerDiv.style.display = 'block';
|
| 854 |
+
}
|
| 855 |
+
finalAnswer += dataObj.content;
|
| 856 |
+
answerContent.innerHTML = renderMarkdown(finalAnswer);
|
| 857 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 858 |
+
}
|
| 859 |
+
} else if (dataObj.type === 'step_thought_done') {
|
| 860 |
+
// 思考完成,准备行动
|
| 861 |
+
isThinking = false;
|
| 862 |
+
currentThoughtText = '';
|
| 863 |
+
currentThoughtDiv = null;
|
| 864 |
+
|
| 865 |
+
if (dataObj.has_action) {
|
| 866 |
+
const actionDiv = document.createElement('div');
|
| 867 |
+
actionDiv.className = 'step-action';
|
| 868 |
+
actionDiv.textContent = '🔧 正在执行工具...';
|
| 869 |
+
currentStepItem.appendChild(actionDiv);
|
| 870 |
+
requestAnimationFrame(() => {
|
| 871 |
+
stepContent.scrollTop = stepContent.scrollHeight;
|
| 872 |
+
});
|
| 873 |
+
}
|
| 874 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 875 |
+
} else if (dataObj.type === 'step') {
|
| 876 |
+
// 步骤完成(工具调用结果)
|
| 877 |
+
hasSteps = true;
|
| 878 |
+
stepContainer.style.display = 'block';
|
| 879 |
+
|
| 880 |
+
const stepItem = document.createElement('div');
|
| 881 |
+
stepItem.className = 'step-item';
|
| 882 |
+
|
| 883 |
+
if (dataObj.thought) {
|
| 884 |
+
const thoughtDiv = document.createElement('div');
|
| 885 |
+
thoughtDiv.className = 'step-thought';
|
| 886 |
+
thoughtDiv.textContent = `💭 ${dataObj.thought}`;
|
| 887 |
+
stepItem.appendChild(thoughtDiv);
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
if (dataObj.action) {
|
| 891 |
+
const actionDiv = document.createElement('div');
|
| 892 |
+
actionDiv.className = 'step-action';
|
| 893 |
+
actionDiv.textContent = `🔧 ${dataObj.action}`;
|
| 894 |
+
stepItem.appendChild(actionDiv);
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
if (dataObj.observation) {
|
| 898 |
+
const obsDiv = document.createElement('div');
|
| 899 |
+
obsDiv.className = 'step-observation';
|
| 900 |
+
if (dataObj.observation.success) {
|
| 901 |
+
obsDiv.classList.add('success');
|
| 902 |
+
obsDiv.textContent = `✅ ${JSON.stringify(dataObj.observation.result, null, 2)}`;
|
| 903 |
+
} else {
|
| 904 |
+
obsDiv.classList.add('error');
|
| 905 |
+
obsDiv.textContent = `❌ ${dataObj.observation.error}`;
|
| 906 |
+
}
|
| 907 |
+
stepItem.appendChild(obsDiv);
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
if (dataObj.final_answer) {
|
| 911 |
+
finalAnswer = dataObj.final_answer;
|
| 912 |
+
isAnswering = true;
|
| 913 |
+
finalAnswerDiv.style.display = 'block';
|
| 914 |
+
answerContent.innerHTML = renderMarkdown(finalAnswer);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
stepContent.appendChild(stepItem);
|
| 918 |
+
// 确保展开状态再滚动
|
| 919 |
+
stepContent.classList.remove('collapsed');
|
| 920 |
+
stepToggle.classList.add('expanded');
|
| 921 |
+
requestAnimationFrame(() => {
|
| 922 |
+
stepContent.scrollTop = stepContent.scrollHeight;
|
| 923 |
+
});
|
| 924 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 925 |
+
} else if (dataObj.type === 'done') {
|
| 926 |
+
// 完成,自动折叠分析过程
|
| 927 |
+
if (hasSteps) {
|
| 928 |
+
stepContent.classList.add('collapsed');
|
| 929 |
+
stepToggle.classList.remove('expanded');
|
| 930 |
+
}
|
| 931 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 932 |
+
} else if (dataObj.type === 'error') {
|
| 933 |
+
throw new Error(dataObj.error);
|
| 934 |
+
}
|
| 935 |
+
} catch (e) {
|
| 936 |
+
// 忽略解析错误
|
| 937 |
+
}
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
// 如果没有步骤,隐藏步骤容器
|
| 943 |
+
if (!hasSteps) {
|
| 944 |
+
stepContainer.style.display = 'none';
|
| 945 |
+
} else if (finalAnswer) {
|
| 946 |
+
// 有步骤且有最终答案时,折叠分析过程
|
| 947 |
+
stepContent.classList.add('collapsed');
|
| 948 |
+
stepToggle.classList.remove('expanded');
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
// 如果没有最终答案,但有内容
|
| 952 |
+
if (finalAnswer && finalAnswerDiv.style.display === 'none') {
|
| 953 |
+
finalAnswerDiv.style.display = 'block';
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
// 添加到历史记录
|
| 957 |
+
messageHistory.push({ role: 'assistant', content: finalAnswer });
|
| 958 |
+
|
| 959 |
+
// 限制历史记录长度
|
| 960 |
+
if (messageHistory.length > MAX_HISTORY_LENGTH) {
|
| 961 |
+
messageHistory = messageHistory.slice(-MAX_HISTORY_LENGTH);
|
| 962 |
+
}
|
| 963 |
+
} catch (error) {
|
| 964 |
+
console.error('Error:', error);
|
| 965 |
+
removeTypingIndicator();
|
| 966 |
+
addMessage('抱歉,获取帮助时出现错误。请检查配置后重试。', 'bot', true);
|
| 967 |
+
} finally {
|
| 968 |
+
isSending = false;
|
| 969 |
+
sendButton.disabled = false;
|
| 970 |
+
sendButton.style.opacity = '1';
|
| 971 |
+
}
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
// 简单的Markdown渲染函数
|
| 975 |
+
function renderMarkdown(text) {
|
| 976 |
+
// 处理代码块
|
| 977 |
+
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
| 978 |
+
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
| 979 |
+
|
| 980 |
+
// 处理标题
|
| 981 |
+
text = text.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
| 982 |
+
text = text.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
| 983 |
+
text = text.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
| 984 |
+
|
| 985 |
+
// 处理粗体和斜体
|
| 986 |
+
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
| 987 |
+
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
| 988 |
+
|
| 989 |
+
// 处理链接
|
| 990 |
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
| 991 |
+
|
| 992 |
+
// 处理列表
|
| 993 |
+
text = text.replace(/^\* (.+)/gim, '<li>$1</li>');
|
| 994 |
+
text = text.replace(/^\- (.+)/gim, '<li>$1</li>');
|
| 995 |
+
text = text.replace(/^(\d+)\. (.+)/gim, '<li>$2</li>');
|
| 996 |
+
|
| 997 |
+
// 处理换行
|
| 998 |
+
text = text.replace(/\n/g, '<br>');
|
| 999 |
+
|
| 1000 |
+
return text;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
// 添加消息到聊天区域
|
| 1004 |
+
function addMessage(text, sender, isError = false) {
|
| 1005 |
+
const messageDiv = document.createElement('div');
|
| 1006 |
+
messageDiv.classList.add('message');
|
| 1007 |
+
if (isError) {
|
| 1008 |
+
messageDiv.style.background = '#ffebee';
|
| 1009 |
+
messageDiv.style.color = '#c62828';
|
| 1010 |
+
messageDiv.style.border = '1px solid #ef5350';
|
| 1011 |
+
} else {
|
| 1012 |
+
messageDiv.classList.add(sender === 'user' ? 'user-message' : 'bot-message');
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
let formattedText;
|
| 1016 |
+
if (sender === 'bot' && !isError) {
|
| 1017 |
+
formattedText = renderMarkdown(text);
|
| 1018 |
+
} else {
|
| 1019 |
+
formattedText = text.replace(/\n/g, '<br>');
|
| 1020 |
+
}
|
| 1021 |
+
messageDiv.innerHTML = formattedText;
|
| 1022 |
+
|
| 1023 |
+
chatMessages.appendChild(messageDiv);
|
| 1024 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
// 显示正在输入指示器
|
| 1028 |
+
function showTypingIndicator() {
|
| 1029 |
+
const typingDiv = document.createElement('div');
|
| 1030 |
+
typingDiv.classList.add('typing-indicator');
|
| 1031 |
+
typingDiv.id = 'typingIndicator';
|
| 1032 |
+
typingDiv.innerHTML = `
|
| 1033 |
+
<div class="typing-dot"></div>
|
| 1034 |
+
<div class="typing-dot"></div>
|
| 1035 |
+
<div class="typing-dot"></div>
|
| 1036 |
+
`;
|
| 1037 |
+
chatMessages.appendChild(typingDiv);
|
| 1038 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
// 移除正在输入指示器
|
| 1042 |
+
function removeTypingIndicator() {
|
| 1043 |
+
const typingIndicator = document.getElementById('typingIndicator');
|
| 1044 |
+
if (typingIndicator) {
|
| 1045 |
+
typingIndicator.remove();
|
| 1046 |
+
}
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
// 事件监听
|
| 1050 |
+
sendButton.addEventListener('click', sendMessage);
|
| 1051 |
+
|
| 1052 |
+
questionInput.addEventListener('keypress', function(e) {
|
| 1053 |
+
if (e.key === 'Enter') {
|
| 1054 |
+
sendMessage();
|
| 1055 |
+
}
|
| 1056 |
+
});
|
| 1057 |
+
|
| 1058 |
+
// ===== 弹窗管理 =====
|
| 1059 |
+
const ModalManager = {
|
| 1060 |
+
config: null,
|
| 1061 |
+
overlay: null,
|
| 1062 |
+
container: null,
|
| 1063 |
+
storageKey: null,
|
| 1064 |
+
|
| 1065 |
+
async init() {
|
| 1066 |
+
this.overlay = document.getElementById('modalOverlay');
|
| 1067 |
+
this.container = document.getElementById('modalContainer');
|
| 1068 |
+
|
| 1069 |
+
if (!this.overlay || !this.container) {
|
| 1070 |
+
console.warn('弹窗元素未找到');
|
| 1071 |
+
return;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
await this.loadConfig();
|
| 1075 |
+
|
| 1076 |
+
if (this.shouldShow()) {
|
| 1077 |
+
const delay = this.config.display?.delay || 500;
|
| 1078 |
+
setTimeout(() => this.show(), delay);
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
this.bindEvents();
|
| 1082 |
+
},
|
| 1083 |
+
|
| 1084 |
+
async loadConfig() {
|
| 1085 |
+
try {
|
| 1086 |
+
const response = await fetch('/api/popup/config');
|
| 1087 |
+
const data = await response.json();
|
| 1088 |
+
this.config = data;
|
| 1089 |
+
this.storageKey = data.storage?.key || 'popup_dismissed';
|
| 1090 |
+
console.log('弹窗配置加载成功:', this.config);
|
| 1091 |
+
} catch (error) {
|
| 1092 |
+
console.error('加载弹窗配置失败:', error);
|
| 1093 |
+
this.config = { enabled: false };
|
| 1094 |
+
}
|
| 1095 |
+
},
|
| 1096 |
+
|
| 1097 |
+
shouldShow() {
|
| 1098 |
+
if (!this.config || !this.config.enabled) {
|
| 1099 |
+
return false;
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
// 检查用户是否已选择"不再显示"
|
| 1103 |
+
const dismissed = this.getStorageValue();
|
| 1104 |
+
if (dismissed) {
|
| 1105 |
+
return false;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
// 检查显示规则
|
| 1109 |
+
const showRules = this.config.showRules || {};
|
| 1110 |
+
const maxShows = showRules.maxShows || 0;
|
| 1111 |
+
const frequency = showRules.frequency || 'once';
|
| 1112 |
+
|
| 1113 |
+
if (maxShows > 0) {
|
| 1114 |
+
const showCount = this.getShowCount();
|
| 1115 |
+
if (showCount >= maxShows) {
|
| 1116 |
+
return false;
|
| 1117 |
+
}
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
if (frequency === 'daily') {
|
| 1121 |
+
const lastShowDate = this.getLastShowDate();
|
| 1122 |
+
const today = new Date().toDateString();
|
| 1123 |
+
if (lastShowDate === today) {
|
| 1124 |
+
return false;
|
| 1125 |
+
}
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
return true;
|
| 1129 |
+
},
|
| 1130 |
+
|
| 1131 |
+
show() {
|
| 1132 |
+
if (!this.config || !this.config.enabled) {
|
| 1133 |
+
return;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
this.renderContent();
|
| 1137 |
+
this.overlay.classList.add('active');
|
| 1138 |
+
this.incrementShowCount();
|
| 1139 |
+
this.setLastShowDate();
|
| 1140 |
+
console.log('弹窗已显示');
|
| 1141 |
+
},
|
| 1142 |
+
|
| 1143 |
+
hide() {
|
| 1144 |
+
this.overlay.classList.remove('active');
|
| 1145 |
+
this.overlay.classList.add('fade-out');
|
| 1146 |
+
|
| 1147 |
+
setTimeout(() => {
|
| 1148 |
+
this.overlay.classList.remove('fade-out');
|
| 1149 |
+
}, 300);
|
| 1150 |
+
|
| 1151 |
+
console.log('弹窗已隐藏');
|
| 1152 |
+
},
|
| 1153 |
+
|
| 1154 |
+
dismiss() {
|
| 1155 |
+
this.setStorageValue(true);
|
| 1156 |
+
this.hide();
|
| 1157 |
+
console.log('用户选择不再显示弹窗');
|
| 1158 |
+
},
|
| 1159 |
+
|
| 1160 |
+
renderContent() {
|
| 1161 |
+
const content = this.config.content || {};
|
| 1162 |
+
const display = this.config.display || {};
|
| 1163 |
+
|
| 1164 |
+
document.getElementById('modalHeaderTitle').textContent = content.title || '提示';
|
| 1165 |
+
const icon = document.getElementById('modalHeaderIcon');
|
| 1166 |
+
icon.className = 'modal-header-icon ' + (content.icon || 'fas fa-info-circle');
|
| 1167 |
+
|
| 1168 |
+
const contentDiv = document.getElementById('modalContent');
|
| 1169 |
+
contentDiv.innerHTML = content.html || '';
|
| 1170 |
+
|
| 1171 |
+
this.renderButtons();
|
| 1172 |
+
|
| 1173 |
+
const closeButton = document.getElementById('modalCloseButton');
|
| 1174 |
+
closeButton.style.display = display.showCloseButton !== false ? 'block' : 'none';
|
| 1175 |
+
},
|
| 1176 |
+
|
| 1177 |
+
renderButtons() {
|
| 1178 |
+
const footer = document.getElementById('modalFooter');
|
| 1179 |
+
footer.innerHTML = '';
|
| 1180 |
+
|
| 1181 |
+
const buttons = this.config.buttons || [];
|
| 1182 |
+
|
| 1183 |
+
buttons.forEach(button => {
|
| 1184 |
+
const btn = document.createElement('button');
|
| 1185 |
+
btn.textContent = button.text || '确定';
|
| 1186 |
+
|
| 1187 |
+
const buttonClass = 'modal-button';
|
| 1188 |
+
switch (button.type) {
|
| 1189 |
+
case 'primary':
|
| 1190 |
+
btn.className = buttonClass + ' modal-button-primary';
|
| 1191 |
+
break;
|
| 1192 |
+
case 'secondary':
|
| 1193 |
+
btn.className = buttonClass + ' modal-button-secondary';
|
| 1194 |
+
break;
|
| 1195 |
+
case 'link':
|
| 1196 |
+
btn.className = buttonClass + ' modal-button-link';
|
| 1197 |
+
break;
|
| 1198 |
+
default:
|
| 1199 |
+
btn.className = buttonClass + ' modal-button-secondary';
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
btn.addEventListener('click', () => this.handleButtonClick(button));
|
| 1203 |
+
footer.appendChild(btn);
|
| 1204 |
+
});
|
| 1205 |
+
},
|
| 1206 |
+
|
| 1207 |
+
handleButtonClick(button) {
|
| 1208 |
+
switch (button.action) {
|
| 1209 |
+
case 'close':
|
| 1210 |
+
this.hide();
|
| 1211 |
+
break;
|
| 1212 |
+
case 'dismiss':
|
| 1213 |
+
this.dismiss();
|
| 1214 |
+
break;
|
| 1215 |
+
case 'url':
|
| 1216 |
+
if (button.url) {
|
| 1217 |
+
window.open(button.url, '_blank');
|
| 1218 |
+
this.hide();
|
| 1219 |
+
}
|
| 1220 |
+
break;
|
| 1221 |
+
default:
|
| 1222 |
+
this.hide();
|
| 1223 |
+
}
|
| 1224 |
+
},
|
| 1225 |
+
|
| 1226 |
+
bindEvents() {
|
| 1227 |
+
const display = this.config.display || {};
|
| 1228 |
+
|
| 1229 |
+
const closeButton = document.getElementById('modalCloseButton');
|
| 1230 |
+
if (closeButton) {
|
| 1231 |
+
closeButton.addEventListener('click', () => this.hide());
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
if (display.closeOnOutsideClick !== false) {
|
| 1235 |
+
this.overlay.addEventListener('click', (e) => {
|
| 1236 |
+
if (e.target === this.overlay) {
|
| 1237 |
+
this.hide();
|
| 1238 |
+
}
|
| 1239 |
+
});
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
if (display.closeOnEscapeKey !== false) {
|
| 1243 |
+
document.addEventListener('keydown', (e) => {
|
| 1244 |
+
if (e.key === 'Escape' && this.overlay.classList.contains('active')) {
|
| 1245 |
+
this.hide();
|
| 1246 |
+
}
|
| 1247 |
+
});
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
const autoCloseAfter = display.autoCloseAfter || 0;
|
| 1251 |
+
if (autoCloseAfter > 0) {
|
| 1252 |
+
setTimeout(() => {
|
| 1253 |
+
if (this.overlay.classList.contains('active')) {
|
| 1254 |
+
this.hide();
|
| 1255 |
+
}
|
| 1256 |
+
}, autoCloseAfter);
|
| 1257 |
+
}
|
| 1258 |
+
},
|
| 1259 |
+
|
| 1260 |
+
getStorageValue() {
|
| 1261 |
+
const storageType = this.config.storage?.type || 'localStorage';
|
| 1262 |
+
const key = this.storageKey;
|
| 1263 |
+
|
| 1264 |
+
if (storageType === 'cookie') {
|
| 1265 |
+
return this.getCookie(key);
|
| 1266 |
+
} else {
|
| 1267 |
+
return localStorage.getItem(key) === 'true';
|
| 1268 |
+
}
|
| 1269 |
+
},
|
| 1270 |
+
|
| 1271 |
+
setStorageValue(value) {
|
| 1272 |
+
const storageType = this.config.storage?.type || 'localStorage';
|
| 1273 |
+
const key = this.storageKey;
|
| 1274 |
+
const expiresIn = this.config.storage?.expiresIn || 0;
|
| 1275 |
+
|
| 1276 |
+
if (storageType === 'cookie') {
|
| 1277 |
+
let expires = '';
|
| 1278 |
+
if (expiresIn > 0) {
|
| 1279 |
+
const date = new Date();
|
| 1280 |
+
date.setTime(date.getTime() + expiresIn);
|
| 1281 |
+
expires = '; expires=' + date.toUTCString();
|
| 1282 |
+
}
|
| 1283 |
+
document.cookie = key + '=' + value + expires + '; path=/';
|
| 1284 |
+
} else {
|
| 1285 |
+
localStorage.setItem(key, String(value));
|
| 1286 |
+
}
|
| 1287 |
+
},
|
| 1288 |
+
|
| 1289 |
+
getCookie(name) {
|
| 1290 |
+
const value = '; ' + document.cookie;
|
| 1291 |
+
const parts = value.split('; ' + name + '=');
|
| 1292 |
+
if (parts.length === 2) {
|
| 1293 |
+
return parts.pop().split(';').shift();
|
| 1294 |
+
}
|
| 1295 |
+
return null;
|
| 1296 |
+
},
|
| 1297 |
+
|
| 1298 |
+
getShowCount() {
|
| 1299 |
+
return parseInt(localStorage.getItem(this.storageKey + '_count') || '0');
|
| 1300 |
+
},
|
| 1301 |
+
|
| 1302 |
+
incrementShowCount() {
|
| 1303 |
+
const count = this.getShowCount() + 1;
|
| 1304 |
+
localStorage.setItem(this.storageKey + '_count', String(count));
|
| 1305 |
+
},
|
| 1306 |
+
|
| 1307 |
+
getLastShowDate() {
|
| 1308 |
+
return localStorage.getItem(this.storageKey + '_last_date') || '';
|
| 1309 |
+
},
|
| 1310 |
+
|
| 1311 |
+
setLastShowDate() {
|
| 1312 |
+
localStorage.setItem(this.storageKey + '_last_date', new Date().toDateString());
|
| 1313 |
+
},
|
| 1314 |
+
|
| 1315 |
+
async reload() {
|
| 1316 |
+
await this.loadConfig();
|
| 1317 |
+
return this.config;
|
| 1318 |
+
},
|
| 1319 |
+
|
| 1320 |
+
reset() {
|
| 1321 |
+
localStorage.removeItem(this.storageKey);
|
| 1322 |
+
localStorage.removeItem(this.storageKey + '_count');
|
| 1323 |
+
localStorage.removeItem(this.storageKey + '_last_date');
|
| 1324 |
+
console.log('弹窗设置已重置');
|
| 1325 |
+
}
|
| 1326 |
+
};
|
| 1327 |
+
|
| 1328 |
+
// 页面加载完成后初始化弹窗
|
| 1329 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1330 |
+
ModalManager.init();
|
| 1331 |
+
});
|
| 1332 |
+
|
| 1333 |
+
// 暴露到全局,方便调试
|
| 1334 |
+
window.ModalManager = ModalManager;
|
| 1335 |
+
</script>
|
| 1336 |
+
</body>
|
| 1337 |
+
</html>
|
tests/__init__.py
ADDED
|
File without changes
|