qa1145 commited on
Commit
d347708
·
verified ·
1 Parent(s): ecf45d2

Upload 28 files

Browse files
.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: Astrbot Help
3
- emoji: 🏆
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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